Compare commits

...

164 Commits

Author SHA1 Message Date
dependabot[bot] 7a9cec0b76 chore(deps): bump docker/setup-buildx-action from 3.12.0 to 4.0.0
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.12.0 to 4.0.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/8d2750c68a42422c14e847fe6c8ac0403b4cbd6f...4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-17 19:22:17 +00:00
Argenis 50e8d4f5f8 fix(ci): use pre-built binaries for Debian Docker image (#3814)
The Debian compatibility image was building from source with QEMU
cross-compilation for ARM64, which is extremely slow and was getting
cancelled by the concurrency group. Switch to using pre-built binaries
(same as the distroless image) with a debian:bookworm-slim runtime base.

- Add Dockerfile.debian.ci (mirrors Dockerfile.ci with Debian runtime)
- Update release-beta-on-push.yml to use docker-ctx + pre-built bins
- Update release-stable-manual.yml with same fix
- Drop GHA cache layers (no longer building from source)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 15:21:15 -04:00
Argenis fc2aac7c94 feat(gateway): persist WS chat sessions across restarts (#3813)
Gateway WebSocket chat sessions were in-memory only — conversation
history was lost on gateway restart, macOS sleep/wake, or client
reconnect. This wires up the existing SessionBackend (SQLite) to
the gateway WS handler so sessions survive restarts and reconnections.

Changes:
- Add delete_session() to SessionBackend trait + SQLite implementation
- Add session_persistence and session_ttl_hours to GatewayConfig
- Add Agent::seed_history() to hydrate agent from persisted messages
- Initialize SqliteSessionBackend in run_gateway() when enabled
- Send session_start message on WS connect with session_id + resumed
- Persist user/assistant messages after each turn
- Add GET /api/sessions and DELETE /api/sessions/{id} REST endpoints
- Bump version to 0.5.0

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 14:26:39 -04:00
Argenis 4caa3f7e6f fix(web): remove duplicate dashboard keys in Turkish locale (#3812)
The Turkish (tr) locale section had a duplicate "Dashboard specific
labels" block that repeated 19 keys already defined earlier, causing
TypeScript error TS1117. Moved the unique keys (provider_model,
paired_yes, etc.) into the primary dashboard section and removed
the duplicate block.

Fixes build failure introduced by #3777.
2026-03-17 14:13:46 -04:00
Argenis 3bc6ec3cf5 fix: only tweet for stable releases, not beta builds (#3808)
Remove tweet job from beta workflow. Update tweet-release.yml to diff
against previous stable tag (excluding betas) to capture all features
across the full release cycle. Simplify tweet format to feature-focused
style without contributor counts.

Supersedes #3575.
2026-03-17 14:06:46 -04:00
Argenis f3fbd1b094 fix(web): preserve provider runtime options in ws agent (#3807)
Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
2026-03-17 14:06:22 -04:00
Yingpeng MA 79e8252d7a feat(web/i18n): add full Chinese locale and complete Turkish translations (#3777)
- Add comprehensive Simplified Chinese (zh) translations for all UI strings
- Extend and complete Turkish (tr) translations
- Fill in missing English (en) translation keys
- Reset default locale to 'en'
- Update language toggle to cycle through all three locales: en → zh → tr
2026-03-17 13:40:25 -04:00
Marijan Petričević 924521c927 config/schema: add serde default to AutonomyConfig (#3691)
Co-authored-by: Argenis <theonlyhennygod@gmail.com>
2026-03-17 13:40:18 -04:00
Argenis 07ca270f03 fix(security): restore tokens.is_empty() guard, add re-pairing hint (#3738)
Revert "always generate pairing code" to tighter security posture:
codes are only generated on first startup when no tokens exist. Add
a CLI hint to the gateway banner so operators know how to re-pair
on demand. Fix install.sh to not use --new on fresh install (avoids
invalidating the auto-generated code). Fix onboard to show an
informational message instead of a throwaway PairingGuard.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 13:40:02 -04:00
Alix-007 e08091a2e2 fix(install): print PATH guidance after cargo install (#3769)
Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
2026-03-17 13:39:53 -04:00
Alix-007 1f1123d071 fix(channels): allow low-risk shell in non-interactive mode (#3771)
Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
2026-03-17 13:39:37 -04:00
Alix-007 d5bc46238a fix(install): skip prebuilt flow on musl (#3788)
Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
2026-03-17 13:39:29 -04:00
Alix-007 843973762a ci(docker): publish debian compatibility image (#3789)
Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
2026-03-17 13:39:20 -04:00
Alix-007 5f8d7d7347 fix(daemon): preserve deferred MCP tools in /api/chat (#3790)
Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
2026-03-17 13:39:12 -04:00
Alix-007 7b3bea8d01 fix(agent): resolve deferred MCP tools by suffix (#3793)
Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
2026-03-17 13:39:03 -04:00
Alix-007 ac461dc704 fix(docker): align debian image glibc baseline (#3794)
Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
2026-03-17 13:38:55 -04:00
Alix-007 f04e56d9a1 feat(skills): support YAML frontmatter in SKILL.md (#3797)
* feat(skills): support YAML frontmatter in SKILL.md

* fix(skills): preserve nested open-skill names

---------

Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
2026-03-17 13:38:49 -04:00
Alix-007 1d6f482b04 fix(build): rerun embedded web assets when dist changes (#3799)
Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
2026-03-17 13:38:40 -04:00
Alix-007 ba6d0a4df9 fix(release): include matrix channel in official builds (#3800)
Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
2026-03-17 13:38:33 -04:00
Alix-007 3cf873ab85 fix(groq): fall back on tool validation 400s (#3778)
Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
Co-authored-by: Argenis <theonlyhennygod@gmail.com>
2026-03-17 09:23:39 -04:00
Argenis 025724913d feat(runtime): add configurable reasoning effort (#3785)
* feat(runtime): add configurable reasoning effort

* fix(test): add missing reasoning_effort field in live test

Add reasoning_effort: None to ProviderRuntimeOptions construction in
openai_codex_vision_e2e.rs to fix E0063 compile error.

---------

Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
2026-03-17 09:21:53 -04:00
project516 49dd4cd9da Change AppImage to tar.gz in arduino-uno-q-setup.md (#3754)
Arduino App Lab is a tar.gz file for Linux, not an AppImage

Co-authored-by: Argenis <theonlyhennygod@gmail.com>
2026-03-17 09:19:38 -04:00
dependabot[bot] 0664a5e854 chore(deps): bump rust from 7d37016 to da9dab7 (#3776)
Bumps rust from `7d37016` to `da9dab7`.

---
updated-dependencies:
- dependency-name: rust
  dependency-version: 1.94-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Argenis <theonlyhennygod@gmail.com>
2026-03-17 09:16:21 -04:00
Argenis acd09fbd86 feat(ci): use pre-built binaries for Docker images (#3784)
Instead of compiling Rust from source inside Docker (~60 min),
download the already-built linux binaries from the build matrix
and copy them into a minimal distroless image (~2 min).

- Add Dockerfile.ci for release workflows (no Rust toolchain needed)
- Update both beta and stable workflows to use pre-built artifacts
- Drop Docker job timeout from 60 to 15 minutes
- Original Dockerfile unchanged for local dev builds
2026-03-17 09:03:13 -04:00
Alix-007 0f7d1fceeb fix(channels): hide tool-call notifications by default (#3779)
Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
Co-authored-by: Argenis <theonlyhennygod@gmail.com>
2026-03-17 08:52:49 -04:00
GhostC 01e13ac92d fix(skills): allow sibling markdown links within skills root (#3781)
Made-with: Cursor
2026-03-17 08:31:20 -04:00
Argenis a9a6113093 fix(docs): revert unauthorized CLAUDE.md additions from #3604 (#3761)
PR #3604 included CLAUDE.md changes referencing non-existent modules
(src/security/taint.rs, src/sop/workflow.rs) and duplicating content
from CONTRIBUTING.md. These additions violate the anti-pattern rule
against modifying CLAUDE.md in feature PRs.
2026-03-17 01:56:51 -04:00
Giulio V 906951a587 feat(multi): LinkedIn tool, WhatsApp voice notes, and Anthropic OAuth fix (#3604)
* feat(tools): add native LinkedIn integration tool

Add a config-gated LinkedIn tool that enables ZeroClaw to interact with
LinkedIn's REST API via OAuth2. Supports creating posts, listing own
posts, commenting, reacting, deleting posts, viewing engagement stats,
and retrieving profile info.

Architecture:
- linkedin.rs: Tool trait impl with action-dispatched design
- linkedin_client.rs: OAuth2 token management and API wrappers
- Config-gated via [linkedin] enabled = false (default off)
- Credentials loaded from workspace .env file
- Automatic token refresh with line-targeted .env update

39 unit tests covering security enforcement, parameter validation,
credential parsing, and token management.

* feat(linkedin): configurable content strategy and API version

- Expand LinkedInConfig with api_version and nested LinkedInContentConfig
  (rss_feeds, github_users, github_repos, topics, persona, instructions)
- Add get_content_strategy tool action so agents can read config at runtime
- Fix hardcoded LinkedIn API version 202402 (expired) → configurable,
  defaulting to 202602
- LinkedInClient accepts api_version as parameter instead of static header
- 4 new tests (43 total), all passing

* feat(linkedin): add multi-provider image generation for posts

Add ImageGenerator with provider chain (DALL-E, Stability AI, Imagen, Flux)
and SVG fallback card. LinkedIn tool create_post now supports generate_image
parameter. Includes LinkedIn image upload (register → upload → reference),
configurable provider priority, and 14 new tests.

* feat(whatsapp): add voice note transcription and TTS voice replies

- Add STT support: download incoming voice notes via wa-rs, transcribe
  with OpenAI Whisper (or Groq), send transcribed text to agent
- Add TTS support: synthesize agent replies to Opus audio via OpenAI
  TTS, upload encrypted media, send as WhatsApp voice note (ptt=true)
- Voice replies only trigger when user sends a voice note; text
  messages get text replies only. Flag is consumed after one use to
  prevent multiple voice notes per agent turn
- Fix transcription module to support OpenAI API key (not just Groq):
  auto-detect provider from API URL, check ANTHROPIC_OAUTH_TOKEN /
  OPENAI_API_KEY / GROQ_API_KEY env vars in priority order
- Add optional api_key field to TranscriptionConfig for explicit key
- Add response_format: opus to OpenAI TTS for WhatsApp compatibility
- Add channel capability note so agent knows TTS is automatic
- Wire transcription + TTS config into WhatsApp Web channel builder

* fix(providers): prefer ANTHROPIC_OAUTH_TOKEN over global api_key

When the Anthropic provider is used alongside a non-Anthropic primary
provider (e.g. custom: gateway), the global api_key would be passed
as credential override, bypassing provider-specific env vars. This
caused Claude Code subscription tokens (sk-ant-oat01-*) to be ignored
in favor of the unrelated gateway JWT.

Fix: for the anthropic provider, check ANTHROPIC_OAUTH_TOKEN and
ANTHROPIC_API_KEY env vars before falling back to the credential
override. This mirrors the existing MiniMax OAuth pattern and enables
subscription-based auth to work as a fallback provider.

* feat(linkedin): add scheduled post support via LinkedIn API

Add scheduled_at parameter to create_post and create_post_with_image.
When provided (RFC 3339 timestamp), the post is created as a DRAFT
with scheduledPublishOptions so LinkedIn publishes it automatically
at the specified time. This enables the cron job to schedule a week
of posts in advance directly on LinkedIn.

* fix(providers): prefer env vars for openai and groq credential resolution

Generalize the Anthropic OAuth fix to also cover openai and groq
providers. When used alongside a non-matching primary provider (e.g.
a custom: gateway), the global api_key would be passed as credential
override, causing auth failures. Now checks provider-specific env
vars (OPENAI_API_KEY, GROQ_API_KEY) before falling back to the
credential override.

* fix(whatsapp): debounce voice replies to voice final answer only

The voice note TTS was triggering on the first send() call, which was
often intermediate tool output (URLs, JSON, web fetch results) rather
than the actual answer. This produced incomprehensible voice notes.

Fix: accumulate substantive replies (>30 chars, not URLs/JSON/code)
in a pending_voice map. A spawned debounce task waits 4 seconds after
the last substantive message, then synthesizes and sends ONE voice
note with the final answer. Intermediate tool outputs are skipped.

This ensures the user hears the actual answer in the correct language,
not raw tool output in English.

* fix(whatsapp): voice in = voice out, text in = text out

Rewrite voice reply logic with clean separation:
- Voice note received: ALL text output suppressed. Latest message
  accumulated silently. After 5s of no new messages, ONE voice note
  sent with the final answer. No tool outputs, no text, just voice.
- Text received: normal text reply, no voice.

Atomic debounce: multiple spawned tasks race but only one can extract
the pending message (remove-inside-lock pattern). Prevents duplicate
voice notes.

* fix(whatsapp): voice replies send both text and voice note

Voice note in → text replies sent normally in real-time PLUS one
voice note with the final answer after 10s debounce. Only substantive
natural-language messages are voiced (tool outputs, URLs, JSON, code
blocks filtered out). Longer debounce (10s) ensures the agent
completes its full tool chain before the voice note fires.

Text in → text out only, no voice.

* fix(channels): suppress tool narration and ack reactions

- Add system prompt instruction telling the agent to NEVER narrate
  tool usage (no "Let me fetch..." or "I will use http_request...")
- Disable ack_reactions (emoji reactions on incoming messages)
- Users see only the final answer, no intermediate steps

* docs(claude): add full CONTRIBUTING.md guidelines to CLAUDE.md

Add PR template requirements, code naming conventions, architecture
boundary rules, validation commands, and branch naming guidance
directly to CLAUDE.md for AI assistant reference.

* fix(docs): add blank lines around headings in CLAUDE.md for markdown lint

* fix(channels): strengthen tool narration suppression and fix large_futures

- Move anti-narration instruction to top of channel system prompt
- Add emphatic instruction for WhatsApp/voice channels specifically
- Add outbound message filter to strip tool-call-like patterns (, 🔧)
- Box::pin the two-phase heartbeat agent::run call (16664 bytes on Linux)
2026-03-17 01:55:05 -04:00
Giulio V 220745e217 feat(channels): add Reddit, Bluesky, and generic Webhook adapters (#3598)
* feat(channels): add Reddit, Bluesky, and generic Webhook adapters

- Reddit: OAuth2 polling for mentions/DMs/replies, comment and DM sending
- Bluesky: AT Protocol session auth, notification polling, post replies
- Webhook: Axum HTTP server for inbound, configurable outbound POST/PUT
- All three follow existing channel patterns with tests

* fix(channels): use neutral test fixtures and improve test naming in webhook
2026-03-17 01:26:58 -04:00
Giulio V 61de3d5648 feat(knowledge): add knowledge graph for expertise capture and reuse (#3596)
* feat(knowledge): add knowledge graph for expertise capture and reuse

SQLite-backed knowledge graph system for consulting firms to capture,
organize, and reuse architecture decisions, solution patterns, lessons
learned, and expert matching across client engagements.

- KnowledgeGraph (src/memory/knowledge_graph.rs): node CRUD, edge
  creation, FTS5 full-text search, tag filtering, subgraph traversal,
  expert ranking by authored contributions, graph statistics
- KnowledgeTool (src/tools/knowledge_tool.rs): Tool trait impl with
  capture, search, relate, suggest, expert_find, lessons_extract, and
  graph_stats actions
- KnowledgeConfig (src/config/schema.rs): disabled by default,
  configurable db_path/max_nodes, cross_workspace_search off by default
  for client data isolation
- Wired into tools factory (conditional on config.knowledge.enabled)

20 unit tests covering node CRUD, edge creation, search ranking,
subgraph queries, expert ranking, and tool actions.

* fix: address CodeRabbit review findings

- Fix UTF-8 truncation panic in truncate_str by using char-based
  iteration instead of byte indexing
- Add config validation for knowledge.max_nodes > 0
- Add subgraph depth boundary validation (must be > 0, capped at 100)

* fix(knowledge): address remaining CodeRabbit review issues

- MAJOR: Add db_path non-empty validation in Config::validate()
- MAJOR: Reject tags containing commas in add_node (comma is separator)
- MAJOR: Fix subgraph depth boundary (0..depth instead of 0..=depth)
- MAJOR: Apply project and node_type filters consistently in both
  tag-only and similarity search paths

* fix: correct subgraph traversal test assertion and sync CI workflows
2026-03-17 01:11:29 -04:00
Giulio V 675a5c9af0 feat(tools): add Google Workspace CLI (gws) integration (#3616)
* feat(tools): add Google Workspace CLI (gws) integration

Adds GoogleWorkspaceTool for interacting with Google Drive, Sheets,
Gmail, Calendar, Docs, and other Workspace services via CLI.

- Config-gated (google_workspace.enabled)
- Service allowlist for restricted access
- Requires shell access for CLI delegation
- Input validation against shell injection
- Wrong-type rejection for all optional parameters
- Config validation for allowed_services (empty, duplicate, malformed)
- Registered in integrations registry and CLI discovery

Closes #2986

* style: fix cargo fmt + clippy violations

* feat(google-workspace): expand config with auth, rate limits, and audit settings

* fix(tools): define missing GWS_TIMEOUT_SECS constant

* fix: Box::pin large futures and resolve duplicate Default impl

---------

Co-authored-by: argenis de la rosa <theonlyhennygod@gmail.com>
2026-03-17 00:52:59 -04:00
Giulio V b099728c27 feat(stt): multi-provider STT with TranscriptionProvider trait (#3614)
* feat(stt): add multi-provider STT with TranscriptionProvider trait

Refactors single-endpoint transcription to support multiple providers:
Groq (existing), OpenAI Whisper, Deepgram, AssemblyAI, and Google Cloud
Speech-to-Text. Adds TranscriptionManager for provider routing with
backward-compatible config fields.

* style: fix cargo fmt + clippy violations

* fix: Box::pin large futures and resolve merge conflicts with master

---------

Co-authored-by: argenis de la rosa <theonlyhennygod@gmail.com>
2026-03-17 00:33:41 -04:00
Argenis 1ca2092ca0 test(channel): add QQ markdown msg_type regression test (#3752)
Verify that QQ send body uses msg_type 2 with nested markdown object
instead of msg_type 0 with top-level content. Adapted from #3668.
2026-03-16 22:03:43 -04:00
Giulio V 5e3308eaaa feat(providers): add Claude Code, Gemini CLI, and KiloCLI subprocess providers (#3615)
* feat(providers): add Claude Code, Gemini CLI, and KiloCLI subprocess providers

Adds three new local subprocess-based providers for AI CLI tools.
Each provider spawns the CLI as a child process, communicates via
stdin/stdout pipes, and parses responses into ChatResponse format.

* fix: resolve clippy unnecessary_debug_formatting and rustfmt violations

* fix: resolve remaining clippy unnecessary_debug_formatting in CLI providers

* fix(providers): add AiAgent CLI category for subprocess providers
2026-03-16 21:51:05 -04:00
Chris Hengge ec255ad788 fix(tool): expand cron_add and cron_update parameter schemas (#3671)
The schedule field in cron_add used a bare {"type":"object"} with a
description string encoding a tagged union in pseudo-notation. The patch
field in cron_update was an opaque {"type":"object"} despite CronJobPatch
having nine fully-typed fields. Both gaps cause weaker instruction-following
models to produce malformed or missing nested JSON when invoking these tools.

Changes:
- cron_add: expand schedule into a oneOf discriminated union with explicit
  properties and required fields for each variant (cron/at/every), matching
  the Schedule enum in src/cron/types.rs exactly
- cron_add: add descriptions to all previously undocumented top-level fields
- cron_add: expand delivery from a bare inline comment to fully-specified
  properties with per-field descriptions
- cron_update: expand patch from opaque object to full properties matching
  CronJobPatch (name, enabled, command, prompt, model, session_target,
  delete_after_run, schedule, delivery)
- cron_update: schedule inside patch mirrors the same oneOf expansion
- Both: add inline NOTE comments flagging that oneOf is correct for
  OpenAI-compatible APIs but SchemaCleanr::clean_for_gemini must be
  applied if Gemini native tool calling is ever wired up
- Both: add schema-shape tests using the existing test_config/test_security
  helper pattern, covering oneOf variant structure, required fields, and
  delivery channel enum completeness

No behavior changes. No new dependencies. Backward compatible: the runtime
deserialization path (serde on Schedule/CronJobPatch) is unchanged.

Co-authored-by: Argenis <theonlyhennygod@gmail.com>
2026-03-16 21:45:49 -04:00
Sid Jain 7182f659ce fix(slack): honor mention_only in runtime channel wiring (#3715)
* feat(slack): wire mention_only group reply policy

* feat(slack): expose mention_only in config and wizard defaults
2026-03-16 21:40:47 -04:00
Ericsunsk ae7681209d fix(openai-codex): decode utf-8 safely across stream chunks (#3723) 2026-03-16 21:40:45 -04:00
Markus Bergholz ee3469e912 Fix: Support Nextcloud Talk Activity Streams 2.0 webhook format (#3737)
* fix

* fix

* format
2026-03-16 21:40:42 -04:00
Argenis fec81d8e75 ci: auto-sync Scoop and AUR on stable release (#3743)
Add workflow_call triggers to pub-scoop.yml and pub-aur.yml so the
stable release workflow can invoke them automatically after publish.

Wire scoop and aur jobs into release-stable-manual.yml as post-publish
steps (parallel with tweet), gated on publish success.

Update ci-map.md trigger docs to reflect auto-called behavior.
2026-03-16 21:34:29 -04:00
Ricardo Madriz 9a073fae1a fix(tools) Wire activated toolset into dispatch (#3747)
* fix(tools): wire ActivatedToolSet into tool dispatch and spec advertisement

When deferred MCP tools are activated via tool_search, they are stored
in ActivatedToolSet but never consulted by the tool call loop.
tool_specs is built once before the iteration loop and never refreshed,
so the provider API tools[] parameter never includes activated tools.
find_tool only searches the static registry, so execution dispatch also
fails silently.

Thread Arc<Mutex<ActivatedToolSet>> from creation sites through to
run_tool_call_loop. Rebuild tool_specs each iteration to merge base
registry specs with activated specs. Add fallback in execute_one_tool
to check the activated set when the static registry lookup misses.

Change ActivatedToolSet internal storage from Box<dyn Tool> to
Arc<dyn Tool> so we can clone the Arc out of the mutex guard before
awaiting tool.execute() (std::sync::MutexGuard is not Send).

* fix(tools): add activated_tools field to new ChannelRuntimeContext test site
2026-03-16 21:34:08 -04:00
Chris Hengge f0db63e53c fix(integrations): wire Cron and Browser status to config fields (#3750)
Both entries had hardcoded |_| IntegrationStatus::Available, ignoring
the live config entirely. Users with cron.enabled = true or
browser.enabled = true saw 'Available' on the /integrations dashboard
card instead of 'Active'.

Root cause: status_fn closures did not capture the Config argument.

Fix: replace the |_| stubs with |c| closures that check c.cron.enabled
and c.browser.enabled respectively, matching the pattern used by every
other wired entry in the registry (Telegram, Discord, Shell, etc.).

What did NOT change: ComingSoon entries, always-Active entries (Shell,
File System), platform entries, or any other registry logic.
2026-03-16 21:34:06 -04:00
Argenis df4dfeaf66 chore: bump version to 0.4.3 (#3749)
Update version across Cargo.toml, Cargo.lock, Scoop manifest,
and AUR PKGBUILD/.SRCINFO for the v0.4.3 stable release.
2026-03-16 21:23:04 -04:00
Giulio V e4ef25e913 feat(security): add Merkle hash-chain audit trail (#3601)
* feat(security): add Merkle hash-chain audit trail

Each audit entry now includes a SHA-256 hash linking it to the previous
entry (entry_hash, prev_hash, sequence), forming a tamper-evident chain.
Modifying any entry invalidates all subsequent hashes.

- Chain fields added to AuditEvent with #[serde(default)] for backward compat
- AuditLogger tracks chain state and recovers from existing logs on restart
- verify_chain() validates hash linkage, sequence continuity, and integrity
- Five new tests: genesis seed, multi-entry verify, tamper detection,
  sequence gap detection, and cross-restart chain recovery

* fix(security): replace personal name with neutral label in audit tests
2026-03-16 18:38:59 -04:00
Argenis c3a3cfc9a6 fix(agent): prevent duplicate tool schema injection in XML dispatcher (#3744)
Remove duplicate tool listing from XmlToolDispatcher::prompt_instructions()
since tool listing is already handled by ToolsSection in prompt.rs. The
method now only emits the XML protocol envelope.

Also fix UTF-8 char boundary panics in memory consolidation truncation by
using char_indices() instead of manual byte-boundary scanning.

Fixes #3643
Supersedes #3678

Co-authored-by: TJUEZ <TJUEZ@users.noreply.github.com>
2026-03-16 18:38:44 -04:00
伊姆 013fca6ad2 fix(config): support socks proxy scheme for Clash Verge (#3001)
Co-authored-by: imu <imu@sgcc.com.cn>
2026-03-16 18:38:03 -04:00
linyibin 23a0f25b44 fix(web): ensure web/dist exists in fresh clones (#3114)
The Rust build expects web/dist to exist (static assets). Track an empty
placeholder via web/dist/.gitkeep and adjust ignore rules to keep build
artifacts ignored while allowing the placeholder file.

Made-with: Cursor
2026-03-16 18:37:14 -04:00
Giulio V 2eaa8c45f4 feat(whatsapp-web): add voice message transcription support (#3617)
Adds audio message detection and transcription to WhatsApp Web channel.
Voice messages (PTT) are downloaded, transcribed via the existing
transcription subsystem (Groq Whisper), and delivered as text content.

- TranscriptionConfig field with builder pattern
- Duration limit enforcement before download
- MIME type mapping for audio formats
- Graceful error handling (skip on failure)
- Preserves full retry/reconnect state machine from master
2026-03-16 18:34:50 -04:00
Sandeep Ghael 85bf649432 fix(channel): resolve multi-room reply routing regression (#3224) (#3378)
* fix(channel): resolve multi-room reply routing regression (#3224)

PR #3224 (f0f0f808, "feat(matrix): add multi-room support") changed the
channel name format in matrix.rs from "matrix" to "matrix:!roomId", but
the channel lookup in mod.rs still does an exact match against
channels_by_name, which is keyed by Channel::name() (returns "matrix").

This mismatch causes target_channel to always resolve to None for Matrix
messages, silently dropping all replies.

Fix: fall back to a prefix match on the base channel name (before ':')
when the exact lookup fails. This preserves multi-room conversation
isolation while correctly routing replies to the originating channel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: apply cargo fmt to channel routing fix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Sandeep (Claude) <sghael+claude@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 18:32:56 -04:00
Giulio V 3ea99a7619 feat(tools): add browser delegation tool (#3610)
* feat(tools): add browser delegation tool for corporate web app interaction

Adds BrowserDelegateTool that delegates browser-based tasks to Claude Code
(or other browser-capable CLIs) for interacting with corporate tools
(Teams, Outlook, Jira, Confluence) via browser automation. Includes domain
validation (allow/blocklist), task templates, Chrome profile persistence
for SSO sessions, and timeout management.

* fix: resolve clippy violation in browser delegation tool

* fix(browser-delegate): validate URLs embedded in task text against domain policy

Scan the task text for http(s):// URLs using regex and validate each
against the allow/block domain lists before forwarding to the browser
CLI subprocess. This prevents bypassing domain restrictions by
embedding blocked URLs in the task parameter.

* fix(browser-delegate): constrain URL schemes, gate on runtime, document config

- Add has_shell_access gate so BrowserDelegateTool is only registered on
  shell-capable runtimes (skipped with warning on WASM/edge runtimes)
- Add boundary tests for javascript: and data: URL scheme rejection
- URL scheme validation (http/https only) and config docs were already
  addressed by a prior commit on this branch

* fix(tools): address CodeRabbit review findings for browser delegation

Remove dead `max_concurrent_tasks` config field and expand doc comments
on the `[browser_delegate]` config section in schema.rs.
2026-03-16 18:32:20 -04:00
Christian Pojoni 14f58c77c1 fix(tool+channel): revert invalid model set via model_routing_config (#3497)
When the LLM hallucinates an invalid model ID through the
model_routing_config tool's set_default action, the invalid model gets
persisted to config.toml. The channel hot-reload then picks it up and
every subsequent message fails with a non-retryable 404, permanently
killing the connection with no user recovery path.

Fix with two layers of defense:

1. Tool probe-and-rollback: after saving the new model, send a minimal
   chat request to verify the model is accessible. If the API returns a
   non-retryable error (404, auth failure, etc.), automatically restore
   the previous config and return a failure notice to the LLM.

2. Channel safety net: in maybe_apply_runtime_config_update, reject
   config reloads when warmup fails with a non-retryable error instead
   of applying the broken config anyway.

Co-authored-by: Christian Pojoni <christian.pojoni@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 18:30:36 -04:00
dependabot[bot] b833eb19f5 chore(deps): bump rust in the docker-all group (#3692)
Bumps the docker-all group with 1 update: rust.


Updates `rust` from 1.93-slim to 1.94-slim

---
updated-dependencies:
- dependency-name: rust
  dependency-version: 1.94-slim
  dependency-type: direct:production
  dependency-group: docker-all
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Argenis <theonlyhennygod@gmail.com>
2026-03-16 18:28:51 -04:00
DotViegas 5db883b453 fix(providers): adjust temperature for OpenAI reasoning models (#2936)
Some OpenAI models (o1, o3, o4, gpt-5 variants) only accept temperature=1.0 and return errors with other values like 0.7. This change automatically adjusts the temperature parameter based on the model being used.

Changes:
- Add adjust_temperature_for_model() function to detect reasoning models
- Apply temperature adjustment in chat_with_system(), chat(), and chat_with_tools()
- Preserve user-specified temperature for standard models (gpt-4o, gpt-4-turbo, etc.)
- Force temperature=1.0 for reasoning models (o1, o3, o4, gpt-5, gpt-5-mini, gpt-5-nano, gpt-5.x-chat-latest)

Testing:
- Add 7 unit tests covering reasoning models, standard models, and edge cases
- All tests pass successfully
- Empirical testing documented in docs/openai-temperature-compatibility.md

Impact:
- Fixes temperature errors when using o1, o3, o4, and gpt-5 model families
- No breaking changes - transparent adjustment for end users
- Standard models continue to work with flexible temperature values

Risk: Low - isolated change within OpenAI provider, well-tested

Rollback: Revert this commit to restore previous behavior

Co-authored-by: Argenis <theonlyhennygod@gmail.com>
2026-03-16 18:28:01 -04:00
Eddie's AI Agent 0ae515b6b8 fix(channel): correct Matrix image marker casing to match canonical format (#3519)
Co-authored-by: Eddie Tong <xinhant@gmail.com>
2026-03-16 18:25:33 -04:00
Giulio V 2deb91455d feat(observability): add Hands dashboard metrics and events (#3595)
Add HandStarted, HandCompleted, and HandFailed event variants to
ObserverEvent, and HandRunDuration, HandFindingsCount, HandSuccessRate
metric variants to ObserverMetric. Update all observer backends (log,
noop, verbose, prometheus, otel) to handle the new variants with
appropriate instrumentation. Prometheus backend registers hand_runs
counter, hand_duration histogram, and hand_findings counter. OTel
backend creates spans and records metrics for hand runs.
2026-03-16 18:24:47 -04:00
smallwhite 595b81be41 fix(telegram): avoid duplicate finalize_draft messages (#3259) 2026-03-16 18:24:19 -04:00
Chris Hengge 4f9d817ddb fix(memory): serialize MemoryCategory as plain string and guard dashboard render crashes (#3051)
The /memory dashboard page rendered a black screen when MemoryCategory::Custom
was serialized by serde's derived impl as a tagged object {"custom":"..."} but
the frontend expected a plain string. No navigation was possible without using
the browser Back button.

Changes:
- src/memory/traits.rs: replace derived serde impls with custom serialize
  (delegates to Display, emits plain snake_case string) and deserialize
  (parses known variants by name, falls through to Custom(s) for unknown).
  Adds memory_category_serde_uses_snake_case and memory_category_custom_roundtrip
  tests. No persistent storage migration needed — all backends (SQLite, Markdown,
  Postgres) use their own category_to_str/str_to_category helpers and never
  read serde-serialized category values back from disk.
- web/src/App.tsx: export ErrorBoundary class so render crashes surface a
  recoverable UI instead of a black screen. Adds aria-live="polite" to the
  pairing error paragraph for screen reader accessibility.
- web/src/components/layout/Layout.tsx: wrap Outlet in ErrorBoundary keyed
  by pathname so the navigation shell stays mounted during a page crash and
  the boundary resets on route change.

Co-authored-by: Argenis <theonlyhennygod@gmail.com>
2026-03-16 18:23:03 -04:00
Darren.Zeng d13f5500e9 fix(install): add missing libssl-dev for Debian/Ubuntu (#3285)
The install.sh script was missing libssl-dev package for Debian/Ubuntu
systems. This caused compilation failures on Debian 12 and other
Debian-based distributions when building ZeroClaw from source.

The package is already included for other distributions:
- Alpine: openssl-dev
- Fedora/RHEL: openssl-devel
- Arch: openssl

This change adds libssl-dev to the apt-get install command to ensure
OpenSSL headers are available during compilation.

Fixes #2914

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-16 18:22:10 -04:00
Chris Hengge 1ccfe643ba fix(channel): bypass mention_only gate for Discord DMs (#2983)
When mention_only is enabled, the bot correctly requires an @mention in
guild (server) channels. However, Direct Messages have no guild_id and
are inherently private and addressed to the bot — requiring a @mention
in a DM is never correct and silently drops all DM messages.

Changes:
- src/channels/discord.rs: detect DMs via absence of guild_id in the
  gateway payload, compute effective_mention_only = self.mention_only && !is_dm,
  and pass that to normalize_incoming_content instead of self.mention_only.
  DMs bypass the mention gate; guild messages retain existing behaviour.
- Adds three tests: DM bypasses mention gate, guild message without mention
  is rejected, guild message with mention passes and strips the mention tag.

Co-authored-by: Argenis <theonlyhennygod@gmail.com>
2026-03-16 18:21:27 -04:00
Vadim Rutkovsky d4d3e03e34 fix: add dummy src/lib.rs in Dockerfile.debian for dep caching stage (#3553) 2026-03-16 18:20:43 -04:00
simonfr aa0f11b0a2 fix(docker): copy build.rs into builder stage to invalidate dummy binary cache (#3570)
* Fix build with Docker

* fix(docker): copy build.rs into builder stage to fix rag feature activation
2026-03-16 18:19:55 -04:00
Argenis 806f8b4020 fix(docker): purge stale zeroclawlabs fingerprints before build (#3741)
The BuildKit cache mount persists .fingerprint and .d files across
source tree changes, causing compilation errors when imports change.
Clear zeroclawlabs-specific artifacts before building to ensure
a clean recompile of the main crate while preserving dep caches.
2026-03-16 18:10:36 -04:00
Ericsunsk 83803cef5b fix(memory): filter autosave noise and scope recall/store by session (#3695)
* fix(memory): filter autosave noise and scope memory by session

* style: format rebase-resolved gateway and memory loader

* fix(tests): update memory loader mock for session-aware context

* fix(openai-codex): decode utf-8 safely across stream chunks
2026-03-16 16:36:35 -04:00
Vast-stars dcb182cdd5 fix(agent): remove bare URL → curl fallback in GLM-style tool call parser (#3694)
* fix(agent): remove bare URL → curl fallback in GLM-style tool call parser

The `parse_glm_style_tool_calls` function had a "Plain URL" fallback
that converted any bare URL line (e.g. `https://example.com`) into a
`shell` tool call running `curl -s '<url>'`. This caused:

- False positives: normal URLs in LLM replies misinterpreted as tool calls
- Swallowed replies: text with URLs not forwarded to the channel
- Unintended shell commands: `curl` executed without user intent

Explicit GLM-format tool calls like `browser_open/url>https://...` and
`shell/command>...` are unaffected — only the bare URL catch-all is
removed.

* style: cargo fmt

---------

Co-authored-by: argenis de la rosa <theonlyhennygod@gmail.com>
2026-03-16 16:36:27 -04:00
Argenis 7c36a403b0 chore: sync Scoop and AUR templates to v0.4.1 (#3736) 2026-03-16 16:36:26 -04:00
Argenis 058dbc8786 feat(channels): add X/Twitter and Mochat channel integrations (#3735)
* feat(channels): add X/Twitter and Mochat channel integrations

Add two new channel implementations to close competitive gaps:

- X/Twitter: Twitter API v2 with mentions polling, tweet threading
  (auto-splits at 280 chars), DM support, and rate limit handling
- Mochat: HTTP polling-based integration with Mochat customer service
  platform, configurable poll interval, message dedup

Both channels follow the existing Channel trait pattern with full
config schema integration, health checks, and dedup.

Closes competitive gap: NanoClaw had X/Twitter, Nanobot had Mochat.

* fix(channels): use write! instead of format_push_string for clippy

Replace url.push_str(&format!(...)) with write!(url, ...) to satisfy
clippy::format_push_string lint on CI.

* fix(channels): rename reply_to parameter to avoid legacy field grep

The component test source_does_not_use_legacy_reply_to_field greps
for "reply_to:" in source files. Rename the parameter to
reply_tweet_id to pass this check.
2026-03-16 16:35:21 -04:00
SimianAstronaut7 aff7a19494 Merge pull request #3641 from zeroclaw-labs/work-issues/3011-fix-dashboard-ws-protocols
fix(gateway): pass bearer token in WebSocket subprotocol for dashboard auth
2026-03-16 16:34:46 -04:00
SimianAstronaut7 0894429b54 Merge pull request #3640 from zeroclaw-labs/work-issues/2881-transcription-initial-prompt
feat(config): support initial_prompt in transcription config
2026-03-16 16:34:43 -04:00
SimianAstronaut7 6b03e885fc Merge pull request #3639 from zeroclaw-labs/work-issues/3474-docker-restart-docs
docs(setup): add Docker/Podman stop/restart instructions
2026-03-16 16:34:41 -04:00
Argenis 84470a2dd2 fix(agent): strip vision markers from history for non-vision providers (#3734)
* fix(agent): strip vision markers from history for non-vision providers

When a user sends an image via Telegram to a non-vision provider, the
`[IMAGE:/path]` marker gets stored in the JSONL session file. Previously,
the rollback only removed it from in-memory history, not from the JSONL
file. On restart, the marker was reloaded and permanently broke the
conversation.

Two fixes:
1. `rollback_orphan_user_turn` now also calls `remove_last` on the
   session store so the poisoned entry is removed from disk.
2. When building history for a non-vision provider, `[IMAGE:]` markers
   are stripped from older history messages (and empty turns are dropped).

Fixes #3674

* fix(agent): only strip vision markers from older history, not current message

The initial fix stripped [IMAGE:] markers from all prior_turns including
the current message, which caused the vision check to never fire. Now
only strip from turns before the last one (the current request), so
fresh image sends still get a proper vision capability error.
2026-03-16 16:25:45 -04:00
Argenis 8a890be021 chore: bump version to 0.4.2 (#3733)
Update version across Cargo.toml, Cargo.lock, Scoop manifest,
and AUR PKGBUILD/.SRCINFO for the v0.4.2 stable release.
2026-03-16 16:02:34 -04:00
Argenis 74a5ff78e7 fix(qq): send markdown messages instead of plain text (#3732)
* fix(ci): decouple tweet from Docker push in release workflows

Remove Docker from the tweet job's dependency chain in both beta and
stable release workflows. Docker multi-platform builds are slow and
can be cancelled by concurrency groups, which was blocking the tweet
from ever firing. The tweet announces the GitHub Release, not the
Docker image.

* fix(qq): send markdown messages instead of plain text

Change msg_type from 0 (plain text) to 2 (markdown) and wrap content
in a markdown object per QQ's API documentation. This ensures markdown
formatting (bold, italic, code blocks, etc.) renders properly in QQ
clients instead of displaying raw syntax.

Fixes #3647
2026-03-16 15:56:09 -04:00
Argenis 93b16dece5 Merge pull request #3731 from zeroclaw-labs/fix/tweet-decouple-docker
fix(ci): decouple tweet from Docker push in release workflows
2026-03-16 15:52:05 -04:00
Argenis c773170753 feat(providers): close AiHubMix, SiliconFlow, and Codex OAuth provider gaps (#3730)
Add env var resolution for AiHubMix (AIHUBMIX_API_KEY) and SiliconFlow
(SILICONFLOW_API_KEY) so users can authenticate via environment variables.

Add factory and credential resolution tests for AiHubMix, SiliconFlow,
and Codex OAuth to ensure all provider aliases work correctly.
2026-03-16 15:48:27 -04:00
argenis de la rosa f210b43977 fix(ci): decouple tweet from Docker push in release workflows
Remove Docker from the tweet job's dependency chain in both beta and
stable release workflows. Docker multi-platform builds are slow and
can be cancelled by concurrency groups, which was blocking the tweet
from ever firing. The tweet announces the GitHub Release, not the
Docker image.
2026-03-16 15:32:29 -04:00
Argenis 50bc360bf4 Merge pull request #3729 from zeroclaw-labs/fix/crates-auto-publish-idempotent
fix(ci): make crates.io publish idempotent across all workflows
2026-03-16 15:30:30 -04:00
Argenis fc8ed583a0 feat(providers): add VOLCENGINE_API_KEY env var for VolcEngine/ByteDance gateway (#3725) 2026-03-16 15:29:36 -04:00
argenis de la rosa d593b6b1e4 fix(ci): make crates.io publish idempotent across all workflows
Both publish-crates-auto.yml and publish-crates.yml now treat
"already exists" from cargo publish as success instead of failing
the workflow. This prevents false failures when the auto-sync and
stable release workflows race or when re-running a publish.
2026-03-16 15:11:29 -04:00
Argenis 426faa3923 Merge pull request #3724 from zeroclaw-labs/fix/release-sync-tweet-and-crates
fix(ci): ensure tweet posts for stable releases and fix beta concurrency
2026-03-16 15:07:58 -04:00
argenis de la rosa 85429b3657 fix(ci): ensure tweet posts for stable releases and fix beta concurrency
- Tweet workflow: stable releases always tweet (no feature-check gate);
  beta tweets now also trigger on fix() commits, not just feat()
- Beta release: use cancel-in-progress to avoid queued runs getting
  stuck when rapid merges hit the concurrency group
2026-03-16 14:54:32 -04:00
Argenis 8adf05f307 Merge pull request #3722 from zeroclaw-labs/ci/restore-package-manager-syncs
ci: restore Homebrew and add Scoop/AUR package manager workflows
2026-03-16 14:30:09 -04:00
Argenis a5f844d7cc fix(daemon): ignore SIGHUP to survive terminal/SSH disconnect (#3721)
* fix(daemon): ignore SIGHUP to survive terminal/SSH disconnect (#3688)

* style: remove redundant continue flagged by clippy
2026-03-16 14:21:59 -04:00
Argenis 7a9e815948 fix(config): add serde default for cli field in ChannelsConfig (#3720)
* fix(config): add serde default for cli field in ChannelsConfig (#3710)

* style: fix rustfmt formatting in test
2026-03-16 14:21:51 -04:00
Argenis 46378cf8b4 fix(security): validate command before rate-limiting in cron once (#3699) (#3719) 2026-03-16 14:21:45 -04:00
Argenis c2133e6e62 fix(docker): prevent dummy binary from being shipped in container (#3687) (#3718) 2026-03-16 14:21:37 -04:00
Argenis e9b3148e73 Merge pull request #3717 from zeroclaw-labs/chore/version-bump-0.4.1
chore: bump version to 0.4.1
2026-03-16 14:21:20 -04:00
argenis de la rosa 3d007f6b55 docs: add Scoop and AUR workflows to CI map and release process 2026-03-16 14:17:18 -04:00
argenis de la rosa f349de78ed ci(aur): add AUR PKGBUILD template and publishing workflow
Adds Arch Linux distribution via AUR:
- dist/aur/PKGBUILD: package build template with cargo dist profile
- dist/aur/.SRCINFO: AUR metadata
- .github/workflows/pub-aur.yml: manual workflow to push to AUR
2026-03-16 14:16:30 -04:00
argenis de la rosa cd40051f4c ci(scoop): add Scoop manifest template and publishing workflow
Adds Windows package distribution via Scoop:
- dist/scoop/zeroclaw.json: manifest template with checkver/autoupdate
- .github/workflows/pub-scoop.yml: manual workflow to update Scoop bucket
2026-03-16 14:15:29 -04:00
argenis de la rosa 6e4b1ede28 ci(homebrew): restore Homebrew core formula publishing workflow
Re-adds the manual-dispatch workflow for bumping the zeroclaw formula
in Homebrew/homebrew-core via a bot-owned fork. Improved from the
previously removed version with safer env-var handling.

Requires secrets: HOMEBREW_CORE_BOT_TOKEN or HOMEBREW_UPSTREAM_PR_TOKEN
Requires variables: HOMEBREW_CORE_BOT_FORK_REPO, HOMEBREW_CORE_BOT_EMAIL
2026-03-16 14:12:48 -04:00
argenis de la rosa cfba009833 chore: regenerate Cargo.lock for v0.4.1 2026-03-16 13:49:39 -04:00
argenis de la rosa 45abd27e4a chore: bump version to 0.4.1
Reflects the addition of heartbeat metrics, SQLite session backend,
and two-tier response cache in this release cycle.
2026-03-16 13:49:36 -04:00
Argenis 566e3cf35b Merge pull request #3712 from zeroclaw-labs/feat/competitive-edge-heartbeat-sessions-caching
feat: competitive edge — heartbeat metrics, SQLite sessions, two-tier prompt cache
2026-03-16 13:47:42 -04:00
Argenis d642b0f3c8 fix(ci): decouple tweet from crates.io and fix duplicate publish handling (#3716)
Drop crates-io from the tweet job's needs so the release announcement
goes out with GitHub release, Docker, and website — not blocked by
crates.io publish timing.

Also fix the duplicate detection: the previous curl-based check used
a URL that didn't match the actual crate, causing cargo publish to
hit "already exists" and fail the whole job. Now we just run cargo
publish and treat "already exists" as success.
2026-03-16 13:46:05 -04:00
Argenis 8153992a11 fix(ci): scope rust-cache by OS image in stable release workflow (#3711)
Same glibc cache mismatch fix as the beta workflow — ubuntu-22.04 builds
must not share cached build-script binaries with ubuntu-latest (24.04).
2026-03-16 12:56:30 -04:00
argenis de la rosa 98688c61ff feat(cache): wire two-tier response cache, multi-provider token tracking, and cache analytics
- Two-tier response cache: in-memory LRU (hot) + SQLite (warm) with TTL-aware eviction
- Wire response cache into agent turn loop (temp==0.0, text-only responses only)
- Parse Anthropic cache_creation_input_tokens/cache_read_input_tokens
- Parse OpenAI prompt_tokens_details.cached_tokens
- Add cached_input_tokens to TokenUsage, prompt_caching to ProviderCapabilities
- Add CacheHit/CacheMiss observer events with Prometheus counters
- Add response_cache_hot_entries config field (default: 256)
2026-03-16 12:44:48 -04:00
argenis de la rosa 9ba5ba5632 feat(sessions): add SQLite backend with FTS5, trait abstraction, and migration
- Add SessionBackend trait abstracting over storage backends (load,
  append, remove_last, list, search, cleanup_stale, compact)
- Add SqliteSessionBackend with WAL mode, FTS5 full-text search,
  session metadata tracking, and TTL-based cleanup
- Add remove_last() and compact() to JSONL SessionStore
- Implement SessionBackend for both JSONL and SQLite backends
- Add automatic JSONL-to-SQLite migration (renames .jsonl → .jsonl.migrated)
- Add config: session_backend ("jsonl"/"sqlite"), session_ttl_hours
- SQLite is the new default backend; JSONL preserved for backward compat
2026-03-16 12:23:18 -04:00
argenis de la rosa 318ed8e9f1 feat(heartbeat): add health metrics, adaptive intervals, and task history
- Add HeartbeatMetrics struct with uptime, consecutive success/failure
  counts, EMA tick duration, and total ticks
- Add compute_adaptive_interval() for exponential backoff on failures
  and faster polling when high-priority tasks are present
- Add SQLite-backed task run history (src/heartbeat/store.rs) mirroring
  the cron/store.rs pattern with output truncation and pruning
- Add dead-man's switch that alerts if heartbeat stops ticking
- Wire metrics, history recording, and adaptive sleep into daemon worker
- Add config fields: adaptive, min/max_interval_minutes,
  deadman_timeout_minutes, deadman_channel, deadman_to, max_run_history
- All new fields are backward-compatible with serde defaults
2026-03-16 12:08:32 -04:00
Argenis 5ddea82789 fix(ci): scope rust-cache by OS image and target in release beta workflow (#3708)
The aarch64-unknown-linux-gnu build runs on ubuntu-22.04 (glibc 2.35)
but was restoring cached build-script binaries compiled on ubuntu-latest
(24.04, glibc 2.39), causing GLIBC_2.39 not found failures.

Adding prefix-key with the OS image and target ensures each matrix entry
gets its own isolated cache, preventing cross-image glibc mismatches.
2026-03-16 11:37:14 -04:00
Argenis a2b18bdb16 chore: gitignore stale target-* build cache dirs (#3707)
Adds /target-*/ to .gitignore to prevent alternative Cargo build
directories (e.g. target-ms365, target-pr) from being tracked.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:21:04 -04:00
Argenis 85271441b5 docs: remove duplicate Vietnamese docs and orphan Greek locale (#3701)
- Remove docs/i18n/vi/ — duplicate of canonical docs/vi/ per docs-contract
- Remove docs/i18n/el/ — orphan Greek locale, not in supported locales list
- Update docs/i18n/README.md to reflect correct canonical paths

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:41:43 -04:00
Argenis 813ae17f48 fix(install): correct version display, show pairing code, bump to 0.4.0 (#3669)
* style: cargo fmt Box::pin calls in cron scheduler

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(install): correct version display, show pairing code, bump to 0.4.0

- Reorder ZEROCLAW_BIN resolution to prefer ~/.cargo/bin over PATH,
  preventing stale binaries from shadowing the fresh install
- Sync binary to ~/.local/bin after cargo install
- Fetch and display gateway pairing code via `gateway get-paircode`
  after service restart instead of referencing non-existent output
- Update fallback message to actionable CLI command
- Bump Cargo.toml version from 0.3.4 to 0.4.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: update Cargo.lock for 0.4.0 version bump

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 09:58:31 -04:00
Argenis 794d87d95c fix(config): add missing #[serde(default)] to Config struct fields (#3700)
Several recently-added Config fields (data_retention, cloud_ops,
conversational_ai, security_ops) were missing #[serde(default)],
causing deserialization failures when those sections are absent
from config files. Also fixes security field whose #[serde(default)]
was accidentally consumed by the backup doc comment.

Fixes test failures: agent_config_deserializes and
browser_config_backward_compat_missing_section.
2026-03-16 09:21:41 -04:00
Argenis d6e5907b65 feat(tools): add cloud transformation accelerator tools (#3663)
* feat(tools): add cloud transformation accelerator tools

Add cloud_ops and cloud_patterns tools providing read-only cloud
transformation analysis: IaC review, migration assessment, cost
analysis, and Well-Architected Framework architecture review.

Includes CloudOpsConfig, SecurityOpsConfig, and ConversationalAiConfig
schema additions, Box::pin fixes for recursive async in cron scheduler,
and approval_manager field in ChannelRuntimeContext test constructors.

Original work by @rareba. Rebased on latest master with conflict
resolution (kept SwarmConfig/SwarmStrategy exports, swarm tool
registration, and approval_manager in test constructors).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: cargo fmt Box::pin calls

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 02:43:55 -04:00
Argenis 861dd3e2e9 feat(tools): add backup/restore and data management tools (#3662)
Add BackupTool for creating, listing, verifying, and restoring
timestamped workspace backups with SHA-256 manifest integrity
checking. Add DataManagementTool for retention status, time-based
purge, and storage statistics. Both tools are config-driven via
new BackupConfig and DataRetentionConfig sections.

Original work by @rareba. Rebased on latest master with conflict
resolution for SwarmConfig/SwarmStrategy exports and swarm tool
registration, and added missing approval_manager fields in
ChannelRuntimeContext test constructors.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 02:35:44 -04:00
Argenis 8a61a283b2 feat(security): add MCSS security operations tool (#3657)
* feat(security): add MCSS security operations tool

Add managed cybersecurity service (MCSS) tool with alert triage,
incident response playbook execution, vulnerability scan parsing,
and security report generation. Includes SecurityOpsConfig, playbook
engine with approval gating, vulnerability scoring, and full test
coverage. Also fixes pre-existing missing approval_manager field in
ChannelRuntimeContext test constructors.

Original work by @rareba. Supersedes #3599.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add SecurityOpsConfig to re-exports, fix test constructors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 02:28:54 -04:00
Argenis dcc0a629ec feat(tools): add project delivery intelligence tool (#3656)
Add a new read-only project_intel tool that provides:
- Status report generation (weekly/sprint/month)
- Risk scanning with configurable sensitivity
- Client update drafting (formal/casual, client/internal)
- Sprint summary generation
- Heuristic effort estimation

Includes multi-language report templates (EN, DE, FR, IT),
ProjectIntelConfig schema with validation, and comprehensive tests.

Also fixes missing approval_manager field in 4 ChannelRuntimeContext
test constructors.

Supersedes #3591 — rebased on latest master. Original work by @rareba.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 02:10:14 -04:00
Argenis a8c6363cde feat(nodes): add secure HMAC-SHA256 node transport layer (#3654)
Add a new `nodes` module with HMAC-SHA256 authenticated transport for
secure inter-node communication over standard HTTPS. Includes replay
protection via timestamped nonces and constant-time signature
comparison.

Also adds `NodeTransportConfig` to the config schema and fixes missing
`approval_manager` field in four `ChannelRuntimeContext` test
constructors that failed compilation on latest master.

Original work by @rareba. Rebased on latest master to resolve merge
conflicts (SwarmConfig/SwarmStrategy exports, duplicate MCP validation,
test constructor fields).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 01:53:47 -04:00
Argenis d9ab017df0 feat(tools): add Microsoft 365 integration via Graph API (#3653)
Add Microsoft 365 tool providing access to Outlook mail, Teams messages,
Calendar events, OneDrive files, and SharePoint search via Microsoft
Graph API. Includes OAuth2 token caching (client credentials and device
code flows), security policy enforcement, and config validation.

Rebased on latest master, resolving conflicts with SwarmConfig exports
and adding approval_manager to ChannelRuntimeContext test constructors.

Original work by @rareba.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 01:44:39 -04:00
Argenis 249434edb2 feat(notion): add Notion database poller channel and API tool (#3650)
Add Notion integration with two components:
- NotionChannel: polls a Notion database for tasks with configurable
  status properties, concurrency limits, and stale task recovery
- NotionTool: provides CRUD operations (query_database, read_page,
  create_page, update_page) for agent-driven Notion interactions

Includes config schema (NotionConfig), onboarding wizard support,
and full unit test coverage for both channel and tool.

Supersedes #3609 — rebased on latest master to resolve merge conflicts
with swarm feature additions in config/mod.rs.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 00:55:23 -04:00
Argenis 62781a8d45 fix(lint): Box::pin crate::agent::run calls to satisfy large_futures (#3675)
Wrap all crate::agent::run() calls with Box::pin() across scheduler,
daemon, gateway tests, and main.rs to satisfy clippy::large_futures.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 00:54:27 -04:00
Chris Hengge 0adec305f9 fix(tools): qualify is_service_environment with super:: inside mod native_backend (#3659)
Commit 811fab3b added is_service_environment() as a top-level function and
called it from two sites. The call at line 445 is at module scope and resolves
fine. The call at line 1473 is inside mod native_backend, which is a child
module — Rust does not implicitly import parent-scope items, so the unqualified
name fails with E0425 (cannot find function in this scope).

Fix: prefix the call with super:: so it resolves to the parent module's
function, matching how mod native_backend already imports other parent items
(e.g. use super::BrowserAction).

The browser-native feature flag is required to reproduce:
  cargo check --features browser-native  # fails without this fix
  cargo check --features browser-native  # clean with this fix

Co-authored-by: Argenis <theonlyhennygod@gmail.com>
2026-03-16 00:35:09 -04:00
Argenis 75701195d7 feat(security): add Nevis IAM integration for SSO/MFA authentication (#3651)
* feat(security): add Nevis IAM integration for SSO/MFA authentication

Add NevisAuthProvider supporting OAuth2/OIDC token validation (local JWKS +
remote introspection), FIDO2/passkey/OTP MFA verification, session management,
and health checks. Add IamPolicy engine mapping Nevis roles to ZeroClaw tool
and workspace permissions with deny-by-default enforcement and audit logging.

Add NevisConfig and NevisRoleMappingConfig to config schema with client_secret
wired through SecretStore encrypt/decrypt. All features disabled by default.

Rebased on latest master to resolve merge conflicts in security/mod.rs (redact
function) and config/schema.rs (test section).

Original work by @rareba. Supersedes #3593.

Co-Authored-By: rareba <5985289+rareba@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: cargo fmt Box::pin calls

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: rareba <5985289+rareba@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 00:34:52 -04:00
Argenis 82fe2e53fd feat(tunnel): add OpenVPN tunnel provider (#3648)
* feat(tunnel): add OpenVPN tunnel provider

Add OpenVPN as a new tunnel provider alongside cloudflare, tailscale,
ngrok, and custom. Includes config schema, validation, factory wiring,
and comprehensive unit tests.

Co-authored-by: rareba <rareba@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add missing approval_manager field to ChannelRuntimeContext constructors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: rareba <rareba@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 00:34:34 -04:00
Argenis 327e2b4c47 style: cargo fmt Box::pin calls in cron scheduler (#3667)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:34:26 -04:00
Argenis 5a5d9ae5f9 fix(lint): Box::pin large futures in cron scheduler and cron_run tool (#3666)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:25:28 -04:00
Argenis d8f228bd15 Merge pull request #3661 from zeroclaw-labs/work/multi-client-workspaces
feat(workspace): add multi-client workspace isolation
2026-03-15 23:10:01 -04:00
Argenis d5eeaed3d9 Merge pull request #3649 from zeroclaw-labs/work/capability-tool-access
feat(security): add capability-based tool access control
2026-03-15 23:09:51 -04:00
Argenis 429094b049 Merge pull request #3660 from zeroclaw-labs/fix/cron-large-future
fix: add Box::pin for large future and missing approval_manager in tests
2026-03-15 23:08:47 -04:00
argenis de la rosa 80213b08ef feat(workspace): add multi-client workspace isolation
Add workspace profile management, security boundary enforcement, and
a workspace management tool for isolated client engagements.

Original work by @rareba. Supersedes #3597 — rebased on latest master.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 22:41:18 -04:00
argenis de la rosa bf67124499 fix: add Box::pin for large future and missing approval_manager in tests
- Box::pin the cron_run execute_job_now call to satisfy clippy::large_futures
- Add missing approval_manager field to 4 query_classification test constructors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 22:32:06 -04:00
argenis de la rosa cb250dfecf fix: add missing approval_manager field to ChannelRuntimeContext test constructors
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 20:03:59 -04:00
argenis de la rosa fabd35c4ea feat(security): add capability-based tool access control
Add an optional `allowed_tools` parameter that restricts which tools are
available to the agent. When `Some(list)`, only tools whose name appears
in the list are retained; when `None`, all tools remain available
(backward compatible). This enables fine-grained capability control for
cron jobs, heartbeat tasks, and CLI invocations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 19:34:34 -04:00
Argenis a695ca4b9c fix(onboard): auto-detect TTY instead of --interactive flag (#3573)
Remove the --interactive flag from `zeroclaw onboard`. The command now
auto-detects whether stdin/stdout are a TTY: if yes and no provider
flags are given, it launches the full interactive wizard; otherwise it
runs the quick (scriptable) setup path.

This means all three install methods work with a single flow:
  curl -fsSL https://zeroclawlabs.ai/install.sh | bash
  cargo install zeroclawlabs && zeroclaw onboard
  docker run … zeroclaw onboard --api-key …
2026-03-15 19:25:55 -04:00
Argenis 811fab3b87 fix(service): headless browser works in service mode (systemd/OpenRC) (#3645)
When zeroclaw runs as a service, the process inherits a minimal
environment without HOME, DISPLAY, or user namespaces. Headless
browsers (Chromium/Firefox) need HOME for profile/cache dirs and
fail with sandbox errors without user namespaces.

- Detect service environment via INVOCATION_ID, JOURNAL_STREAM,
  or missing HOME on Linux
- Auto-apply --no-sandbox and --disable-dev-shm-usage for Chrome
  in service mode
- Set HOME fallback and CHROMIUM_FLAGS on agent-browser commands
- systemd unit: add Environment=HOME=%h and PassEnvironment
- OpenRC script: export HOME=/var/lib/zeroclaw with start_pre()
  to create the directory

Closes #3584
2026-03-15 19:16:36 -04:00
Argenis 1a5d91fe69 fix(channels): wire query_classification config into channel message processing (#3619)
The QueryClassificationConfig was parsed from config but never applied
during channel message processing. This adds the query_classification
field to ChannelRuntimeContext and invokes the classifier in
process_channel_message to override the route when a classification
rule matches a model_routes hint.

Closes #3579
2026-03-15 19:16:32 -04:00
Argenis 6eec1c81b9 fix(ci): use ubuntu-22.04 for Linux release builds (#3573)
Build against glibc 2.35 to ensure binary compatibility with Ubuntu 22.04+.
2026-03-15 18:57:30 -04:00
Argenis 602db8bca1 fix: exclude name field from Mistral tool_calls (#3572)
* fix: exclude name field from Mistral tool_calls (#3572)

Add skip_serializing_if to the compatibility fields (name, arguments,
parameters) on the ToolCall struct so they are omitted from the JSON
payload when None. Mistral's API returns 422 "Extra inputs are not
permitted" when these extra null fields are present in tool_calls.

* fix: format serde attribute for CI lint compliance
2026-03-15 18:38:41 -04:00
simianastronaut 2539bcafe0 fix(gateway): pass bearer token in WebSocket subprotocol for dashboard auth
The dashboard WebSocket client was only sending ['zeroclaw.v1'] as the
protocols parameter, omitting the bearer token subprotocol. When
require_pairing = true, the server extracts the token from
Sec-WebSocket-Protocol as a fallback (browsers cannot set custom
headers on WebSocket connections). Without the bearer.<token> entry
in the protocols array, subprotocol-based authentication always failed.

Include `bearer.<token>` in the protocols array when a token is
available, matching the server's extract_ws_token() expectation.

Closes #3011

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 16:37:41 -04:00
simianastronaut 37d76f7c42 feat(config): support initial_prompt in transcription config for proper noun recognition
Add `initial_prompt: Option<String>` to `TranscriptionConfig` and pass
it as the `prompt` field in the Whisper API multipart POST when present.
This lets users bias transcription toward expected vocabulary (proper
nouns, technical terms) via the config file.

Closes #2881

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 16:31:55 -04:00
simianastronaut 41b46f23e3 docs(setup): add Docker/Podman stop/restart instructions
Users who installed via `./install.sh --docker` had no documented way to
restart the container after stopping it. Add clear lifecycle instructions
(stop, start, restart, logs, health check) to both the bootstrap guide and
the operations runbook, covering docker-compose, manual `docker run`, and
Podman-specific flags.

Closes #3474

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 16:26:28 -04:00
SimianAstronaut7 314e1d3ae8 Merge pull request #3638 from zeroclaw-labs/work-issues/3487-channel-approval-manager
fix(security): enforce approval policy for channel-driven runs
2026-03-15 16:11:14 -04:00
SimianAstronaut7 82be05b1e9 Merge pull request #3636 from zeroclaw-labs/work-issues/3628-surface-tool-failures-in-chat
feat(agent): surface tool call failure reasons in chat
2026-03-15 16:07:38 -04:00
SimianAstronaut7 1373659058 Merge pull request #3634 from zeroclaw-labs/work-issues/3477-fix-matrix-channel-key
fix(channel): use plain "matrix" channel key for consistent outbound routing
2026-03-15 16:07:36 -04:00
Argenis c7f064e866 fix(channels): surface visible warning when whatsapp-web feature is missing (#3629)
The WhatsApp Web QR code was not shown during onboarding channel launch
because the wizard allowed configuring WhatsApp Web mode even when the
binary was built without the `whatsapp-web` feature flag. At runtime,
the channel was silently skipped with only a tracing::warn that most
users never see.

- Add compile-time warning in the onboarding wizard when WhatsApp Web
  mode is selected but the feature is not compiled in
- Add eprintln! in collect_configured_channels so users see a visible
  terminal warning when the feature is missing at startup

Closes #3577
2026-03-15 16:07:13 -04:00
Giulio V 9c1d63e109 feat(hands): add autonomous knowledge-accumulating agent packages (#3603)
Introduce the Hands system — autonomous agent packages that run on
schedules and accumulate knowledge over time. Each Hand maintains a
rolling context of findings across runs so the agent grows smarter
with every execution.

This PR adds:
- Hand definition type (TOML-deserializable, reuses cron Schedule)
- HandRun / HandRunStatus for execution records
- HandContext for rolling cross-run knowledge accumulation
- File-based persistence (load/save context as JSON)
- Directory-based Hand loading from ~/.zeroclaw/hands/*.toml
- 20 unit tests covering deserialization, persistence roundtrip,
  history capping, fact deduplication, and error handling

Execution integration with the agent loop is deferred to a follow-up.
2026-03-15 16:06:14 -04:00
Argenis 966edf1553 Merge pull request #3635 from zeroclaw-labs/chore/bump-v0.3.4
chore: bump version to v0.3.4
2026-03-15 15:59:51 -04:00
simianastronaut a1af84d992 fix(security): enforce approval policy for channel-driven runs
Channel-driven runs (Telegram, Matrix, Discord, etc.) previously bypassed
the ApprovalManager entirely — `None` was passed into the tool-call loop,
so `auto_approve`, `always_ask`, and supervised approval checks were
silently skipped for all non-CLI execution paths.

Add a non-interactive mode to ApprovalManager that enforces the same
autonomy config policies but auto-denies tools requiring interactive
approval (since no operator is present on channel runs). Specifically:

- Add `ApprovalManager::for_non_interactive()` constructor that creates
  a manager which auto-denies tools needing approval instead of prompting
- Add `is_non_interactive()` method so the tool-call loop can distinguish
  interactive (CLI prompt) from non-interactive (auto-deny) managers
- Update tool-call loop: non-interactive managers auto-deny instead of
  the previous auto-approve behavior for non-CLI channels
- Wire the non-interactive approval manager into ChannelRuntimeContext
  so channel runs enforce the full approval policy
- Add 8 tests covering non-interactive approval behavior

Security implications:
- `always_ask` tools are now denied on channels (previously bypassed)
- Supervised-mode unknown tools are now denied on channels (previously
  bypassed)
- `auto_approve` tools continue to work on channels unchanged
- `full` autonomy mode is unaffected (no approval needed regardless)
- `read_only` mode is unaffected (blocks execution elsewhere)

Closes #3487

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 15:56:57 -04:00
simianastronaut 0ad1965081 feat(agent): surface tool call failure reasons in chat progress messages
When a tool call fails (security policy block, hook cancellation, user
denial, or execution error), the failure reason is now included in the
progress message sent to the chat channel via on_delta. Previously only
a  icon was shown; now users see the actual reason (e.g. "Command not
allowed by security policy") without needing to check `zeroclaw doctor
traces`.

Closes #3628

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 15:49:27 -04:00
argenis de la rosa 70e8e7ebcd chore: bump version to v0.3.4
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 15:44:59 -04:00
Alix-007 2bcb82c5b3 fix(python): point docs URL at master branch (#3334)
Co-authored-by: Alix-007 <Alix-007@users.noreply.github.com>
2026-03-15 15:43:35 -04:00
simianastronaut e211b5c3e3 fix(channel): use plain "matrix" channel key for consistent outbound routing
The Matrix channel listener was building channel keys as `matrix:<room_id>`,
but the runtime channel mapping expects the plain key `matrix`. This mismatch
caused replies to silently drop in deployments using the Matrix channel.

Closes #3477

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 15:42:43 -04:00
Argenis 8691476577 Merge pull request #3624 from zeroclaw-labs/feat/multi-swarm-and-bugfixes
feat(swarm): multi-agent swarm orchestration + bug fixes (#3572, #3573)
2026-03-15 15:42:03 -04:00
SimianAstronaut7 e34a804255 Merge pull request #3632 from zeroclaw-labs/work-issues/3544-fix-codex-sse-buffering
fix(provider): use incremental SSE stream reading for openai-codex responses
2026-03-15 15:34:39 -04:00
SimianAstronaut7 6120b3f705 Merge pull request #3630 from zeroclaw-labs/work-issues/3567-allow-commands-bypass-high-risk
fix(security): let explicit allowed_commands bypass high-risk block
2026-03-15 15:34:37 -04:00
SimianAstronaut7 f175261e32 Merge pull request #3631 from zeroclaw-labs/work-issues/3486-fix-matrix-image-marker
fix(channels): use canonical IMAGE marker in Matrix channel
2026-03-15 15:34:31 -04:00
simianastronaut fd9f66cad7 fix(provider): use incremental SSE stream reading for openai-codex responses
Replace full-body buffering (`response.text().await`) in
`decode_responses_body()` with incremental `bytes_stream()` chunk
processing.  The previous approach held the HTTP connection open until
every byte had arrived; on high-latency links the long-lived connection
would frequently drop mid-read, producing the "error decoding response
body" failure on the first attempt (succeeding only after retry).

Reading chunks incrementally lets each network segment complete within
its own timeout window, eliminating the systematic first-attempt failure.

Closes #3544

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 15:22:55 -04:00
simianastronaut d928ebc92e fix(channels): use canonical IMAGE marker in Matrix channel
Matrix image messages used lowercase `[image: ...]` format instead of
the canonical `[IMAGE:...]` marker used by all other channels (Telegram,
Slack, Discord, QQ, LinQ). This caused Matrix image attachments to
bypass the multimodal vision pipeline which looks for `[IMAGE:...]`.

Closes #3486

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 15:14:53 -04:00
simianastronaut 9fca9f478a fix(security): let explicit allowed_commands bypass high-risk block
When `block_high_risk_commands = true`, commands like `curl` and `wget`
were unconditionally blocked even if explicitly listed in
`allowed_commands`. This made it impossible to use legitimate API calls
in full autonomy mode.

Now, if a command is explicitly named in `allowed_commands` (not via
the wildcard `*`), it is exempt from the `block_high_risk_commands`
gate. The wildcard entry intentionally does NOT grant this exemption,
preserving the safety net for broad allowlists.

Other security gates (supervised-mode approval, rate limiting, path
policy, argument validation) remain fully enforced.

Closes #3567

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 15:13:32 -04:00
SimianAstronaut7 7106632b51 Merge pull request #3627 from zeroclaw-labs/work-issues/3533-fix-utf8-slice-panic
fix(agent): use char-boundary-safe slicing to prevent CJK text panic
2026-03-15 14:36:49 -04:00
SimianAstronaut7 b834278754 Merge pull request #3626 from zeroclaw-labs/work-issues/3563-fix-cron-add-nl-security
fix(cron): add --agent flag so natural language prompts bypass shell security
2026-03-15 14:36:46 -04:00
SimianAstronaut7 186f6d9797 Merge pull request #3625 from zeroclaw-labs/work-issues/3568-http-request-private-hosts
feat(tool): add allow_private_hosts option to http_request tool
2026-03-15 14:36:44 -04:00
simianastronaut 6cdc92a256 fix(agent): use char-boundary-safe slicing to prevent CJK text panic
Replace unsafe byte-index string slicing (`&text[..N]`) with
char-boundary-safe alternatives in memory consolidation and security
redaction to prevent panics when multi-byte UTF-8 characters (e.g.
Chinese/Japanese/Korean) span the slice boundary.

Fixes the same class of bug as the prior fix in `execute_one_tool`
(commit 8fcbb6eb), applied to two remaining instances:
- `src/memory/consolidation.rs`: truncation at byte 4000 and 200
- `src/security/mod.rs`: `redact()` prefix at byte 4

Adds regression tests with CJK input for both locations.

Closes #3533

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:27:09 -04:00
simianastronaut 02599dcd3c fix(cron): add --agent flag to CLI cron commands to bypass shell security validation
The CLI `cron add` command always routed the second positional argument
through shell security policy validation, which blocked natural language
prompts like "Check server health: disk space, memory, CPU load". This
adds an `--agent` flag to `cron add`, `cron add-at`, `cron add-every`,
and `cron once` so that natural language prompts are correctly stored as
agent jobs without shell command validation.

Closes #3563

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:26:45 -04:00
simianastronaut fe64d7ef7e feat(tool): add allow_private_hosts option to http_request tool (#3568)
The http_request tool unconditionally blocked all private/LAN hosts with
no opt-out, preventing legitimate use cases like calling a local Home
Assistant instance or internal APIs. This adds an `allow_private_hosts`
config flag (default: false) under `[http_request]` that, when set to
true, skips the private-host SSRF check while still enforcing the domain
allowlist.

Closes #3568

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:23:54 -04:00
argenis de la rosa 996dbe95cf feat(swarm): multi-agent swarm orchestration, Mistral tool fix, restore --interactive
- Add SwarmTool with sequential (pipeline), parallel (fan-out/fan-in),
  and router (LLM-selected) strategies for multi-agent workflows
- Add SwarmConfig and SwarmStrategy to config schema
- Fix Mistral 422 error by adding skip_serializing_if to ToolCall
  compat fields (name, arguments, parameters, kind) — Fixes #3572
- Restore `zeroclaw onboard --interactive` flag with run_wizard
  routing and mutual-exclusion validation — Fixes #3573
- 20 new swarm tests, 2 serialization tests, 1 CLI test, config tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 14:23:20 -04:00
Argenis 45f953be6d Merge pull request #3578 from zeroclaw-labs/chore/bump-v0.3.3
chore: bump version to v0.3.3
2026-03-15 09:53:13 -04:00
argenis de la rosa 82f29bbcb1 chore: bump version to v0.3.3
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 09:41:22 -04:00
Argenis 93b5a0b824 feat(context): token-based compaction, persistent sessions, and LLM consolidation (#3574)
Comprehensive long-running context upgrades:

- Token-based compaction: replace message-count trigger with token
  estimation (~4 chars/token). Compaction fires when estimated tokens
  exceed max_context_tokens (default 32K) OR message count exceeds
  max_history_messages. Cuts at user-turn boundaries only.

- Persistent sessions: JSONL append-only session files per channel
  sender in {workspace}/sessions/. Sessions survive daemon restarts.
  Hydrates in-memory history from disk on startup.

- LLM-driven memory consolidation: two-phase extraction after each
  conversation turn. Phase 1 writes a timestamped history entry (Daily).
  Phase 2 extracts new facts/preferences to Core memory (if any).
  Replaces raw message auto-save with semantic extraction.

- New config fields: agent.max_context_tokens (32000),
  channels_config.session_persistence (true).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 09:25:23 -04:00
Argenis 08a67c4a2d chore: bump version to v0.3.2 (#3564)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 06:47:37 -04:00
Argenis c86a0673ba feat(heartbeat): two-phase execution, structured tasks, and auto-routing (#3562)
Upgrade heartbeat system with 4 key improvements:

- Two-phase heartbeat: Phase 1 asks LLM "skip or run?" to save API cost
  on quiet periods. Phase 2 executes only selected tasks.
- Structured task format: `- [priority|status] task text` with
  high/medium/low priority and active/paused/completed status.
- Decision intelligence: LLM-driven smart filtering via structured prompt
  at temperature 0.0 for deterministic decisions.
- Delivery routing: auto-detect best configured channel when no explicit
  target is set (telegram > discord > slack > mattermost).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 06:11:59 -04:00
Argenis cabf99ba07 Merge pull request #3539 from zeroclaw-labs/cleanup
chore: add .wrangler/ to gitignore
2026-03-14 22:53:12 -04:00
argenis de la rosa 2d978a6b64 chore: add .wrangler/ to gitignore and clean up stale files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 22:41:17 -04:00
Argenis 4dbc9266c1 Merge pull request #3536 from zeroclaw-labs/test/termux-release-validation
test: add Termux release validation script
2026-03-14 22:21:21 -04:00
argenis de la rosa ea0b3c8c8c test: add Termux release validation script
Validates the aarch64-linux-android release artifact: download, archive
integrity, ELF format, architecture, checksum, and install.sh detection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 22:10:33 -04:00
218 changed files with 35721 additions and 7626 deletions
@@ -74,4 +74,4 @@ jobs:
if [ -n "${{ matrix.linker_env || '' }}" ] && [ -n "${{ matrix.linker || '' }}" ]; then
export "${{ matrix.linker_env }}=${{ matrix.linker }}"
fi
cargo build --release --locked --target ${{ matrix.target }}
cargo build --release --locked --features channel-matrix --target ${{ matrix.target }}
+169
View File
@@ -0,0 +1,169 @@
name: Pub AUR Package
on:
workflow_call:
inputs:
release_tag:
description: "Existing release tag (vX.Y.Z)"
required: true
type: string
dry_run:
description: "Generate PKGBUILD only (no push)"
required: false
default: false
type: boolean
secrets:
AUR_SSH_KEY:
required: false
workflow_dispatch:
inputs:
release_tag:
description: "Existing release tag (vX.Y.Z)"
required: true
type: string
dry_run:
description: "Generate PKGBUILD only (no push)"
required: false
default: true
type: boolean
concurrency:
group: aur-publish-${{ github.run_id }}
cancel-in-progress: false
permissions:
contents: read
jobs:
publish-aur:
name: Update AUR Package
runs-on: ubuntu-latest
env:
RELEASE_TAG: ${{ inputs.release_tag }}
DRY_RUN: ${{ inputs.dry_run }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate and compute metadata
id: meta
shell: bash
run: |
set -euo pipefail
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::release_tag must be vX.Y.Z format."
exit 1
fi
version="${RELEASE_TAG#v}"
tarball_url="https://github.com/${GITHUB_REPOSITORY}/archive/refs/tags/${RELEASE_TAG}.tar.gz"
tarball_sha="$(curl -fsSL "$tarball_url" | sha256sum | awk '{print $1}')"
if [[ -z "$tarball_sha" ]]; then
echo "::error::Could not compute SHA256 for source tarball."
exit 1
fi
{
echo "version=$version"
echo "tarball_url=$tarball_url"
echo "tarball_sha=$tarball_sha"
} >> "$GITHUB_OUTPUT"
{
echo "### AUR Package Metadata"
echo "- version: \`${version}\`"
echo "- tarball_url: \`${tarball_url}\`"
echo "- tarball_sha: \`${tarball_sha}\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Generate PKGBUILD
id: pkgbuild
shell: bash
env:
VERSION: ${{ steps.meta.outputs.version }}
TARBALL_SHA: ${{ steps.meta.outputs.tarball_sha }}
run: |
set -euo pipefail
pkgbuild_file="$(mktemp)"
sed -e "s/^pkgver=.*/pkgver=${VERSION}/" \
-e "s/^sha256sums=.*/sha256sums=('${TARBALL_SHA}')/" \
dist/aur/PKGBUILD > "$pkgbuild_file"
echo "pkgbuild_file=$pkgbuild_file" >> "$GITHUB_OUTPUT"
echo "### Generated PKGBUILD" >> "$GITHUB_STEP_SUMMARY"
echo '```bash' >> "$GITHUB_STEP_SUMMARY"
cat "$pkgbuild_file" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
- name: Generate .SRCINFO
id: srcinfo
shell: bash
env:
VERSION: ${{ steps.meta.outputs.version }}
TARBALL_SHA: ${{ steps.meta.outputs.tarball_sha }}
run: |
set -euo pipefail
srcinfo_file="$(mktemp)"
sed -e "s/pkgver = .*/pkgver = ${VERSION}/" \
-e "s/sha256sums = .*/sha256sums = ${TARBALL_SHA}/" \
-e "s|zeroclaw-[0-9.]*.tar.gz|zeroclaw-${VERSION}.tar.gz|g" \
-e "s|/v[0-9.]*\.tar\.gz|/v${VERSION}.tar.gz|g" \
dist/aur/.SRCINFO > "$srcinfo_file"
echo "srcinfo_file=$srcinfo_file" >> "$GITHUB_OUTPUT"
- name: Push to AUR
if: inputs.dry_run == false
shell: bash
env:
AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }}
PKGBUILD_FILE: ${{ steps.pkgbuild.outputs.pkgbuild_file }}
SRCINFO_FILE: ${{ steps.srcinfo.outputs.srcinfo_file }}
VERSION: ${{ steps.meta.outputs.version }}
run: |
set -euo pipefail
if [[ -z "${AUR_SSH_KEY}" ]]; then
echo "::error::Secret AUR_SSH_KEY is required for non-dry-run."
exit 1
fi
mkdir -p ~/.ssh
echo "$AUR_SSH_KEY" > ~/.ssh/aur
chmod 600 ~/.ssh/aur
cat >> ~/.ssh/config <<SSH_CONFIG
Host aur.archlinux.org
IdentityFile ~/.ssh/aur
User aur
StrictHostKeyChecking accept-new
SSH_CONFIG
tmp_dir="$(mktemp -d)"
git clone ssh://aur@aur.archlinux.org/zeroclaw.git "$tmp_dir/aur"
cp "$PKGBUILD_FILE" "$tmp_dir/aur/PKGBUILD"
cp "$SRCINFO_FILE" "$tmp_dir/aur/.SRCINFO"
cd "$tmp_dir/aur"
git config user.name "zeroclaw-bot"
git config user.email "bot@zeroclaw.dev"
git add PKGBUILD .SRCINFO
git commit -m "zeroclaw ${VERSION}"
git push origin HEAD
echo "AUR package updated to ${VERSION}"
- name: Summary
shell: bash
run: |
if [[ "$DRY_RUN" == "true" ]]; then
echo "Dry run complete: PKGBUILD generated, no push performed."
else
echo "Publish complete: AUR package pushed."
fi
+206
View File
@@ -0,0 +1,206 @@
name: Pub Homebrew Core
on:
workflow_dispatch:
inputs:
release_tag:
description: "Existing release tag to publish (vX.Y.Z)"
required: true
type: string
dry_run:
description: "Patch formula only (no push/PR)"
required: false
default: true
type: boolean
concurrency:
group: homebrew-core-${{ github.run_id }}
cancel-in-progress: false
permissions:
contents: read
jobs:
publish-homebrew-core:
name: Publish Homebrew Core PR
runs-on: ubuntu-latest
env:
UPSTREAM_REPO: Homebrew/homebrew-core
FORMULA_PATH: Formula/z/zeroclaw.rb
RELEASE_TAG: ${{ inputs.release_tag }}
DRY_RUN: ${{ inputs.dry_run }}
BOT_FORK_REPO: ${{ vars.HOMEBREW_CORE_BOT_FORK_REPO }}
BOT_EMAIL: ${{ vars.HOMEBREW_CORE_BOT_EMAIL }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate release tag and version alignment
id: release_meta
shell: bash
run: |
set -euo pipefail
semver_pattern='^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$'
if [[ ! "$RELEASE_TAG" =~ $semver_pattern ]]; then
echo "::error::release_tag must match semver-like format (vX.Y.Z[-suffix])."
exit 1
fi
if ! git rev-parse "refs/tags/${RELEASE_TAG}" >/dev/null 2>&1; then
git fetch --tags origin
fi
tag_version="${RELEASE_TAG#v}"
cargo_version="$(git show "${RELEASE_TAG}:Cargo.toml" \
| sed -n 's/^version = "\([^"]*\)"/\1/p' | head -n1)"
if [[ -z "$cargo_version" ]]; then
echo "::error::Unable to read Cargo.toml version from tag ${RELEASE_TAG}."
exit 1
fi
if [[ "$cargo_version" != "$tag_version" ]]; then
echo "::error::Tag ${RELEASE_TAG} does not match Cargo.toml version (${cargo_version})."
exit 1
fi
tarball_url="https://github.com/${GITHUB_REPOSITORY}/archive/refs/tags/${RELEASE_TAG}.tar.gz"
tarball_sha="$(curl -fsSL "$tarball_url" | sha256sum | awk '{print $1}')"
{
echo "tag_version=$tag_version"
echo "tarball_url=$tarball_url"
echo "tarball_sha=$tarball_sha"
} >> "$GITHUB_OUTPUT"
{
echo "### Release Metadata"
echo "- release_tag: \`${RELEASE_TAG}\`"
echo "- cargo_version: \`${cargo_version}\`"
echo "- tarball_sha256: \`${tarball_sha}\`"
echo "- dry_run: ${DRY_RUN}"
} >> "$GITHUB_STEP_SUMMARY"
- name: Patch Homebrew formula
id: patch_formula
shell: bash
env:
HOMEBREW_CORE_BOT_TOKEN: ${{ secrets.HOMEBREW_UPSTREAM_PR_TOKEN || secrets.HOMEBREW_CORE_BOT_TOKEN }}
GH_TOKEN: ${{ secrets.HOMEBREW_UPSTREAM_PR_TOKEN || secrets.HOMEBREW_CORE_BOT_TOKEN }}
run: |
set -euo pipefail
tmp_repo="$(mktemp -d)"
echo "tmp_repo=$tmp_repo" >> "$GITHUB_OUTPUT"
if [[ "$DRY_RUN" == "true" ]]; then
git clone --depth=1 "https://github.com/${UPSTREAM_REPO}.git" "$tmp_repo/homebrew-core"
else
if [[ -z "${BOT_FORK_REPO}" ]]; then
echo "::error::Repository variable HOMEBREW_CORE_BOT_FORK_REPO is required when dry_run=false."
exit 1
fi
if [[ -z "${HOMEBREW_CORE_BOT_TOKEN}" ]]; then
echo "::error::Repository secret HOMEBREW_CORE_BOT_TOKEN is required when dry_run=false."
exit 1
fi
if [[ "$BOT_FORK_REPO" != */* ]]; then
echo "::error::HOMEBREW_CORE_BOT_FORK_REPO must be in owner/repo format."
exit 1
fi
if ! gh api "repos/${BOT_FORK_REPO}" >/dev/null 2>&1; then
echo "::error::HOMEBREW_CORE_BOT_TOKEN cannot access ${BOT_FORK_REPO}."
exit 1
fi
gh repo clone "${BOT_FORK_REPO}" "$tmp_repo/homebrew-core" -- --depth=1
fi
repo_dir="$tmp_repo/homebrew-core"
formula_file="$repo_dir/$FORMULA_PATH"
if [[ ! -f "$formula_file" ]]; then
echo "::error::Formula file not found: $FORMULA_PATH"
exit 1
fi
if [[ "$DRY_RUN" == "false" ]]; then
if git -C "$repo_dir" remote get-url upstream >/dev/null 2>&1; then
git -C "$repo_dir" remote set-url upstream "https://github.com/${UPSTREAM_REPO}.git"
else
git -C "$repo_dir" remote add upstream "https://github.com/${UPSTREAM_REPO}.git"
fi
if git -C "$repo_dir" ls-remote --exit-code --heads upstream main >/dev/null 2>&1; then
upstream_ref="main"
else
upstream_ref="master"
fi
git -C "$repo_dir" fetch --depth=1 upstream "$upstream_ref"
branch_name="zeroclaw-${RELEASE_TAG}-${GITHUB_RUN_ID}"
git -C "$repo_dir" checkout -B "$branch_name" "upstream/$upstream_ref"
echo "branch_name=$branch_name" >> "$GITHUB_OUTPUT"
fi
tarball_url="$(grep 'tarball_url=' "$GITHUB_OUTPUT" | head -1 | cut -d= -f2-)"
tarball_sha="$(grep 'tarball_sha=' "$GITHUB_OUTPUT" | head -1 | cut -d= -f2-)"
perl -0pi -e "s|^ url \".*\"| url \"${tarball_url}\"|m" "$formula_file"
perl -0pi -e "s|^ sha256 \".*\"| sha256 \"${tarball_sha}\"|m" "$formula_file"
perl -0pi -e "s|^ license \".*\"| license \"Apache-2.0 OR MIT\"|m" "$formula_file"
git -C "$repo_dir" diff -- "$FORMULA_PATH" > "$tmp_repo/formula.diff"
if [[ ! -s "$tmp_repo/formula.diff" ]]; then
echo "::error::No formula changes generated. Nothing to publish."
exit 1
fi
{
echo "### Formula Diff"
echo '```diff'
cat "$tmp_repo/formula.diff"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Push branch and open Homebrew PR
if: inputs.dry_run == false
shell: bash
env:
GH_TOKEN: ${{ secrets.HOMEBREW_UPSTREAM_PR_TOKEN || secrets.HOMEBREW_CORE_BOT_TOKEN }}
TMP_REPO: ${{ steps.patch_formula.outputs.tmp_repo }}
BRANCH_NAME: ${{ steps.patch_formula.outputs.branch_name }}
TAG_VERSION: ${{ steps.release_meta.outputs.tag_version }}
TARBALL_URL: ${{ steps.release_meta.outputs.tarball_url }}
TARBALL_SHA: ${{ steps.release_meta.outputs.tarball_sha }}
run: |
set -euo pipefail
repo_dir="${TMP_REPO}/homebrew-core"
fork_owner="${BOT_FORK_REPO%%/*}"
bot_email="${BOT_EMAIL:-${fork_owner}@users.noreply.github.com}"
git -C "$repo_dir" config user.name "$fork_owner"
git -C "$repo_dir" config user.email "$bot_email"
git -C "$repo_dir" add "$FORMULA_PATH"
git -C "$repo_dir" commit -m "zeroclaw ${TAG_VERSION}"
gh auth setup-git
git -C "$repo_dir" push --set-upstream origin "$BRANCH_NAME"
pr_body="Automated formula bump from ZeroClaw release workflow.
- Release tag: ${RELEASE_TAG}
- Source tarball: ${TARBALL_URL}
- Source sha256: ${TARBALL_SHA}"
gh pr create \
--repo "$UPSTREAM_REPO" \
--base main \
--head "${fork_owner}:${BRANCH_NAME}" \
--title "zeroclaw ${TAG_VERSION}" \
--body "$pr_body"
- name: Summary
shell: bash
run: |
if [[ "$DRY_RUN" == "true" ]]; then
echo "Dry run complete: formula diff generated, no push/PR performed."
else
echo "Publish complete: branch pushed and PR opened from bot fork."
fi
+165
View File
@@ -0,0 +1,165 @@
name: Pub Scoop Manifest
on:
workflow_call:
inputs:
release_tag:
description: "Existing release tag (vX.Y.Z)"
required: true
type: string
dry_run:
description: "Generate manifest only (no push)"
required: false
default: false
type: boolean
secrets:
SCOOP_BUCKET_TOKEN:
required: false
workflow_dispatch:
inputs:
release_tag:
description: "Existing release tag (vX.Y.Z)"
required: true
type: string
dry_run:
description: "Generate manifest only (no push)"
required: false
default: true
type: boolean
concurrency:
group: scoop-publish-${{ github.run_id }}
cancel-in-progress: false
permissions:
contents: read
jobs:
publish-scoop:
name: Update Scoop Manifest
runs-on: ubuntu-latest
env:
RELEASE_TAG: ${{ inputs.release_tag }}
DRY_RUN: ${{ inputs.dry_run }}
SCOOP_BUCKET_REPO: ${{ vars.SCOOP_BUCKET_REPO }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate and compute metadata
id: meta
shell: bash
run: |
set -euo pipefail
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::release_tag must be vX.Y.Z format."
exit 1
fi
version="${RELEASE_TAG#v}"
zip_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}/zeroclaw-x86_64-pc-windows-msvc.zip"
sums_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}/SHA256SUMS"
sha256="$(curl -fsSL "$sums_url" | grep 'zeroclaw-x86_64-pc-windows-msvc.zip' | awk '{print $1}')"
if [[ -z "$sha256" ]]; then
echo "::error::Could not find Windows binary hash in SHA256SUMS for ${RELEASE_TAG}."
exit 1
fi
{
echo "version=$version"
echo "zip_url=$zip_url"
echo "sha256=$sha256"
} >> "$GITHUB_OUTPUT"
{
echo "### Scoop Manifest Metadata"
echo "- version: \`${version}\`"
echo "- zip_url: \`${zip_url}\`"
echo "- sha256: \`${sha256}\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Generate manifest
id: manifest
shell: bash
env:
VERSION: ${{ steps.meta.outputs.version }}
ZIP_URL: ${{ steps.meta.outputs.zip_url }}
SHA256: ${{ steps.meta.outputs.sha256 }}
run: |
set -euo pipefail
manifest_file="$(mktemp)"
cat > "$manifest_file" <<MANIFEST
{
"version": "${VERSION}",
"description": "Zero overhead. Zero compromise. 100% Rust. The fastest, smallest AI assistant.",
"homepage": "https://github.com/zeroclaw-labs/zeroclaw",
"license": "MIT|Apache-2.0",
"architecture": {
"64bit": {
"url": "${ZIP_URL}",
"hash": "${SHA256}",
"bin": "zeroclaw.exe"
}
},
"checkver": {
"github": "https://github.com/zeroclaw-labs/zeroclaw"
},
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/zeroclaw-labs/zeroclaw/releases/download/v\$version/zeroclaw-x86_64-pc-windows-msvc.zip"
}
},
"hash": {
"url": "https://github.com/zeroclaw-labs/zeroclaw/releases/download/v\$version/SHA256SUMS",
"regex": "([a-f0-9]{64})\\\\s+zeroclaw-x86_64-pc-windows-msvc\\\\.zip"
}
}
}
MANIFEST
jq '.' "$manifest_file" > "${manifest_file}.formatted"
mv "${manifest_file}.formatted" "$manifest_file"
echo "manifest_file=$manifest_file" >> "$GITHUB_OUTPUT"
echo "### Generated Manifest" >> "$GITHUB_STEP_SUMMARY"
echo '```json' >> "$GITHUB_STEP_SUMMARY"
cat "$manifest_file" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
- name: Push to Scoop bucket
if: inputs.dry_run == false
shell: bash
env:
GH_TOKEN: ${{ secrets.SCOOP_BUCKET_TOKEN }}
MANIFEST_FILE: ${{ steps.manifest.outputs.manifest_file }}
VERSION: ${{ steps.meta.outputs.version }}
run: |
set -euo pipefail
if [[ -z "${SCOOP_BUCKET_REPO}" ]]; then
echo "::error::Repository variable SCOOP_BUCKET_REPO is required (e.g. zeroclaw-labs/scoop-zeroclaw)."
exit 1
fi
tmp_dir="$(mktemp -d)"
gh repo clone "${SCOOP_BUCKET_REPO}" "$tmp_dir/bucket" -- --depth=1
mkdir -p "$tmp_dir/bucket/bucket"
cp "$MANIFEST_FILE" "$tmp_dir/bucket/bucket/zeroclaw.json"
cd "$tmp_dir/bucket"
git config user.name "zeroclaw-bot"
git config user.email "bot@zeroclaw.dev"
git add bucket/zeroclaw.json
git commit -m "zeroclaw ${VERSION}"
gh auth setup-git
git push origin HEAD
echo "Scoop manifest updated to ${VERSION}"
+12 -1
View File
@@ -103,9 +103,20 @@ jobs:
run: rm -rf web/node_modules web/src web/package.json web/package-lock.json web/tsconfig*.json web/vite.config.ts web/index.html
- name: Publish to crates.io
run: cargo publish --locked --allow-dirty --no-verify
shell: bash
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
VERSION: ${{ needs.detect-version-change.outputs.version }}
run: |
# Publish to crates.io; treat "already exists" as success
# (manual publish or stable workflow may have already published)
OUTPUT=$(cargo publish --locked --allow-dirty --no-verify 2>&1) && exit 0
echo "$OUTPUT"
if echo "$OUTPUT" | grep -q 'already exists'; then
echo "::notice::zeroclawlabs@${VERSION} already on crates.io — skipping"
exit 0
fi
exit 1
- name: Verify published
shell: bash
+11 -1
View File
@@ -75,6 +75,16 @@ jobs:
- name: Publish to crates.io
if: "!inputs.dry_run"
run: cargo publish --locked --allow-dirty --no-verify
shell: bash
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
VERSION: ${{ inputs.version }}
run: |
# Publish to crates.io; treat "already exists" as success
OUTPUT=$(cargo publish --locked --allow-dirty --no-verify 2>&1) && exit 0
echo "$OUTPUT"
if echo "$OUTPUT" | grep -q 'already exists'; then
echo "::notice::zeroclawlabs@${VERSION} already on crates.io — skipping"
exit 0
fi
exit 1
+60 -19
View File
@@ -5,8 +5,8 @@ on:
branches: [master]
concurrency:
group: release
cancel-in-progress: false
group: release-beta
cancel-in-progress: true
permissions:
contents: write
@@ -155,11 +155,13 @@ jobs:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
# Use ubuntu-22.04 for Linux builds to link against glibc 2.35,
# ensuring compatibility with Ubuntu 22.04+ (#3573).
- os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
artifact: zeroclaw
ext: tar.gz
- os: ubuntu-latest
- os: ubuntu-22.04
target: aarch64-unknown-linux-gnu
artifact: zeroclaw
ext: tar.gz
@@ -187,6 +189,8 @@ jobs:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
if: runner.os != 'Windows'
with:
prefix-key: ${{ matrix.os }}-${{ matrix.target }}
- uses: actions/download-artifact@v4
with:
@@ -209,7 +213,7 @@ jobs:
if [ -n "${{ matrix.linker_env || '' }}" ] && [ -n "${{ matrix.linker || '' }}" ]; then
export "${{ matrix.linker_env }}=${{ matrix.linker }}"
fi
cargo build --release --locked --target ${{ matrix.target }}
cargo build --release --locked --features channel-matrix --target ${{ matrix.target }}
- name: Package (Unix)
if: runner.os != 'Windows'
@@ -290,11 +294,45 @@ jobs:
name: Push Docker Image
needs: [version, build]
runs-on: ubuntu-latest
timeout-minutes: 60
timeout-minutes: 15
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: zeroclaw-x86_64-unknown-linux-gnu
path: artifacts/
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: zeroclaw-aarch64-unknown-linux-gnu
path: artifacts/
- name: Prepare Docker context with pre-built binaries
run: |
mkdir -p docker-ctx/bin/amd64 docker-ctx/bin/arm64
tar xzf artifacts/zeroclaw-x86_64-unknown-linux-gnu.tar.gz -C docker-ctx/bin/amd64
tar xzf artifacts/zeroclaw-aarch64-unknown-linux-gnu.tar.gz -C docker-ctx/bin/arm64
mkdir -p docker-ctx/zeroclaw-data/.zeroclaw docker-ctx/zeroclaw-data/workspace
printf '%s\n' \
'workspace_dir = "/zeroclaw-data/workspace"' \
'config_path = "/zeroclaw-data/.zeroclaw/config.toml"' \
'api_key = ""' \
'default_provider = "openrouter"' \
'default_model = "anthropic/claude-sonnet-4-20250514"' \
'default_temperature = 0.7' \
'' \
'[gateway]' \
'port = 42617' \
'host = "[::]"' \
'allow_public_bind = true' \
> docker-ctx/zeroclaw-data/.zeroclaw/config.toml
cp Dockerfile.ci docker-ctx/Dockerfile
cp Dockerfile.debian.ci docker-ctx/Dockerfile.debian
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
@@ -305,21 +343,24 @@ jobs:
- name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
context: docker-ctx
push: true
build-args: |
ZEROCLAW_CARGO_FEATURES=channel-matrix
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.version.outputs.tag }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:beta
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
# ── Post-publish: only run after ALL artifacts are live ──────────────
tweet:
name: Tweet Release
needs: [version, publish, docker, redeploy-website]
uses: ./.github/workflows/tweet-release.yml
with:
release_tag: ${{ needs.version.outputs.tag }}
release_url: https://github.com/zeroclaw-labs/zeroclaw/releases/tag/${{ needs.version.outputs.tag }}
secrets: inherit
- name: Build and push Debian compatibility image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: docker-ctx
file: docker-ctx/Dockerfile.debian
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.version.outputs.tag }}-debian
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:beta-debian
platforms: linux/amd64,linux/arm64
# Tweet removed — only stable releases should tweet (see tweet-release.yml).
+90 -15
View File
@@ -156,11 +156,13 @@ jobs:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
# Use ubuntu-22.04 for Linux builds to link against glibc 2.35,
# ensuring compatibility with Ubuntu 22.04+ (#3573).
- os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
artifact: zeroclaw
ext: tar.gz
- os: ubuntu-latest
- os: ubuntu-22.04
target: aarch64-unknown-linux-gnu
artifact: zeroclaw
ext: tar.gz
@@ -188,6 +190,8 @@ jobs:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
if: runner.os != 'Windows'
with:
prefix-key: ${{ matrix.os }}-${{ matrix.target }}
- uses: actions/download-artifact@v4
with:
@@ -210,7 +214,7 @@ jobs:
if [ -n "${{ matrix.linker_env || '' }}" ] && [ -n "${{ matrix.linker || '' }}" ]; then
export "${{ matrix.linker_env }}=${{ matrix.linker }}"
fi
cargo build --release --locked --target ${{ matrix.target }}
cargo build --release --locked --features channel-matrix --target ${{ matrix.target }}
- name: Package (Unix)
if: runner.os != 'Windows'
@@ -303,13 +307,16 @@ jobs:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
VERSION: ${{ inputs.version }}
run: |
# Skip if this version is already on crates.io (auto-sync may have published it)
# Publish to crates.io; treat "already exists" as success
# (auto-publish workflow may have already published this version)
CRATE_NAME=$(sed -n 's/^name = "\([^"]*\)"/\1/p' Cargo.toml | head -1)
if curl -sfL "https://crates.io/api/v1/crates/${CRATE_NAME}/${VERSION}" | grep -q '"version"'; then
echo "::notice::${CRATE_NAME}@${VERSION} already published on crates.io — skipping"
else
cargo publish --locked --allow-dirty --no-verify
OUTPUT=$(cargo publish --locked --allow-dirty --no-verify 2>&1) && exit 0
echo "$OUTPUT"
if echo "$OUTPUT" | grep -q 'already exists'; then
echo "::notice::${CRATE_NAME}@${VERSION} already on crates.io — skipping"
exit 0
fi
exit 1
redeploy-website:
name: Trigger Website Redeploy
@@ -330,11 +337,45 @@ jobs:
name: Push Docker Image
needs: [validate, build]
runs-on: ubuntu-latest
timeout-minutes: 60
timeout-minutes: 15
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: zeroclaw-x86_64-unknown-linux-gnu
path: artifacts/
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: zeroclaw-aarch64-unknown-linux-gnu
path: artifacts/
- name: Prepare Docker context with pre-built binaries
run: |
mkdir -p docker-ctx/bin/amd64 docker-ctx/bin/arm64
tar xzf artifacts/zeroclaw-x86_64-unknown-linux-gnu.tar.gz -C docker-ctx/bin/amd64
tar xzf artifacts/zeroclaw-aarch64-unknown-linux-gnu.tar.gz -C docker-ctx/bin/arm64
mkdir -p docker-ctx/zeroclaw-data/.zeroclaw docker-ctx/zeroclaw-data/workspace
printf '%s\n' \
'workspace_dir = "/zeroclaw-data/workspace"' \
'config_path = "/zeroclaw-data/.zeroclaw/config.toml"' \
'api_key = ""' \
'default_provider = "openrouter"' \
'default_model = "anthropic/claude-sonnet-4-20250514"' \
'default_temperature = 0.7' \
'' \
'[gateway]' \
'port = 42617' \
'host = "[::]"' \
'allow_public_bind = true' \
> docker-ctx/zeroclaw-data/.zeroclaw/config.toml
cp Dockerfile.ci docker-ctx/Dockerfile
cp Dockerfile.debian.ci docker-ctx/Dockerfile.debian
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
@@ -345,19 +386,53 @@ jobs:
- name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
context: docker-ctx
push: true
build-args: |
ZEROCLAW_CARGO_FEATURES=channel-matrix
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.validate.outputs.tag }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
# ── Post-publish: only run after ALL artifacts are live ──────────────
- name: Build and push Debian compatibility image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: docker-ctx
file: docker-ctx/Dockerfile.debian
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.validate.outputs.tag }}-debian
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:debian
platforms: linux/amd64,linux/arm64
# ── Post-publish: package manager auto-sync ─────────────────────────
scoop:
name: Update Scoop Manifest
needs: [validate, publish]
if: ${{ !cancelled() && needs.publish.result == 'success' }}
uses: ./.github/workflows/pub-scoop.yml
with:
release_tag: ${{ needs.validate.outputs.tag }}
dry_run: false
secrets: inherit
aur:
name: Update AUR Package
needs: [validate, publish]
if: ${{ !cancelled() && needs.publish.result == 'success' }}
uses: ./.github/workflows/pub-aur.yml
with:
release_tag: ${{ needs.validate.outputs.tag }}
dry_run: false
secrets: inherit
# ── Post-publish: tweet after release + website are live ──────────────
# Docker push can be slow; don't let it block the tweet.
tweet:
name: Tweet Release
needs: [validate, publish, docker, crates-io, redeploy-website]
needs: [validate, publish, redeploy-website]
if: ${{ !cancelled() && needs.publish.result == 'success' }}
uses: ./.github/workflows/tweet-release.yml
with:
release_tag: ${{ needs.validate.outputs.tag }}
+35 -42
View File
@@ -5,7 +5,7 @@ on:
workflow_call:
inputs:
release_tag:
description: "Release tag (e.g. v0.3.0 or v0.3.0-beta.42)"
description: "Stable release tag (e.g. v0.3.0)"
required: true
type: string
release_url:
@@ -53,9 +53,18 @@ jobs:
exit 0
fi
# Find the PREVIOUS release tag (including betas) to check for new features
# Stable releases (no -beta suffix) always tweet — they represent
# the full release cycle, so skipping them loses visibility.
if [[ ! "$RELEASE_TAG" =~ -beta\. ]]; then
echo "Stable release ${RELEASE_TAG} — always tweet"
echo "skip=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Find the previous STABLE release tag (exclude betas) to check for new features
PREV_TAG=$(git tag --sort=-creatordate \
| grep -v "^${RELEASE_TAG}$" \
| grep -vE '\-beta\.' \
| head -1 || echo "")
if [ -z "$PREV_TAG" ]; then
@@ -63,15 +72,15 @@ jobs:
exit 0
fi
# Count new feat() commits since the previous release
NEW_FEATS=$(git log "${PREV_TAG}..${RELEASE_TAG}" --pretty=format:"%s" --no-merges \
| grep -ciE '^feat(\(|:)' || echo "0")
# Count new feat() OR fix() commits since the previous release
NEW_CHANGES=$(git log "${PREV_TAG}..${RELEASE_TAG}" --pretty=format:"%s" --no-merges \
| grep -ciE '^(feat|fix)(\(|:)' || echo "0")
if [ "$NEW_FEATS" -eq 0 ]; then
echo "No new features since ${PREV_TAG} — skipping tweet"
if [ "$NEW_CHANGES" -eq 0 ]; then
echo "No new features or fixes since ${PREV_TAG} — skipping tweet"
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "${NEW_FEATS} new feature(s) since ${PREV_TAG} — tweeting"
echo "${NEW_CHANGES} new change(s) since ${PREV_TAG} — tweeting"
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
@@ -89,53 +98,37 @@ jobs:
if [ -n "$MANUAL_TEXT" ]; then
TWEET="$MANUAL_TEXT"
else
# For features: diff against the PREVIOUS release (including betas)
# This prevents duplicate feature lists across consecutive betas
PREV_RELEASE=$(git tag --sort=-creatordate \
| grep -v "^${RELEASE_TAG}$" \
| head -1 || echo "")
# For contributors: diff against the last STABLE release
# This captures everyone across the full release cycle
# Diff against the last STABLE release (exclude betas) to capture
# ALL features accumulated across the full beta cycle
PREV_STABLE=$(git tag --sort=-creatordate \
| grep -v "^${RELEASE_TAG}$" \
| grep -vE '\-beta\.' \
| head -1 || echo "")
FEAT_RANGE="${PREV_RELEASE:+${PREV_RELEASE}..}${RELEASE_TAG}"
CONTRIB_RANGE="${PREV_STABLE:+${PREV_STABLE}..}${RELEASE_TAG}"
RANGE="${PREV_STABLE:+${PREV_STABLE}..}${RELEASE_TAG}"
# Extract NEW features only since the last release
FEATURES=$(git log "$FEAT_RANGE" --pretty=format:"%s" --no-merges \
# Extract ALL features since the last stable release
FEATURES=$(git log "$RANGE" --pretty=format:"%s" --no-merges \
| grep -iE '^feat(\(|:)' \
| sed 's/^feat(\([^)]*\)): /\1: /' \
| sed 's/^feat: //' \
| sed 's/ (#[0-9]*)$//' \
| sort -uf \
| head -4 \
| while IFS= read -r line; do echo "🚀 ${line}"; done || true)
if [ -z "$FEATURES" ]; then
FEATURES="🚀 Incremental improvements and polish"
fi
# Count ALL contributors across the full release cycle
GIT_AUTHORS=$(git log "$CONTRIB_RANGE" --pretty=format:"%an" --no-merges | sort -uf || true)
CO_AUTHORS=$(git log "$CONTRIB_RANGE" --pretty=format:"%b" --no-merges \
| grep -ioE 'Co-Authored-By: *[^<]+' \
| sed 's/Co-Authored-By: *//i' \
| sed 's/ *$//' \
| sort -uf || true)
TOTAL_COUNT=$(printf "%s\n%s" "$GIT_AUTHORS" "$CO_AUTHORS" \
| sort -uf \
| grep -v '^$' \
| grep -viE '\[bot\]$|^dependabot|^github-actions|^copilot|^ZeroClaw Bot|^ZeroClaw Runner|^ZeroClaw Agent|^blacksmith' \
| grep -c . || echo "0")
FEAT_COUNT=$(echo "$FEATURES" | grep -c . || echo "0")
# Build tweet — new features, contributor count, hashtags
TWEET=$(printf "🦀 ZeroClaw %s\n\n%s\n\n🙌 %s contributors\n\n%s\n\n#zeroclaw #rust #ai #opensource" \
"$RELEASE_TAG" "$FEATURES" "$TOTAL_COUNT" "$RELEASE_URL")
# Format top features with rocket emoji (limit to 6 for tweet space)
FEAT_LIST=$(echo "$FEATURES" \
| head -6 \
| while IFS= read -r line; do echo "🚀 ${line}"; done || true)
if [ -z "$FEAT_LIST" ]; then
FEAT_LIST="🚀 Incremental improvements and polish"
fi
# Build tweet — feature-focused style
TWEET=$(printf "🦀 ZeroClaw %s\n\n%s\n\nZero overhead. Zero compromise. 100%% Rust.\n\n#zeroclaw #rust #ai #opensource" \
"$RELEASE_TAG" "$FEAT_LIST")
fi
# X/Twitter counts any URL as 23 chars (t.co shortening).
+7 -2
View File
@@ -1,6 +1,8 @@
/target
/target-*/
firmware/*/target
web/dist/
web/dist/*
!web/dist/.gitkeep
*.db
*.db-journal
.DS_Store
@@ -43,4 +45,7 @@ credentials.json
lcov.info
# IDE's stuff
.idea
.idea
# Wrangler cache
.wrangler/
Generated
+2 -1
View File
@@ -7945,7 +7945,7 @@ dependencies = [
[[package]]
name = "zeroclawlabs"
version = "0.3.1"
version = "0.5.0"
dependencies = [
"anyhow",
"async-imap",
@@ -8005,6 +8005,7 @@ dependencies = [
"serde-big-array",
"serde_ignored",
"serde_json",
"serde_yaml",
"sha2",
"shellexpand",
"tempfile",
+2 -1
View File
@@ -4,7 +4,7 @@ resolver = "2"
[package]
name = "zeroclawlabs"
version = "0.3.1"
version = "0.5.0"
edition = "2021"
authors = ["theonlyhennygod"]
license = "MIT OR Apache-2.0"
@@ -53,6 +53,7 @@ matrix-sdk = { version = "0.16", optional = true, default-features = false, feat
serde = { version = "1.0", default-features = false, features = ["derive"] }
serde_json = { version = "1.0", default-features = false, features = ["std"] }
serde_ignored = "0.1"
serde_yaml = "0.9"
# Config
directories = "6.0"
+19 -3
View File
@@ -1,9 +1,10 @@
# syntax=docker/dockerfile:1.7
# ── Stage 1: Build ────────────────────────────────────────────
FROM rust:1.93-slim@sha256:9663b80a1621253d30b146454f903de48f0af925c967be48c84745537cd35d8b AS builder
FROM rust:1.94-slim@sha256:da9dab7a6b8dd428e71718402e97207bb3e54167d37b5708616050b1e8f60ed6 AS builder
WORKDIR /app
ARG ZEROCLAW_CARGO_FEATURES=""
# Install build dependencies
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
@@ -24,7 +25,11 @@ RUN mkdir -p src benches crates/robot-kit/src \
RUN --mount=type=cache,id=zeroclaw-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=zeroclaw-cargo-git,target=/usr/local/cargo/git,sharing=locked \
--mount=type=cache,id=zeroclaw-target,target=/app/target,sharing=locked \
cargo build --release --locked
if [ -n "$ZEROCLAW_CARGO_FEATURES" ]; then \
cargo build --release --locked --features "$ZEROCLAW_CARGO_FEATURES"; \
else \
cargo build --release --locked; \
fi
RUN rm -rf src benches crates/robot-kit/src
# 2. Copy only build-relevant source paths (avoid cache-busting on docs/tests/scripts)
@@ -33,6 +38,7 @@ COPY benches/ benches/
COPY crates/ crates/
COPY firmware/ firmware/
COPY web/ web/
COPY *.rs .
# Keep release builds resilient when frontend dist assets are not prebuilt in Git.
RUN mkdir -p web/dist && \
if [ ! -f web/dist/index.html ]; then \
@@ -50,12 +56,22 @@ RUN mkdir -p web/dist && \
' </body>' \
'</html>' > web/dist/index.html; \
fi
RUN touch src/main.rs
RUN --mount=type=cache,id=zeroclaw-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=zeroclaw-cargo-git,target=/usr/local/cargo/git,sharing=locked \
--mount=type=cache,id=zeroclaw-target,target=/app/target,sharing=locked \
cargo build --release --locked && \
rm -rf target/release/.fingerprint/zeroclawlabs-* \
target/release/deps/zeroclawlabs-* \
target/release/incremental/zeroclawlabs-* && \
if [ -n "$ZEROCLAW_CARGO_FEATURES" ]; then \
cargo build --release --locked --features "$ZEROCLAW_CARGO_FEATURES"; \
else \
cargo build --release --locked; \
fi && \
cp target/release/zeroclaw /app/zeroclaw && \
strip /app/zeroclaw
RUN size=$(stat -c%s /app/zeroclaw 2>/dev/null || stat -f%z /app/zeroclaw) && \
if [ "$size" -lt 1000000 ]; then echo "ERROR: binary too small (${size} bytes), likely dummy build artifact" && exit 1; fi
# Prepare runtime directory structure and default config inline (no extra stage)
RUN mkdir -p /zeroclaw-data/.zeroclaw /zeroclaw-data/workspace && \
+25
View File
@@ -0,0 +1,25 @@
# Dockerfile.ci — CI/release image using pre-built binaries.
# Used by release workflows to skip the ~60 min Rust compilation.
# The main Dockerfile is still used for local dev builds.
# ── Runtime (Distroless) ─────────────────────────────────────
FROM gcr.io/distroless/cc-debian13:nonroot@sha256:84fcd3c223b144b0cb6edc5ecc75641819842a9679a3a58fd6294bec47532bf7
ARG TARGETARCH
# Copy the pre-built binary for this platform (amd64 or arm64)
COPY bin/${TARGETARCH}/zeroclaw /usr/local/bin/zeroclaw
# Runtime directory structure and default config
COPY --chown=65534:65534 zeroclaw-data/ /zeroclaw-data/
ENV LANG=C.UTF-8
ENV ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace
ENV HOME=/zeroclaw-data
ENV ZEROCLAW_GATEWAY_PORT=42617
WORKDIR /zeroclaw-data
USER 65534:65534
EXPOSE 42617
ENTRYPOINT ["zeroclaw"]
CMD ["gateway"]
+17 -4
View File
@@ -15,10 +15,11 @@
# Or with docker compose:
# docker compose -f docker-compose.yml -f docker-compose.debian.yml up
# ── Stage 1: Build (identical to main Dockerfile) ───────────
FROM rust:1.93-slim@sha256:9663b80a1621253d30b146454f903de48f0af925c967be48c84745537cd35d8b AS builder
# ── Stage 1: Build (match runtime glibc baseline) ───────────
FROM rust:1.94-bookworm AS builder
WORKDIR /app
ARG ZEROCLAW_CARGO_FEATURES=""
# Install build dependencies
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
@@ -33,12 +34,17 @@ COPY crates/robot-kit/Cargo.toml crates/robot-kit/Cargo.toml
# Create dummy targets declared in Cargo.toml so manifest parsing succeeds.
RUN mkdir -p src benches crates/robot-kit/src \
&& echo "fn main() {}" > src/main.rs \
&& echo "" > src/lib.rs \
&& echo "fn main() {}" > benches/agent_benchmarks.rs \
&& echo "pub fn placeholder() {}" > crates/robot-kit/src/lib.rs
RUN --mount=type=cache,id=zeroclaw-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=zeroclaw-cargo-git,target=/usr/local/cargo/git,sharing=locked \
--mount=type=cache,id=zeroclaw-target,target=/app/target,sharing=locked \
cargo build --release --locked
if [ -n "$ZEROCLAW_CARGO_FEATURES" ]; then \
cargo build --release --locked --features "$ZEROCLAW_CARGO_FEATURES"; \
else \
cargo build --release --locked; \
fi
RUN rm -rf src benches crates/robot-kit/src
# 2. Copy only build-relevant source paths (avoid cache-busting on docs/tests/scripts)
@@ -64,12 +70,19 @@ RUN mkdir -p web/dist && \
' </body>' \
'</html>' > web/dist/index.html; \
fi
RUN touch src/main.rs
RUN --mount=type=cache,id=zeroclaw-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=zeroclaw-cargo-git,target=/usr/local/cargo/git,sharing=locked \
--mount=type=cache,id=zeroclaw-target,target=/app/target,sharing=locked \
cargo build --release --locked && \
if [ -n "$ZEROCLAW_CARGO_FEATURES" ]; then \
cargo build --release --locked --features "$ZEROCLAW_CARGO_FEATURES"; \
else \
cargo build --release --locked; \
fi && \
cp target/release/zeroclaw /app/zeroclaw && \
strip /app/zeroclaw
RUN size=$(stat -c%s /app/zeroclaw 2>/dev/null || stat -f%z /app/zeroclaw) && \
if [ "$size" -lt 1000000 ]; then echo "ERROR: binary too small (${size} bytes), likely dummy build artifact" && exit 1; fi
# Prepare runtime directory structure and default config inline (no extra stage)
RUN mkdir -p /zeroclaw-data/.zeroclaw /zeroclaw-data/workspace && \
+34
View File
@@ -0,0 +1,34 @@
# Dockerfile.debian.ci — CI/release Debian image using pre-built binaries.
# Mirrors Dockerfile.ci but uses debian:bookworm-slim with shell tools
# so the agent can use shell-based tools (pwd, ls, git, curl, etc.).
# Used by release workflows to skip ~60 min QEMU cross-compilation.
# ── Runtime (Debian with shell) ────────────────────────────────
FROM debian:bookworm-slim
ARG TARGETARCH
# Install essential tools for agent shell operations
RUN apt-get update && apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
# Copy the pre-built binary for this platform (amd64 or arm64)
COPY bin/${TARGETARCH}/zeroclaw /usr/local/bin/zeroclaw
# Runtime directory structure and default config
COPY --chown=65534:65534 zeroclaw-data/ /zeroclaw-data/
ENV LANG=C.UTF-8
ENV ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace
ENV HOME=/zeroclaw-data
ENV ZEROCLAW_GATEWAY_PORT=42617
WORKDIR /zeroclaw-data
USER 65534:65534
EXPOSE 42617
ENTRYPOINT ["zeroclaw"]
CMD ["gateway"]
+53 -2
View File
@@ -1,22 +1,30 @@
use std::fs;
use std::path::Path;
use std::process::Command;
use std::time::SystemTime;
fn main() {
let dist_dir = Path::new("web/dist");
let web_dir = Path::new("web");
// Tell Cargo to re-run this script when web source files change.
// Tell Cargo to re-run this script when web sources or bundled assets change.
println!("cargo:rerun-if-changed=web/src");
println!("cargo:rerun-if-changed=web/public");
println!("cargo:rerun-if-changed=web/index.html");
println!("cargo:rerun-if-changed=web/package.json");
println!("cargo:rerun-if-changed=web/package-lock.json");
println!("cargo:rerun-if-changed=web/tsconfig.json");
println!("cargo:rerun-if-changed=web/tsconfig.app.json");
println!("cargo:rerun-if-changed=web/tsconfig.node.json");
println!("cargo:rerun-if-changed=web/vite.config.ts");
println!("cargo:rerun-if-changed=web/dist");
// Attempt to build the web frontend if npm is available and web/dist is
// missing or stale. The build is best-effort: when Node.js is not
// installed (e.g. CI containers, cross-compilation, minimal dev setups)
// we fall back to the existing stub/empty dist directory so the Rust
// build still succeeds.
let needs_build = !dist_dir.join("index.html").exists();
let needs_build = web_build_required(web_dir, dist_dir);
if needs_build && web_dir.join("package.json").exists() {
if let Ok(npm) = which_npm() {
@@ -77,6 +85,49 @@ fn main() {
ensure_dist_dir(dist_dir);
}
fn web_build_required(web_dir: &Path, dist_dir: &Path) -> bool {
let Some(dist_mtime) = latest_modified(dist_dir) else {
return true;
};
[
web_dir.join("src"),
web_dir.join("public"),
web_dir.join("index.html"),
web_dir.join("package.json"),
web_dir.join("package-lock.json"),
web_dir.join("tsconfig.json"),
web_dir.join("tsconfig.app.json"),
web_dir.join("tsconfig.node.json"),
web_dir.join("vite.config.ts"),
]
.into_iter()
.filter_map(|path| latest_modified(&path))
.any(|mtime| mtime > dist_mtime)
}
fn latest_modified(path: &Path) -> Option<SystemTime> {
let metadata = fs::metadata(path).ok()?;
if metadata.is_file() {
return metadata.modified().ok();
}
if !metadata.is_dir() {
return None;
}
let mut latest = metadata.modified().ok();
let entries = fs::read_dir(path).ok()?;
for entry in entries.flatten() {
if let Some(child_mtime) = latest_modified(&entry.path()) {
latest = Some(match latest {
Some(current) if current >= child_mtime => current,
_ => child_mtime,
});
}
}
latest
}
/// Ensure the dist directory exists so `rust-embed` does not fail at compile
/// time even when the web frontend is not built.
fn ensure_dist_dir(dist_dir: &Path) {
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env bash
# Termux release validation script
# Validates the aarch64-linux-android release artifact for Termux compatibility.
#
# Usage:
# ./dev/test-termux-release.sh [version]
#
# Examples:
# ./dev/test-termux-release.sh 0.3.1
# ./dev/test-termux-release.sh # auto-detects from Cargo.toml
#
set -euo pipefail
BLUE='\033[0;34m'
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[0;33m'
BOLD='\033[1m'
DIM='\033[2m'
RESET='\033[0m'
pass() { echo -e " ${GREEN}${RESET} $*"; }
fail() { echo -e " ${RED}${RESET} $*"; FAILURES=$((FAILURES + 1)); }
info() { echo -e "${BLUE}${RESET} ${BOLD}$*${RESET}"; }
warn() { echo -e "${YELLOW}!${RESET} $*"; }
FAILURES=0
TARGET="aarch64-linux-android"
VERSION="${1:-}"
if [[ -z "$VERSION" ]]; then
if [[ -f Cargo.toml ]]; then
VERSION=$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)
fi
fi
if [[ -z "$VERSION" ]]; then
echo "Usage: $0 <version>"
echo " e.g. $0 0.3.1"
exit 1
fi
TAG="v${VERSION}"
ASSET_NAME="zeroclaw-${TARGET}.tar.gz"
ASSET_URL="https://github.com/zeroclaw-labs/zeroclaw/releases/download/${TAG}/${ASSET_NAME}"
TEMP_DIR="$(mktemp -d -t zeroclaw-termux-test-XXXXXX)"
cleanup() { rm -rf "$TEMP_DIR"; }
trap cleanup EXIT
echo
echo -e "${BOLD}Termux Release Validation — ${TAG}${RESET}"
echo -e "${DIM}Target: ${TARGET}${RESET}"
echo
# --- Test 1: Release tag exists ---
info "Checking release tag ${TAG}"
if gh release view "$TAG" >/dev/null 2>&1; then
pass "Release ${TAG} exists"
else
fail "Release ${TAG} not found"
echo -e "${RED}Release has not been published yet. Wait for the release workflow to complete.${RESET}"
exit 1
fi
# --- Test 2: Android asset is listed ---
info "Checking for ${ASSET_NAME} in release assets"
ASSETS=$(gh release view "$TAG" --json assets -q '.assets[].name')
if echo "$ASSETS" | grep -q "$ASSET_NAME"; then
pass "Asset ${ASSET_NAME} found in release"
else
fail "Asset ${ASSET_NAME} not found in release"
echo "Available assets:"
echo "$ASSETS" | sed 's/^/ /'
exit 1
fi
# --- Test 3: Download the asset ---
info "Downloading ${ASSET_NAME}"
if curl -fsSL "$ASSET_URL" -o "$TEMP_DIR/$ASSET_NAME"; then
FILESIZE=$(wc -c < "$TEMP_DIR/$ASSET_NAME" | tr -d ' ')
pass "Downloaded successfully (${FILESIZE} bytes)"
else
fail "Download failed from ${ASSET_URL}"
exit 1
fi
# --- Test 4: Archive integrity ---
info "Verifying archive integrity"
if tar -tzf "$TEMP_DIR/$ASSET_NAME" >/dev/null 2>&1; then
pass "Archive is a valid gzip tar"
else
fail "Archive is corrupted or not a valid tar.gz"
exit 1
fi
# --- Test 5: Contains zeroclaw binary ---
info "Checking archive contents"
CONTENTS=$(tar -tzf "$TEMP_DIR/$ASSET_NAME")
if echo "$CONTENTS" | grep -q "^zeroclaw$"; then
pass "Archive contains 'zeroclaw' binary"
else
fail "Archive does not contain 'zeroclaw' binary"
echo "Contents:"
echo "$CONTENTS" | sed 's/^/ /'
fi
# --- Test 6: Extract and inspect binary ---
info "Extracting and inspecting binary"
tar -xzf "$TEMP_DIR/$ASSET_NAME" -C "$TEMP_DIR"
BINARY="$TEMP_DIR/zeroclaw"
if [[ -f "$BINARY" ]]; then
pass "Binary extracted"
else
fail "Binary not found after extraction"
exit 1
fi
# --- Test 7: ELF format and architecture ---
info "Checking binary format"
FILE_INFO=$(file "$BINARY")
if echo "$FILE_INFO" | grep -q "ELF"; then
pass "Binary is ELF format"
else
fail "Binary is not ELF format: $FILE_INFO"
fi
if echo "$FILE_INFO" | grep -qi "aarch64\|ARM aarch64"; then
pass "Binary targets aarch64 architecture"
else
fail "Binary does not target aarch64: $FILE_INFO"
fi
if echo "$FILE_INFO" | grep -qi "android\|bionic"; then
pass "Binary is linked for Android/Bionic"
else
# Android binaries may not always show "android" in file output,
# check with readelf if available
if command -v readelf >/dev/null 2>&1; then
INTERP=$(readelf -l "$BINARY" 2>/dev/null | grep -o '/[^ ]*linker[^ ]*' || true)
if echo "$INTERP" | grep -qi "android\|bionic"; then
pass "Binary uses Android linker: $INTERP"
else
warn "Could not confirm Android linkage (interpreter: ${INTERP:-unknown})"
warn "file output: $FILE_INFO"
fi
else
warn "Could not confirm Android linkage (readelf not available)"
warn "file output: $FILE_INFO"
fi
fi
# --- Test 8: Binary is stripped ---
info "Checking binary optimization"
if echo "$FILE_INFO" | grep -q "stripped"; then
pass "Binary is stripped (release optimized)"
else
warn "Binary may not be stripped"
fi
# --- Test 9: Binary is not dynamically linked to glibc ---
info "Checking for glibc dependencies"
if command -v readelf >/dev/null 2>&1; then
NEEDED=$(readelf -d "$BINARY" 2>/dev/null | grep NEEDED || true)
if echo "$NEEDED" | grep -qi "libc\.so\.\|libpthread\|libdl"; then
# Check if it's glibc or bionic
if echo "$NEEDED" | grep -qi "libc\.so\.6"; then
fail "Binary links against glibc (libc.so.6) — will not work on Termux"
else
pass "Binary links against libc (likely Bionic)"
fi
else
pass "No glibc dependencies detected"
fi
else
warn "readelf not available — skipping dynamic library check"
fi
# --- Test 10: SHA256 checksum verification ---
info "Verifying SHA256 checksum"
CHECKSUMS_URL="https://github.com/zeroclaw-labs/zeroclaw/releases/download/${TAG}/SHA256SUMS"
if curl -fsSL "$CHECKSUMS_URL" -o "$TEMP_DIR/SHA256SUMS" 2>/dev/null; then
EXPECTED=$(grep "$ASSET_NAME" "$TEMP_DIR/SHA256SUMS" | awk '{print $1}')
if [[ -n "$EXPECTED" ]]; then
if command -v sha256sum >/dev/null 2>&1; then
ACTUAL=$(sha256sum "$TEMP_DIR/$ASSET_NAME" | awk '{print $1}')
elif command -v shasum >/dev/null 2>&1; then
ACTUAL=$(shasum -a 256 "$TEMP_DIR/$ASSET_NAME" | awk '{print $1}')
else
warn "No sha256sum or shasum available"
ACTUAL=""
fi
if [[ -n "$ACTUAL" && "$ACTUAL" == "$EXPECTED" ]]; then
pass "SHA256 checksum matches"
elif [[ -n "$ACTUAL" ]]; then
fail "SHA256 mismatch: expected=$EXPECTED actual=$ACTUAL"
fi
else
warn "No checksum entry for ${ASSET_NAME} in SHA256SUMS"
fi
else
warn "Could not download SHA256SUMS"
fi
# --- Test 11: install.sh Termux detection ---
info "Validating install.sh Termux detection"
INSTALL_SH="install.sh"
if [[ ! -f "$INSTALL_SH" ]]; then
INSTALL_SH="$(dirname "$0")/../install.sh"
fi
if [[ -f "$INSTALL_SH" ]]; then
if grep -q 'TERMUX_VERSION' "$INSTALL_SH"; then
pass "install.sh checks TERMUX_VERSION"
else
fail "install.sh does not check TERMUX_VERSION"
fi
if grep -q 'aarch64-linux-android' "$INSTALL_SH"; then
pass "install.sh maps to aarch64-linux-android target"
else
fail "install.sh does not map to aarch64-linux-android"
fi
# Simulate Termux detection (mock uname as Linux since we may run on macOS)
detect_result=$(
bash -c '
TERMUX_VERSION="0.118"
os="Linux"
arch="aarch64"
case "$os:$arch" in
Linux:aarch64|Linux:arm64)
if [[ -n "${TERMUX_VERSION:-}" || -d "/data/data/com.termux" ]]; then
echo "aarch64-linux-android"
else
echo "aarch64-unknown-linux-gnu"
fi
;;
esac
'
)
if [[ "$detect_result" == "aarch64-linux-android" ]]; then
pass "Termux detection returns correct target (simulated)"
else
fail "Termux detection returned: $detect_result (expected aarch64-linux-android)"
fi
else
warn "install.sh not found — skipping detection tests"
fi
# --- Summary ---
echo
if [[ "$FAILURES" -eq 0 ]]; then
echo -e "${GREEN}${BOLD}All tests passed!${RESET}"
echo -e "${DIM}The Termux release artifact for ${TAG} is valid.${RESET}"
else
echo -e "${RED}${BOLD}${FAILURES} test(s) failed.${RESET}"
exit 1
fi
+16
View File
@@ -0,0 +1,16 @@
pkgbase = zeroclaw
pkgdesc = Zero overhead. Zero compromise. 100% Rust. The fastest, smallest AI assistant.
pkgver = 0.4.3
pkgrel = 1
url = https://github.com/zeroclaw-labs/zeroclaw
arch = x86_64
license = MIT
license = Apache-2.0
makedepends = cargo
makedepends = git
depends = gcc-libs
depends = openssl
source = zeroclaw-0.4.3.tar.gz::https://github.com/zeroclaw-labs/zeroclaw/archive/refs/tags/v0.4.3.tar.gz
sha256sums = SKIP
pkgname = zeroclaw
+32
View File
@@ -0,0 +1,32 @@
# Maintainer: zeroclaw-labs <bot@zeroclaw.dev>
pkgname=zeroclaw
pkgver=0.4.3
pkgrel=1
pkgdesc="Zero overhead. Zero compromise. 100% Rust. The fastest, smallest AI assistant."
arch=('x86_64')
url="https://github.com/zeroclaw-labs/zeroclaw"
license=('MIT' 'Apache-2.0')
depends=('gcc-libs' 'openssl')
makedepends=('cargo' 'git')
source=("${pkgname}-${pkgver}.tar.gz::https://github.com/zeroclaw-labs/zeroclaw/archive/refs/tags/v${pkgver}.tar.gz")
sha256sums=('SKIP')
prepare() {
cd "${pkgname}-${pkgver}"
export RUSTUP_TOOLCHAIN=stable
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd "${pkgname}-${pkgver}"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo build --frozen --release --profile dist
}
package() {
cd "${pkgname}-${pkgver}"
install -Dm0755 -t "${pkgdir}/usr/bin/" "target/dist/zeroclaw"
install -Dm0644 LICENSE-MIT "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE-MIT"
install -Dm0644 LICENSE-APACHE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE-APACHE"
}
+27
View File
@@ -0,0 +1,27 @@
{
"version": "0.4.3",
"description": "Zero overhead. Zero compromise. 100% Rust. The fastest, smallest AI assistant.",
"homepage": "https://github.com/zeroclaw-labs/zeroclaw",
"license": "MIT|Apache-2.0",
"architecture": {
"64bit": {
"url": "https://github.com/zeroclaw-labs/zeroclaw/releases/download/v0.4.3/zeroclaw-x86_64-pc-windows-msvc.zip",
"hash": "",
"bin": "zeroclaw.exe"
}
},
"checkver": {
"github": "https://github.com/zeroclaw-labs/zeroclaw"
},
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/zeroclaw-labs/zeroclaw/releases/download/v$version/zeroclaw-x86_64-pc-windows-msvc.zip"
}
},
"hash": {
"url": "https://github.com/zeroclaw-labs/zeroclaw/releases/download/v$version/SHA256SUMS",
"regex": "([a-f0-9]{64})\\s+zeroclaw-x86_64-pc-windows-msvc\\.zip"
}
}
}
+3
View File
@@ -10,6 +10,9 @@
services:
zeroclaw:
image: ghcr.io/zeroclaw-labs/zeroclaw:latest
# For ARM64 environments where the distroless image exits immediately,
# switch to the Debian compatibility image instead:
# image: ghcr.io/zeroclaw-labs/zeroclaw:debian
# Or build locally (distroless, no shell):
# build: .
# Or build the Debian variant (includes bash, git, curl):
+16 -6
View File
@@ -37,6 +37,12 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
- `.github/workflows/pub-homebrew-core.yml` (`Pub Homebrew Core`)
- Purpose: manual, bot-owned Homebrew core formula bump PR flow for tagged releases
- Guardrail: release tag must match `Cargo.toml` version
- `.github/workflows/pub-scoop.yml` (`Pub Scoop Manifest`)
- Purpose: Scoop bucket manifest update for Windows; auto-called by stable release, also manual dispatch
- Guardrail: release tag must be `vX.Y.Z` format; Windows binary hash extracted from `SHA256SUMS`
- `.github/workflows/pub-aur.yml` (`Pub AUR Package`)
- Purpose: AUR PKGBUILD push for Arch Linux; auto-called by stable release, also manual dispatch
- Guardrail: release tag must be `vX.Y.Z` format; source tarball SHA256 computed at publish time
- `.github/workflows/pr-label-policy-check.yml` (`Label Policy Sanity`)
- Purpose: validate shared contributor-tier policy in `.github/label-policy.json` and ensure label workflows consume that policy
- `.github/workflows/test-rust-build.yml` (`Rust Reusable Job`)
@@ -75,6 +81,8 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
- `Docker`: tag push (`v*`) for publish, matching PRs to `master` for smoke build, manual dispatch for smoke only
- `Release`: tag push (`v*`), weekly schedule (verification-only), manual dispatch (verification or publish)
- `Pub Homebrew Core`: manual dispatch only
- `Pub Scoop Manifest`: auto-called by stable release, also manual dispatch
- `Pub AUR Package`: auto-called by stable release, also manual dispatch
- `Security Audit`: push to `master`, PRs to `master`, weekly schedule
- `Sec Vorpal Reviewdog`: manual dispatch only
- `Workflow Sanity`: PR/push when `.github/workflows/**`, `.github/*.yml`, or `.github/*.yaml` change
@@ -92,12 +100,14 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
2. Docker failures on PRs: inspect `.github/workflows/pub-docker-img.yml` `pr-smoke` job.
3. Release failures (tag/manual/scheduled): inspect `.github/workflows/pub-release.yml` and the `prepare` job outputs.
4. Homebrew formula publish failures: inspect `.github/workflows/pub-homebrew-core.yml` summary output and bot token/fork variables.
5. Security failures: inspect `.github/workflows/sec-audit.yml` and `deny.toml`.
6. Workflow syntax/lint failures: inspect `.github/workflows/workflow-sanity.yml`.
7. PR intake failures: inspect `.github/workflows/pr-intake-checks.yml` sticky comment and run logs.
8. Label policy parity failures: inspect `.github/workflows/pr-label-policy-check.yml`.
9. Docs failures in CI: inspect `docs-quality` job logs in `.github/workflows/ci-run.yml`.
10. Strict delta lint failures in CI: inspect `lint-strict-delta` job logs and compare with `BASE_SHA` diff scope.
5. Scoop manifest publish failures: inspect `.github/workflows/pub-scoop.yml` summary output and `SCOOP_BUCKET_REPO`/`SCOOP_BUCKET_TOKEN` settings.
6. AUR package publish failures: inspect `.github/workflows/pub-aur.yml` summary output and `AUR_SSH_KEY` secret.
7. Security failures: inspect `.github/workflows/sec-audit.yml` and `deny.toml`.
8. Workflow syntax/lint failures: inspect `.github/workflows/workflow-sanity.yml`.
9. PR intake failures: inspect `.github/workflows/pr-intake-checks.yml` sticky comment and run logs.
10. Label policy parity failures: inspect `.github/workflows/pr-label-policy-check.yml`.
11. Docs failures in CI: inspect `docs-quality` job logs in `.github/workflows/ci-run.yml`.
12. Strict delta lint failures in CI: inspect `lint-strict-delta` job logs and compare with `BASE_SHA` diff scope.
## Maintenance Rules
+37
View File
@@ -23,6 +23,8 @@ Release automation lives in:
- `.github/workflows/pub-release.yml`
- `.github/workflows/pub-homebrew-core.yml` (manual Homebrew formula PR, bot-owned)
- `.github/workflows/pub-scoop.yml` (manual Scoop bucket manifest update)
- `.github/workflows/pub-aur.yml` (manual AUR PKGBUILD push)
Modes:
@@ -115,6 +117,41 @@ Workflow guardrails:
- formula license is normalized to `Apache-2.0 OR MIT`
- PR is opened from the bot fork into `Homebrew/homebrew-core:master`
### 7) Publish Scoop manifest (Windows)
Run `Pub Scoop Manifest` manually:
- `release_tag`: `vX.Y.Z`
- `dry_run`: `true` first, then `false`
Required repository settings for non-dry-run:
- secret: `SCOOP_BUCKET_TOKEN` (PAT with push access to the bucket repo)
- variable: `SCOOP_BUCKET_REPO` (for example `zeroclaw-labs/scoop-zeroclaw`)
Workflow guardrails:
- release tag must be `vX.Y.Z` format
- Windows binary SHA256 extracted from `SHA256SUMS` release asset
- manifest pushed to `bucket/zeroclaw.json` in the Scoop bucket repo
### 8) Publish AUR package (Arch Linux)
Run `Pub AUR Package` manually:
- `release_tag`: `vX.Y.Z`
- `dry_run`: `true` first, then `false`
Required repository settings for non-dry-run:
- secret: `AUR_SSH_KEY` (SSH private key registered with AUR)
Workflow guardrails:
- release tag must be `vX.Y.Z` format
- source tarball SHA256 computed from the tagged release
- PKGBUILD and .SRCINFO pushed to AUR `zeroclaw` package
## Emergency / Recovery Path
If tag-push release fails after artifacts are validated:
+1 -1
View File
@@ -31,7 +31,7 @@ Build with `--features hardware` to include Uno Q support.
### 1.1 Configure Uno Q via App Lab
1. Download [Arduino App Lab](https://docs.arduino.cc/software/app-lab/) (AppImage on Linux).
1. Download [Arduino App Lab](https://docs.arduino.cc/software/app-lab/) (tar.gz on Linux).
2. Connect Uno Q via USB, power it on.
3. Open App Lab, connect to the board.
4. Follow the setup wizard:
+3 -4
View File
@@ -1,15 +1,14 @@
# ZeroClaw i18n Docs Index
Canonical localized documentation trees live here.
Localized documentation trees live here and under `docs/`.
## Locales
- Vietnamese: [vi/README.md](vi/README.md)
- Vietnamese (canonical): [`docs/vi/`](../vi/)
- Chinese (Simplified): [`docs/i18n/zh-CN/`](zh-CN/)
## Structure
- Docs structure map (language/part/function): [../maintainers/structure-README.md](../maintainers/structure-README.md)
- Canonical Vietnamese tree: `docs/i18n/vi/`
- Compatibility Vietnamese paths: `docs/vi/` and `docs/*.vi.md`
See overall coverage and conventions in [../maintainers/i18n-coverage.md](../maintainers/i18n-coverage.md).
-114
View File
@@ -1,114 +0,0 @@
# Οδηγός Ρυθμίσεων ZeroClaw (config.toml)
Αυτός ο οδηγός εξηγεί τις πιο σημαντικές ρυθμίσεις που μπορείτε να κάνετε στο αρχείο `config.toml`.
Τελευταίος έλεγχος: **19 Φεβρουαρίου 2026**.
## Πού βρίσκεται το αρχείο ρυθμίσεων;
Το ZeroClaw ψάχνει για τις ρυθμίσεις με την εξής σειρά:
1. Στη διαδρομή που ορίζει η μεταβλητή `ZEROCLAW_WORKSPACE`.
2. Στο αρχείο `~/.zeroclaw/config.toml` (αυτή είναι η συνηθισμένη θέση).
## Βασικές Ρυθμίσεις (Core)
| Ρύθμιση | Τι ορίζει |
|---|---|
| `default_provider` | Ποιον πάροχο AI χρησιμοποιείτε (π.χ. `openai`, `ollama`). |
| `default_model` | Ποιο συγκεκριμένο μοντέλο AI χρησιμοποιείτε (π.χ. `gpt-4o`). |
| `default_temperature` | Πόσο "δημιουργική" θα είναι η AI (τιμή από 0 έως 2). |
## 1. Συμπεριφορά της AI (Agent)
- `max_tool_iterations`: Πόσες φορές μπορεί η AI να χρησιμοποιήσει εργαλεία για να απαντήσει σε 1 μήνυμα (προεπιλογή: 10).
- `max_history_messages`: Πόσα προηγούμενα μηνύματα θυμάται η AI στη συνομιλία (προεπιλογή: 50).
## 2. Αυτονομία και Ασφάλεια (Autonomy)
Εδώ ρυθμίζετε πόση ελευθερία έχει η AI να κάνει αλλαγές στον υπολογιστή σας.
- `level`:
- `read_only`: Μπορεί μόνο να διαβάζει αρχεία.
- `supervised`: Χρειάζεται την έγκρισή σας για σημαντικές ενέργειες (προεπιλογή).
- `full`: Μπορεί να τρέχει εντολές ελεύθερα (προσοχή!).
- `allowed_commands`: Λίστα με τις εντολές που επιτρέπεται να τρέχει η AI.
- `forbidden_paths`: Φάκελοι που η AI **δεν** επιτρέπεται να αγγίξει (π.χ. `/etc`).
## 3. Μνήμη (Memory)
Πώς αποθηκεύει η AI τις πληροφορίες που της δίνετε.
- `backend`: Μπορεί να είναι `sqlite` (βάση δεδομένων), `markdown` (απλά αρχεία κειμένου) ή `none` (καμία μνήμη).
## 4. Κανάλια Επικοινωνίας (Channels)
Κάθε κανάλι (Telegram, Discord κ.λπ.) έχει τη δική του ενότητα στο αρχείο.
Παράδειγμα για το **Telegram**:
```toml
[channels_config.telegram]
bot_token = "το-κλειδί-σας"
allowed_users = ["το-όνομά-σας"] # Ποιοι επιτρέπεται να μιλάνε στο bot
```
## 5. Έλεγχος Κόστους (Cost)
Αν χρησιμοποιείτε πληρωμένες υπηρεσίες AI, μπορείτε να βάλετε όρια.
- `daily_limit_usd`: Μέγιστο ποσό ανά ημέρα (π.χ. 10.00 δολάρια).
- `monthly_limit_usd`: Μέγιστο ποσό ανά μήνα.
## 6. Εικόνες (Multimodal)
Ρυθμίσεις για το πώς η AI βλέπει εικόνες.
- `max_images`: Μέγιστος αριθμός εικόνων ανά μήνυμα.
- `allow_remote_fetch`: Αν επιτρέπεται στην AI να κατεβάζει εικόνες από το ίντερνετ μέσω συνδέσμων (links).
---
## Συμβουλές
- Αν αλλάξετε το αρχείο `config.toml`, πρέπει να κάνετε επανεκκίνηση το ZeroClaw για να δει τις αλλαγές.
- Χρησιμοποιήστε την εντολή `zeroclaw doctor` για να βεβαιωθείτε ότι οι ρυθμίσεις σας είναι σωστές.
## Ενημέρωση (2026-03-03)
- Στην ενότητα `[agent]` προστέθηκαν τα `allowed_tools` και `denied_tools`.
- Αν το `allowed_tools` δεν είναι κενό, ο primary agent βλέπει μόνο τα εργαλεία της λίστας.
- Το `denied_tools` εφαρμόζεται μετά το allowlist και αφαιρεί επιπλέον εργαλεία.
- Άγνωστες τιμές στο `allowed_tools` αγνοούνται (με debug log) και δεν μπλοκάρουν την εκκίνηση.
- Αν `allowed_tools` και `denied_tools` καταλήξουν να αφαιρέσουν όλα τα εκτελέσιμα εργαλεία, η εκκίνηση αποτυγχάνει άμεσα με σαφές μήνυμα ρύθμισης.
- Για πλήρη πίνακα πεδίων και παράδειγμα, δείτε το αγγλικό `config-reference.md` στην ενότητα `[agent]`.
- Μην μοιράζεστε ποτέ το αρχείο `config.toml` με άλλους, καθώς περιέχει τα μυστικά κλειδιά σας (tokens).
## Ενημέρωση (2026-03-12)
- Στην ενότητα `[agent]` προστέθηκε το `tool_filter_groups` για φιλτράρισμα schema tool MCP ανά γύρο.
### `tool_filter_groups`
Μειώνει τα tokens ανά γύρο περιορίζοντας ποια schema tool MCP αποστέλλονται στο LLM. Τα ενσωματωμένα εργαλεία (χωρίς πρόθεμα `mcp_`) περνούν πάντα αναλλοίωτα.
Κάθε εγγραφή είναι πίνακας με τα εξής πεδία:
| Πεδίο | Τύπος | Σκοπός |
|---|---|---|
| `mode` | `"always"` \| `"dynamic"` | `always`: το εργαλείο συμπεριλαμβάνεται πάντα. `dynamic`: συμπεριλαμβάνεται μόνο όταν το μήνυμα χρήστη περιέχει λέξη-κλειδί. |
| `tools` | `[string]` | Μοτίβα ονόματος εργαλείου. Υποστηρίζεται ένα `*` wildcard (π.χ. `"mcp_vikunja_*"`). |
| `keywords` | `[string]` | (Μόνο για dynamic) Υποαλφαριθμητικά χωρίς διάκριση πεζών-κεφαλαίων που αντιστοιχούν στο τελευταίο μήνυμα χρήστη. |
Όταν το `tool_filter_groups` είναι κενό, η λειτουργία είναι ανενεργή και όλα τα εργαλεία περνούν κανονικά (συμβατό με προηγούμενες εκδόσεις).
Παράδειγμα:
```toml
[agent]
# Τα εργαλεία MCP Vikunja είναι πάντα διαθέσιμα.
[[agent.tool_filter_groups]]
mode = "always"
tools = ["mcp_vikunja_*"]
# Τα εργαλεία MCP browser συμπεριλαμβάνονται μόνο όταν ο χρήστης αναφέρει πλοήγηση.
[[agent.tool_filter_groups]]
mode = "dynamic"
tools = ["mcp_browser_*"]
keywords = ["περιήγηση", "πλοήγηση", "άνοιγμα url", "στιγμιότυπο"]
```
-94
View File
@@ -1,94 +0,0 @@
# Tài liệu ZeroClaw (Tiếng Việt)
Đây là trang chủ tiếng Việt của hệ thống tài liệu.
Đồng bộ lần cuối: **2026-02-21**.
> Lưu ý: Tên lệnh, khóa cấu hình và đường dẫn API giữ nguyên tiếng Anh. Khi có sai khác, tài liệu tiếng Anh là bản gốc.
## Tra cứu nhanh
| Tôi muốn… | Xem tài liệu |
|---|---|
| Cài đặt và chạy nhanh | [../../../README.vi.md](../../../README.vi.md) / [../../../README.md](../../../README.md) |
| Cài đặt bằng một lệnh | [one-click-bootstrap.md](one-click-bootstrap.md) |
| Tìm lệnh theo tác vụ | [commands-reference.md](commands-reference.md) |
| Kiểm tra giá trị mặc định và khóa cấu hình | [config-reference.md](config-reference.md) |
| Kết nối provider / endpoint tùy chỉnh | [custom-providers.md](custom-providers.md) |
| Cấu hình Z.AI / GLM provider | [zai-glm-setup.md](zai-glm-setup.md) |
| Sử dụng tích hợp LangGraph | [langgraph-integration.md](langgraph-integration.md) |
| Vận hành hàng ngày (runbook) | [operations-runbook.md](operations-runbook.md) |
| Khắc phục sự cố cài đặt/chạy/kênh | [troubleshooting.md](troubleshooting.md) |
| Cấu hình Matrix phòng mã hóa (E2EE) | [matrix-e2ee-guide.md](matrix-e2ee-guide.md) |
| Xem theo danh mục | [SUMMARY.md](SUMMARY.md) |
| Xem bản chụp PR/Issue | [project-triage-snapshot-2026-02-18.md](../../maintainers/project-triage-snapshot-2026-02-18.md) |
## Tìm nhanh
- Cài đặt lần đầu hoặc khởi động nhanh → [getting-started/README.md](getting-started/README.md)
- Cần tra cứu lệnh CLI / khóa cấu hình → [reference/README.md](reference/README.md)
- Cần vận hành / triển khai sản phẩm → [operations/README.md](operations/README.md)
- Gặp lỗi hoặc hồi quy → [troubleshooting.md](troubleshooting.md)
- Tìm hiểu bảo mật và lộ trình → [security/README.md](security/README.md)
- Làm việc với bo mạch / thiết bị ngoại vi → [hardware/README.md](hardware/README.md)
- Đóng góp / review / quy trình CI → [contributing/README.md](contributing/README.md)
- Xem toàn bộ bản đồ tài liệu → [SUMMARY.md](SUMMARY.md)
## Theo danh mục
- Bắt đầu: [getting-started/README.md](getting-started/README.md)
- Tra cứu: [reference/README.md](reference/README.md)
- Vận hành & triển khai: [operations/README.md](operations/README.md)
- Bảo mật: [security/README.md](security/README.md)
- Phần cứng & ngoại vi: [hardware/README.md](hardware/README.md)
- Đóng góp & CI: [contributing/README.md](contributing/README.md)
- Ảnh chụp dự án: [project/README.md](project/README.md)
## Theo vai trò
### Người dùng / Vận hành
- [commands-reference.md](commands-reference.md) — tra cứu lệnh theo tác vụ
- [providers-reference.md](providers-reference.md) — ID provider, bí danh, biến môi trường xác thực
- [channels-reference.md](channels-reference.md) — khả năng kênh và hướng dẫn thiết lập
- [matrix-e2ee-guide.md](matrix-e2ee-guide.md) — thiết lập phòng mã hóa Matrix (E2EE)
- [config-reference.md](config-reference.md) — khóa cấu hình quan trọng và giá trị mặc định an toàn
- [custom-providers.md](custom-providers.md) — mẫu tích hợp provider / base URL tùy chỉnh
- [zai-glm-setup.md](zai-glm-setup.md) — thiết lập Z.AI/GLM và ma trận endpoint
- [langgraph-integration.md](langgraph-integration.md) — tích hợp dự phòng cho model/tool-calling
- [operations-runbook.md](operations-runbook.md) — vận hành runtime hàng ngày và quy trình rollback
- [troubleshooting.md](troubleshooting.md) — dấu hiệu lỗi thường gặp và cách khắc phục
### Người đóng góp / Bảo trì
- [CONTRIBUTING.md](../../../CONTRIBUTING.md)
- [pr-workflow.md](pr-workflow.md)
- [reviewer-playbook.md](reviewer-playbook.md)
- [ci-map.md](ci-map.md)
- [actions-source-policy.md](actions-source-policy.md)
### Bảo mật / Độ tin cậy
> Lưu ý: Mục này gồm tài liệu đề xuất/lộ trình, có thể chứa lệnh hoặc cấu hình chưa triển khai. Để biết hành vi thực tế, xem [config-reference.md](config-reference.md), [operations-runbook.md](operations-runbook.md) và [troubleshooting.md](troubleshooting.md) trước.
- [security/README.md](security/README.md)
- [agnostic-security.md](agnostic-security.md)
- [frictionless-security.md](frictionless-security.md)
- [sandboxing.md](sandboxing.md)
- [audit-logging.md](audit-logging.md)
- [resource-limits.md](resource-limits.md)
- [security-roadmap.md](security-roadmap.md)
## Quản lý tài liệu
- Mục lục thống nhất (TOC): [SUMMARY.md](SUMMARY.md)
- Bản đồ cấu trúc docs (ngôn ngữ/phần/chức năng): [../../maintainers/structure-README.md](../../maintainers/structure-README.md)
- Danh mục và phân loại tài liệu: [docs-inventory.md](../../maintainers/docs-inventory.md)
## Ngôn ngữ khác
- English: [README.md](../../README.md)
- 简体中文: [README.zh-CN.md](../../README.zh-CN.md)
- 日本語: [README.ja.md](../../README.ja.md)
- Русский: [README.ru.md](../../README.ru.md)
- Français: [README.fr.md](../../README.fr.md)
-78
View File
@@ -1,78 +0,0 @@
# Mục lục tài liệu ZeroClaw (Tiếng Việt)
Đây là mục lục thống nhất cho hệ thống tài liệu tiếng Việt.
Đồng bộ lần cuối: **2026-02-21**.
## Điểm vào
- Bản đồ cấu trúc docs (ngôn ngữ/phần/chức năng): [../../maintainers/structure-README.md](../../maintainers/structure-README.md)
- README tiếng Việt: [../../../README.vi.md](../../../README.vi.md)
- Docs hub tiếng Việt: [README.md](README.md)
## Danh mục
### 1) Bắt đầu
- [getting-started/README.md](getting-started/README.md)
- [one-click-bootstrap.md](one-click-bootstrap.md)
### 2) Lệnh / Cấu hình / Tích hợp
- [reference/README.md](reference/README.md)
- [commands-reference.md](commands-reference.md)
- [providers-reference.md](providers-reference.md)
- [channels-reference.md](channels-reference.md)
- [config-reference.md](config-reference.md)
- [custom-providers.md](custom-providers.md)
- [zai-glm-setup.md](zai-glm-setup.md)
- [langgraph-integration.md](langgraph-integration.md)
### 3) Vận hành & Triển khai
- [operations/README.md](operations/README.md)
- [operations-runbook.md](operations-runbook.md)
- [release-process.md](release-process.md)
- [troubleshooting.md](troubleshooting.md)
- [network-deployment.md](network-deployment.md)
- [mattermost-setup.md](mattermost-setup.md)
- [matrix-e2ee-guide.md](matrix-e2ee-guide.md)
### 4) Bảo mật
- [security/README.md](security/README.md)
- [agnostic-security.md](agnostic-security.md)
- [frictionless-security.md](frictionless-security.md)
- [sandboxing.md](sandboxing.md)
- [resource-limits.md](resource-limits.md)
- [audit-logging.md](audit-logging.md)
- [security-roadmap.md](security-roadmap.md)
### 5) Phần cứng & Ngoại vi
- [hardware/README.md](hardware/README.md)
- [hardware-peripherals-design.md](hardware-peripherals-design.md)
- [adding-boards-and-tools.md](adding-boards-and-tools.md)
- [nucleo-setup.md](nucleo-setup.md)
- [arduino-uno-q-setup.md](arduino-uno-q-setup.md)
- [datasheets/nucleo-f401re.md](datasheets/nucleo-f401re.md)
- [datasheets/arduino-uno.md](datasheets/arduino-uno.md)
- [datasheets/esp32.md](datasheets/esp32.md)
### 6) Đóng góp & CI
- [contributing/README.md](contributing/README.md)
- [CONTRIBUTING.md](../../../CONTRIBUTING.md)
- [pr-workflow.md](pr-workflow.md)
- [reviewer-playbook.md](reviewer-playbook.md)
- [ci-map.md](ci-map.md)
- [actions-source-policy.md](actions-source-policy.md)
### 7) Dự án
- [project/README.md](project/README.md)
- [proxy-agent-playbook.md](proxy-agent-playbook.md)
## Ngôn ngữ khác
- English TOC: [../../SUMMARY.md](../../SUMMARY.md)
-95
View File
@@ -1,95 +0,0 @@
# Chính sách nguồn Actions (Giai đoạn 1)
Tài liệu này định nghĩa chính sách kiểm soát nguồn GitHub Actions hiện tại cho repository này.
Mục tiêu Giai đoạn 1: khóa nguồn action với ít gián đoạn nhất, trước khi pin SHA đầy đủ.
## Chính sách hiện tại
- Quyền Actions repository: được bật
- Chế độ action cho phép: đã chọn
- Yêu cầu pin SHA: false (hoãn đến Giai đoạn 2)
Các mẫu allowlist được chọn:
- `actions/*` (bao gồm `actions/cache`, `actions/checkout`, `actions/upload-artifact`, `actions/download-artifact` và các first-party action khác)
- `docker/*`
- `dtolnay/rust-toolchain@*`
- `DavidAnson/markdownlint-cli2-action@*`
- `lycheeverse/lychee-action@*`
- `EmbarkStudios/cargo-deny-action@*`
- `rustsec/audit-check@*`
- `rhysd/actionlint@*`
- `softprops/action-gh-release@*`
- `sigstore/cosign-installer@*`
- `useblacksmith/*` (cơ sở hạ tầng self-hosted runner Blacksmith)
## Xuất kiểm soát thay đổi
Dùng các lệnh sau để xuất chính sách hiệu lực hiện tại phục vụ kiểm toán/kiểm soát thay đổi:
```bash
gh api repos/zeroclaw-labs/zeroclaw/actions/permissions
gh api repos/zeroclaw-labs/zeroclaw/actions/permissions/selected-actions
```
Ghi lại mỗi thay đổi chính sách với:
- ngày/giờ thay đổi (UTC)
- tác nhân
- lý do
- delta allowlist (mẫu được thêm/xóa)
- ghi chú rollback
## Lý do giai đoạn này
- Giảm rủi ro chuỗi cung ứng từ các marketplace action chưa được review.
- Bảo tồn chức năng CI/CD hiện tại với chi phí migration thấp.
- Chuẩn bị cho Giai đoạn 2 pin SHA đầy đủ mà không chặn phát triển đang diễn ra.
## Bảo vệ workflow agentic
Vì repository này có khối lượng thay đổi do agent tạo ra cao:
- Mọi PR thêm hoặc thay đổi nguồn action `uses:` phải bao gồm ghi chú tác động allowlist.
- Các action bên thứ ba mới yêu cầu review maintainer tường minh trước khi đưa vào allowlist.
- Chỉ mở rộng allowlist cho các action bị thiếu đã được xác minh; tránh các ngoại lệ wildcard rộng.
- Giữ hướng dẫn rollback trong mô tả PR cho các thay đổi chính sách Actions.
## Checklist xác thực
Sau khi thay đổi allowlist, xác thực:
1. `CI`
2. `Docker`
3. `Security Audit`
4. `Workflow Sanity`
5. `Release` (khi an toàn để chạy)
Failure mode cần chú ý:
- `action is not allowed by policy`
Nếu gặp phải, chỉ thêm action tin cậy còn thiếu cụ thể đó, chạy lại và ghi lại lý do.
Ghi chú quét gần đây nhất:
- 2026-02-17: Cache phụ thuộc Rust được migrate từ `Swatinem/rust-cache` sang `useblacksmith/rust-cache`
- Không cần mẫu allowlist mới (`useblacksmith/*` đã có trong allowlist)
- 2026-02-16: Phụ thuộc ẩn được phát hiện trong `release-beta-on-push.yml`: `sigstore/cosign-installer@...`
- Đã thêm mẫu allowlist: `sigstore/cosign-installer@*`
- 2026-02-16: Migration Blacksmith chặn thực thi workflow
- Đã thêm mẫu allowlist: `useblacksmith/*` cho cơ sở hạ tầng self-hosted runner
- Actions: `useblacksmith/setup-docker-builder@v1`, `useblacksmith/build-push-action@v2`
- 2026-02-17: Cập nhật cân bằng tính tái tạo/độ tươi của security audit
- Đã thêm mẫu allowlist: `rustsec/audit-check@*`
- Thay thế thực thi nội tuyến `cargo install cargo-audit` bằng `rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998` được pin trong `security.yml`
- Supersedes đề xuất phiên bản nổi trong #588 trong khi giữ chính sách nguồn action rõ ràng
## Rollback
Đường dẫn bỏ chặn khẩn cấp:
1. Tạm thời đặt chính sách Actions trở về `all`.
2. Khôi phục allowlist đã chọn sau khi xác định các mục còn thiếu.
3. Ghi lại sự cố và delta allowlist cuối cùng.
-116
View File
@@ -1,116 +0,0 @@
# Thêm Board và Tool — Hướng dẫn phần cứng ZeroClaw
Hướng dẫn này giải thích cách thêm board phần cứng mới và tool tùy chỉnh vào ZeroClaw.
## Bắt đầu nhanh: Thêm board qua CLI
```bash
# Thêm board (cập nhật ~/.zeroclaw/config.toml)
zeroclaw peripheral add nucleo-f401re /dev/ttyACM0
zeroclaw peripheral add arduino-uno /dev/cu.usbmodem12345
zeroclaw peripheral add rpi-gpio native # cho Raspberry Pi GPIO (Linux)
# Khởi động lại daemon để áp dụng
zeroclaw daemon --host 127.0.0.1 --port 3000
```
## Các board được hỗ trợ
| Board | Transport | Ví dụ đường dẫn |
|-------|-----------|-----------------|
| nucleo-f401re | serial | /dev/ttyACM0, /dev/cu.usbmodem* |
| arduino-uno | serial | /dev/ttyACM0, /dev/cu.usbmodem* |
| arduino-uno-q | bridge | (IP của Uno Q) |
| rpi-gpio | native | native |
| esp32 | serial | /dev/ttyUSB0 |
## Cấu hình thủ công
Chỉnh sửa `~/.zeroclaw/config.toml`:
```toml
[peripherals]
enabled = true
datasheet_dir = "docs/datasheets" # tùy chọn: RAG cho "turn on red led" → pin 13
[[peripherals.boards]]
board = "nucleo-f401re"
transport = "serial"
path = "/dev/ttyACM0"
baud = 115200
[[peripherals.boards]]
board = "arduino-uno"
transport = "serial"
path = "/dev/cu.usbmodem12345"
baud = 115200
```
## Thêm Datasheet (RAG)
Đặt file `.md` hoặc `.txt` vào `docs/datasheets/` (hoặc `datasheet_dir` của bạn). Đặt tên file theo board: `nucleo-f401re.md`, `arduino-uno.md`.
### Pin Aliases (Khuyến nghị)
Thêm mục `## Pin Aliases` để agent có thể ánh xạ "red led" → pin 13:
```markdown
# My Board
## Pin Aliases
| alias | pin |
|-------------|-----|
| red_led | 13 |
| builtin_led | 13 |
| user_led | 5 |
```
Hoặc dùng định dạng key-value:
```markdown
## Pin Aliases
red_led: 13
builtin_led: 13
```
### PDF Datasheets
Với feature `rag-pdf`, ZeroClaw có thể lập chỉ mục file PDF:
```bash
cargo build --features hardware,rag-pdf
```
Đặt file PDF vào thư mục datasheet. Chúng sẽ được trích xuất và chia nhỏ thành các đoạn cho RAG.
## Thêm loại board mới
1. **Tạo datasheet**`docs/datasheets/my-board.md` với pin aliases và thông tin GPIO.
2. **Thêm vào config**`zeroclaw peripheral add my-board /dev/ttyUSB0`
3. **Triển khai peripheral** (tùy chọn) — Với giao thức tùy chỉnh, hãy implement trait `Peripheral` trong `src/peripherals/` và đăng ký trong `create_peripheral_tools`.
Xem `docs/hardware-peripherals-design.md` để hiểu toàn bộ thiết kế.
## Thêm Tool tùy chỉnh
1. Implement trait `Tool` trong `src/tools/`.
2. Đăng ký trong `create_peripheral_tools` (với hardware tool) hoặc tool registry của agent.
3. Thêm mô tả tool vào `tool_descs` của agent trong `src/agent/loop_.rs`.
## Tham chiếu CLI
| Lệnh | Mô tả |
|------|-------|
| `zeroclaw peripheral list` | Liệt kê các board đã cấu hình |
| `zeroclaw peripheral add <board> <path>` | Thêm board (ghi vào config) |
| `zeroclaw peripheral flash` | Nạp firmware Arduino |
| `zeroclaw peripheral flash-nucleo` | Nạp firmware Nucleo |
| `zeroclaw hardware discover` | Liệt kê thiết bị USB |
| `zeroclaw hardware info` | Thông tin chip qua probe-rs |
## Xử lý sự cố
- **Không tìm thấy serial port** — Trên macOS dùng `/dev/cu.usbmodem*`; trên Linux dùng `/dev/ttyACM0` hoặc `/dev/ttyUSB0`.
- **Build với hardware** — `cargo build --features hardware`
- **probe-rs cho Nucleo** — `cargo build --features hardware,probe`
-355
View File
@@ -1,355 +0,0 @@
# Bảo mật không phụ thuộc nền tảng
> ⚠️ **Trạng thái: Đề xuất / Lộ trình**
>
> Tài liệu này mô tả các hướng tiếp cận đề xuất và có thể bao gồm các lệnh hoặc cấu hình giả định.
> Để biết hành vi runtime hiện tại, xem [config-reference.md](config-reference.md), [operations-runbook.md](operations-runbook.md), và [troubleshooting.md](troubleshooting.md).
## Câu hỏi cốt lõi: liệu các tính năng bảo mật có làm hỏng
1. ❓ Quá trình cross-compilation nhanh?
2. ❓ Kiến trúc pluggable (hoán đổi bất kỳ thành phần nào)?
3. ❓ Tính agnostic phần cứng (ARM, x86, RISC-V)?
4. ❓ Hỗ trợ phần cứng nhỏ (<5MB RAM, board $10)?
**Câu trả lời: KHÔNG với tất cả** — Bảo mật được thiết kế dưới dạng **feature flags tùy chọn** với **conditional compilation theo từng nền tảng**.
---
## 1. Tốc độ build: bảo mật ẩn sau feature flag
### Cargo.toml: các tính năng bảo mật đặt sau features
```toml
[features]
default = ["basic-security"]
# Basic security (luôn bật, không tốn overhead)
basic-security = []
# Platform-specific sandboxing (opt-in theo từng nền tảng)
sandbox-landlock = [] # Chỉ Linux
sandbox-firejail = [] # Chỉ Linux
sandbox-bubblewrap = []# macOS/Linux
sandbox-docker = [] # Tất cả nền tảng (nặng)
# Bộ bảo mật đầy đủ (dành cho production build)
security-full = [
"basic-security",
"sandbox-landlock",
"resource-monitoring",
"audit-logging",
]
# Resource & audit monitoring
resource-monitoring = []
audit-logging = []
# Development build (nhanh nhất, không phụ thuộc thêm)
dev = []
```
### Lệnh build (chọn profile phù hợp)
```bash
# Dev build cực nhanh (không có extras bảo mật)
cargo build --profile dev
# Release build với basic security (mặc định)
cargo build --release
# → Bao gồm: allowlist, path blocking, injection protection
# → Không bao gồm: Landlock, Firejail, audit logging
# Production build với full security
cargo build --release --features security-full
# → Bao gồm: Tất cả
# Chỉ sandbox theo nền tảng cụ thể
cargo build --release --features sandbox-landlock # Linux
cargo build --release --features sandbox-docker # Tất cả nền tảng
```
### Conditional compilation: không overhead khi tắt
```rust
// src/security/mod.rs
#[cfg(feature = "sandbox-landlock")]
mod landlock;
#[cfg(feature = "sandbox-landlock")]
pub use landlock::LandlockSandbox;
#[cfg(feature = "sandbox-firejail")]
mod firejail;
#[cfg(feature = "sandbox-firejail")]
pub use firejail::FirejailSandbox;
// Basic security luôn được include (không cần feature flag)
pub mod policy; // allowlist, path blocking, injection protection
```
**Kết quả**: Khi các feature bị tắt, code thậm chí không được biên dịch — **binary hoàn toàn không bị phình to**.
---
## 2. Kiến trúc pluggable: bảo mật cũng là một trait
### Security backend trait (hoán đổi như mọi thứ khác)
```rust
// src/security/traits.rs
#[async_trait]
pub trait Sandbox: Send + Sync {
/// Bọc lệnh với lớp bảo vệ sandbox
fn wrap_command(&self, cmd: &mut std::process::Command) -> std::io::Result<()>;
/// Kiểm tra sandbox có khả dụng trên nền tảng này không
fn is_available(&self) -> bool;
/// Tên dễ đọc
fn name(&self) -> &str;
}
// No-op sandbox (luôn khả dụng)
pub struct NoopSandbox;
impl Sandbox for NoopSandbox {
fn wrap_command(&self, _cmd: &mut std::process::Command) -> std::io::Result<()> {
Ok(()) // Pass-through, không thay đổi
}
fn is_available(&self) -> bool { true }
fn name(&self) -> &str { "none" }
}
```
### Factory pattern: tự động chọn dựa trên features
```rust
// src/security/factory.rs
pub fn create_sandbox() -> Box<dyn Sandbox> {
#[cfg(feature = "sandbox-landlock")]
{
if LandlockSandbox::is_available() {
return Box::new(LandlockSandbox::new());
}
}
#[cfg(feature = "sandbox-firejail")]
{
if FirejailSandbox::is_available() {
return Box::new(FirejailSandbox::new());
}
}
#[cfg(feature = "sandbox-bubblewrap")]
{
if BubblewrapSandbox::is_available() {
return Box::new(BubblewrapSandbox::new());
}
}
#[cfg(feature = "sandbox-docker")]
{
if DockerSandbox::is_available() {
return Box::new(DockerSandbox::new());
}
}
// Fallback: luôn khả dụng
Box::new(NoopSandbox)
}
```
**Giống như providers, channels và memory — bảo mật cũng là pluggable!**
---
## 3. Agnostic phần cứng: cùng binary, nhiều nền tảng
### Ma trận hành vi đa nền tảng
| Nền tảng | Build trên | Hành vi runtime |
|----------|-----------|------------------|
| **Linux ARM** (Raspberry Pi) | ✅ Có | Landlock → None (graceful) |
| **Linux x86_64** | ✅ Có | Landlock → Firejail → None |
| **macOS ARM** (M1/M2) | ✅ Có | Bubblewrap → None |
| **macOS x86_64** | ✅ Có | Bubblewrap → None |
| **Windows ARM** | ✅ Có | None (app-layer) |
| **Windows x86_64** | ✅ Có | None (app-layer) |
| **RISC-V Linux** | ✅ Có | Landlock → None |
### Cơ chế hoạt động: phát hiện tại runtime
```rust
// src/security/detect.rs
impl SandboxingStrategy {
/// Chọn sandbox tốt nhất có sẵn TẠI RUNTIME
pub fn detect() -> SandboxingStrategy {
#[cfg(target_os = "linux")]
{
// Thử Landlock trước (phát hiện tính năng kernel)
if Self::probe_landlock() {
return SandboxingStrategy::Landlock;
}
// Thử Firejail (phát hiện công cụ user-space)
if Self::probe_firejail() {
return SandboxingStrategy::Firejail;
}
}
#[cfg(target_os = "macos")]
{
if Self::probe_bubblewrap() {
return SandboxingStrategy::Bubblewrap;
}
}
// Fallback luôn khả dụng
SandboxingStrategy::ApplicationLayer
}
}
```
**Cùng một binary chạy ở khắp nơi** — chỉ tự điều chỉnh mức độ bảo vệ dựa trên những gì có sẵn.
---
## 4. Phần cứng nhỏ: phân tích tác động bộ nhớ
### Tác động kích thước binary (ước tính)
| Tính năng | Kích thước code | RAM overhead | Trạng thái |
|---------|-----------|--------------|--------|
| **ZeroClaw cơ bản** | 3.4MB | <5MB | ✅ Hiện tại |
| **+ Landlock** | +50KB | +100KB | ✅ Linux 5.13+ |
| **+ Firejail wrapper** | +20KB | +0KB (external) | ✅ Linux + firejail |
| **+ Memory monitoring** | +30KB | +50KB | ✅ Tất cả nền tảng |
| **+ Audit logging** | +40KB | +200KB (buffered) | ✅ Tất cả nền tảng |
| **Full security** | +140KB | +350KB | ✅ Vẫn <6MB tổng |
### Tương thích phần cứng $10
| Phần cứng | RAM | ZeroClaw (cơ bản) | ZeroClaw (full security) | Trạng thái |
|----------|-----|-----------------|--------------------------|--------|
| **Raspberry Pi Zero** | 512MB | ✅ 2% | ✅ 2.5% | Hoạt động |
| **Orange Pi Zero** | 512MB | ✅ 2% | ✅ 2.5% | Hoạt động |
| **NanoPi NEO** | 256MB | ✅ 4% | ✅ 5% | Hoạt động |
| **C.H.I.P.** | 512MB | ✅ 2% | ✅ 2.5% | Hoạt động |
| **Rock64** | 1GB | ✅ 1% | ✅ 1.2% | Hoạt động |
**Ngay cả với full security, ZeroClaw chỉ dùng <5% RAM trên board $10.**
---
## 5. Tính hoán đổi: mọi thứ vẫn pluggable
### Cam kết chính của ZeroClaw: hoán đổi bất kỳ thứ gì
```rust
// Providers (đã pluggable)
Box<dyn Provider>
// Channels (đã pluggable)
Box<dyn Channel>
// Memory (đã pluggable)
Box<dyn MemoryBackend>
// Tunnels (đã pluggable)
Box<dyn Tunnel>
// BÂY GIỜ CŨNG: Security (mới pluggable)
Box<dyn Sandbox>
Box<dyn Auditor>
Box<dyn ResourceMonitor>
```
### Hoán đổi security backend qua config
```toml
# Không dùng sandbox (nhanh nhất, chỉ app-layer)
[security.sandbox]
backend = "none"
# Dùng Landlock (Linux kernel LSM, native)
[security.sandbox]
backend = "landlock"
# Dùng Firejail (user-space, cần cài firejail)
[security.sandbox]
backend = "firejail"
# Dùng Docker (nặng nhất, cách ly hoàn toàn)
[security.sandbox]
backend = "docker"
```
**Giống như hoán đổi OpenAI sang Gemini, hay SQLite sang PostgreSQL.**
---
## 6. Tác động phụ thuộc: thêm tối thiểu
### Phụ thuộc hiện tại (để tham khảo)
```
reqwest, tokio, serde, anyhow, uuid, chrono, rusqlite,
axum, tracing, opentelemetry, ...
```
### Phụ thuộc của các security feature
| Tính năng | Phụ thuộc mới | Nền tảng |
|---------|------------------|----------|
| **Landlock** | `landlock` crate (pure Rust) | Chỉ Linux |
| **Firejail** | Không (binary ngoài) | Chỉ Linux |
| **Bubblewrap** | Không (binary ngoài) | macOS/Linux |
| **Docker** | `bollard` crate (Docker API) | Tất cả nền tảng |
| **Memory monitoring** | Không (std::alloc) | Tất cả nền tảng |
| **Audit logging** | Không (đã có hmac/sha2) | Tất cả nền tảng |
**Kết quả**: Hầu hết tính năng **không thêm phụ thuộc Rust mới** — chúng hoặc:
1. Dùng pure-Rust crate (landlock)
2. Bọc binary ngoài (Firejail, Bubblewrap)
3. Dùng phụ thuộc sẵn có (hmac, sha2 đã có trong Cargo.toml)
---
## Tóm tắt: các giá trị chính được bảo toàn
| Giá trị | Trước | Sau (có bảo mật) | Trạng thái |
|------------|--------|----------------------|--------|
| **<5MB RAM** | ✅ <5MB | ✅ <6MB (trường hợp xấu nhất) | ✅ Bảo toàn |
| **<10ms startup** | ✅ <10ms | ✅ <15ms (detection) | ✅ Bảo toàn |
| **3.4MB binary** | ✅ 3.4MB | ✅ 3.5MB (với tất cả features) | ✅ Bảo toàn |
| **ARM + x86 + RISC-V** | ✅ Tất cả | ✅ Tất cả | ✅ Bảo toàn |
| **Phần cứng $10** | ✅ Hoạt động | ✅ Hoạt động | ✅ Bảo toàn |
| **Pluggable everything** | ✅ Có | ✅ Có (cả bảo mật) | ✅ Cải thiện |
| **Cross-platform** | ✅ Có | ✅ Có | ✅ Bảo toàn |
---
## Điểm mấu chốt: feature flags + conditional compilation
```bash
# Developer build (nhanh nhất, không có extra feature)
cargo build --profile dev
# Standard release (build hiện tại của bạn)
cargo build --release
# Production với full security
cargo build --release --features security-full
# Nhắm đến phần cứng cụ thể
cargo build --release --target aarch64-unknown-linux-gnu # Raspberry Pi
cargo build --release --target riscv64gc-unknown-linux-gnu # RISC-V
cargo build --release --target armv7-unknown-linux-gnueabihf # ARMv7
```
**Mọi target, mọi nền tảng, mọi trường hợp sử dụng — vẫn nhanh, vẫn nhỏ, vẫn agnostic.**
-217
View File
@@ -1,217 +0,0 @@
# ZeroClaw trên Arduino Uno Q — Hướng dẫn từng bước
Chạy ZeroClaw trên phía Linux của Arduino Uno Q. Telegram hoạt động qua WiFi; điều khiển GPIO dùng Bridge (yêu cầu một ứng dụng App Lab tối giản).
---
## Những gì đã có sẵn (Không cần thay đổi code)
ZeroClaw bao gồm mọi thứ cần thiết cho Arduino Uno Q. **Clone repo và làm theo hướng dẫn này — không cần patch hay code tùy chỉnh nào.**
| Thành phần | Vị trí | Mục đích |
|------------|--------|---------|
| Bridge app | `firmware/uno-q-bridge/` | MCU sketch + Python socket server (port 9999) cho GPIO |
| Bridge tools | `src/peripherals/uno_q_bridge.rs` | Tool `gpio_read` / `gpio_write` giao tiếp với Bridge qua TCP |
| Setup command | `src/peripherals/uno_q_setup.rs` | `zeroclaw peripheral setup-uno-q` triển khai Bridge qua scp + arduino-app-cli |
| Config schema | `board = "arduino-uno-q"`, `transport = "bridge"` | Được hỗ trợ trong `config.toml` |
Build với `--features hardware` (hoặc features mặc định) để bao gồm hỗ trợ Uno Q.
---
## Yêu cầu trước khi bắt đầu
- Arduino Uno Q đã cấu hình WiFi
- Arduino App Lab đã cài trên Mac (để thiết lập và triển khai lần đầu)
- API key cho LLM (OpenRouter, v.v.)
---
## Phase 1: Thiết lập Uno Q lần đầu (Một lần duy nhất)
### 1.1 Cấu hình Uno Q qua App Lab
1. Tải [Arduino App Lab](https://docs.arduino.cc/software/app-lab/) (AppImage trên Linux).
2. Kết nối Uno Q qua USB, bật nguồn.
3. Mở App Lab, kết nối với board.
4. Làm theo hướng dẫn cài đặt:
- Đặt username và password (cho SSH)
- Cấu hình WiFi (SSID, password)
- Áp dụng các bản cập nhật firmware nếu có
5. Ghi lại địa chỉ IP hiển thị (ví dụ: `arduino@192.168.1.42`) hoặc tìm sau qua `ip addr show` trong terminal của App Lab.
### 1.2 Xác nhận truy cập SSH
```bash
ssh arduino@<UNO_Q_IP>
# Nhập password đã đặt
```
---
## Phase 2: Cài đặt ZeroClaw trên Uno Q
### Phương án A: Build trực tiếp trên thiết bị (Đơn giản hơn, ~2040 phút)
```bash
# SSH vào Uno Q
ssh arduino@<UNO_Q_IP>
# Cài Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source ~/.cargo/env
# Cài các gói phụ thuộc build (Debian)
sudo apt-get update
sudo apt-get install -y pkg-config libssl-dev
# Clone zeroclaw (hoặc scp project của bạn)
git clone https://github.com/zeroclaw-labs/zeroclaw.git
cd zeroclaw
# Build (~1530 phút trên Uno Q)
cargo build --release
# Cài đặt
sudo cp target/release/zeroclaw /usr/local/bin/
```
### Phương án B: Cross-Compile trên Mac (Nhanh hơn)
```bash
# Trên Mac — thêm target aarch64
rustup target add aarch64-unknown-linux-gnu
# Cài cross-compiler (macOS; cần cho linking)
brew tap messense/macos-cross-toolchains
brew install aarch64-unknown-linux-gnu
# Build
CC_aarch64_unknown_linux_gnu=aarch64-unknown-linux-gnu-gcc cargo build --release --target aarch64-unknown-linux-gnu
# Copy sang Uno Q
scp target/aarch64-unknown-linux-gnu/release/zeroclaw arduino@<UNO_Q_IP>:~/
ssh arduino@<UNO_Q_IP> "sudo mv ~/zeroclaw /usr/local/bin/"
```
Nếu cross-compile thất bại, dùng Phương án A và build trực tiếp trên thiết bị.
---
## Phase 3: Cấu hình ZeroClaw
### 3.1 Chạy Onboard (hoặc tạo Config thủ công)
```bash
ssh arduino@<UNO_Q_IP>
# Cấu hình nhanh
zeroclaw onboard --api-key YOUR_OPENROUTER_KEY --provider openrouter
# Hoặc tạo config thủ công
mkdir -p ~/.zeroclaw/workspace
nano ~/.zeroclaw/config.toml
```
### 3.2 config.toml tối giản
```toml
api_key = "YOUR_OPENROUTER_API_KEY"
default_provider = "openrouter"
default_model = "anthropic/claude-sonnet-4-6"
[peripherals]
enabled = false
# GPIO qua Bridge yêu cầu Phase 4
[channels_config.telegram]
bot_token = "YOUR_TELEGRAM_BOT_TOKEN"
allowed_users = ["*"]
[gateway]
host = "127.0.0.1"
port = 3000
allow_public_bind = false
[agent]
compact_context = true
```
---
## Phase 4: Chạy ZeroClaw Daemon
```bash
ssh arduino@<UNO_Q_IP>
# Chạy daemon (Telegram polling hoạt động qua WiFi)
zeroclaw daemon --host 127.0.0.1 --port 3000
```
**Tại bước này:** Telegram chat hoạt động. Gửi tin nhắn tới bot — ZeroClaw phản hồi. Chưa có GPIO.
---
## Phase 5: GPIO qua Bridge (ZeroClaw xử lý tự động)
ZeroClaw bao gồm Bridge app và setup command.
### 5.1 Triển khai Bridge App
**Từ Mac** (với repo zeroclaw):
```bash
zeroclaw peripheral setup-uno-q --host 192.168.0.48
```
**Từ Uno Q** (đã SSH vào):
```bash
zeroclaw peripheral setup-uno-q
```
Lệnh này copy Bridge app vào `~/ArduinoApps/uno-q-bridge` và khởi động nó.
### 5.2 Thêm vào config.toml
```toml
[peripherals]
enabled = true
[[peripherals.boards]]
board = "arduino-uno-q"
transport = "bridge"
```
### 5.3 Chạy ZeroClaw
```bash
zeroclaw daemon --host 127.0.0.1 --port 3000
```
Giờ khi bạn nhắn tin cho Telegram bot *"Turn on the LED"* hoặc *"Set pin 13 high"*, ZeroClaw dùng `gpio_write` qua Bridge.
---
## Tóm tắt: Các lệnh từ đầu đến cuối
| Bước | Lệnh |
|------|------|
| 1 | Cấu hình Uno Q trong App Lab (WiFi, SSH) |
| 2 | `ssh arduino@<IP>` |
| 3 | `curl -sSf https://sh.rustup.rs \| sh -s -- -y && source ~/.cargo/env` |
| 4 | `sudo apt-get install -y pkg-config libssl-dev` |
| 5 | `git clone https://github.com/zeroclaw-labs/zeroclaw.git && cd zeroclaw` |
| 6 | `cargo build --release --no-default-features` |
| 7 | `zeroclaw onboard --api-key KEY --provider openrouter` |
| 8 | Chỉnh sửa `~/.zeroclaw/config.toml` (thêm Telegram bot_token) |
| 9 | `zeroclaw daemon --host 127.0.0.1 --port 3000` |
| 10 | Nhắn tin cho Telegram bot — nó phản hồi |
---
## Xử lý sự cố
- **"command not found: zeroclaw"** — Dùng đường dẫn đầy đủ: `/usr/local/bin/zeroclaw` hoặc đảm bảo `~/.cargo/bin` nằm trong PATH.
- **Telegram không phản hồi** — Kiểm tra bot_token, allowed_users, và Uno Q có kết nối internet (WiFi).
- **Hết bộ nhớ** — Dùng `--no-default-features` để giảm kích thước binary; cân nhắc `compact_context = true`.
- **Lệnh GPIO bị bỏ qua** — Đảm bảo Bridge app đang chạy (`zeroclaw peripheral setup-uno-q` triển khai và khởi động nó). Config phải có `board = "arduino-uno-q"``transport = "bridge"`.
- **LLM provider (GLM/Zhipu)** — Dùng `default_provider = "glm"` hoặc `"zhipu"` với `GLM_API_KEY` trong env hoặc config. ZeroClaw dùng endpoint v4 chính xác.
-192
View File
@@ -1,192 +0,0 @@
# Audit logging
> ⚠️ **Trạng thái: Đề xuất / Lộ trình**
>
> Tài liệu này mô tả các hướng tiếp cận đề xuất và có thể bao gồm các lệnh hoặc cấu hình giả định.
> Để biết hành vi runtime hiện tại, xem [config-reference.md](config-reference.md), [operations-runbook.md](operations-runbook.md), và [troubleshooting.md](troubleshooting.md).
## Vấn đề
ZeroClaw ghi log các hành động nhưng thiếu audit trail chống giả mạo cho:
- Ai đã thực thi lệnh nào
- Khi nào và từ channel nào
- Những tài nguyên nào được truy cập
- Chính sách bảo mật có bị kích hoạt không
---
## Định dạng audit log đề xuất
```json
{
"timestamp": "2026-02-16T12:34:56Z",
"event_id": "evt_1a2b3c4d",
"event_type": "command_execution",
"actor": {
"channel": "telegram",
"user_id": "123456789",
"username": "@alice"
},
"action": {
"command": "ls -la",
"risk_level": "low",
"approved": false,
"allowed": true
},
"result": {
"success": true,
"exit_code": 0,
"duration_ms": 15
},
"security": {
"policy_violation": false,
"rate_limit_remaining": 19
},
"signature": "SHA256:abc123..." // HMAC để chống giả mạo
}
```
---
## Triển khai
```rust
// src/security/audit.rs
use serde::{Deserialize, Serialize};
use std::io::Write;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEvent {
pub timestamp: String,
pub event_id: String,
pub event_type: AuditEventType,
pub actor: Actor,
pub action: Action,
pub result: ExecutionResult,
pub security: SecurityContext,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AuditEventType {
CommandExecution,
FileAccess,
ConfigurationChange,
AuthSuccess,
AuthFailure,
PolicyViolation,
}
pub struct AuditLogger {
log_path: PathBuf,
signing_key: Option<hmac::Hmac<sha2::Sha256>>,
}
impl AuditLogger {
pub fn log(&self, event: &AuditEvent) -> anyhow::Result<()> {
let mut line = serde_json::to_string(event)?;
// Thêm chữ ký HMAC nếu key được cấu hình
if let Some(ref key) = self.signing_key {
let signature = compute_hmac(key, line.as_bytes());
line.push_str(&format!("\n\"signature\": \"{}\"", signature));
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.log_path)?;
writeln!(file, "{}", line)?;
file.sync_all()?; // Flush cưỡng bức để đảm bảo độ bền
Ok(())
}
pub fn search(&self, filter: AuditFilter) -> Vec<AuditEvent> {
// Tìm kiếm file log theo tiêu chí filter
todo!()
}
}
```
---
## Config schema
```toml
[security.audit]
enabled = true
log_path = "~/.config/zeroclaw/audit.log"
max_size_mb = 100
rotate = "daily" # daily | weekly | size
# Chống giả mạo
sign_events = true
signing_key_path = "~/.config/zeroclaw/audit.key"
# Những gì cần log
log_commands = true
log_file_access = true
log_auth_events = true
log_policy_violations = true
```
---
## CLI truy vấn audit
```bash
# Hiển thị tất cả lệnh được thực thi bởi @alice
zeroclaw audit --user @alice
# Hiển thị tất cả lệnh rủi ro cao
zeroclaw audit --risk high
# Hiển thị vi phạm trong 24 giờ qua
zeroclaw audit --since 24h --violations-only
# Xuất sang JSON để phân tích
zeroclaw audit --format json --output audit.json
# Xác minh tính toàn vẹn của log
zeroclaw audit --verify-signatures
```
---
## Xoay vòng log
```rust
pub fn rotate_audit_log(log_path: &PathBuf, max_size: u64) -> anyhow::Result<()> {
let metadata = std::fs::metadata(log_path)?;
if metadata.len() < max_size {
return Ok(());
}
// Xoay vòng: audit.log -> audit.log.1 -> audit.log.2 -> ...
let stem = log_path.file_stem().unwrap_or_default();
let extension = log_path.extension().and_then(|s| s.to_str()).unwrap_or("log");
for i in (1..10).rev() {
let old_name = format!("{}.{}.{}", stem, i, extension);
let new_name = format!("{}.{}.{}", stem, i + 1, extension);
let _ = std::fs::rename(old_name, new_name);
}
let rotated = format!("{}.1.{}", stem, extension);
std::fs::rename(log_path, &rotated)?;
Ok(())
}
```
---
## Thứ tự triển khai
| Giai đoạn | Tính năng | Công sức | Giá trị bảo mật |
|-------|---------|--------|----------------|
| **P0** | Ghi log sự kiện cơ bản | Thấp | Trung bình |
| **P1** | Query CLI | Trung bình | Trung bình |
| **P2** | Ký HMAC | Trung bình | Cao |
| **P3** | Xoay vòng log + lưu trữ | Thấp | Trung bình |
-424
View File
@@ -1,424 +0,0 @@
# Tài liệu tham khảo Channels
Tài liệu này là nguồn tham khảo chính thức về cấu hình channel trong ZeroClaw.
Với các phòng Matrix được mã hóa, xem hướng dẫn chuyên biệt:
- [Hướng dẫn Matrix E2EE](matrix-e2ee-guide.md)
## Truy cập nhanh
- Cần tham khảo config đầy đủ theo từng channel: xem mục `## 4. Ví dụ cấu hình theo từng channel`.
- Cần chẩn đoán khi không nhận được phản hồi: xem mục `## 6. Danh sách kiểm tra xử lý sự cố`.
- Cần hỗ trợ phòng Matrix được mã hóa: dùng [Hướng dẫn Matrix E2EE](matrix-e2ee-guide.md).
- Cần thông tin triển khai/mạng (polling vs webhook): dùng [Network Deployment](network-deployment.md).
## FAQ: Cấu hình Matrix thành công nhưng không có phản hồi
Đây là triệu chứng phổ biến nhất (cùng loại với issue #499). Kiểm tra theo thứ tự sau:
1. **Allowlist không khớp**: `allowed_users` không bao gồm người gửi (hoặc để trống).
2. **Room đích sai**: bot chưa tham gia room được cấu hình `room_id` / alias.
3. **Token/tài khoản không khớp**: token hợp lệ nhưng thuộc tài khoản Matrix khác.
4. **Thiếu E2EE device identity**: `whoami` không trả về `device_id` và config không cung cấp giá trị này.
5. **Thiếu key sharing/trust**: các khóa room chưa được chia sẻ cho thiết bị bot, nên không thể giải mã sự kiện mã hóa.
6. **Trạng thái runtime cũ**: config đã thay đổi nhưng `zeroclaw daemon` chưa được khởi động lại.
---
## 1. Namespace cấu hình
Tất cả cài đặt channel nằm trong `channels_config` trong `~/.zeroclaw/config.toml`.
```toml
[channels_config]
cli = true
```
Mỗi channel được bật bằng cách tạo sub-table tương ứng (ví dụ: `[channels_config.telegram]`).
## Chuyển đổi model runtime trong chat (Telegram / Discord)
Khi chạy `zeroclaw channel start` (hoặc chế độ daemon), Telegram và Discord hỗ trợ chuyển đổi runtime theo phạm vi người gửi:
- `/models` — hiển thị các provider hiện có và lựa chọn hiện tại
- `/models <provider>` — chuyển provider cho phiên người gửi hiện tại
- `/model` — hiển thị model hiện tại và các model ID đã cache (nếu có)
- `/model <model-id>` — chuyển model cho phiên người gửi hiện tại
Lưu ý:
- Việc chuyển đổi chỉ xóa lịch sử hội thoại trong bộ nhớ của người gửi đó, tránh ô nhiễm ngữ cảnh giữa các model.
- Xem trước bộ nhớ cache model từ `zeroclaw models refresh --provider <ID>`.
- Đây là lệnh chat runtime, không phải lệnh con CLI.
## Giao thức marker hình ảnh đầu vào
ZeroClaw hỗ trợ đầu vào multimodal qua các marker nội tuyến trong tin nhắn:
- Cú pháp: ``[IMAGE:<source>]``
- `<source>` có thể là:
- Đường dẫn file cục bộ
- Data URI (`data:image/...;base64,...`)
- URL từ xa chỉ khi `[multimodal].allow_remote_fetch = true`
Lưu ý vận hành:
- Marker được phân tích trong các tin nhắn người dùng trước khi gọi provider.
- Capability của provider được kiểm tra tại runtime: nếu provider không hỗ trợ vision, request thất bại với lỗi capability có cấu trúc (`capability=vision`).
- Các phần `media` của Linq webhook có MIME type `image/*` được tự động chuyển đổi sang định dạng marker này.
## Channel Matrix
### Tùy chọn Build Feature (`channel-matrix`)
Hỗ trợ Matrix được kiểm soát tại thời điểm biên dịch bằng Cargo feature `channel-matrix`.
- Các bản build mặc định đã bao gồm hỗ trợ Matrix (`default = ["hardware", "channel-matrix"]`).
- Để lặp lại nhanh hơn khi không cần Matrix:
```bash
cargo check --no-default-features --features hardware
```
- Để bật tường minh hỗ trợ Matrix trong feature set tùy chỉnh:
```bash
cargo check --no-default-features --features hardware,channel-matrix
```
Nếu `[channels_config.matrix]` có mặt nhưng binary được build mà không có `channel-matrix`, các lệnh `zeroclaw channel list`, `zeroclaw channel doctor`, và `zeroclaw channel start` sẽ ghi log rằng Matrix bị bỏ qua có chủ ý trong bản build này.
---
## 2. Chế độ phân phối tóm tắt
| Channel | Chế độ nhận | Cần cổng inbound công khai? |
|---|---|---|
| CLI | local stdin/stdout | Không |
| Telegram | polling | Không |
| Discord | gateway/websocket | Không |
| Slack | events API | Không (luồng token-based) |
| Mattermost | polling | Không |
| Matrix | sync API (hỗ trợ E2EE) | Không |
| Signal | signal-cli HTTP bridge | Không (endpoint bridge cục bộ) |
| WhatsApp | webhook (Cloud API) hoặc websocket (Web mode) | Cloud API: Có (HTTPS callback công khai), Web mode: Không |
| Webhook | gateway endpoint (`/webhook`) | Thường là có |
| Email | IMAP polling + SMTP send | Không |
| IRC | IRC socket | Không |
| Lark/Feishu | websocket (mặc định) hoặc webhook | Chỉ ở chế độ Webhook |
| DingTalk | stream mode | Không |
| QQ | bot gateway | Không |
| iMessage | tích hợp cục bộ | Không |
---
## 3. Ngữ nghĩa allowlist
Với các channel có allowlist người gửi:
- Allowlist trống: từ chối tất cả tin nhắn đầu vào.
- `"*"`: cho phép tất cả người gửi (chỉ dùng để xác minh tạm thời).
- Danh sách tường minh: chỉ cho phép những người gửi được liệt kê.
Tên trường khác nhau theo channel:
- `allowed_users` (Telegram/Discord/Slack/Mattermost/Matrix/IRC/Lark/DingTalk/QQ)
- `allowed_from` (Signal)
- `allowed_numbers` (WhatsApp)
- `allowed_senders` (Email)
- `allowed_contacts` (iMessage)
---
## 4. Ví dụ cấu hình theo từng channel
### 4.1 Telegram
```toml
[channels_config.telegram]
bot_token = "123456:telegram-token"
allowed_users = ["*"]
stream_mode = "off" # tùy chọn: off | partial
draft_update_interval_ms = 1000 # tùy chọn: giới hạn tần suất chỉnh sửa khi streaming một phần
mention_only = false # tùy chọn: yêu cầu @mention trong nhóm
interrupt_on_new_message = false # tùy chọn: hủy yêu cầu đang xử lý cùng người gửi cùng chat
```
Lưu ý về Telegram:
- `interrupt_on_new_message = true` giữ lại các lượt người dùng bị gián đoạn trong lịch sử hội thoại, sau đó khởi động lại việc tạo nội dung với tin nhắn mới nhất.
- Phạm vi gián đoạn rất chặt chẽ: cùng người gửi trong cùng chat. Tin nhắn từ các chat khác nhau được xử lý độc lập.
### 4.2 Discord
```toml
[channels_config.discord]
bot_token = "discord-bot-token"
guild_id = "123456789012345678" # tùy chọn
allowed_users = ["*"]
listen_to_bots = false
mention_only = false
```
### 4.3 Slack
```toml
[channels_config.slack]
bot_token = "xoxb-..."
app_token = "xapp-..." # tùy chọn
channel_id = "C1234567890" # tùy chọn
allowed_users = ["*"]
```
### 4.4 Mattermost
```toml
[channels_config.mattermost]
url = "https://mm.example.com"
bot_token = "mattermost-token"
channel_id = "channel-id" # bắt buộc để lắng nghe
allowed_users = ["*"]
```
### 4.5 Matrix
```toml
[channels_config.matrix]
homeserver = "https://matrix.example.com"
access_token = "syt_..."
user_id = "@zeroclaw:matrix.example.com" # tùy chọn, khuyến nghị cho E2EE
device_id = "DEVICEID123" # tùy chọn, khuyến nghị cho E2EE
room_id = "!room:matrix.example.com" # hoặc room alias (#ops:matrix.example.com)
allowed_users = ["*"]
```
Xem [Hướng dẫn Matrix E2EE](matrix-e2ee-guide.md) để xử lý sự cố phòng mã hóa.
### 4.6 Signal
```toml
[channels_config.signal]
http_url = "http://127.0.0.1:8686"
account = "+1234567890"
group_id = "dm" # tùy chọn: "dm" / group id / bỏ qua
allowed_from = ["*"]
ignore_attachments = false
ignore_stories = true
```
### 4.7 WhatsApp
ZeroClaw hỗ trợ hai backend WhatsApp:
- **Chế độ Cloud API** (`phone_number_id` + `access_token` + `verify_token`)
- **Chế độ WhatsApp Web** (`session_path`, yêu cầu build flag `--features whatsapp-web`)
Chế độ Cloud API:
```toml
[channels_config.whatsapp]
access_token = "EAAB..."
phone_number_id = "123456789012345"
verify_token = "your-verify-token"
app_secret = "your-app-secret" # tùy chọn nhưng được khuyến nghị
allowed_numbers = ["*"]
```
Chế độ WhatsApp Web:
```toml
[channels_config.whatsapp]
session_path = "~/.zeroclaw/state/whatsapp-web/session.db"
pair_phone = "15551234567" # tùy chọn; bỏ qua để dùng QR flow
pair_code = "" # tùy chọn pair code tùy chỉnh
allowed_numbers = ["*"]
```
Lưu ý:
- Build với `cargo build --features whatsapp-web` (hoặc lệnh run tương đương).
- Giữ `session_path` trên bộ nhớ lưu trữ bền vững để tránh phải liên kết lại sau khi khởi động lại.
- Định tuyến trả lời sử dụng JID của chat nguồn, vì vậy cả trả lời trực tiếp và nhóm đều hoạt động đúng.
### 4.8 Cấu hình Webhook Channel (Gateway)
`channels_config.webhook` bật hành vi gateway đặc thù cho webhook.
```toml
[channels_config.webhook]
port = 8080
secret = "optional-shared-secret"
```
Chạy với gateway/daemon và xác minh `/health`.
### 4.9 Email
```toml
[channels_config.email]
imap_host = "imap.example.com"
imap_port = 993
imap_folder = "INBOX"
smtp_host = "smtp.example.com"
smtp_port = 465
smtp_tls = true
username = "bot@example.com"
password = "email-password"
from_address = "bot@example.com"
poll_interval_secs = 60
allowed_senders = ["*"]
```
### 4.10 IRC
```toml
[channels_config.irc]
server = "irc.libera.chat"
port = 6697
nickname = "zeroclaw-bot"
username = "zeroclaw" # tùy chọn
channels = ["#zeroclaw"]
allowed_users = ["*"]
server_password = "" # tùy chọn
nickserv_password = "" # tùy chọn
sasl_password = "" # tùy chọn
verify_tls = true
```
### 4.11 Lark / Feishu
```toml
[channels_config.lark]
app_id = "cli_xxx"
app_secret = "xxx"
encrypt_key = "" # tùy chọn
verification_token = "" # tùy chọn
allowed_users = ["*"]
use_feishu = false
receive_mode = "websocket" # hoặc "webhook"
port = 8081 # bắt buộc ở chế độ webhook
```
Hỗ trợ onboarding hướng dẫn:
```bash
zeroclaw onboard
```
Trình hướng dẫn bao gồm bước **Lark/Feishu** chuyên biệt với:
- Chọn khu vực (`Feishu (CN)` hoặc `Lark (International)`)
- Xác minh thông tin xác thực với endpoint auth của Open Platform chính thức
- Chọn chế độ nhận (`websocket` hoặc `webhook`)
- Tùy chọn nhập verification token webhook (khuyến nghị để tăng cường kiểm tra tính xác thực của callback)
Hành vi token runtime:
- `tenant_access_token` được cache với thời hạn làm mới dựa trên `expire`/`expires_in` từ phản hồi xác thực.
- Các yêu cầu gửi tự động thử lại một lần sau khi token bị vô hiệu hóa khi Feishu/Lark trả về HTTP `401` hoặc mã lỗi nghiệp vụ `99991663` (`Invalid access token`).
- Nếu lần thử lại vẫn trả về phản hồi token không hợp lệ, lời gọi gửi sẽ thất bại với trạng thái/nội dung upstream để dễ xử lý sự cố hơn.
### 4.12 DingTalk
```toml
[channels_config.dingtalk]
client_id = "ding-app-key"
client_secret = "ding-app-secret"
allowed_users = ["*"]
```
### 4.13 QQ
```toml
[channels_config.qq]
app_id = "qq-app-id"
app_secret = "qq-app-secret"
allowed_users = ["*"]
```
### 4.14 iMessage
```toml
[channels_config.imessage]
allowed_contacts = ["*"]
```
---
## 5. Quy trình xác thực
1. Cấu hình một channel với allowlist rộng (`"*"`) để xác minh ban đầu.
2. Chạy:
```bash
zeroclaw onboard --channels-only
zeroclaw daemon
```
1. Gửi tin nhắn từ người gửi dự kiến.
2. Xác nhận nhận được phản hồi.
3. Siết chặt allowlist từ `"*"` thành các ID cụ thể.
---
## 6. Danh sách kiểm tra xử lý sự cố
Nếu channel có vẻ đã kết nối nhưng không phản hồi:
1. Xác nhận danh tính người gửi được cho phép bởi trường allowlist đúng.
2. Xác nhận tài khoản bot đã là thành viên/có quyền trong room/channel đích.
3. Xác nhận token/secret hợp lệ (và chưa hết hạn/bị thu hồi).
4. Xác nhận giả định về chế độ truyền tải:
- Các channel polling/websocket không cần HTTP inbound công khai
- Các channel webhook cần HTTPS callback có thể truy cập được
5. Khởi động lại `zeroclaw daemon` sau khi thay đổi config.
Đặc biệt với các phòng Matrix mã hóa, dùng:
- [Hướng dẫn Matrix E2EE](matrix-e2ee-guide.md)
---
## 7. Phụ lục vận hành: bảng từ khóa log
Dùng phụ lục này để phân loại sự cố nhanh. Khớp từ khóa log trước, sau đó thực hiện các bước xử lý sự cố ở trên.
### 7.1 Lệnh capture được khuyến nghị
```bash
RUST_LOG=info zeroclaw daemon 2>&1 | tee /tmp/zeroclaw.log
```
Sau đó lọc các sự kiện channel/gateway:
```bash
rg -n "Matrix|Telegram|Discord|Slack|Mattermost|Signal|WhatsApp|Email|IRC|Lark|DingTalk|QQ|iMessage|Webhook|Channel" /tmp/zeroclaw.log
```
### 7.2 Bảng từ khóa
| Thành phần | Tín hiệu khởi động / hoạt động bình thường | Tín hiệu ủy quyền / chính sách | Tín hiệu truyền tải / lỗi |
|---|---|---|---|
| Telegram | `Telegram channel listening for messages...` | `Telegram: ignoring message from unauthorized user:` | `Telegram poll error:` / `Telegram parse error:` / `Telegram polling conflict (409):` |
| Discord | `Discord: connected and identified` | `Discord: ignoring message from unauthorized user:` | `Discord: received Reconnect (op 7)` / `Discord: received Invalid Session (op 9)` |
| Slack | `Slack channel listening on #` | `Slack: ignoring message from unauthorized user:` | `Slack poll error:` / `Slack parse error:` |
| Mattermost | `Mattermost channel listening on` | `Mattermost: ignoring message from unauthorized user:` | `Mattermost poll error:` / `Mattermost parse error:` |
| Matrix | `Matrix channel listening on room` / `Matrix room ... is encrypted; E2EE decryption is enabled via matrix-sdk.` | `Matrix whoami failed; falling back to configured session hints for E2EE session restore:` / `Matrix whoami failed while resolving listener user_id; using configured user_id hint:` | `Matrix sync error: ... retrying...` |
| Signal | `Signal channel listening via SSE on` | (kiểm tra allowlist được thực thi bởi `allowed_from`) | `Signal SSE returned ...` / `Signal SSE connect error:` |
| WhatsApp (channel) | `WhatsApp channel active (webhook mode).` / `WhatsApp Web connected successfully` | `WhatsApp: ignoring message from unauthorized number:` / `WhatsApp Web: message from ... not in allowed list` | `WhatsApp send failed:` / `WhatsApp Web stream error:` |
| Webhook / WhatsApp (gateway) | `WhatsApp webhook verified successfully` | `Webhook: rejected — not paired / invalid bearer token` / `Webhook: rejected request — invalid or missing X-Webhook-Secret` / `WhatsApp webhook verification failed — token mismatch` | `Webhook JSON parse error:` |
| Email | `Email polling every ...` / `Email sent to ...` | `Blocked email from ...` | `Email poll failed:` / `Email poll task panicked:` |
| IRC | `IRC channel connecting to ...` / `IRC registered as ...` | (kiểm tra allowlist được thực thi bởi `allowed_users`) | `IRC SASL authentication failed (...)` / `IRC server does not support SASL...` / `IRC nickname ... is in use, trying ...` |
| Lark / Feishu | `Lark: WS connected` / `Lark event callback server listening on` | `Lark WS: ignoring ... (not in allowed_users)` / `Lark: ignoring message from unauthorized user:` | `Lark: ping failed, reconnecting` / `Lark: heartbeat timeout, reconnecting` / `Lark: WS read error:` |
| DingTalk | `DingTalk: connected and listening for messages...` | `DingTalk: ignoring message from unauthorized user:` | `DingTalk WebSocket error:` / `DingTalk: message channel closed` |
| QQ | `QQ: connected and identified` | `QQ: ignoring C2C message from unauthorized user:` / `QQ: ignoring group message from unauthorized user:` | `QQ: received Reconnect (op 7)` / `QQ: received Invalid Session (op 9)` / `QQ: message channel closed` |
| iMessage | `iMessage channel listening (AppleScript bridge)...` | (allowlist liên hệ được thực thi bởi `allowed_contacts`) | `iMessage poll error:` |
### 7.3 Từ khóa của runtime supervisor
Nếu một channel task cụ thể bị crash hoặc thoát, channel supervisor trong `channels/mod.rs` phát ra:
- `Channel <name> exited unexpectedly; restarting`
- `Channel <name> error: ...; restarting`
- `Channel message worker crashed:`
Các thông báo này xác nhận cơ chế tự restart đang hoạt động. Kiểm tra log trước đó để tìm nguyên nhân gốc rễ.
-125
View File
@@ -1,125 +0,0 @@
# Bản đồ CI Workflow
Tài liệu này giải thích từng GitHub workflow làm gì, khi nào chạy và liệu nó có nên chặn merge hay không.
Để biết hành vi phân phối theo từng sự kiện qua PR, merge, push và release, xem [`.github/workflows/master-branch-flow.md`](../../../.github/workflows/master-branch-flow.md).
## Chặn merge và Tùy chọn
Các kiểm tra chặn merge nên giữ nhỏ và mang tính quyết định. Các kiểm tra tùy chọn hữu ích cho tự động hóa và bảo trì, nhưng không nên chặn phát triển bình thường.
### Chặn merge
- `.github/workflows/ci-run.yml` (`CI`)
- Mục đích: Rust validation (`cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D clippy::correctness`, strict delta lint gate trên các dòng Rust thay đổi, `test`, kiểm tra smoke release build) + kiểm tra chất lượng tài liệu khi tài liệu thay đổi (`markdownlint` chỉ chặn các vấn đề trên dòng thay đổi; link check chỉ quét các link mới được thêm trên dòng thay đổi)
- Hành vi bổ sung: đối với PR và push ảnh hưởng Rust, `CI Required Gate` yêu cầu `lint` + `test` + `build` (không có shortcut chỉ build trên PR)
- Hành vi bổ sung: các PR thay đổi `.github/workflows/**` yêu cầu ít nhất một review phê duyệt từ login trong `WORKFLOW_OWNER_LOGINS` (fallback biến repository: `theonlyhennygod,JordanTheJet,SimianAstronaut7`)
- Hành vi bổ sung: lint gate chạy trước `test`/`build`; khi lint/docs gate thất bại trên PR, CI đăng comment phản hồi hành động được với tên gate thất bại và các lệnh sửa cục bộ
- Merge gate: `CI Required Gate`
- `.github/workflows/workflow-sanity.yml` (`Workflow Sanity`)
- Mục đích: lint các file GitHub workflow (`actionlint`, kiểm tra tab)
- Khuyến nghị cho các PR thay đổi workflow
- `.github/workflows/pr-intake-checks.yml` (`PR Intake Checks`)
- Mục đích: kiểm tra PR an toàn trước CI (độ đầy đủ template, tab/trailing-whitespace/conflict marker trên dòng thêm) với comment sticky phản hồi ngay lập tức
### Quan trọng nhưng không chặn
- `.github/workflows/pub-docker-img.yml` (`Docker`)
- Mục đích: kiểm tra Docker smoke trên PR lên `master` và publish image khi push tag (`v*`) only
- `.github/workflows/sec-audit.yml` (`Security Audit`)
- Mục đích: advisory phụ thuộc (`rustsec/audit-check`, SHA được pin) và kiểm tra chính sách/giấy phép (`cargo deny`)
- `.github/workflows/sec-codeql.yml` (`CodeQL Analysis`)
- Mục đích: phân tích tĩnh theo lịch/thủ công để phát hiện vấn đề bảo mật
- `.github/workflows/sec-vorpal-reviewdog.yml` (`Sec Vorpal Reviewdog`)
- Mục đích: quét phản hồi secure-coding thủ công cho các file non-Rust được hỗ trợ (`.py`, `.js`, `.jsx`, `.ts`, `.tsx`) sử dụng annotation reviewdog
- Kiểm soát nhiễu: loại trừ các đường dẫn test/fixture phổ biến và pattern file test theo mặc định (`include_tests=false`)
- `.github/workflows/pub-release.yml` (`Release`)
- Mục đích: build release artifact ở chế độ xác minh (thủ công/theo lịch) và publish GitHub release khi push tag hoặc chế độ publish thủ công
- `.github/workflows/pub-homebrew-core.yml` (`Pub Homebrew Core`)
- Mục đích: luồng PR bump formula Homebrew core thủ công, do bot sở hữu cho các tagged release
- Bảo vệ: release tag phải khớp version `Cargo.toml`
- `.github/workflows/pr-label-policy-check.yml` (`Label Policy Sanity`)
- Mục đích: xác thực chính sách bậc contributor dùng chung trong `.github/label-policy.json` và đảm bảo các label workflow sử dụng chính sách đó
- `.github/workflows/test-rust-build.yml` (`Rust Reusable Job`)
- Mục đích: Rust setup/cache có thể tái sử dụng + trình chạy lệnh cho các workflow-call consumer
### Tự động hóa repository tùy chọn
- `.github/workflows/pr-labeler.yml` (`PR Labeler`)
- Mục đích: nhãn phạm vi/đường dẫn + nhãn kích thước/rủi ro + nhãn module chi tiết (`<module>: <component>`)
- Hành vi bổ sung: mô tả nhãn được quản lý tự động như tooltip khi di chuột để giải thích từng quy tắc phán đoán tự động
- Hành vi bổ sung: từ khóa liên quan đến provider trong các thay đổi provider/config/onboard/integration được thăng cấp lên nhãn `provider:*` (ví dụ `provider:kimi`, `provider:deepseek`)
- Hành vi bổ sung: loại bỏ trùng lặp phân cấp chỉ giữ nhãn phạm vi cụ thể nhất (ví dụ `tool:composio` triệt tiêu `tool:core``tool`)
- Hành vi bổ sung: namespace module được nén gọn — một module cụ thể giữ `prefix:component`; nhiều module cụ thể thu gọn thành chỉ `prefix`
- Hành vi bổ sung: áp dụng bậc contributor trên PR theo số PR đã merge (`trusted` >=5, `experienced` >=10, `principal` >=20, `distinguished` >=50)
- Hành vi bổ sung: bộ nhãn cuối cùng được sắp xếp theo ưu tiên (`risk:*` đầu tiên, sau đó `size:*`, rồi bậc contributor, cuối là nhãn module/đường dẫn)
- Hành vi bổ sung: màu nhãn được quản lý theo thứ tự hiển thị để tạo gradient trái-phải mượt mà khi có nhiều nhãn
- Quản trị thủ công: hỗ trợ `workflow_dispatch` với `mode=audit|repair` để kiểm tra/sửa metadata nhãn được quản lý drift trên toàn repository
- Hành vi bổ sung: nhãn rủi ro + kích thước được tự sửa khi chỉnh sửa nhãn PR thủ công (sự kiện `labeled`/`unlabeled`); áp dụng `risk: manual` khi maintainer cố ý ghi đè lựa chọn rủi ro tự động
- Đường dẫn heuristic rủi ro cao: `src/security/**`, `src/runtime/**`, `src/gateway/**`, `src/tools/**`, `.github/workflows/**`
- Bảo vệ: maintainer có thể áp dụng `risk: manual` để đóng băng tính toán lại rủi ro tự động
- `.github/workflows/pr-auto-response.yml` (`PR Auto Responder`)
- Mục đích: giới thiệu contributor lần đầu + phân tuyến dựa trên nhãn (`r:support`, `r:needs-repro`, v.v.)
- Hành vi bổ sung: áp dụng bậc contributor trên issue theo số PR đã merge (`trusted` >=5, `experienced` >=10, `principal` >=20, `distinguished` >=50), khớp chính xác ngưỡng bậc PR
- Hành vi bổ sung: nhãn bậc contributor được coi là do tự động hóa quản lý (thêm/xóa thủ công trên PR/issue bị tự sửa)
- Bảo vệ: các luồng đóng dựa trên nhãn chỉ dành cho issue; PR không bao giờ bị tự đóng bởi nhãn route
- `.github/workflows/pr-check-stale.yml` (`Stale`)
- Mục đích: tự động hóa vòng đời issue/PR stale
- `.github/dependabot.yml` (`Dependabot`)
- Mục đích: PR cập nhật phụ thuộc được nhóm, giới hạn tốc độ (Cargo + GitHub Actions)
- `.github/workflows/pr-check-status.yml` (`PR Hygiene`)
- Mục đích: nhắc nhở các PR stale-nhưng-còn-hoạt-động để rebase/re-run các kiểm tra bắt buộc trước khi hàng đợi bị đói
## Bản đồ Trigger
- `CI`: push lên `master`, PR lên `master`
- `Docker`: push tag (`v*`) để publish, PR lên `master` tương ứng để smoke build, dispatch thủ công chỉ smoke
- `Release`: push tag (`v*`), lịch hàng tuần (chỉ xác minh), dispatch thủ công (xác minh hoặc publish)
- `Pub Homebrew Core`: dispatch thủ công only
- `Security Audit`: push lên `master`, PR lên `master`, lịch hàng tuần
- `Sec Vorpal Reviewdog`: dispatch thủ công only
- `Workflow Sanity`: PR/push khi `.github/workflows/**`, `.github/*.yml` hoặc `.github/*.yaml` thay đổi
- `PR Intake Checks`: `pull_request_target` khi opened/reopened/synchronize/edited/ready_for_review
- `Label Policy Sanity`: PR/push khi `.github/label-policy.json`, `.github/workflows/pr-labeler.yml` hoặc `.github/workflows/pr-auto-response.yml` thay đổi
- `PR Labeler`: sự kiện vòng đời `pull_request_target`
- `PR Auto Responder`: issue opened/labeled, `pull_request_target` opened/labeled
- `Stale PR Check`: lịch hàng ngày, dispatch thủ công
- `Dependabot`: tất cả PR cập nhật nhắm vào `master`
- `PR Hygiene`: lịch mỗi 12 giờ, dispatch thủ công
## Hướng dẫn triage nhanh
1. `CI Required Gate` thất bại: bắt đầu với `.github/workflows/ci-run.yml`.
2. Docker thất bại trên PR: kiểm tra job `pr-smoke` trong `.github/workflows/pub-docker-img.yml`.
3. Release thất bại (tag/thủ công/theo lịch): kiểm tra `.github/workflows/pub-release.yml` và kết quả job `prepare`.
4. Lỗi publish formula Homebrew: kiểm tra output tóm tắt `.github/workflows/pub-homebrew-core.yml` và biến bot token/fork.
5. Security thất bại: kiểm tra `.github/workflows/sec-audit.yml``deny.toml`.
6. Lỗi cú pháp/lint workflow: kiểm tra `.github/workflows/workflow-sanity.yml`.
7. PR intake thất bại: kiểm tra comment sticky `.github/workflows/pr-intake-checks.yml` và run log.
8. Lỗi parity chính sách nhãn: kiểm tra `.github/workflows/pr-label-policy-check.yml`.
9. Lỗi tài liệu trong CI: kiểm tra log job `docs-quality` trong `.github/workflows/ci-run.yml`.
10. Lỗi strict delta lint trong CI: kiểm tra log job `lint-strict-delta` và so sánh với phạm vi diff `BASE_SHA`.
## Quy tắc bảo trì
- Giữ các kiểm tra chặn merge mang tính quyết định và tái tạo được (`--locked` khi áp dụng được).
- Tuân theo `docs/release-process.md` để kiểm tra trước khi publish và kỷ luật tag.
- Giữ chính sách chất lượng Rust chặn merge nhất quán giữa `.github/workflows/ci-run.yml`, `dev/ci.sh``.githooks/pre-push` (`./scripts/ci/rust_quality_gate.sh` + `./scripts/ci/rust_strict_delta_gate.sh`).
- Dùng `./scripts/ci/rust_strict_delta_gate.sh` (hoặc `./dev/ci.sh lint-delta`) làm merge gate nghiêm ngặt gia tăng cho các dòng Rust thay đổi.
- Chạy kiểm tra lint nghiêm ngặt đầy đủ thường xuyên qua `./scripts/ci/rust_quality_gate.sh --strict` (ví dụ qua `./dev/ci.sh lint-strict`) và theo dõi việc dọn dẹp trong các PR tập trung.
- Giữ gating markdown tài liệu theo gia tăng qua `./scripts/ci/docs_quality_gate.sh` (chặn vấn đề dòng thay đổi, báo cáo vấn đề baseline riêng).
- Giữ gating link tài liệu theo gia tăng qua `./scripts/ci/collect_changed_links.py` + lychee (chỉ kiểm tra link mới thêm trên dòng thay đổi).
- Ưu tiên quyền workflow tường minh (least privilege).
- Giữ chính sách nguồn Actions hạn chế theo allowlist đã được phê duyệt (xem `docs/actions-source-policy.md`).
- Sử dụng bộ lọc đường dẫn cho các workflow tốn kém khi thực tế.
- Giữ kiểm tra chất lượng tài liệu ít nhiễu (markdown gia tăng + kiểm tra link mới thêm gia tăng).
- Giữ khối lượng cập nhật phụ thuộc được kiểm soát (nhóm + giới hạn PR).
- Tránh kết hợp tự động hóa giới thiệu/cộng đồng với logic gating merge.
## Kiểm soát tác dụng phụ tự động hóa
- Ưu tiên tự động hóa mang tính quyết định có thể ghi đè thủ công (`risk: manual`) khi ngữ cảnh tinh tế.
- Giữ comment auto-response không trùng lặp để tránh nhiễu triage.
- Giữ hành vi tự đóng trong phạm vi issue; maintainer quyết định đóng/merge PR.
- Nếu tự động hóa sai, sửa nhãn trước, rồi tiếp tục review với lý do rõ ràng.
- Dùng nhãn `superseded` / `stale-candidate` để cắt tỉa PR trùng lặp hoặc ngủ đông trước khi review sâu.
-159
View File
@@ -1,159 +0,0 @@
# Tham khảo lệnh ZeroClaw
Dựa trên CLI hiện tại (`zeroclaw --help`).
Xác minh lần cuối: **2026-02-20**.
## Lệnh cấp cao nhất
| Lệnh | Mục đích |
|---|---|
| `onboard` | Khởi tạo workspace/config nhanh hoặc tương tác |
| `agent` | Chạy chat tương tác hoặc chế độ gửi tin nhắn đơn |
| `gateway` | Khởi động gateway webhook và HTTP WhatsApp |
| `daemon` | Khởi động runtime có giám sát (gateway + channels + heartbeat/scheduler tùy chọn) |
| `service` | Quản lý vòng đời dịch vụ cấp hệ điều hành |
| `doctor` | Chạy chẩn đoán và kiểm tra trạng thái |
| `status` | Hiển thị cấu hình và tóm tắt hệ thống |
| `cron` | Quản lý tác vụ định kỳ |
| `models` | Làm mới danh mục model của provider |
| `providers` | Liệt kê ID provider, bí danh và provider đang dùng |
| `channel` | Quản lý kênh và kiểm tra sức khỏe kênh |
| `integrations` | Kiểm tra chi tiết tích hợp |
| `skills` | Liệt kê/cài đặt/gỡ bỏ skills |
| `migrate` | Nhập dữ liệu từ runtime khác (hiện hỗ trợ OpenClaw) |
| `config` | Xuất schema cấu hình dạng máy đọc được |
| `completions` | Tạo script tự hoàn thành cho shell ra stdout |
| `hardware` | Phát hiện và kiểm tra phần cứng USB |
| `peripheral` | Cấu hình và nạp firmware thiết bị ngoại vi |
## Nhóm lệnh
### `onboard`
- `zeroclaw onboard`
- `zeroclaw onboard --channels-only`
- `zeroclaw onboard --api-key <KEY> --provider <ID> --memory <sqlite|lucid|markdown|none>`
- `zeroclaw onboard --api-key <KEY> --provider <ID> --model <MODEL_ID> --memory <sqlite|lucid|markdown|none>`
### `agent`
- `zeroclaw agent`
- `zeroclaw agent -m "Hello"`
- `zeroclaw agent --provider <ID> --model <MODEL> --temperature <0.0-2.0>`
- `zeroclaw agent --peripheral <board:path>`
### `gateway` / `daemon`
- `zeroclaw gateway [--host <HOST>] [--port <PORT>]`
- `zeroclaw daemon [--host <HOST>] [--port <PORT>]`
### `service`
- `zeroclaw service install`
- `zeroclaw service start`
- `zeroclaw service stop`
- `zeroclaw service restart`
- `zeroclaw service status`
- `zeroclaw service uninstall`
### `cron`
- `zeroclaw cron list`
- `zeroclaw cron add <expr> [--tz <IANA_TZ>] <command>`
- `zeroclaw cron add-at <rfc3339_timestamp> <command>`
- `zeroclaw cron add-every <every_ms> <command>`
- `zeroclaw cron once <delay> <command>`
- `zeroclaw cron remove <id>`
- `zeroclaw cron pause <id>`
- `zeroclaw cron resume <id>`
### `models`
- `zeroclaw models refresh`
- `zeroclaw models refresh --provider <ID>`
- `zeroclaw models refresh --force`
`models refresh` hiện hỗ trợ làm mới danh mục trực tiếp cho các provider: `openrouter`, `openai`, `anthropic`, `groq`, `mistral`, `deepseek`, `xai`, `together-ai`, `gemini`, `ollama`, `astrai`, `venice`, `fireworks`, `cohere`, `moonshot`, `glm`, `zai`, `qwen``nvidia`.
### `channel`
- `zeroclaw channel list`
- `zeroclaw channel start`
- `zeroclaw channel doctor`
- `zeroclaw channel bind-telegram <IDENTITY>`
- `zeroclaw channel add <type> <json>`
- `zeroclaw channel remove <name>`
Lệnh trong chat khi runtime đang chạy (Telegram/Discord):
- `/models`
- `/models <provider>`
- `/model`
- `/model <model-id>`
Channel runtime cũng theo dõi `config.toml` và tự động áp dụng thay đổi cho:
- `default_provider`
- `default_model`
- `default_temperature`
- `api_key` / `api_url` (cho provider mặc định)
- `reliability.*` cài đặt retry của provider
`add/remove` hiện chuyển hướng về thiết lập có hướng dẫn / cấu hình thủ công (chưa hỗ trợ đầy đủ mutator khai báo).
### `integrations`
- `zeroclaw integrations info <name>`
### `skills`
- `zeroclaw skills list`
- `zeroclaw skills install <source>`
- `zeroclaw skills remove <name>`
`<source>` chấp nhận git remote (`https://...`, `http://...`, `ssh://...``git@host:owner/repo.git`) hoặc đường dẫn cục bộ.
Skill manifest (`SKILL.toml`) hỗ trợ `prompts``[[tools]]`; cả hai được đưa vào system prompt của agent khi chạy, giúp model có thể tuân theo hướng dẫn skill mà không cần đọc thủ công.
### `migrate`
- `zeroclaw migrate openclaw [--source <path>] [--dry-run]`
### `config`
- `zeroclaw config schema`
`config schema` xuất JSON Schema (draft 2020-12) cho toàn bộ hợp đồng `config.toml` ra stdout.
### `completions`
- `zeroclaw completions bash`
- `zeroclaw completions fish`
- `zeroclaw completions zsh`
- `zeroclaw completions powershell`
- `zeroclaw completions elvish`
`completions` chỉ xuất ra stdout để script có thể được source trực tiếp mà không bị lẫn log/cảnh báo.
### `hardware`
- `zeroclaw hardware discover`
- `zeroclaw hardware introspect <path>`
- `zeroclaw hardware info [--chip <chip_name>]`
### `peripheral`
- `zeroclaw peripheral list`
- `zeroclaw peripheral add <board> <path>`
- `zeroclaw peripheral flash [--port <serial_port>]`
- `zeroclaw peripheral setup-uno-q [--host <ip_or_host>]`
- `zeroclaw peripheral flash-nucleo`
## Kiểm tra nhanh
Để xác minh nhanh tài liệu với binary hiện tại:
```bash
zeroclaw --help
zeroclaw <command> --help
```
-521
View File
@@ -1,521 +0,0 @@
# Tham khảo cấu hình ZeroClaw
Các mục cấu hình thường dùng và giá trị mặc định.
Xác minh lần cuối: **2026-02-19**.
Thứ tự tìm config khi khởi động:
1. Biến `ZEROCLAW_WORKSPACE` (nếu được đặt)
2. Marker `~/.zeroclaw/active_workspace.toml` (nếu có)
3. Mặc định `~/.zeroclaw/config.toml`
ZeroClaw ghi log đường dẫn config đã giải quyết khi khởi động ở mức `INFO`:
- `Config loaded` với các trường: `path`, `workspace`, `source`, `initialized`
Lệnh xuất schema:
- `zeroclaw config schema` (xuất JSON Schema draft 2020-12 ra stdout)
## Khóa chính
| Khóa | Mặc định | Ghi chú |
|---|---|---|
| `default_provider` | `openrouter` | ID hoặc bí danh provider |
| `default_model` | `anthropic/claude-sonnet-4-6` | Model định tuyến qua provider đã chọn |
| `default_temperature` | `0.7` | Nhiệt độ model |
## `[observability]`
| Khóa | Mặc định | Mục đích |
|---|---|---|
| `backend` | `none` | Backend quan sát: `none`, `noop`, `log`, `prometheus`, `otel`, `opentelemetry` hoặc `otlp` |
| `otel_endpoint` | `http://localhost:4318` | Endpoint OTLP HTTP khi backend là `otel` |
| `otel_service_name` | `zeroclaw` | Tên dịch vụ gửi đến OTLP collector |
Lưu ý:
- `backend = "otel"` dùng OTLP HTTP export với blocking exporter client để span và metric có thể được gửi an toàn từ context ngoài Tokio.
- Bí danh `opentelemetry``otlp` trỏ đến cùng backend OTel.
Ví dụ:
```toml
[observability]
backend = "otel"
otel_endpoint = "http://localhost:4318"
otel_service_name = "zeroclaw"
```
## Ghi đè provider qua biến môi trường
Provider cũng có thể chọn qua biến môi trường. Thứ tự ưu tiên:
1. `ZEROCLAW_PROVIDER` (ghi đè tường minh, luôn thắng khi có giá trị)
2. `PROVIDER` (dự phòng kiểu cũ, chỉ áp dụng khi provider trong config chưa đặt hoặc vẫn là `openrouter`)
3. `default_provider` trong `config.toml`
Lưu ý cho người dùng container:
- Nếu `config.toml` đặt provider tùy chỉnh như `custom:https://.../v1`, biến `PROVIDER=openrouter` mặc định từ Docker/container sẽ không thay thế nó.
- Dùng `ZEROCLAW_PROVIDER` khi cố ý muốn biến môi trường ghi đè provider đã cấu hình.
## `[agent]`
| Khóa | Mặc định | Mục đích |
|---|---|---|
| `compact_context` | `false` | Khi bật: bootstrap_max_chars=6000, rag_chunk_limit=2. Dùng cho model 13B trở xuống |
| `max_tool_iterations` | `10` | Số vòng lặp tool-call tối đa mỗi tin nhắn trên CLI, gateway và channels |
| `max_history_messages` | `50` | Số tin nhắn lịch sử tối đa giữ lại mỗi phiên |
| `parallel_tools` | `false` | Bật thực thi tool song song trong một lượt |
| `tool_dispatcher` | `auto` | Chiến lược dispatch tool |
| `tool_call_dedup_exempt` | `[]` | Tên tool được miễn kiểm tra trùng lặp trong cùng một lượt |
Lưu ý:
- Đặt `max_tool_iterations = 0` sẽ dùng giá trị mặc định an toàn `10`.
- Nếu tin nhắn kênh vượt giá trị này, runtime trả về: `Agent exceeded maximum tool iterations (<value>)`.
- Trong vòng lặp tool của CLI, gateway và channel, các lời gọi tool độc lập được thực thi đồng thời mặc định khi không cần phê duyệt; thứ tự kết quả giữ ổn định.
- `parallel_tools` áp dụng cho API `Agent::turn()`. Không ảnh hưởng đến vòng lặp runtime của CLI, gateway hay channel.
- `tool_call_dedup_exempt` nhận mảng tên tool chính xác. Các tool trong danh sách được phép gọi nhiều lần với cùng tham số trong một lượt. Ví dụ: `tool_call_dedup_exempt = ["browser"]`.
## `[agents.<name>]`
Cấu hình agent phụ (sub-agent). Mỗi khóa dưới `[agents]` định nghĩa một agent phụ có tên mà agent chính có thể ủy quyền.
| Khóa | Mặc định | Mục đích |
|---|---|---|
| `provider` | _bắt buộc_ | Tên provider (ví dụ `"ollama"`, `"openrouter"`, `"anthropic"`) |
| `model` | _bắt buộc_ | Tên model cho agent phụ |
| `system_prompt` | chưa đặt | System prompt tùy chỉnh cho agent phụ (tùy chọn) |
| `api_key` | chưa đặt | API key tùy chỉnh (mã hóa khi `secrets.encrypt = true`) |
| `temperature` | chưa đặt | Temperature tùy chỉnh cho agent phụ |
| `max_depth` | `3` | Độ sâu đệ quy tối đa cho ủy quyền lồng nhau |
| `agentic` | `false` | Bật chế độ vòng lặp tool-call nhiều lượt cho agent phụ |
| `allowed_tools` | `[]` | Danh sách tool được phép ở chế độ agentic |
| `max_iterations` | `10` | Số vòng tool-call tối đa cho chế độ agentic |
Lưu ý:
- `agentic = false` giữ nguyên hành vi ủy quyền prompt→response đơn lượt.
- `agentic = true` yêu cầu ít nhất một mục khớp trong `allowed_tools`.
- Tool `delegate` bị loại khỏi allowlist của agent phụ để tránh vòng lặp ủy quyền.
```toml
[agents.researcher]
provider = "openrouter"
model = "anthropic/claude-sonnet-4-6"
system_prompt = "You are a research assistant."
max_depth = 2
agentic = true
allowed_tools = ["web_search", "http_request", "file_read"]
max_iterations = 8
[agents.coder]
provider = "ollama"
model = "qwen2.5-coder:32b"
temperature = 0.2
```
## `[runtime]`
| Khóa | Mặc định | Mục đích |
|---|---|---|
| `reasoning_enabled` | chưa đặt (`None`) | Ghi đè toàn cục cho reasoning/thinking trên provider hỗ trợ |
Lưu ý:
- `reasoning_enabled = false` tắt tường minh reasoning phía provider cho provider hỗ trợ (hiện tại `ollama`, qua trường `think: false`).
- `reasoning_enabled = true` yêu cầu reasoning tường minh (`think: true` trên `ollama`).
- Để trống giữ mặc định của provider.
## `[skills]`
| Khóa | Mặc định | Mục đích |
|---|---|---|
| `open_skills_enabled` | `false` | Cho phép tải/đồng bộ kho `open-skills` cộng đồng |
| `open_skills_dir` | chưa đặt | Đường dẫn cục bộ cho `open-skills` (mặc định `$HOME/open-skills` khi bật) |
Lưu ý:
- Mặc định an toàn: ZeroClaw **không** clone hay đồng bộ `open-skills` trừ khi `open_skills_enabled = true`.
- Ghi đè qua biến môi trường:
- `ZEROCLAW_OPEN_SKILLS_ENABLED` chấp nhận `1/0`, `true/false`, `yes/no`, `on/off`.
- `ZEROCLAW_OPEN_SKILLS_DIR` ghi đè đường dẫn kho khi có giá trị.
- Thứ tự ưu tiên: `ZEROCLAW_OPEN_SKILLS_ENABLED``skills.open_skills_enabled` trong `config.toml` → mặc định `false`.
## `[composio]`
| Khóa | Mặc định | Mục đích |
|---|---|---|
| `enabled` | `false` | Bật công cụ OAuth do Composio quản lý |
| `api_key` | chưa đặt | API key Composio cho tool `composio` |
| `entity_id` | `default` | `user_id` mặc định gửi khi gọi connect/execute |
Lưu ý:
- Tương thích ngược: `enable = true` kiểu cũ được chấp nhận như bí danh cho `enabled = true`.
- Nếu `enabled = false` hoặc thiếu `api_key`, tool `composio` không được đăng ký.
- ZeroClaw yêu cầu Composio v3 tools với `toolkit_versions=latest` và thực thi với `version="latest"` để tránh bản tool mặc định cũ.
- Luồng thông thường: gọi `connect`, hoàn tất OAuth trên trình duyệt, rồi chạy `execute` cho hành động mong muốn.
- Nếu Composio trả lỗi thiếu connected-account, gọi `list_accounts` (tùy chọn với `app`) và truyền `connected_account_id` trả về cho `execute`.
## `[cost]`
| Khóa | Mặc định | Mục đích |
|---|---|---|
| `enabled` | `false` | Bật theo dõi chi phí |
| `daily_limit_usd` | `10.00` | Giới hạn chi tiêu hàng ngày (USD) |
| `monthly_limit_usd` | `100.00` | Giới hạn chi tiêu hàng tháng (USD) |
| `warn_at_percent` | `80` | Cảnh báo khi chi tiêu đạt tỷ lệ phần trăm này |
| `allow_override` | `false` | Cho phép vượt ngân sách khi dùng cờ `--override` |
Lưu ý:
- Khi `enabled = true`, runtime theo dõi ước tính chi phí mỗi yêu cầu và áp dụng giới hạn ngày/tháng.
- Tại ngưỡng `warn_at_percent`, cảnh báo được gửi nhưng yêu cầu vẫn tiếp tục.
- Khi đạt giới hạn, yêu cầu bị từ chối trừ khi `allow_override = true` và cờ `--override` được truyền.
## `[identity]`
| Khóa | Mặc định | Mục đích |
|---|---|---|
| `format` | `openclaw` | Định dạng danh tính: `"openclaw"` (mặc định) hoặc `"aieos"` |
| `aieos_path` | chưa đặt | Đường dẫn file AIEOS JSON (tương đối với workspace) |
| `aieos_inline` | chưa đặt | AIEOS JSON nội tuyến (thay thế cho đường dẫn file) |
Lưu ý:
- Dùng `format = "aieos"` với `aieos_path` hoặc `aieos_inline` để tải tài liệu danh tính AIEOS / OpenClaw.
- Chỉ nên đặt một trong hai `aieos_path` hoặc `aieos_inline`; `aieos_path` được ưu tiên.
## `[multimodal]`
| Khóa | Mặc định | Mục đích |
|---|---|---|
| `max_images` | `4` | Số marker ảnh tối đa mỗi yêu cầu |
| `max_image_size_mb` | `5` | Giới hạn kích thước ảnh trước khi mã hóa base64 |
| `allow_remote_fetch` | `false` | Cho phép tải ảnh từ URL `http(s)` trong marker |
Lưu ý:
- Runtime chấp nhận marker ảnh trong tin nhắn với cú pháp: ``[IMAGE:<source>]``.
- Nguồn hỗ trợ:
- Đường dẫn file cục bộ (ví dụ ``[IMAGE:/tmp/screenshot.png]``)
- Data URI (ví dụ ``[IMAGE:data:image/png;base64,...]``)
- URL từ xa chỉ khi `allow_remote_fetch = true`
- Kiểu MIME cho phép: `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `image/bmp`.
- Khi provider đang dùng không hỗ trợ vision, yêu cầu thất bại với lỗi capability có cấu trúc (`capability=vision`) thay vì bỏ qua ảnh.
## `[browser]`
| Khóa | Mặc định | Mục đích |
|---|---|---|
| `enabled` | `false` | Bật tool `browser_open` (mở URL trong trình duyệt mặc định hệ thống, không thu thập dữ liệu) |
| `allowed_domains` | `[]` | Tên miền cho phép cho `browser_open` (khớp chính xác hoặc subdomain) |
| `session_name` | chưa đặt | Tên phiên trình duyệt (cho tự động hóa agent-browser) |
| `backend` | `agent_browser` | Backend tự động hóa: `"agent_browser"`, `"rust_native"`, `"computer_use"` hoặc `"auto"` |
| `native_headless` | `true` | Chế độ headless cho backend rust-native |
| `native_webdriver_url` | `http://127.0.0.1:9515` | URL endpoint WebDriver cho backend rust-native |
| `native_chrome_path` | chưa đặt | Đường dẫn Chrome/Chromium tùy chọn cho backend rust-native |
### `[browser.computer_use]`
| Khóa | Mặc định | Mục đích |
|---|---|---|
| `endpoint` | `http://127.0.0.1:8787/v1/actions` | Endpoint sidecar cho hành động computer-use (chuột/bàn phím/screenshot cấp OS) |
| `api_key` | chưa đặt | Bearer token tùy chọn cho sidecar computer-use (mã hóa khi lưu) |
| `timeout_ms` | `15000` | Thời gian chờ mỗi hành động (mili giây) |
| `allow_remote_endpoint` | `false` | Cho phép endpoint từ xa/công khai cho sidecar |
| `window_allowlist` | `[]` | Danh sách cho phép tiêu đề cửa sổ/tiến trình gửi đến sidecar |
| `max_coordinate_x` | chưa đặt | Giới hạn trục X cho hành động dựa trên tọa độ (tùy chọn) |
| `max_coordinate_y` | chưa đặt | Giới hạn trục Y cho hành động dựa trên tọa độ (tùy chọn) |
Lưu ý:
- Khi `backend = "computer_use"`, agent ủy quyền hành động trình duyệt cho sidecar tại `computer_use.endpoint`.
- `allow_remote_endpoint = false` (mặc định) từ chối mọi endpoint không phải loopback để tránh lộ ra ngoài.
- Dùng `window_allowlist` để giới hạn cửa sổ OS mà sidecar có thể tương tác.
## `[http_request]`
| Khóa | Mặc định | Mục đích |
|---|---|---|
| `enabled` | `false` | Bật tool `http_request` cho tương tác API |
| `allowed_domains` | `[]` | Tên miền cho phép (khớp chính xác hoặc subdomain) |
| `max_response_size` | `1000000` | Kích thước response tối đa (byte, mặc định: 1 MB) |
| `timeout_secs` | `30` | Thời gian chờ yêu cầu (giây) |
Lưu ý:
- Mặc định từ chối tất cả: nếu `allowed_domains` rỗng, mọi yêu cầu HTTP bị từ chối.
- Dùng khớp tên miền chính xác hoặc subdomain (ví dụ `"api.example.com"`, `"example.com"`).
## `[gateway]`
| Khóa | Mặc định | Mục đích |
|---|---|---|
| `host` | `127.0.0.1` | Địa chỉ bind |
| `port` | `3000` | Cổng lắng nghe gateway |
| `require_pairing` | `true` | Yêu cầu ghép nối trước khi xác thực bearer |
| `allow_public_bind` | `false` | Chặn lộ public do vô ý |
## `[autonomy]`
| Khóa | Mặc định | Mục đích |
|---|---|---|
| `level` | `supervised` | `read_only`, `supervised` hoặc `full` |
| `workspace_only` | `true` | Giới hạn ghi/lệnh trong phạm vi workspace |
| `allowed_commands` | _bắt buộc để chạy shell_ | Danh sách lệnh được phép |
| `forbidden_paths` | `[]` | Danh sách đường dẫn bị cấm |
| `max_actions_per_hour` | `100` | Ngân sách hành động mỗi giờ |
| `max_cost_per_day_cents` | `1000` | Giới hạn chi tiêu mỗi ngày (cent) |
| `require_approval_for_medium_risk` | `true` | Yêu cầu phê duyệt cho lệnh rủi ro trung bình |
| `block_high_risk_commands` | `true` | Chặn cứng lệnh rủi ro cao |
| `auto_approve` | `[]` | Thao tác tool luôn được tự động phê duyệt |
| `always_ask` | `[]` | Thao tác tool luôn yêu cầu phê duyệt |
Lưu ý:
- `level = "full"` bỏ qua phê duyệt rủi ro trung bình cho shell execution, nhưng vẫn áp dụng guardrail đã cấu hình.
- Phân tích toán tử/dấu phân cách shell nhận biết dấu ngoặc kép. Ký tự như `;` trong đối số được trích dẫn được xử lý là ký tự, không phải dấu phân cách lệnh.
- Toán tử chuỗi shell không trích dẫn vẫn được kiểm tra bởi policy (`;`, `|`, `&&`, `||`, chạy nền và chuyển hướng).
## `[memory]`
| Khóa | Mặc định | Mục đích |
|---|---|---|
| `backend` | `sqlite` | `sqlite`, `lucid`, `markdown`, `none` |
| `auto_save` | `true` | Chỉ lưu đầu vào người dùng (đầu ra assistant bị loại) |
| `embedding_provider` | `none` | `none`, `openai` hoặc endpoint tùy chỉnh |
| `embedding_model` | `text-embedding-3-small` | ID model embedding, hoặc tuyến `hint:<name>` |
| `embedding_dimensions` | `1536` | Kích thước vector mong đợi cho model embedding đã chọn |
| `vector_weight` | `0.7` | Trọng số vector trong xếp hạng kết hợp |
| `keyword_weight` | `0.3` | Trọng số từ khóa trong xếp hạng kết hợp |
Lưu ý:
- Chèn ngữ cảnh memory bỏ qua khóa auto-save `assistant_resp*` kiểu cũ để tránh tóm tắt do model tạo bị coi là sự thật.
## `[[model_routes]]` và `[[embedding_routes]]`
Route hint giúp tên tích hợp ổn định khi model ID thay đổi.
### `[[model_routes]]`
| Khóa | Mặc định | Mục đích |
|---|---|---|
| `hint` | _bắt buộc_ | Tên hint tác vụ (ví dụ `"reasoning"`, `"fast"`, `"code"`, `"summarize"`) |
| `provider` | _bắt buộc_ | Provider đích (phải khớp tên provider đã biết) |
| `model` | _bắt buộc_ | Model sử dụng với provider đó |
| `api_key` | chưa đặt | API key tùy chỉnh cho provider của route này (tùy chọn) |
### `[[embedding_routes]]`
| Khóa | Mặc định | Mục đích |
|---|---|---|
| `hint` | _bắt buộc_ | Tên route hint (ví dụ `"semantic"`, `"archive"`, `"faq"`) |
| `provider` | _bắt buộc_ | Embedding provider (`"none"`, `"openai"` hoặc `"custom:<url>"`) |
| `model` | _bắt buộc_ | Model embedding sử dụng với provider đó |
| `dimensions` | chưa đặt | Ghi đè kích thước embedding cho route này (tùy chọn) |
| `api_key` | chưa đặt | API key tùy chỉnh cho provider của route này (tùy chọn) |
```toml
[memory]
embedding_model = "hint:semantic"
[[model_routes]]
hint = "reasoning"
provider = "openrouter"
model = "provider/model-id"
[[embedding_routes]]
hint = "semantic"
provider = "openai"
model = "text-embedding-3-small"
dimensions = 1536
```
Chiến lược nâng cấp:
1. Giữ hint ổn định (`hint:reasoning`, `hint:semantic`).
2. Chỉ cập nhật `model = "...phiên-bản-mới..."` trong mục route.
3. Kiểm tra bằng `zeroclaw doctor` trước khi khởi động lại/triển khai.
## `[query_classification]`
Tự động định tuyến tin nhắn đến hint `[[model_routes]]` theo mẫu nội dung.
| Khóa | Mặc định | Mục đích |
|---|---|---|
| `enabled` | `false` | Bật phân loại truy vấn tự động |
| `rules` | `[]` | Quy tắc phân loại (đánh giá theo thứ tự ưu tiên) |
Mỗi rule trong `rules`:
| Khóa | Mặc định | Mục đích |
|---|---|---|
| `hint` | _bắt buộc_ | Phải khớp giá trị hint trong `[[model_routes]]` |
| `keywords` | `[]` | Khớp chuỗi con không phân biệt hoa thường |
| `patterns` | `[]` | Khớp chuỗi chính xác phân biệt hoa thường (cho code fence, từ khóa như `"fn "`) |
| `min_length` | chưa đặt | Chỉ khớp nếu độ dài tin nhắn ≥ N ký tự |
| `max_length` | chưa đặt | Chỉ khớp nếu độ dài tin nhắn ≤ N ký tự |
| `priority` | `0` | Rule ưu tiên cao hơn được kiểm tra trước |
```toml
[query_classification]
enabled = true
[[query_classification.rules]]
hint = "reasoning"
keywords = ["explain", "analyze", "why"]
min_length = 200
priority = 10
[[query_classification.rules]]
hint = "fast"
keywords = ["hi", "hello", "thanks"]
max_length = 50
priority = 5
```
## `[channels_config]`
Cấu hình kênh cấp cao nằm dưới `channels_config`.
| Khóa | Mặc định | Mục đích |
|---|---|---|
| `message_timeout_secs` | `300` | Thời gian chờ cơ bản (giây) cho xử lý tin nhắn kênh; runtime tự điều chỉnh theo độ sâu tool-loop (lên đến 4x) |
Ví dụ:
- `[channels_config.telegram]`
- `[channels_config.discord]`
- `[channels_config.whatsapp]`
- `[channels_config.email]`
Lưu ý:
- Mặc định `300s` tối ưu cho LLM chạy cục bộ (Ollama) vốn chậm hơn cloud API.
- Ngân sách timeout runtime là `message_timeout_secs * scale`, trong đó `scale = min(max_tool_iterations, 4)` và tối thiểu `1`.
- Việc điều chỉnh này tránh timeout sai khi lượt LLM đầu chậm/retry nhưng các lượt tool-loop sau vẫn cần hoàn tất.
- Nếu dùng cloud API (OpenAI, Anthropic, v.v.), có thể giảm xuống `60` hoặc thấp hơn.
- Giá trị dưới `30` bị giới hạn thành `30` để tránh timeout liên tục.
- Khi timeout xảy ra, người dùng nhận: `⚠️ Request timed out while waiting for the model. Please try again.`
- Hành vi ngắt chỉ Telegram được điều khiển bằng `channels_config.telegram.interrupt_on_new_message` (mặc định `false`).
Khi bật, tin nhắn mới từ cùng người gửi trong cùng chat sẽ hủy yêu cầu đang xử lý và giữ ngữ cảnh người dùng bị ngắt.
- Khi `zeroclaw channel start` đang chạy, thay đổi `default_provider`, `default_model`, `default_temperature`, `api_key`, `api_url``reliability.*` được áp dụng nóng từ `config.toml` ở tin nhắn tiếp theo.
Xem ma trận kênh và hành vi allowlist chi tiết tại [channels-reference.md](channels-reference.md).
### `[channels_config.whatsapp]`
WhatsApp hỗ trợ hai backend dưới cùng một bảng config.
Chế độ Cloud API (webhook Meta):
| Khóa | Bắt buộc | Mục đích |
|---|---|---|
| `access_token` | Có | Bearer token Meta Cloud API |
| `phone_number_id` | Có | ID số điện thoại Meta |
| `verify_token` | Có | Token xác minh webhook |
| `app_secret` | Tùy chọn | Bật xác minh chữ ký webhook (`X-Hub-Signature-256`) |
| `allowed_numbers` | Khuyến nghị | Số điện thoại cho phép gửi đến (`[]` = từ chối tất cả, `"*"` = cho phép tất cả) |
Chế độ WhatsApp Web (client gốc):
| Khóa | Bắt buộc | Mục đích |
|---|---|---|
| `session_path` | Có | Đường dẫn phiên SQLite lưu trữ lâu dài |
| `pair_phone` | Tùy chọn | Số điện thoại cho luồng pair-code (chỉ chữ số) |
| `pair_code` | Tùy chọn | Mã pair tùy chỉnh (nếu không sẽ tự tạo) |
| `allowed_numbers` | Khuyến nghị | Số điện thoại cho phép gửi đến (`[]` = từ chối tất cả, `"*"` = cho phép tất cả) |
Lưu ý:
- WhatsApp Web yêu cầu build flag `whatsapp-web`.
- Nếu cả Cloud lẫn Web đều có cấu hình, Cloud được ưu tiên để tương thích ngược.
## `[hardware]`
Cấu hình truy cập phần cứng vật lý (STM32, probe, serial).
| Khóa | Mặc định | Mục đích |
|---|---|---|
| `enabled` | `false` | Bật truy cập phần cứng |
| `transport` | `none` | Chế độ truyền: `"none"`, `"native"`, `"serial"` hoặc `"probe"` |
| `serial_port` | chưa đặt | Đường dẫn cổng serial (ví dụ `"/dev/ttyACM0"`) |
| `baud_rate` | `115200` | Tốc độ baud serial |
| `probe_target` | chưa đặt | Chip đích cho probe (ví dụ `"STM32F401RE"`) |
| `workspace_datasheets` | `false` | Bật RAG datasheet workspace (đánh chỉ mục PDF schematic để AI tra cứu chân) |
Lưu ý:
- Dùng `transport = "serial"` với `serial_port` cho kết nối USB-serial.
- Dùng `transport = "probe"` với `probe_target` cho nạp qua debug-probe (ví dụ ST-Link).
- Xem [hardware-peripherals-design.md](hardware-peripherals-design.md) để biết chi tiết giao thức.
## `[peripherals]`
Bo mạch ngoại vi trở thành tool agent khi được bật.
| Khóa | Mặc định | Mục đích |
|---|---|---|
| `enabled` | `false` | Bật hỗ trợ ngoại vi (bo mạch trở thành tool agent) |
| `boards` | `[]` | Danh sách cấu hình bo mạch |
| `datasheet_dir` | chưa đặt | Đường dẫn tài liệu datasheet (tương đối workspace) cho RAG |
Mỗi mục trong `boards`:
| Khóa | Mặc định | Mục đích |
|---|---|---|
| `board` | _bắt buộc_ | Loại bo mạch: `"nucleo-f401re"`, `"rpi-gpio"`, `"esp32"`, v.v. |
| `transport` | `serial` | Kiểu truyền: `"serial"`, `"native"`, `"websocket"` |
| `path` | chưa đặt | Đường dẫn serial: `"/dev/ttyACM0"`, `"/dev/ttyUSB0"` |
| `baud` | `115200` | Tốc độ baud cho serial |
```toml
[peripherals]
enabled = true
datasheet_dir = "docs/datasheets"
[[peripherals.boards]]
board = "nucleo-f401re"
transport = "serial"
path = "/dev/ttyACM0"
baud = 115200
[[peripherals.boards]]
board = "rpi-gpio"
transport = "native"
```
Lưu ý:
- Đặt file `.md`/`.txt` datasheet đặt tên theo bo mạch (ví dụ `nucleo-f401re.md`, `rpi-gpio.md`) trong `datasheet_dir` cho RAG.
- Xem [hardware-peripherals-design.md](hardware-peripherals-design.md) để biết giao thức bo mạch và ghi chú firmware.
## Giá trị mặc định liên quan bảo mật
- Allowlist kênh mặc định từ chối tất cả (`[]` nghĩa là từ chối tất cả)
- Gateway mặc định yêu cầu ghép nối
- Mặc định chặn public bind
## Lệnh kiểm tra
Sau khi chỉnh config:
```bash
zeroclaw status
zeroclaw doctor
zeroclaw channel doctor
zeroclaw service restart
```
## Tài liệu liên quan
- [channels-reference.md](channels-reference.md)
- [providers-reference.md](providers-reference.md)
- [operations-runbook.md](operations-runbook.md)
- [troubleshooting.md](troubleshooting.md)
-18
View File
@@ -1,18 +0,0 @@
# Tài liệu đóng góp, review và CI
Dành cho contributor, reviewer và maintainer.
## Chính sách cốt lõi
- Hướng dẫn đóng góp: [CONTRIBUTING.md](../../../../CONTRIBUTING.md)
- Quy tắc quy trình PR: [../pr-workflow.md](../pr-workflow.md)
- Sổ tay reviewer: [../reviewer-playbook.md](../reviewer-playbook.md)
- Bản đồ CI và quyền sở hữu: [../ci-map.md](../ci-map.md)
- Chính sách nguồn Actions: [../actions-source-policy.md](../actions-source-policy.md)
## Thứ tự đọc được đề xuất
1. `CONTRIBUTING.md`
2. `../pr-workflow.md`
3. `../reviewer-playbook.md`
4. `../ci-map.md`
-111
View File
@@ -1,111 +0,0 @@
# Cấu hình Provider Tùy chỉnh
ZeroClaw hỗ trợ endpoint API tùy chỉnh cho cả provider tương thích OpenAI lẫn Anthropic.
## Các loại Provider
### Endpoint tương thích OpenAI (`custom:`)
Dành cho các dịch vụ triển khai định dạng API của OpenAI:
```toml
default_provider = "custom:https://your-api.com"
api_key = "your-api-key"
default_model = "your-model-name"
```
### Endpoint tương thích Anthropic (`anthropic-custom:`)
Dành cho các dịch vụ triển khai định dạng API của Anthropic:
```toml
default_provider = "anthropic-custom:https://your-api.com"
api_key = "your-api-key"
default_model = "your-model-name"
```
## Phương thức cấu hình
### File Config
Chỉnh sửa `~/.zeroclaw/config.toml`:
```toml
api_key = "your-api-key"
default_provider = "anthropic-custom:https://api.example.com"
default_model = "claude-sonnet-4-6"
```
### Biến môi trường
Với provider `custom:``anthropic-custom:`, dùng biến môi trường chứa key chung:
```bash
export API_KEY="your-api-key"
# hoặc: export ZEROCLAW_API_KEY="your-api-key"
zeroclaw agent
```
## Kiểm tra cấu hình
Xác minh endpoint tùy chỉnh của bạn:
```bash
# Chế độ tương tác
zeroclaw agent
# Kiểm tra tin nhắn đơn
zeroclaw agent -m "test message"
```
## Xử lý sự cố
### Lỗi xác thực
- Kiểm tra lại API key
- Kiểm tra định dạng URL endpoint (phải bao gồm `http://` hoặc `https://`)
- Đảm bảo endpoint có thể truy cập từ mạng của bạn
### Không tìm thấy Model
- Xác nhận tên model khớp với các model mà provider cung cấp
- Kiểm tra tài liệu của provider để biết định danh model chính xác
- Đảm bảo endpoint và dòng model khớp nhau. Một số gateway tùy chỉnh chỉ cung cấp một tập con model.
- Xác minh các model có sẵn từ cùng endpoint và key đã cấu hình:
```bash
curl -sS https://your-api.com/models \
-H "Authorization: Bearer $API_KEY"
```
- Nếu gateway không triển khai `/models`, gửi một request chat tối giản và kiểm tra thông báo lỗi model mà provider trả về.
### Sự cố kết nối
- Kiểm tra khả năng truy cập endpoint: `curl -I https://your-api.com`
- Xác minh cài đặt firewall/proxy
- Kiểm tra trang trạng thái của provider
## Ví dụ
### LLM Server cục bộ
```toml
default_provider = "custom:http://localhost:8080"
default_model = "local-model"
```
### Proxy của doanh nghiệp
```toml
default_provider = "anthropic-custom:https://llm-proxy.corp.example.com"
api_key = "internal-token"
```
### Cloud Provider Gateway
```toml
default_provider = "custom:https://gateway.cloud-provider.com/v1"
api_key = "gateway-api-key"
default_model = "gpt-4"
```
-37
View File
@@ -1,37 +0,0 @@
# Arduino Uno
## Pin Aliases
| alias | pin |
|-------------|-----|
| red_led | 13 |
| builtin_led | 13 |
| user_led | 13 |
## Tổng quan
Arduino Uno là board vi điều khiển dựa trên ATmega328P. Có 14 pin digital I/O (013) và 6 đầu vào analog (A0A5).
## Pin Digital
- **Pins 013:** Digital I/O. Có thể là INPUT hoặc OUTPUT.
- **Pin 13:** LED tích hợp (onboard). Kết nối LED với GND hoặc dùng để xuất tín hiệu.
- **Pins 01:** Cũng dùng cho Serial (RX/TX). Tránh dùng nếu đang sử dụng Serial.
## GPIO
- `digitalWrite(pin, HIGH)` hoặc `digitalWrite(pin, LOW)` để xuất tín hiệu.
- `digitalRead(pin)` để đọc đầu vào (trả về 0 hoặc 1).
- Số pin trong giao thức ZeroClaw: 013.
## Serial
- UART trên pin 0 (RX) và 1 (TX).
- USB qua ATmega16U2 hoặc CH340 (bản clone).
- Baud rate: 115200 cho firmware ZeroClaw.
## ZeroClaw Tools
- `gpio_read`: Đọc giá trị pin (0 hoặc 1).
- `gpio_write`: Đặt pin lên cao (1) hoặc xuống thấp (0).
- `arduino_upload`: Agent tạo code Arduino sketch đầy đủ; ZeroClaw biên dịch và tải lên qua arduino-cli. Dùng cho "make a heart", các pattern tùy chỉnh — agent viết code, không cần chỉnh sửa thủ công. Pin 13 = LED tích hợp.
-22
View File
@@ -1,22 +0,0 @@
# Tham chiếu GPIO ESP32
## Pin Aliases
| alias | pin |
|-------------|-----|
| builtin_led | 2 |
| red_led | 2 |
## Các pin thông dụng (ESP32 / ESP32-C3)
- **GPIO 2**: LED tích hợp trên nhiều dev board (output)
- **GPIO 13**: Đầu ra mục đích chung
- **GPIO 21/20**: Thường dùng cho UART0 TX/RX (tránh nếu đang dùng serial)
## Giao thức
ZeroClaw host gửi JSON qua serial (115200 baud):
- `gpio_read`: `{"id":"1","cmd":"gpio_read","args":{"pin":13}}`
- `gpio_write`: `{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}`
Response: `{"id":"1","ok":true,"result":"0"}` hoặc `{"id":"1","ok":true,"result":"done"}`
-16
View File
@@ -1,16 +0,0 @@
# GPIO Nucleo-F401RE
## Pin Aliases
| alias | pin |
|-------------|-----|
| red_led | 13 |
| user_led | 13 |
| ld2 | 13 |
| builtin_led | 13 |
## GPIO
Pin 13: User LED (LD2)
- Output, mức cao tích cực (active high)
- PA5 trên STM32F401
-312
View File
@@ -1,312 +0,0 @@
# Bảo mật không gây cản trở
> ⚠️ **Trạng thái: Đề xuất / Lộ trình**
>
> Tài liệu này mô tả các hướng tiếp cận đề xuất và có thể bao gồm các lệnh hoặc cấu hình giả định.
> Để biết hành vi runtime hiện tại, xem [config-reference.md](config-reference.md), [operations-runbook.md](operations-runbook.md), và [troubleshooting.md](troubleshooting.md).
## Nguyên tắc cốt lõi
>
> **"Các tính năng bảo mật nên như túi khí — luôn hiện diện, bảo vệ, và vô hình cho đến khi cần."**
## Thiết kế: tự động phát hiện âm thầm
### 1. Không thêm bước wizard mới (giữ nguyên 9 bước, < 60 giây)
```rust
// Wizard không thay đổi
// Các tính năng bảo mật tự phát hiện ở nền
pub fn run_wizard() -> Result<Config> {
// ... 9 bước hiện có, không thay đổi ...
let config = Config {
// ... các trường hiện có ...
// MỚI: Bảo mật tự phát hiện (không hiển thị trong wizard)
security: SecurityConfig::autodetect(), // Âm thầm!
};
config.save().await?;
Ok(config)
}
```
### 2. Logic tự phát hiện (chạy một lần khi khởi động lần đầu)
```rust
// src/security/detect.rs
impl SecurityConfig {
/// Phát hiện sandbox khả dụng và bật tự động
/// Trả về giá trị mặc định thông minh dựa trên nền tảng + công cụ có sẵn
pub fn autodetect() -> Self {
Self {
// Sandbox: ưu tiên Landlock (native), rồi Firejail, rồi none
sandbox: SandboxConfig::autodetect(),
// Resource limits: luôn bật monitoring
resources: ResourceLimits::default(),
// Audit: bật mặc định, log vào config dir
audit: AuditConfig::default(),
// Mọi thứ khác: giá trị mặc định an toàn
..SecurityConfig::default()
}
}
}
impl SandboxConfig {
pub fn autodetect() -> Self {
#[cfg(target_os = "linux")]
{
// Ưu tiên Landlock (native, không phụ thuộc)
if Self::probe_landlock() {
return Self {
enabled: true,
backend: SandboxBackend::Landlock,
..Self::default()
};
}
// Fallback: Firejail nếu đã cài
if Self::probe_firejail() {
return Self {
enabled: true,
backend: SandboxBackend::Firejail,
..Self::default()
};
}
}
#[cfg(target_os = "macos")]
{
// Thử Bubblewrap trên macOS
if Self::probe_bubblewrap() {
return Self {
enabled: true,
backend: SandboxBackend::Bubblewrap,
..Self::default()
};
}
}
// Fallback: tắt (nhưng vẫn có application-layer security)
Self {
enabled: false,
backend: SandboxBackend::None,
..Self::default()
}
}
#[cfg(target_os = "linux")]
fn probe_landlock() -> bool {
// Thử tạo Landlock ruleset tối thiểu
// Nếu thành công, kernel hỗ trợ Landlock
landlock::Ruleset::new()
.set_access_fs(landlock::AccessFS::read_file)
.add_path(Path::new("/tmp"), landlock::AccessFS::read_file)
.map(|ruleset| ruleset.restrict_self().is_ok())
.unwrap_or(false)
}
fn probe_firejail() -> bool {
// Kiểm tra lệnh firejail có tồn tại không
std::process::Command::new("firejail")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
}
```
### 3. Lần chạy đầu: ghi log âm thầm
```bash
$ zeroclaw agent -m "hello"
# Lần đầu: phát hiện âm thầm
[INFO] Detecting security features...
[INFO] ✓ Landlock sandbox enabled (kernel 6.2+)
[INFO] ✓ Memory monitoring active (512MB limit)
[INFO] ✓ Audit logging enabled (~/.config/zeroclaw/audit.log)
# Các lần sau: yên lặng
$ zeroclaw agent -m "hello"
[agent] Thinking...
```
### 4. File config: tất cả giá trị mặc định được ẩn
```toml
# ~/.config/zeroclaw/config.toml
# Các section này KHÔNG được ghi trừ khi người dùng tùy chỉnh
# [security.sandbox]
# enabled = true # (mặc định, tự phát hiện)
# backend = "landlock" # (mặc định, tự phát hiện)
# [security.resources]
# max_memory_mb = 512 # (mặc định)
# [security.audit]
# enabled = true # (mặc định)
```
Chỉ khi người dùng thay đổi:
```toml
[security.sandbox]
enabled = false # Người dùng tắt tường minh
[security.resources]
max_memory_mb = 1024 # Người dùng tăng giới hạn
```
### 5. Người dùng nâng cao: kiểm soát tường minh
```bash
# Kiểm tra trạng thái đang hoạt động
$ zeroclaw security --status
Security Status:
✓ Sandbox: Landlock (Linux kernel 6.2)
✓ Memory monitoring: 512MB limit
✓ Audit logging: ~/.config/zeroclaw/audit.log
47 events logged today
# Tắt sandbox tường minh (ghi vào config)
$ zeroclaw config set security.sandbox.enabled false
# Bật backend cụ thể
$ zeroclaw config set security.sandbox.backend firejail
# Điều chỉnh giới hạn
$ zeroclaw config set security.resources.max_memory_mb 2048
```
### 6. Giảm cấp nhẹ nhàng
| Nền tảng | Tốt nhất có thể | Fallback | Tệ nhất |
|----------|---------------|----------|------------|
| **Linux 5.13+** | Landlock | None | Chỉ App-layer |
| **Linux (bất kỳ)** | Firejail | Landlock | Chỉ App-layer |
| **macOS** | Bubblewrap | None | Chỉ App-layer |
| **Windows** | None | - | Chỉ App-layer |
**App-layer security luôn hiện diện** — đây là allowlist/path blocking/injection protection hiện có, vốn đã toàn diện.
---
## Mở rộng config schema
```rust
// src/config/schema.rs
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityConfig {
/// Cấu hình sandbox (tự phát hiện nếu không đặt)
#[serde(default)]
pub sandbox: SandboxConfig,
/// Giới hạn tài nguyên (áp dụng mặc định nếu không đặt)
#[serde(default)]
pub resources: ResourceLimits,
/// Audit logging (bật mặc định)
#[serde(default)]
pub audit: AuditConfig,
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
sandbox: SandboxConfig::autodetect(), // Phát hiện âm thầm!
resources: ResourceLimits::default(),
audit: AuditConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxConfig {
/// Bật sandboxing (mặc định: tự phát hiện)
#[serde(default)]
pub enabled: Option<bool>, // None = tự phát hiện
/// Sandbox backend (mặc định: tự phát hiện)
#[serde(default)]
pub backend: SandboxBackend,
/// Tham số Firejail tùy chỉnh (tùy chọn)
#[serde(default)]
pub firejail_args: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SandboxBackend {
Auto, // Tự phát hiện (mặc định)
Landlock, // Linux kernel LSM
Firejail, // User-space sandbox
Bubblewrap, // User namespaces
Docker, // Container (nặng)
None, // Tắt
}
impl Default for SandboxBackend {
fn default() -> Self {
Self::Auto // Luôn tự phát hiện mặc định
}
}
```
---
## So sánh trải nghiệm người dùng
### Trước (hiện tại)
```bash
$ zeroclaw onboard
[1/9] Workspace Setup...
[2/9] AI Provider...
...
[9/9] Workspace Files...
✓ Security: Supervised | workspace-scoped
```
### Sau (với bảo mật không gây cản trở)
```bash
$ zeroclaw onboard
[1/9] Workspace Setup...
[2/9] AI Provider...
...
[9/9] Workspace Files...
✓ Security: Supervised | workspace-scoped | Landlock sandbox ✓
# ↑ Chỉ thêm một từ, tự phát hiện âm thầm!
```
---
## Tương thích ngược
| Tình huống | Hành vi |
|----------|----------|
| **Config hiện có** | Hoạt động không thay đổi, tính năng mới là opt-in |
| **Cài mới** | Tự phát hiện và bật bảo mật khả dụng |
| **Không có sandbox** | Fallback về app-layer (vẫn an toàn) |
| **Người dùng tắt** | Một flag config: `sandbox.enabled = false` |
---
## Tóm tắt
**Không ảnh hưởng wizard** — giữ nguyên 9 bước, < 60 giây
**Không thêm prompt** — tự phát hiện âm thầm
**Không breaking change** — tương thích ngược
**Có thể opt-out** — flag config tường minh
**Hiển thị trạng thái**`zeroclaw security --status`
Wizard vẫn là "thiết lập nhanh ứng dụng phổ quát" — bảo mật chỉ **lặng lẽ tốt hơn**.
-29
View File
@@ -1,29 +0,0 @@
# Tài liệu Bắt đầu
Dành cho cài đặt lần đầu và làm quen nhanh.
## Lộ trình bắt đầu
1. Tổng quan và khởi động nhanh: [../../../README.vi.md](../../../README.vi.md)
2. Cài đặt một lệnh và chế độ bootstrap kép: [../one-click-bootstrap.md](../one-click-bootstrap.md)
3. Tìm lệnh theo tác vụ: [../commands-reference.md](../commands-reference.md)
## Chọn hướng đi
| Tình huống | Lệnh |
|----------|---------|
| Có API key, muốn cài nhanh nhất | `zeroclaw onboard --api-key sk-... --provider openrouter` |
| Muốn được hướng dẫn từng bước | `zeroclaw onboard` |
| Đã có config, chỉ cần sửa kênh | `zeroclaw onboard --channels-only` |
| Dùng xác thực subscription | Xem [Subscription Auth](../../../README.md#subscription-auth-openai-codex--claude-code) |
## Thiết lập và kiểm tra
- Thiết lập nhanh: `zeroclaw onboard --api-key "sk-..." --provider openrouter`
- Thiết lập hướng dẫn: `zeroclaw onboard`
- Kiểm tra môi trường: `zeroclaw status` + `zeroclaw doctor`
## Tiếp theo
- Vận hành runtime: [../operations/README.md](../operations/README.md)
- Tra cứu tham khảo: [../reference/README.md](../reference/README.md)
-324
View File
@@ -1,324 +0,0 @@
# Thiết kế Hardware Peripherals — ZeroClaw
ZeroClaw cho phép các vi điều khiển (MCU) và máy tính nhúng (SBC) **phân tích lệnh ngôn ngữ tự nhiên theo thời gian thực**, tổng hợp code phù hợp với từng phần cứng, và thực thi tương tác với ngoại vi trực tiếp.
## 1. Tầm nhìn
**Mục tiêu:** ZeroClaw đóng vai trò là AI agent có hiểu biết về phần cứng, cụ thể:
- Nhận lệnh ngôn ngữ tự nhiên (ví dụ: "Di chuyển cánh tay X", "Bật LED") qua các kênh như WhatsApp, Telegram
- Truy xuất tài liệu phần cứng chính xác (datasheet, register map)
- Tổng hợp code/logic Rust bằng LLM (Gemini, các mô hình mã nguồn mở)
- Thực thi logic để điều khiển ngoại vi (GPIO, I2C, SPI)
- Lưu trữ code tối ưu để tái sử dụng về sau
**Hình dung trực quan:** ZeroClaw = bộ não hiểu phần cứng. Ngoại vi = tay chân mà nó điều khiển.
## 2. Hai chế độ vận hành
### Chế độ 1: Edge-Native (Độc lập trên thiết bị)
**Mục tiêu:** Các board có WiFi (ESP32, Raspberry Pi).
ZeroClaw chạy **trực tiếp trên thiết bị**. Board khởi động server gRPC/nanoRPC và giao tiếp với ngoại vi ngay tại chỗ.
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ ZeroClaw on ESP32 / Raspberry Pi (Edge-Native) │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────────────────┐ │
│ │ Channels │───►│ Agent Loop │───►│ RAG: datasheets, register maps │ │
│ │ WhatsApp │ │ (LLM calls) │ │ → LLM context │ │
│ │ Telegram │ └──────┬───────┘ └─────────────────────────────────┘ │
│ └─────────────┘ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────────┐│
│ │ Code synthesis → Wasm / dynamic exec → GPIO / I2C / SPI → persist ││
│ └─────────────────────────────────────────────────────────────────────────┘│
│ │
│ gRPC/nanoRPC server ◄──► Peripherals (GPIO, I2C, SPI, sensors, actuators) │
└─────────────────────────────────────────────────────────────────────────────┘
```
**Luồng xử lý:**
1. Người dùng gửi WhatsApp: *"Turn on LED on pin 13"*
2. ZeroClaw truy xuất tài liệu theo board (ví dụ: bản đồ GPIO của ESP32)
3. LLM tổng hợp code Rust
4. Code chạy trong sandbox (Wasm hoặc dynamic linking)
5. GPIO được bật/tắt; kết quả trả về người dùng
6. Code tối ưu được lưu lại để tái sử dụng cho các yêu cầu "Turn on LED" sau này
**Toàn bộ diễn ra trên thiết bị.** Không cần máy chủ trung gian.
### Chế độ 2: Host-Mediated (Phát triển / Gỡ lỗi)
**Mục tiêu:** Phần cứng kết nối qua USB / J-Link / Aardvark với máy chủ (macOS, Linux).
ZeroClaw chạy trên **máy chủ** và duy trì kết nối phần cứng tới thiết bị mục tiêu. Dùng cho phát triển, kiểm tra nội tâm, và nạp firmware.
```
┌─────────────────────┐ ┌──────────────────────────────────┐
│ ZeroClaw on Mac │ USB / J-Link / │ STM32 Nucleo-F401RE │
│ │ Aardvark │ (or other MCU) │
│ - Channels │ ◄────────────────► │ - Memory map │
│ - LLM │ │ - Peripherals (GPIO, ADC, I2C) │
│ - Hardware probe │ VID/PID │ - Flash / RAM │
│ - Flash / debug │ discovery │ │
└─────────────────────┘ └──────────────────────────────────┘
```
**Luồng xử lý:**
1. Người dùng gửi Telegram: *"What are the readable memory addresses on this USB device?"*
2. ZeroClaw nhận diện phần cứng đang kết nối (VID/PID, kiến trúc)
3. Thực hiện ánh xạ bộ nhớ; gợi ý các vùng địa chỉ khả dụng
4. Trả kết quả về người dùng
**Hoặc:**
1. Người dùng: *"Flash this firmware to the Nucleo"*
2. ZeroClaw ghi/nạp firmware qua OpenOCD hoặc probe-rs
3. Xác nhận thành công
**Hoặc:**
1. ZeroClaw tự phát hiện: *"STM32 Nucleo on /dev/ttyACM0, ARM Cortex-M4"*
2. Gợi ý: *"I can read/write GPIO, ADC, flash. What would you like to do?"*
---
### So sánh hai chế độ
| Khía cạnh | Edge-Native | Host-Mediated |
|-----------|-------------|---------------|
| ZeroClaw chạy trên | Thiết bị (ESP32, RPi) | Máy chủ (Mac, Linux) |
| Kết nối phần cứng | Cục bộ (GPIO, I2C, SPI) | USB, J-Link, Aardvark |
| LLM | Trên thiết bị hoặc cloud (Gemini) | Máy chủ (cloud hoặc local) |
| Trường hợp sử dụng | Sản xuất, độc lập | Phát triển, gỡ lỗi, kiểm tra |
| Kênh liên lạc | WhatsApp, v.v. (qua WiFi) | Telegram, CLI, v.v. |
## 3. Các chế độ cũ / Đơn giản hơn (Trước khi có LLM trên Edge)
Dành cho các board không có WiFi hoặc trước khi Edge-Native hoàn chỉnh:
### Chế độ A: Host + Remote Peripheral (STM32 qua serial)
Máy chủ chạy ZeroClaw; ngoại vi chạy firmware tối giản. JSON đơn giản qua serial.
### Chế độ B: RPi làm Host (Native GPIO)
ZeroClaw trên Pi; GPIO qua rppal hoặc sysfs. Không cần firmware riêng.
## 4. Yêu cầu kỹ thuật
| Yêu cầu | Mô tả |
|---------|-------|
| **Ngôn ngữ** | Thuần Rust. `no_std` khi áp dụng được cho các target nhúng (STM32, ESP32). |
| **Giao tiếp** | Stack gRPC hoặc nanoRPC nhẹ để xử lý lệnh với độ trễ thấp. |
| **Thực thi động** | Chạy an toàn logic do LLM tạo ra theo thời gian thực: Wasm runtime để cô lập, hoặc dynamic linking khi được hỗ trợ. |
| **Truy xuất tài liệu** | Pipeline RAG (Retrieval-Augmented Generation) để đưa đoạn trích datasheet, register map và pinout vào ngữ cảnh LLM. |
| **Nhận diện phần cứng** | Nhận dạng thiết bị USB qua VID/PID; phát hiện kiến trúc (ARM Cortex-M, RISC-V, v.v.). |
### Pipeline RAG (Truy xuất Datasheet)
- **Lập chỉ mục:** Datasheet, hướng dẫn tham chiếu, register map (PDF → các đoạn, embeddings).
- **Truy xuất:** Khi người dùng hỏi ("turn on LED"), lấy các đoạn liên quan (ví dụ: phần GPIO của board mục tiêu).
- **Chèn vào:** Thêm vào system prompt hoặc ngữ cảnh LLM.
- **Kết quả:** LLM tạo code chính xác, đặc thù cho từng board.
### Các lựa chọn thực thi động
| Lựa chọn | Ưu điểm | Nhược điểm |
|----------|---------|-----------|
| **Wasm** | Sandboxed, di động, không cần FFI | Overhead; truy cập phần cứng từ Wasm bị hạn chế |
| **Dynamic linking** | Tốc độ native, truy cập phần cứng đầy đủ | Phụ thuộc nền tảng; lo ngại bảo mật |
| **Interpreted DSL** | An toàn, có thể kiểm tra | Chậm hơn; biểu đạt hạn chế |
| **Pre-compiled templates** | Nhanh, bảo mật | Kém linh hoạt; cần thư viện template |
**Khuyến nghị:** Bắt đầu với pre-compiled templates + parameterization; tiến lên Wasm cho logic do người dùng định nghĩa khi đã ổn định.
## 5. CLI và Config
### CLI Flags
```bash
# Edge-Native: run on device (ESP32, RPi)
zeroclaw agent --mode edge
# Host-Mediated: connect to USB/J-Link target
zeroclaw agent --peripheral nucleo-f401re:/dev/ttyACM0
zeroclaw agent --probe jlink
# Hardware introspection
zeroclaw hardware discover
zeroclaw hardware introspect /dev/ttyACM0
```
### Config (config.toml)
```toml
[peripherals]
enabled = true
mode = "host" # "edge" | "host"
datasheet_dir = "docs/datasheets" # RAG: board-specific docs for LLM context
[[peripherals.boards]]
board = "nucleo-f401re"
transport = "serial"
path = "/dev/ttyACM0"
baud = 115200
[[peripherals.boards]]
board = "rpi-gpio"
transport = "native"
[[peripherals.boards]]
board = "esp32"
transport = "wifi"
# Edge-Native: ZeroClaw runs on ESP32
```
## 6. Kiến trúc: Peripheral là điểm mở rộng
### Trait mới: `Peripheral`
```rust
/// A hardware peripheral that exposes capabilities as tools.
#[async_trait]
pub trait Peripheral: Send + Sync {
fn name(&self) -> &str;
fn board_type(&self) -> &str; // e.g. "nucleo-f401re", "rpi-gpio"
async fn connect(&mut self) -> anyhow::Result<()>;
async fn disconnect(&mut self) -> anyhow::Result<()>;
async fn health_check(&self) -> bool;
/// Tools this peripheral provides (gpio_read, gpio_write, sensor_read, etc.)
fn tools(&self) -> Vec<Box<dyn Tool>>;
}
```
### Luồng xử lý
1. **Khởi động:** ZeroClaw nạp config, đọc `peripherals.boards`.
2. **Kết nối:** Với mỗi board, tạo impl `Peripheral`, gọi `connect()`.
3. **Tools:** Thu thập tools từ tất cả peripheral đã kết nối; gộp với tools mặc định.
4. **Vòng lặp agent:** Agent có thể gọi `gpio_write`, `sensor_read`, v.v. — các lệnh này chuyển tiếp tới peripheral.
5. **Tắt máy:** Gọi `disconnect()` trên từng peripheral.
### Hỗ trợ Board
| Board | Transport | Firmware / Driver | Tools |
|-------|-----------|-------------------|-------|
| nucleo-f401re | serial | Zephyr / Embassy | gpio_read, gpio_write, adc_read |
| rpi-gpio | native | rppal or sysfs | gpio_read, gpio_write |
| esp32 | serial/ws | ESP-IDF / Embassy | gpio, wifi, mqtt |
## 7. Giao thức giao tiếp
### gRPC / nanoRPC (Edge-Native, Host-Mediated)
Dành cho RPC có kiểu dữ liệu, độ trễ thấp giữa ZeroClaw và các peripheral:
- **nanoRPC** hoặc **tonic** (gRPC): Dịch vụ định nghĩa bằng Protobuf.
- Phương thức: `GpioWrite`, `GpioRead`, `I2cTransfer`, `SpiTransfer`, `MemoryRead`, `FlashWrite`, v.v.
- Hỗ trợ streaming, gọi hai chiều, và sinh code từ file `.proto`.
### Serial Fallback (Host-Mediated, legacy)
JSON đơn giản qua serial cho các board không hỗ trợ gRPC:
**Request (host → peripheral):**
```json
{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}
```
**Response (peripheral → host):**
```json
{"id":"1","ok":true,"result":"done"}
```
## 8. Firmware (Repo hoặc Crate riêng)
- **zeroclaw-firmware** hoặc **zeroclaw-peripheral** — một crate/workspace riêng biệt.
- Targets: `thumbv7em-none-eabihf` (STM32), `armv7-unknown-linux-gnueabihf` (RPi), v.v.
- Dùng `embassy` hoặc Zephyr cho STM32.
- Triển khai giao thức nêu trên.
- Người dùng nạp lên board; ZeroClaw kết nối và tự phát hiện khả năng.
## 9. Các giai đoạn triển khai
### Phase 1: Skeleton ✅ (Hoàn thành)
- [x] Thêm trait `Peripheral`, config schema, CLI (`zeroclaw peripheral list/add`)
- [x] Thêm flag `--peripheral` cho agent
- [x] Ghi tài liệu vào AGENTS.md
### Phase 2: Host-Mediated — Phát hiện phần cứng ✅ (Hoàn thành)
- [x] `zeroclaw hardware discover`: liệt kê thiết bị USB (VID/PID)
- [x] Board registry: ánh xạ VID/PID → kiến trúc, tên (ví dụ: Nucleo-F401RE)
- [x] `zeroclaw hardware introspect <path>`: memory map, danh sách peripheral
### Phase 3: Host-Mediated — Serial / J-Link
- [x] `SerialPeripheral` cho STM32 qua USB CDC
- [ ] Tích hợp probe-rs hoặc OpenOCD để nạp/gỡ lỗi firmware
- [x] Tools: `gpio_read`, `gpio_write` (memory_read, flash_write trong tương lai)
### Phase 4: Pipeline RAG ✅ (Hoàn thành)
- [x] Lập chỉ mục datasheet (markdown/text → các đoạn)
- [x] Truy xuất và chèn vào ngữ cảnh LLM cho các truy vấn liên quan phần cứng
- [x] Bổ sung prompt đặc thù theo board
**Cách dùng:** Thêm `datasheet_dir = "docs/datasheets"` vào `[peripherals]` trong config.toml. Đặt file `.md` hoặc `.txt` được đặt tên theo board (ví dụ: `nucleo-f401re.md`, `rpi-gpio.md`). Các file trong `_generic/` hoặc tên `generic.md` áp dụng cho mọi board. Các đoạn được truy xuất theo từ khóa và chèn vào ngữ cảnh tin nhắn người dùng.
### Phase 5: Edge-Native — RPi ✅ (Hoàn thành)
- [x] ZeroClaw trên Raspberry Pi (native GPIO qua rppal)
- [ ] Server gRPC/nanoRPC cho truy cập peripheral cục bộ
- [ ] Lưu trữ code (lưu các đoạn code đã tổng hợp)
### Phase 6: Edge-Native — ESP32
- [x] ESP32 qua Host-Mediated (serial transport) — cùng giao thức JSON như STM32
- [x] Crate firmware `esp32` (`firmware/esp32`) — GPIO qua UART
- [x] ESP32 trong hardware registry (CH340 VID/PID)
- [ ] ZeroClaw *chạy trực tiếp trên* ESP32 (WiFi + LLM, edge-native) — tương lai
- [ ] Thực thi Wasm hoặc dựa trên template cho logic do LLM tạo ra
**Cách dùng:** Nạp `firmware/esp32` vào ESP32, thêm `board = "esp32"`, `transport = "serial"`, `path = "/dev/ttyUSB0"` vào config.
### Phase 7: Thực thi động (Code do LLM tạo ra)
- [ ] Thư viện template: các đoạn GPIO/I2C/SPI có tham số
- [ ] Tùy chọn: Wasm runtime cho logic do người dùng định nghĩa (sandboxed)
- [ ] Lưu và tái sử dụng các đường code tối ưu
## 10. Các khía cạnh bảo mật
- **Serial path:** Xác thực `path` nằm trong danh sách cho phép (ví dụ: `/dev/ttyACM*`, `/dev/ttyUSB*`); không bao giờ dùng đường dẫn tùy ý.
- **GPIO:** Giới hạn những pin nào được phép truy cập; tránh các pin nguồn/reset.
- **Không lưu bí mật trên peripheral:** Firmware không nên lưu API key; máy chủ xử lý xác thực.
## 11. Ngoài phạm vi (Hiện tại)
- Chạy ZeroClaw đầy đủ *trực tiếp trên* STM32 bare-metal (không có WiFi, RAM hạn chế) — dùng Host-Mediated thay thế
- Đảm bảo thời gian thực — peripheral hoạt động theo kiểu best-effort
- Thực thi code native tùy ý từ LLM — ưu tiên Wasm hoặc templates
## 12. Tài liệu liên quan
- [adding-boards-and-tools.md](./adding-boards-and-tools.md) — Cách thêm board và datasheet
- [network-deployment.md](network-deployment.md) — Triển khai RPi và mạng
## 13. Tham khảo
- [Zephyr RTOS Rust support](https://docs.zephyrproject.org/latest/develop/languages/rust/index.html)
- [Embassy](https://embassy.dev/) — async embedded framework
- [rppal](https://github.com/golemparts/rppal) — Raspberry Pi GPIO in Rust
- [STM32 Nucleo-F401RE](https://www.st.com/en/evaluation-tools/nucleo-f401re.html)
- [tonic](https://github.com/hyperium/tonic) — gRPC for Rust
- [probe-rs](https://probe.rs/) — ARM debug probe, flash, memory access
- [nusb](https://github.com/nic-hartley/nusb) — USB device enumeration (VID/PID)
## 14. Tóm tắt ý tưởng gốc
> *"Các board như ESP, Raspberry Pi, hoặc các board có WiFi có thể kết nối với LLM (Gemini hoặc mã nguồn mở). ZeroClaw chạy trên thiết bị, tạo gRPC riêng, khởi động nó, và giao tiếp với ngoại vi. Người dùng hỏi qua WhatsApp: 'di chuyển cánh tay X' hoặc 'bật LED'. ZeroClaw lấy tài liệu chính xác, viết code, thực thi, lưu trữ tối ưu, chạy, và bật LED — tất cả trên board phát triển.*
>
> *Với STM Nucleo kết nối qua USB/J-Link/Aardvark vào Mac: ZeroClaw từ Mac truy cập phần cứng, cài đặt hoặc ghi những gì cần thiết lên thiết bị, và trả kết quả. Ví dụ: 'Hey ZeroClaw, những địa chỉ khả dụng/đọc được trên thiết bị USB này là gì?' Nó có thể tự tìm ra thiết bị nào đang kết nối ở đâu và đưa ra gợi ý."*
-19
View File
@@ -1,19 +0,0 @@
# Tài liệu phần cứng và ngoại vi
Tích hợp board, firmware và ngoại vi.
Hệ thống phần cứng của ZeroClaw cho phép điều khiển trực tiếp vi điều khiển và ngoại vi thông qua trait `Peripheral`. Mỗi board cung cấp các tool cho GPIO, ADC và các thao tác cảm biến, cho phép tương tác phần cứng do agent điều khiển trên các board như STM32 Nucleo, Raspberry Pi và ESP32. Xem [../hardware-peripherals-design.md](../hardware-peripherals-design.md) để biết kiến trúc đầy đủ.
## Điểm bắt đầu
- Kiến trúc và mô hình ngoại vi: [../hardware-peripherals-design.md](../hardware-peripherals-design.md)
- Thêm board/tool mới: [../adding-boards-and-tools.md](../adding-boards-and-tools.md)
- Thiết lập Nucleo: [../nucleo-setup.md](../nucleo-setup.md)
- Thiết lập Arduino Uno R4 WiFi: [../arduino-uno-q-setup.md](../arduino-uno-q-setup.md)
## Datasheet
- Chỉ mục datasheet: [../datasheets](../datasheets)
- STM32 Nucleo-F401RE: [../datasheets/nucleo-f401re.md](../datasheets/nucleo-f401re.md)
- Arduino Uno: [../datasheets/arduino-uno.md](../datasheets/arduino-uno.md)
- ESP32: [../datasheets/esp32.md](../datasheets/esp32.md)
-239
View File
@@ -1,239 +0,0 @@
# Hướng dẫn Tích hợp LangGraph
Hướng dẫn này giải thích cách sử dụng gói Python `zeroclaw-tools` để gọi tool nhất quán với bất kỳ LLM provider nào tương thích OpenAI.
## Bối cảnh
Một số LLM provider, đặc biệt là các model Trung Quốc như GLM-5 (Zhipu AI), có hành vi gọi tool không nhất quán khi dùng phương thức text-based tool invocation. Core Rust của ZeroClaw sử dụng structured tool calling theo định dạng OpenAI API, nhưng một số model phản hồi tốt hơn với cách tiếp cận khác.
LangGraph cung cấp một stateful graph execution engine đảm bảo hành vi gọi tool nhất quán bất kể khả năng native của model nền tảng.
## Kiến trúc
```
┌─────────────────────────────────────────────────────────────┐
│ Your Application │
├─────────────────────────────────────────────────────────────┤
│ zeroclaw-tools Agent │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ LangGraph StateGraph │ │
│ │ │ │
│ │ ┌────────────┐ ┌────────────┐ │ │
│ │ │ Agent │ ──────▶ │ Tools │ │ │
│ │ │ Node │ ◀────── │ Node │ │ │
│ │ └────────────┘ └────────────┘ │ │
│ │ │ │ │ │
│ │ ▼ ▼ │ │
│ │ [Continue?] [Execute Tool] │ │
│ │ │ │ │ │
│ │ Yes │ No Result│ │ │
│ │ ▼ ▼ │ │
│ │ [END] [Back to Agent] │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
├─────────────────────────────────────────────────────────────┤
│ OpenAI-Compatible LLM Provider │
│ (Z.AI, OpenRouter, Groq, DeepSeek, Ollama, etc.) │
└─────────────────────────────────────────────────────────────┘
```
## Bắt đầu nhanh
### Cài đặt
```bash
pip install zeroclaw-tools
```
### Sử dụng cơ bản
```python
import asyncio
from zeroclaw_tools import create_agent, shell, file_read, file_write
from langchain_core.messages import HumanMessage
async def main():
agent = create_agent(
tools=[shell, file_read, file_write],
model="glm-5",
api_key="your-api-key",
base_url="https://api.z.ai/api/coding/paas/v4"
)
result = await agent.ainvoke({
"messages": [HumanMessage(content="Read /etc/hostname and tell me the machine name")]
})
print(result["messages"][-1].content)
asyncio.run(main())
```
## Các Tool Hiện có
### Tool cốt lõi
| Tool | Mô tả |
|------|-------|
| `shell` | Thực thi lệnh shell |
| `file_read` | Đọc nội dung file |
| `file_write` | Ghi nội dung vào file |
### Tool mở rộng
| Tool | Mô tả |
|------|-------|
| `web_search` | Tìm kiếm web (yêu cầu `BRAVE_API_KEY`) |
| `http_request` | Thực hiện HTTP request |
| `memory_store` | Lưu dữ liệu vào bộ nhớ lâu dài |
| `memory_recall` | Truy xuất dữ liệu đã lưu |
## Tool tùy chỉnh
Tạo tool riêng của bạn bằng decorator `@tool`:
```python
from zeroclaw_tools import tool, create_agent
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
# Your implementation
return f"Weather in {city}: Sunny, 25°C"
@tool
def query_database(sql: str) -> str:
"""Execute a SQL query and return results."""
# Your implementation
return "Query returned 5 rows"
agent = create_agent(
tools=[get_weather, query_database],
model="glm-5",
api_key="your-key"
)
```
## Cấu hình Provider
### Z.AI / GLM-5
```python
agent = create_agent(
model="glm-5",
api_key="your-zhipu-key",
base_url="https://api.z.ai/api/coding/paas/v4"
)
```
### OpenRouter
```python
agent = create_agent(
model="anthropic/claude-sonnet-4-6",
api_key="your-openrouter-key",
base_url="https://openrouter.ai/api/v1"
)
```
### Groq
```python
agent = create_agent(
model="llama-3.3-70b-versatile",
api_key="your-groq-key",
base_url="https://api.groq.com/openai/v1"
)
```
### Ollama (cục bộ)
```python
agent = create_agent(
model="llama3.2",
base_url="http://localhost:11434/v1"
)
```
## Tích hợp Discord Bot
```python
import os
from zeroclaw_tools.integrations import DiscordBot
bot = DiscordBot(
token=os.environ["DISCORD_TOKEN"],
guild_id=123456789, # Your Discord server ID
allowed_users=["123456789"], # User IDs that can use the bot
api_key=os.environ["API_KEY"],
model="glm-5"
)
bot.run()
```
## Sử dụng qua CLI
```bash
# Set environment variables
export API_KEY="your-key"
export BRAVE_API_KEY="your-brave-key" # Optional, for web search
# Single message
zeroclaw-tools "What is the current date?"
# Interactive mode
zeroclaw-tools -i
```
## So sánh với Rust ZeroClaw
| Khía cạnh | Rust ZeroClaw | zeroclaw-tools |
|--------|---------------|-----------------|
| **Hiệu năng** | Cực nhanh (~10ms khởi động) | Khởi động Python (~500ms) |
| **Bộ nhớ** | <5 MB | ~50 MB |
| **Kích thước binary** | ~3.4 MB | pip package |
| **Tính nhất quán của tool** | Phụ thuộc model | LangGraph đảm bảo |
| **Khả năng mở rộng** | Rust traits | Python decorators |
| **Hệ sinh thái** | Rust crates | PyPI packages |
**Khi nào dùng Rust ZeroClaw:**
- Triển khai edge cho môi trường production
- Môi trường hạn chế tài nguyên (Raspberry Pi, v.v.)
- Yêu cầu hiệu năng tối đa
**Khi nào dùng zeroclaw-tools:**
- Các model có tool calling native không nhất quán
- Phát triển trung tâm vào Python
- Prototyping nhanh
- Tích hợp với hệ sinh thái Python ML
## Xử lý sự cố
### Lỗi "API key required"
Đặt biến môi trường `API_KEY` hoặc truyền `api_key` vào `create_agent()`.
### Tool call không được thực thi
Đảm bảo model của bạn hỗ trợ function calling. Một số model cũ có thể không hỗ trợ tool.
### Rate limiting
Thêm độ trễ giữa các lần gọi hoặc tự triển khai rate limiting:
```python
import asyncio
for message in messages:
result = await agent.ainvoke({"messages": [message]})
await asyncio.sleep(1) # Rate limit
```
## Dự án Liên quan
- [rs-graph-llm](https://github.com/a-agmon/rs-graph-llm) - Rust LangGraph alternative
- [langchain-rust](https://github.com/Abraxas-365/langchain-rust) - LangChain for Rust
- [llm-chain](https://github.com/sobelio/llm-chain) - LLM chains in Rust
-141
View File
@@ -1,141 +0,0 @@
# Hướng dẫn Matrix E2EE
Hướng dẫn này giải thích cách chạy ZeroClaw ổn định trong các phòng Matrix, bao gồm các phòng mã hóa đầu cuối (E2EE).
Tài liệu tập trung vào lỗi phổ biến mà người dùng báo cáo:
> "Matrix đã cấu hình đúng, kiểm tra thành công, nhưng bot không phản hồi."
## 0. FAQ nhanh (triệu chứng lớp #499)
Nếu Matrix có vẻ đã kết nối nhưng không có phản hồi, hãy xác minh những điều sau trước:
1. Người gửi được cho phép bởi `allowed_users` (khi kiểm tra: `["*"]`).
2. Tài khoản bot đã tham gia đúng phòng mục tiêu.
3. Token thuộc về cùng tài khoản bot (kiểm tra bằng `whoami`).
4. Phòng mã hóa có identity thiết bị (`device_id`) và chia sẻ key hợp lệ.
5. Daemon đã được khởi động lại sau khi thay đổi cấu hình.
---
## 1. Yêu cầu
Trước khi kiểm tra luồng tin nhắn, hãy đảm bảo tất cả các điều sau đều đúng:
1. Tài khoản bot đã tham gia phòng mục tiêu.
2. Access token thuộc về cùng tài khoản bot.
3. `room_id` chính xác:
- ưu tiên: canonical room ID (`!room:server`)
- được hỗ trợ: room alias (`#alias:server`) và ZeroClaw sẽ tự resolve
4. `allowed_users` cho phép người gửi (`["*"]` để kiểm tra mở).
5. Với phòng E2EE, thiết bị bot đã nhận được encryption key cho phòng.
---
## 2. Cấu hình
Dùng `~/.zeroclaw/config.toml`:
```toml
[channels_config.matrix]
homeserver = "https://matrix.example.com"
access_token = "syt_your_token"
# Optional but recommended for E2EE stability:
user_id = "@zeroclaw:matrix.example.com"
device_id = "DEVICEID123"
# Room ID or alias
room_id = "!xtHhdHIIVEZbDPvTvZ:matrix.example.com"
# room_id = "#ops:matrix.example.com"
# Use ["*"] during initial verification, then tighten.
allowed_users = ["*"]
```
### Về `user_id` và `device_id`
- ZeroClaw cố đọc identity từ Matrix `/_matrix/client/v3/account/whoami`.
- Nếu `whoami` không trả về `device_id`, hãy đặt `device_id` thủ công.
- Các gợi ý này đặc biệt quan trọng để khôi phục phiên E2EE.
---
## 3. Quy trình Xác minh Nhanh
1. Chạy thiết lập channel và daemon:
```bash
zeroclaw onboard --channels-only
zeroclaw daemon
```
1. Gửi một tin nhắn văn bản thuần trong phòng Matrix đã cấu hình.
2. Xác nhận log ZeroClaw có thông tin khởi động Matrix listener và không có lỗi sync/auth lặp lại.
3. Trong phòng mã hóa, xác minh bot có thể đọc và phản hồi tin nhắn mã hóa từ các người dùng được phép.
---
## 4. Xử lý sự cố "Không có Phản hồi"
Dùng checklist này theo thứ tự.
### A. Phòng và tư cách thành viên
- Đảm bảo tài khoản bot đã tham gia phòng.
- Nếu dùng alias (`#...`), xác minh nó resolve về đúng canonical room.
### B. Allowlist người gửi
- Nếu `allowed_users = []`, tất cả tin nhắn đến đều bị từ chối.
- Để chẩn đoán, tạm thời đặt `allowed_users = ["*"]`.
### C. Token và identity
- Xác thực token bằng:
```bash
curl -sS -H "Authorization: Bearer $MATRIX_TOKEN" \
"https://matrix.example.com/_matrix/client/v3/account/whoami"
```
- Kiểm tra `user_id` trả về khớp với tài khoản bot.
- Nếu `device_id` bị thiếu, đặt `channels_config.matrix.device_id` thủ công.
### D. Kiểm tra dành riêng cho E2EE
- Thiết bị bot phải nhận được room key từ các thiết bị tin cậy.
- Nếu key không được chia sẻ tới thiết bị này, các sự kiện mã hóa không thể giải mã.
- Xác minh độ tin cậy thiết bị và chia sẻ key trong quy trình Matrix client/admin của bạn.
- Nếu log hiện `matrix_sdk_crypto::backups: Trying to backup room keys but no backup key was found`, quá trình khôi phục key backup chưa được bật trên thiết bị này. Cảnh báo này thường không gây lỗi nghiêm trọng cho luồng tin nhắn trực tiếp, nhưng bạn vẫn nên hoàn thiện thiết lập key backup/recovery.
- Nếu người nhận thấy tin nhắn bot là "unverified", hãy xác minh/ký thiết bị bot từ một phiên Matrix tin cậy và giữ `channels_config.matrix.device_id` ổn định qua các lần khởi động lại.
### E. Định dạng tin nhắn (Markdown)
- ZeroClaw gửi phản hồi văn bản Matrix dưới dạng nội dung `m.room.message` hỗ trợ markdown.
- Các Matrix client hỗ trợ `formatted_body` sẽ render in đậm, danh sách và code block.
- Nếu định dạng hiển thị dưới dạng văn bản thuần, kiểm tra khả năng của client trước, sau đó xác nhận ZeroClaw đang chạy bản build bao gồm Matrix output hỗ trợ markdown.
### F. Kiểm tra fresh start
Sau khi cập nhật cấu hình, khởi động lại daemon và gửi tin nhắn mới (không chỉ xem lại lịch sử cũ).
---
## 5. Ghi chú Vận hành
- Giữ Matrix token tránh khỏi log và ảnh chụp màn hình.
- Bắt đầu với `allowed_users` thoáng, sau đó thu hẹp về các user ID cụ thể.
- Ưu tiên dùng canonical room ID trong production để tránh alias drift.
---
## 6. Tài liệu Liên quan
- [Channels Reference](./channels-reference.md)
- [Phụ lục từ khoá log vận hành](./channels-reference.md#7-operations-appendix-log-keywords-matrix)
- [Network Deployment](./network-deployment.md)
- [Agnostic Security](agnostic-security.md)
- [Reviewer Playbook](reviewer-playbook.md)
-63
View File
@@ -1,63 +0,0 @@
# Hướng dẫn Tích hợp Mattermost
ZeroClaw hỗ trợ tích hợp native với Mattermost thông qua REST API v4. Tích hợp này lý tưởng cho các môi trường self-hosted, riêng tư hoặc air-gapped nơi giao tiếp nội bộ là yêu cầu bắt buộc.
## Điều kiện tiên quyết
1. **Mattermost Server**: Một instance Mattermost đang chạy (self-hosted hoặc cloud).
2. **Tài khoản Bot**:
- Vào **Main Menu > Integrations > Bot Accounts**.
- Nhấn **Add Bot Account**.
- Đặt username (ví dụ: `zeroclaw-bot`).
- Bật quyền **post:all****channel:read** (hoặc các scope phù hợp).
- Lưu **Access Token**.
3. **Channel ID**:
- Mở channel Mattermost mà bạn muốn bot theo dõi.
- Nhấn vào header channel và chọn **View Info**.
- Sao chép **ID** (ví dụ: `7j8k9l...`).
## Cấu hình
Thêm phần sau vào `config.toml` của bạn trong phần `[channels_config]`:
```toml
[channels_config.mattermost]
url = "https://mm.your-domain.com"
bot_token = "your-bot-access-token"
channel_id = "your-channel-id"
allowed_users = ["user-id-1", "user-id-2"]
thread_replies = true
mention_only = true
```
### Các trường cấu hình
| Trường | Mô tả |
|---|---|
| `url` | Base URL của Mattermost server của bạn. |
| `bot_token` | Personal Access Token của tài khoản bot. |
| `channel_id` | (Tùy chọn) ID của channel cần lắng nghe. Bắt buộc ở chế độ `listen`. |
| `allowed_users` | (Tùy chọn) Danh sách Mattermost User ID được phép tương tác với bot. Dùng `["*"]` để cho phép tất cả mọi người. |
| `thread_replies` | (Tùy chọn) Tin nhắn người dùng ở top-level có được trả lời trong thread không. Mặc định: `true`. Các phản hồi trong thread hiện có luôn ở lại trong thread đó. |
| `mention_only` | (Tùy chọn) Khi `true`, chỉ các tin nhắn đề cập rõ ràng username bot (ví dụ `@zeroclaw-bot`) mới được xử lý. Mặc định: `false`. |
## Cuộc hội thoại dạng Thread
ZeroClaw hỗ trợ Mattermost thread ở cả hai chế độ:
- Nếu người dùng gửi tin nhắn trong một thread hiện có, ZeroClaw luôn phản hồi trong cùng thread đó.
- Nếu `thread_replies = true` (mặc định), tin nhắn top-level được trả lời bằng cách tạo thread trên bài đăng đó.
- Nếu `thread_replies = false`, tin nhắn top-level được trả lời ở cấp độ gốc của channel.
## Chế độ Mention-Only
Khi `mention_only = true`, ZeroClaw áp dụng bộ lọc bổ sung sau khi xác thực `allowed_users`:
- Tin nhắn không đề cập rõ ràng đến bot sẽ bị bỏ qua.
- Tin nhắn có `@bot_username` sẽ được xử lý.
- Token `@bot_username` được loại bỏ trước khi gửi nội dung đến model.
Chế độ này hữu ích trong các channel chia sẻ bận rộn để giảm các lần gọi model không cần thiết.
## Ghi chú Bảo mật
Tích hợp Mattermost được thiết kế cho **giao tiếp nội bộ**. Bằng cách tự host Mattermost server, toàn bộ lịch sử giao tiếp của agent vẫn nằm trong hạ tầng của bạn, tránh việc bên thứ ba ghi lại log.
-206
View File
@@ -1,206 +0,0 @@
# Triển khai mạng — ZeroClaw trên Raspberry Pi và mạng nội bộ
Tài liệu này hướng dẫn triển khai ZeroClaw trên Raspberry Pi hoặc host khác trong mạng nội bộ, với các channel Telegram và webhook tùy chọn.
---
## 1. Tổng quan
| Chế độ | Cần cổng đến? | Trường hợp dùng |
|------|----------------------|----------|
| **Telegram polling** | Không | ZeroClaw poll Telegram API; hoạt động từ bất kỳ đâu |
| **Matrix sync (kể cả E2EE)** | Không | ZeroClaw sync qua Matrix client API; không cần webhook đến |
| **Discord/Slack** | Không | Tương tự — chỉ outbound |
| **Gateway webhook** | Có | POST /webhook, WhatsApp, v.v. cần public URL |
| **Gateway pairing** | Có | Nếu bạn pair client qua gateway |
**Lưu ý:** Telegram, Discord và Slack dùng **long-polling** — ZeroClaw thực hiện các request ra ngoài. Không cần port forwarding hoặc public IP.
---
## 2. ZeroClaw trên Raspberry Pi
### 2.1 Điều kiện tiên quyết
- Raspberry Pi (3/4/5) với Raspberry Pi OS
- Thiết bị ngoại vi USB (Arduino, Nucleo) nếu dùng serial transport
- Tùy chọn: `rppal` cho native GPIO (`peripheral-rpi` feature)
### 2.2 Cài đặt
```bash
# Build for RPi (or cross-compile from host)
cargo build --release --features hardware
# Or install via your preferred method
```
### 2.3 Cấu hình
Chỉnh sửa `~/.zeroclaw/config.toml`:
```toml
[peripherals]
enabled = true
[[peripherals.boards]]
board = "rpi-gpio"
transport = "native"
# Or Arduino over USB
[[peripherals.boards]]
board = "arduino-uno"
transport = "serial"
path = "/dev/ttyACM0"
baud = 115200
[channels_config.telegram]
bot_token = "YOUR_BOT_TOKEN"
allowed_users = []
[gateway]
host = "127.0.0.1"
port = 3000
allow_public_bind = false
```
### 2.4 Chạy Daemon (chỉ cục bộ)
```bash
zeroclaw daemon --host 127.0.0.1 --port 3000
```
- Gateway bind vào `127.0.0.1` — không tiếp cận được từ máy khác
- Channel Telegram hoạt động: ZeroClaw poll Telegram API (outbound)
- Không cần tường lửa hay port forwarding
---
## 3. Bind vào 0.0.0.0 (mạng nội bộ)
Để cho phép các thiết bị khác trong LAN của bạn truy cập gateway (ví dụ: để pairing hoặc webhook):
### 3.1 Tùy chọn A: Opt-in rõ ràng
```toml
[gateway]
host = "0.0.0.0"
port = 3000
allow_public_bind = true
```
```bash
zeroclaw daemon --host 0.0.0.0 --port 3000
```
**Bảo mật:** `allow_public_bind = true` phơi bày gateway với mạng nội bộ của bạn. Chỉ dùng trên mạng LAN tin cậy.
### 3.2 Tùy chọn B: Tunnel (khuyến nghị cho Webhook)
Nếu bạn cần **public URL** (ví dụ: webhook WhatsApp, client bên ngoài):
1. Chạy gateway trên localhost:
```bash
zeroclaw daemon --host 127.0.0.1 --port 3000
```
2. Khởi động tunnel:
```toml
[tunnel]
provider = "tailscale" # or "ngrok", "cloudflare"
```
Hoặc dùng `zeroclaw tunnel` (xem tài liệu tunnel).
3. ZeroClaw sẽ từ chối `0.0.0.0` trừ khi `allow_public_bind = true` hoặc có tunnel đang hoạt động.
---
## 4. Telegram Polling (Không cần cổng đến)
Telegram dùng **long-polling** theo mặc định:
- ZeroClaw gọi `https://api.telegram.org/bot{token}/getUpdates`
- Không cần cổng đến hoặc public IP
- Hoạt động sau NAT, trên RPi, trong home lab
**Cấu hình:**
```toml
[channels_config.telegram]
bot_token = "YOUR_BOT_TOKEN"
allowed_users = [] # deny-by-default, bind identities explicitly
```
Chạy `zeroclaw daemon` — channel Telegram khởi động tự động.
Để cho phép một tài khoản Telegram lúc runtime:
```bash
zeroclaw channel bind-telegram <IDENTITY>
```
`<IDENTITY>` có thể là Telegram user ID dạng số hoặc username (không có `@`).
### 4.1 Quy tắc Single Poller (Quan trọng)
Telegram Bot API `getUpdates` chỉ hỗ trợ một poller hoạt động cho mỗi bot token.
- Chỉ chạy một instance runtime cho cùng token (khuyến nghị: service `zeroclaw daemon`).
- Không chạy `cargo run -- channel start` hay tiến trình bot khác cùng lúc.
Nếu gặp lỗi này:
`Conflict: terminated by other getUpdates request`
bạn đang có xung đột polling. Dừng các instance thừa và chỉ khởi động lại một daemon duy nhất.
---
## 5. Webhook Channel (WhatsApp, Tùy chỉnh)
Các channel dựa trên webhook cần **public URL** để Meta (WhatsApp) hoặc client của bạn có thể POST sự kiện.
### 5.1 Tailscale Funnel
```toml
[tunnel]
provider = "tailscale"
```
Tailscale Funnel phơi bày gateway của bạn qua URL `*.ts.net`. Không cần port forwarding.
### 5.2 ngrok
```toml
[tunnel]
provider = "ngrok"
```
Hoặc chạy ngrok thủ công:
```bash
ngrok http 3000
# Use the HTTPS URL for your webhook
```
### 5.3 Cloudflare Tunnel
Cấu hình Cloudflare Tunnel để forward đến `127.0.0.1:3000`, sau đó đặt webhook URL của bạn về hostname công khai của tunnel.
---
## 6. Checklist: Triển khai RPi
- [ ] Build với `--features hardware` (và `peripheral-rpi` nếu dùng native GPIO)
- [ ] Cấu hình `[peripherals]` và `[channels_config.telegram]`
- [ ] Chạy `zeroclaw daemon --host 127.0.0.1 --port 3000` (Telegram hoạt động không cần 0.0.0.0)
- [ ] Để truy cập LAN: `--host 0.0.0.0` + `allow_public_bind = true` trong config
- [ ] Để dùng webhook: dùng Tailscale, ngrok hoặc Cloudflare tunnel
---
## 7. Tham khảo
- [channels-reference.md](./channels-reference.md) — Tổng quan cấu hình channel
- [matrix-e2ee-guide.md](./matrix-e2ee-guide.md) — Thiết lập Matrix và xử lý sự cố phòng mã hóa
- [hardware-peripherals-design.md](hardware-peripherals-design.md) — Thiết kế peripherals
- [adding-boards-and-tools.md](adding-boards-and-tools.md) — Thiết lập phần cứng và thêm board
-147
View File
@@ -1,147 +0,0 @@
# ZeroClaw trên Nucleo-F401RE — Hướng dẫn từng bước
Chạy ZeroClaw trên Mac hoặc Linux. Kết nối Nucleo-F401RE qua USB. Điều khiển GPIO (LED, các pin) qua Telegram hoặc CLI.
---
## Lấy thông tin board qua Telegram (Không cần nạp firmware)
ZeroClaw có thể đọc thông tin chip từ Nucleo qua USB **mà không cần nạp firmware nào**. Nhắn tin cho Telegram bot của bạn:
- *"What board info do I have?"*
- *"Board info"*
- *"What hardware is connected?"*
- *"Chip info"*
Agent dùng tool `hardware_board_info` để trả về tên chip, kiến trúc và memory map. Với feature `probe`, nó đọc dữ liệu trực tiếp qua USB/SWD; nếu không, nó trả về thông tin tĩnh từ datasheet.
**Cấu hình:** Thêm Nucleo vào `config.toml` trước (để agent biết board nào cần truy vấn):
```toml
[[peripherals.boards]]
board = "nucleo-f401re"
transport = "serial"
path = "/dev/ttyACM0"
baud = 115200
```
**Thay thế bằng CLI:**
```bash
cargo build --features hardware,probe
zeroclaw hardware info
zeroclaw hardware discover
```
---
## Những gì đã có sẵn (Không cần thay đổi code)
ZeroClaw bao gồm mọi thứ cần thiết cho Nucleo-F401RE:
| Thành phần | Vị trí | Mục đích |
|------------|--------|---------|
| Firmware | `firmware/nucleo/` | Embassy Rust — USART2 (115200), gpio_read, gpio_write |
| Serial peripheral | `src/peripherals/serial.rs` | Giao thức JSON-over-serial (giống Arduino/ESP32) |
| Flash command | `zeroclaw peripheral flash-nucleo` | Build firmware, nạp qua probe-rs |
Giao thức: JSON phân tách bằng dòng mới. Request: `{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}`. Response: `{"id":"1","ok":true,"result":"done"}`.
---
## Yêu cầu trước khi bắt đầu
- Board Nucleo-F401RE
- Cáp USB (USB-A sang Mini-USB; Nucleo có ST-Link tích hợp sẵn)
- Để nạp firmware: `cargo install probe-rs-tools --locked` (hoặc dùng [install script](https://probe.rs/docs/getting-started/installation/))
---
## Phase 1: Nạp Firmware
### 1.1 Kết nối Nucleo
1. Kết nối Nucleo với Mac/Linux qua USB.
2. Board xuất hiện như thiết bị USB (ST-Link). Không cần driver riêng trên các hệ thống hiện đại.
### 1.2 Nạp qua ZeroClaw
Từ thư mục gốc của repo zeroclaw:
```bash
zeroclaw peripheral flash-nucleo
```
Lệnh này build `firmware/nucleo` và chạy `probe-rs run --chip STM32F401RETx`. Firmware chạy ngay sau khi nạp xong.
### 1.3 Nạp thủ công (Phương án thay thế)
```bash
cd firmware/nucleo
cargo build --release --target thumbv7em-none-eabihf
probe-rs run --chip STM32F401RETx target/thumbv7em-none-eabihf/release/nucleo
```
---
## Phase 2: Tìm Serial Port
- **macOS:** `/dev/cu.usbmodem*` hoặc `/dev/tty.usbmodem*` (ví dụ: `/dev/cu.usbmodem101`)
- **Linux:** `/dev/ttyACM0` (hoặc kiểm tra `dmesg` sau khi cắm vào)
USART2 (PA2/PA3) được bridge sang cổng COM ảo của ST-Link, vì vậy máy chủ thấy một thiết bị serial duy nhất.
---
## Phase 3: Cấu hình ZeroClaw
Thêm vào `~/.zeroclaw/config.toml`:
```toml
[peripherals]
enabled = true
[[peripherals.boards]]
board = "nucleo-f401re"
transport = "serial"
path = "/dev/cu.usbmodem101" # điều chỉnh theo port của bạn
baud = 115200
```
---
## Phase 4: Chạy và Kiểm thử
```bash
zeroclaw daemon --host 127.0.0.1 --port 3000
```
Hoặc dùng agent trực tiếp:
```bash
zeroclaw agent --message "Turn on the LED on pin 13"
```
Pin 13 = PA5 = User LED (LD2) trên Nucleo-F401RE.
---
## Tóm tắt: Các lệnh
| Bước | Lệnh |
|------|------|
| 1 | Kết nối Nucleo qua USB |
| 2 | `cargo install probe-rs-tools --locked` |
| 3 | `zeroclaw peripheral flash-nucleo` |
| 4 | Thêm Nucleo vào config.toml (path = serial port của bạn) |
| 5 | `zeroclaw daemon` hoặc `zeroclaw agent -m "Turn on LED"` |
---
## Xử lý sự cố
- **flash-nucleo không nhận ra** — Build từ repo: `cargo run --features hardware -- peripheral flash-nucleo`. Subcommand này chỉ có trong repo build, không có trong cài đặt từ crates.io.
- **Không tìm thấy probe-rs** — `cargo install probe-rs-tools --locked` (crate `probe-rs` là thư viện; CLI nằm trong `probe-rs-tools`)
- **Không phát hiện được probe** — Đảm bảo Nucleo đã kết nối. Thử cáp/cổng USB khác.
- **Không tìm thấy serial port** — Trên Linux, thêm user vào nhóm `dialout`: `sudo usermod -a -G dialout $USER`, rồi đăng xuất/đăng nhập lại.
- **Lệnh GPIO bị bỏ qua** — Kiểm tra `path` trong config có khớp với serial port của bạn. Chạy `zeroclaw peripheral list` để xác nhận.
-120
View File
@@ -1,120 +0,0 @@
# Cài đặt một lệnh
Cách cài đặt và khởi tạo ZeroClaw nhanh nhất.
Xác minh lần cuối: **2026-02-20**.
## Cách 0: Homebrew (macOS/Linuxbrew)
```bash
brew install zeroclaw
```
## Cách A (Khuyến nghị): Clone + chạy script cục bộ
```bash
git clone https://github.com/zeroclaw-labs/zeroclaw.git
cd zeroclaw
./install.sh
```
Mặc định script sẽ:
1. `cargo build --release --locked`
2. `cargo install --path . --force --locked`
### Kiểm tra tài nguyên và binary dựng sẵn
Build từ mã nguồn thường yêu cầu tối thiểu:
- **2 GB RAM + swap**
- **6 GB dung lượng trống**
Khi tài nguyên hạn chế, bootstrap sẽ thử tải binary dựng sẵn trước.
```bash
./install.sh --prefer-prebuilt
```
Chỉ dùng binary dựng sẵn, báo lỗi nếu không tìm thấy bản phù hợp:
```bash
./install.sh --prebuilt-only
```
Bỏ qua binary dựng sẵn, buộc build từ mã nguồn:
```bash
./install.sh --force-source-build
```
## Bootstrap kép
Mặc định là **chỉ ứng dụng** (build/cài ZeroClaw), yêu cầu Rust toolchain sẵn có.
Với máy mới, bật bootstrap môi trường:
```bash
./install.sh --install-system-deps --install-rust
```
Lưu ý:
- `--install-system-deps` cài các thành phần biên dịch/build cần thiết (có thể cần `sudo`).
- `--install-rust` cài Rust qua `rustup` nếu chưa có.
- `--prefer-prebuilt` thử tải binary dựng sẵn trước, nếu không có thì build từ nguồn.
- `--prebuilt-only` tắt phương án build từ nguồn.
- `--force-source-build` tắt hoàn toàn phương án binary dựng sẵn.
## Cách B: Lệnh từ xa một dòng
```bash
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/install.sh | bash
```
Với môi trường yêu cầu bảo mật cao, nên dùng Cách A để kiểm tra script trước khi chạy.
Nếu chạy Cách B ngoài thư mục repo, bootstrap script sẽ tự clone workspace tạm, build, cài đặt rồi dọn dẹp.
## Chế độ thiết lập tùy chọn
### Thiết lập trong container (Docker)
```bash
./install.sh --docker
```
Lệnh này build image ZeroClaw cục bộ và chạy thiết lập trong container, lưu config/workspace vào `./.zeroclaw-docker`.
### Thiết lập nhanh (không tương tác)
```bash
./install.sh --api-key "sk-..." --provider openrouter
```
Hoặc dùng biến môi trường:
```bash
ZEROCLAW_API_KEY="sk-..." ZEROCLAW_PROVIDER="openrouter" ./install.sh
```
## Các cờ hữu ích
- `--install-system-deps`
- `--install-rust`
- `--skip-build`
- `--skip-install`
- `--provider <id>`
Xem tất cả tùy chọn:
```bash
./install.sh --help
```
## Tài liệu liên quan
- [README.vi.md](../../../README.vi.md)
- [commands-reference.md](commands-reference.md)
- [providers-reference.md](providers-reference.md)
- [channels-reference.md](channels-reference.md)
-128
View File
@@ -1,128 +0,0 @@
# Sổ tay Vận hành ZeroClaw
Tài liệu này dành cho các operator chịu trách nhiệm duy trì tính sẵn sàng, tình trạng bảo mật và xử lý sự cố.
Cập nhật lần cuối: **2026-02-18**.
## Phạm vi
Dùng tài liệu này cho các tác vụ vận hành day-2:
- khởi động và giám sát runtime
- kiểm tra sức khoẻ và chẩn đoán hệ thống
- triển khai an toàn và rollback
- phân loại và khôi phục sau sự cố
Nếu đây là lần cài đặt đầu tiên, hãy bắt đầu từ [one-click-bootstrap.md](one-click-bootstrap.md).
## Các chế độ Runtime
| Chế độ | Lệnh | Khi nào dùng |
|---|---|---|
| Foreground runtime | `zeroclaw daemon` | gỡ lỗi cục bộ, phiên ngắn |
| Foreground gateway only | `zeroclaw gateway` | kiểm thử webhook endpoint |
| User service | `zeroclaw service install && zeroclaw service start` | runtime được quản lý liên tục bởi operator |
## Checklist Cơ bản cho Operator
1. Xác thực cấu hình:
```bash
zeroclaw status
```
1. Kiểm tra chẩn đoán:
```bash
zeroclaw doctor
zeroclaw channel doctor
```
1. Khởi động runtime:
```bash
zeroclaw daemon
```
1. Để chạy như user session service liên tục:
```bash
zeroclaw service install
zeroclaw service start
zeroclaw service status
```
## Tín hiệu Sức khoẻ và Trạng thái
| Tín hiệu | Lệnh / File | Kỳ vọng |
|---|---|---|
| Tính hợp lệ của config | `zeroclaw doctor` | không có lỗi nghiêm trọng |
| Kết nối channel | `zeroclaw channel doctor` | các channel đã cấu hình đều khoẻ mạnh |
| Tóm tắt runtime | `zeroclaw status` | provider/model/channels như mong đợi |
| Heartbeat/trạng thái daemon | `~/.zeroclaw/daemon_state.json` | file được cập nhật định kỳ |
## Log và Chẩn đoán
### macOS / Windows (log của service wrapper)
- `~/.zeroclaw/logs/daemon.stdout.log`
- `~/.zeroclaw/logs/daemon.stderr.log`
### Linux (systemd user service)
```bash
journalctl --user -u zeroclaw.service -f
```
## Quy trình Phân loại Sự cố (Fast Path)
1. Chụp trạng thái hệ thống:
```bash
zeroclaw status
zeroclaw doctor
zeroclaw channel doctor
```
1. Kiểm tra trạng thái service:
```bash
zeroclaw service status
```
1. Nếu service không khoẻ, khởi động lại sạch:
```bash
zeroclaw service stop
zeroclaw service start
```
1. Nếu các channel vẫn thất bại, kiểm tra allowlist và thông tin xác thực trong `~/.zeroclaw/config.toml`.
2. Nếu liên quan đến gateway, kiểm tra cài đặt bind/auth (`[gateway]`) và khả năng tiếp cận cục bộ.
## Quy trình Thay đổi An toàn
Trước khi áp dụng thay đổi cấu hình:
1. sao lưu `~/.zeroclaw/config.toml`
2. chỉ áp dụng một thay đổi logic tại một thời điểm
3. chạy `zeroclaw doctor`
4. khởi động lại daemon/service
5. xác minh bằng `status` + `channel doctor`
## Quy trình Rollback
Nếu một lần triển khai gây ra suy giảm hành vi:
1. khôi phục `config.toml` trước đó
2. khởi động lại runtime (`daemon` hoặc `service`)
3. xác nhận khôi phục qua `doctor` và kiểm tra sức khoẻ channel
4. ghi lại nguyên nhân gốc rễ và biện pháp khắc phục sự cố
## Tài liệu Liên quan
- [one-click-bootstrap.md](one-click-bootstrap.md)
- [troubleshooting.md](troubleshooting.md)
- [config-reference.md](config-reference.md)
- [commands-reference.md](commands-reference.md)
-24
View File
@@ -1,24 +0,0 @@
# Tài liệu vận hành và triển khai
Dành cho operator vận hành ZeroClaw liên tục hoặc trên production.
## Vận hành cốt lõi
- Sổ tay Day-2: [../operations-runbook.md](../operations-runbook.md)
- Sổ tay Release: [../release-process.md](../release-process.md)
- Ma trận xử lý sự cố: [../troubleshooting.md](../troubleshooting.md)
- Triển khai mạng/gateway an toàn: [../network-deployment.md](../network-deployment.md)
- Thiết lập Mattermost (dành riêng cho channel): [../mattermost-setup.md](../mattermost-setup.md)
## Luồng thường gặp
1. Xác thực runtime (`status`, `doctor`, `channel doctor`)
2. Áp dụng từng thay đổi config một lần
3. Khởi động lại service/daemon
4. Xác minh tình trạng channel và gateway
5. Rollback nhanh nếu hành vi bị hồi quy
## Liên quan
- Tham chiếu config: [../config-reference.md](../config-reference.md)
- Bộ sưu tập bảo mật: [../security/README.md](../security/README.md)
-366
View File
@@ -1,366 +0,0 @@
# Quy trình PR ZeroClaw (Cộng tác khối lượng cao)
Tài liệu này định nghĩa cách ZeroClaw xử lý khối lượng PR lớn trong khi vẫn duy trì:
- Hiệu suất cao
- Hiệu quả cao
- Tính ổn định cao
- Khả năng mở rộng cao
- Tính bền vững cao
- Bảo mật cao
Tài liệu liên quan:
- [`docs/README.md`](README.md) — phân loại và điều hướng tài liệu.
- [`docs/ci-map.md`](ci-map.md) — quyền sở hữu từng workflow, trigger và luồng triage.
- [`docs/reviewer-playbook.md`](reviewer-playbook.md) — hướng dẫn thực thi cho reviewer hàng ngày.
## 0. Tóm tắt
- **Mục đích:** cung cấp mô hình vận hành PR mang tính quyết định và dựa trên rủi ro cho cộng tác thông lượng cao.
- **Đối tượng:** contributor, maintainer và reviewer có hỗ trợ agent.
- **Phạm vi:** cài đặt repository, vòng đời PR, hợp đồng sẵn sàng, phân tuyến rủi ro, kỷ luật hàng đợi và giao thức phục hồi.
- **Ngoài phạm vi:** thay thế cấu hình branch protection hoặc file CI workflow làm nguồn triển khai chính thức.
---
## 1. Lối tắt theo tình huống PR
Dùng phần này để phân tuyến nhanh trước khi review sâu toàn bộ.
### 1.1 Intake chưa đầy đủ
1. Yêu cầu hoàn thiện template và bằng chứng còn thiếu trong một comment dạng checklist.
2. Dừng review sâu cho đến khi các vấn đề intake được giải quyết.
Xem tiếp:
- [Mục 5.1](#51-definition-of-ready-dor-trước-khi-yêu-cầu-review)
### 1.2 `CI Required Gate` đang thất bại
1. Phân tuyến lỗi qua CI map và ưu tiên sửa các gate mang tính quyết định trước.
2. Chỉ đánh giá lại rủi ro sau khi CI trả về tín hiệu rõ ràng.
Xem tiếp:
- [docs/ci-map.md](ci-map.md)
- [Mục 4.2](#42-bước-b-validation)
### 1.3 Đụng đến đường dẫn rủi ro cao
1. Chuyển sang luồng review sâu.
2. Yêu cầu rollback rõ ràng, bằng chứng về failure mode và kiểm tra ranh giới bảo mật.
Xem tiếp:
- [Mục 9](#9-quy-tắc-bảo-mật-và-ổn-định)
- [docs/reviewer-playbook.md](reviewer-playbook.md)
### 1.4 PR bị supersede hoặc trùng lặp
1. Yêu cầu liên kết supersede rõ ràng và dọn dẹp hàng đợi.
2. Đóng PR bị supersede sau khi maintainer xác nhận.
Xem tiếp:
- [Mục 8.2](#82-kiểm-soát-áp-lực-backlog)
---
## 2. Mục tiêu quản trị và vòng kiểm soát
### 2.1 Mục tiêu quản trị
1. Giữ thông lượng merge có thể dự đoán được khi tải PR lớn.
2. Giữ chất lượng tín hiệu CI ở mức cao (phản hồi nhanh, ít false positive).
3. Giữ review bảo mật rõ ràng đối với các bề mặt rủi ro.
4. Giữ các thay đổi dễ suy luận và dễ hoàn tác.
5. Giữ các artifact trong repository không bị rò rỉ dữ liệu cá nhân/nhạy cảm.
### 2.2 Logic thiết kế quản trị (vòng kiểm soát)
Workflow này được phân lớp có chủ đích để giảm tải cho reviewer trong khi vẫn đảm bảo trách nhiệm rõ ràng:
1. **Phân loại intake:** nhãn theo đường dẫn/kích thước/rủi ro/module phân tuyến PR đến độ sâu review phù hợp.
2. **Validation mang tính quyết định:** merge gate phụ thuộc vào các kiểm tra tái tạo được, không phải comment mang tính chủ quan.
3. **Độ sâu review theo rủi ro:** đường dẫn rủi ro cao kích hoạt review sâu; đường dẫn rủi ro thấp được xử lý nhanh.
4. **Hợp đồng merge ưu tiên rollback:** mọi đường dẫn merge đều bao gồm các bước phục hồi cụ thể.
Tự động hóa hỗ trợ việc triage và bảo vệ, nhưng trách nhiệm merge cuối cùng vẫn thuộc về maintainer và tác giả PR.
---
## 3. Cài đặt repository bắt buộc
Duy trì các quy tắc branch protection sau trên `master`:
- Yêu cầu status check trước khi merge.
- Yêu cầu check `CI Required Gate`.
- Yêu cầu review pull request trước khi merge.
- Yêu cầu review CODEOWNERS cho các đường dẫn được bảo vệ.
- Với `.github/workflows/**`, yêu cầu phê duyệt từ owner qua `CI Required Gate` (`WORKFLOW_OWNER_LOGINS`) và giới hạn quyền bypass branch/ruleset cho org owner.
- Danh sách workflow-owner mặc định được cấu hình qua biến repository `WORKFLOW_OWNER_LOGINS` (xem CODEOWNERS cho maintainer hiện tại).
- Hủy bỏ approval cũ khi có commit mới được đẩy lên.
- Hạn chế force-push trên các branch được bảo vệ.
- Tất cả PR của contributor nhắm trực tiếp vào `master`.
---
## 4. Sổ tay vòng đời PR
### 4.1 Bước A: Intake
- Contributor mở PR với `.github/pull_request_template.md` đầy đủ.
- `PR Labeler` áp dụng nhãn phạm vi/đường dẫn + nhãn kích thước + nhãn rủi ro + nhãn module (ví dụ `channel:telegram`, `provider:kimi`, `tool:shell`) và bậc contributor theo số PR đã merge (`trusted` >=5, `experienced` >=10, `principal` >=20, `distinguished` >=50), đồng thời loại bỏ trùng lặp nhãn phạm vi ít cụ thể hơn khi đã có nhãn module cụ thể hơn.
- Đối với tất cả các tiền tố module, nhãn module được nén gọn để giảm nhiễu: một module cụ thể giữ `prefix:component`, nhưng nhiều module cụ thể thu gọn thành nhãn phạm vi cơ sở `prefix`.
- Thứ tự nhãn ưu tiên đầu tiên: `risk:*` -> `size:*` -> bậc contributor -> nhãn module/đường dẫn.
- Maintainer có thể chạy `PR Labeler` thủ công (`workflow_dispatch`) ở chế độ `audit` để kiểm tra drift hoặc chế độ `repair` để chuẩn hóa metadata nhãn được quản lý trên toàn repository.
- Di chuột qua nhãn trên GitHub hiển thị mô tả được quản lý tự động (tóm tắt quy tắc/ngưỡng).
- Màu nhãn được quản lý được sắp xếp theo thứ tự hiển thị để tạo gradient mượt mà trên các hàng nhãn dài.
- `PR Auto Responder` đăng hướng dẫn lần đầu, xử lý phân tuyến dựa trên nhãn cho các mục tín hiệu thấp và tự động áp dụng bậc contributor cho issue với cùng ngưỡng như `PR Labeler` (`trusted` >=5, `experienced` >=10, `principal` >=20, `distinguished` >=50).
### 4.2 Bước B: Validation
- `CI Required Gate` là merge gate.
- PR chỉ thay đổi tài liệu sử dụng fast-path và bỏ qua các Rust job nặng.
- PR không phải tài liệu phải vượt qua lint, test và kiểm tra smoke release build.
- PR ảnh hưởng Rust sử dụng cùng bộ gate bắt buộc như push lên `master` (không có shortcut chỉ build trên PR).
### 4.3 Bước C: Review
- Reviewer ưu tiên theo nhãn rủi ro và kích thước.
- Các đường dẫn nhạy cảm về bảo mật (`src/security`, `src/runtime`, `src/gateway` và CI workflow) yêu cầu sự chú ý của maintainer.
- PR lớn (`size: L`/`size: XL`) nên được chia nhỏ trừ khi có lý do thuyết phục.
### 4.4 Bước D: Merge
- Ưu tiên **squash merge** để giữ lịch sử gọn gàng.
- Tiêu đề PR nên theo phong cách Conventional Commit.
- Chỉ merge khi đường dẫn rollback đã được ghi lại.
---
## 5. Hợp đồng sẵn sàng PR (DoR / DoD)
### 5.1 Definition of Ready (DoR) trước khi yêu cầu review
- Template PR đã hoàn thiện đầy đủ.
- Ranh giới phạm vi rõ ràng (những gì đã thay đổi / những gì không thay đổi).
- Bằng chứng validation đã đính kèm (không chỉ là "CI sẽ kiểm tra").
- Các trường bảo mật và rollback đã hoàn thành cho các đường dẫn rủi ro.
- Kiểm tra tính riêng tư/vệ sinh dữ liệu đã hoàn thành và ngôn ngữ test trung lập/theo phạm vi dự án.
- Nếu có ngôn ngữ giống danh tính trong test/ví dụ, cần được chuẩn hóa về nhãn gốc ZeroClaw/dự án.
### 5.2 Definition of Done (DoD) sẵn sàng merge
- `CI Required Gate` đã xanh.
- Các reviewer bắt buộc đã phê duyệt (bao gồm các đường dẫn CODEOWNERS).
- Nhãn phân loại rủi ro khớp với các đường dẫn đã chạm.
- Tác động migration/tương thích đã được ghi lại.
- Đường dẫn rollback cụ thể và nhanh chóng.
---
## 6. Chính sách kích thước và lô PR
### 6.1 Phân loại kích thước
- `size: XS` <= 80 dòng thay đổi
- `size: S` <= 250 dòng thay đổi
- `size: M` <= 500 dòng thay đổi
- `size: L` <= 1000 dòng thay đổi
- `size: XL` > 1000 dòng thay đổi
### 6.2 Chính sách
- Mặc định hướng đến `XS/S/M`.
- PR `L/XL` cần lý do biện minh rõ ràng và bằng chứng test chặt chẽ hơn.
- Nếu tính năng lớn không thể tránh khỏi, chia thành các stacked PR.
### 6.3 Hành vi tự động hóa
- `PR Labeler` áp dụng nhãn `size:*` từ số dòng thay đổi thực tế.
- PR chỉ tài liệu/nặng lockfile được chuẩn hóa để tránh thổi phồng kích thước.
---
## 7. Chính sách đóng góp AI/Agent
PR có sự hỗ trợ AI được chào đón, và review cũng có thể được hỗ trợ bằng agent.
### 7.1 Bắt buộc
1. Tóm tắt PR rõ ràng với ranh giới phạm vi.
2. Bằng chứng test/validation cụ thể.
3. Ghi chú tác động bảo mật và rollback cho các thay đổi rủi ro.
### 7.2 Khuyến nghị
1. Ghi chú ngắn gọn về tool/workflow khi tự động hóa ảnh hưởng đáng kể đến thay đổi.
2. Đoạn prompt/kế hoạch tùy chọn để tái tạo được.
Chúng tôi **không** yêu cầu contributor định lượng quyền sở hữu dòng AI-vs-human.
### 7.3 Trọng tâm review cho PR nặng AI
- Tương thích hợp đồng.
- Ranh giới bảo mật.
- Xử lý lỗi và hành vi fallback.
- Hồi quy hiệu suất và bộ nhớ.
---
## 8. SLA review và kỷ luật hàng đợi
- Mục tiêu triage maintainer đầu tiên: trong vòng 48 giờ.
- Nếu PR bị chặn, maintainer để lại một checklist hành động được.
- Tự động hóa `stale` được dùng để giữ hàng đợi lành mạnh; maintainer có thể áp dụng `no-stale` khi cần.
- Tự động hóa `pr-hygiene` kiểm tra các PR mở mỗi 12 giờ và đăng nhắc nhở khi PR không có commit mới trong 48+ giờ và rơi vào một trong hai trường hợp: đang tụt hậu so với `master` hoặc thiếu/thất bại `CI Required Gate` trên head commit.
### 8.1 Kiểm soát ngân sách hàng đợi
- Sử dụng ngân sách hàng đợi review: giới hạn số PR đang được review sâu đồng thời mỗi maintainer và giữ phần còn lại ở trạng thái triage.
- Đối với công việc stacked, yêu cầu `Depends on #...` rõ ràng để thứ tự review mang tính quyết định.
### 8.2 Kiểm soát áp lực backlog
- Nếu một PR mới thay thế một PR cũ đang mở, yêu cầu `Supersedes #...` và đóng PR cũ sau khi maintainer xác nhận.
- Đánh dấu các PR ngủ đông/dư thừa bằng `stale-candidate` hoặc `superseded` để giảm nỗ lực review trùng lặp.
### 8.3 Kỷ luật triage issue
- `r:needs-repro` cho báo cáo lỗi chưa đầy đủ (yêu cầu repro mang tính quyết định trước khi triage sâu).
- `r:support` cho các mục sử dụng/trợ giúp nên xử lý ngoài bug backlog.
- Nhãn `invalid` / `duplicate` kích hoạt tự động hóa đóng **chỉ issue** kèm hướng dẫn.
### 8.4 Bảo vệ tác dụng phụ của tự động hóa
- `PR Auto Responder` loại bỏ trùng lặp comment dựa trên nhãn để tránh spam.
- Các luồng đóng tự động chỉ giới hạn cho issue, không phải PR.
- Maintainer có thể đóng băng tính toán lại rủi ro tự động bằng `risk: manual` khi ngữ cảnh yêu cầu ghi đè thủ công.
---
## 9. Quy tắc bảo mật và ổn định
Các thay đổi ở những khu vực này yêu cầu review chặt chẽ hơn và bằng chứng test mạnh hơn:
- `src/security/**`
- Quản lý tiến trình runtime.
- Hành vi ingress/xác thực gateway (`src/gateway/**`).
- Ranh giới truy cập filesystem.
- Hành vi mạng/xác thực.
- GitHub workflow và pipeline release.
- Các tool có khả năng thực thi (`src/tools/**`).
### 9.1 Tối thiểu cho PR rủi ro
- Tuyên bố mối đe dọa/rủi ro.
- Ghi chú biện pháp giảm thiểu.
- Các bước rollback.
### 9.2 Khuyến nghị cho PR rủi ro cao
- Bao gồm một test tập trung chứng minh hành vi ranh giới.
- Bao gồm một kịch bản failure mode rõ ràng và sự suy giảm mong đợi.
Đối với các đóng góp có hỗ trợ agent, reviewer cũng nên xác minh rằng tác giả hiểu hành vi runtime và blast radius.
---
## 10. Giao thức phục hồi sự cố
Nếu một PR đã merge gây ra hồi quy:
1. Revert PR ngay lập tức trên `master`.
2. Mở issue theo dõi với phân tích nguyên nhân gốc.
3. Chỉ đưa lại bản sửa lỗi khi có test hồi quy.
Ưu tiên khôi phục nhanh chất lượng dịch vụ hơn là bản vá hoàn hảo nhưng chậm trễ.
---
## 11. Checklist merge của maintainer
- Phạm vi tập trung và dễ hiểu.
- CI gate đã xanh.
- Kiểm tra chất lượng tài liệu đã xanh khi tài liệu thay đổi.
- Các trường tác động bảo mật đã hoàn thành.
- Các trường tính riêng tư/vệ sinh dữ liệu đã hoàn thành và bằng chứng đã được biên tập/ẩn danh.
- Ghi chú workflow agent đủ để tái tạo (nếu tự động hóa được sử dụng).
- Kế hoạch rollback rõ ràng.
- Tiêu đề commit theo Conventional Commits.
---
## 12. Mô hình vận hành review agent
Để giữ chất lượng review ổn định khi khối lượng PR cao, sử dụng mô hình review hai làn.
### 12.1 Làn A: triage nhanh (thân thiện với agent)
- Xác nhận độ đầy đủ của template PR.
- Xác nhận tín hiệu CI gate (`CI Required Gate`).
- Xác nhận phân loại rủi ro qua nhãn và các đường dẫn đã chạm.
- Xác nhận tuyên bố rollback tồn tại.
- Xác nhận phần tính riêng tư/vệ sinh dữ liệu và các yêu cầu diễn đạt trung lập đã được thỏa mãn.
- Xác nhận bất kỳ ngôn ngữ giống danh tính nào đều sử dụng thuật ngữ gốc ZeroClaw/dự án.
### 12.2 Làn B: review sâu (dựa trên rủi ro)
Bắt buộc cho các thay đổi rủi ro cao (security/runtime/gateway/CI):
- Xác thực giả định mô hình mối đe dọa.
- Xác thực hành vi failure mode và suy giảm.
- Xác thực tương thích ngược và tác động migration.
- Xác thực tác động observability/logging.
---
## 13. Ưu tiên hàng đợi và kỷ luật nhãn
### 13.1 Khuyến nghị thứ tự triage
1. `size: XS`/`size: S` + sửa lỗi/bảo mật.
2. `size: M` thay đổi tập trung.
3. `size: L`/`size: XL` yêu cầu chia nhỏ hoặc review theo giai đoạn.
### 13.2 Kỷ luật nhãn
- Nhãn đường dẫn xác định quyền sở hữu hệ thống con nhanh chóng.
- Nhãn kích thước điều hướng chiến lược lô.
- Nhãn rủi ro điều hướng độ sâu review (`risk: low/medium/high`).
- Nhãn module (`<module>: <component>`) cải thiện phân tuyến reviewer cho các thay đổi cụ thể theo integration và các module mới được thêm vào trong tương lai.
- `risk: manual` cho phép maintainer bảo tồn phán đoán rủi ro của con người khi tự động hóa thiếu ngữ cảnh.
- `no-stale` được dành riêng cho công việc đã được chấp nhận nhưng bị chặn.
---
## 14. Hợp đồng bàn giao agent
Khi một agent bàn giao cho agent khác (hoặc cho maintainer), bao gồm:
1. Ranh giới phạm vi (những gì đã thay đổi / những gì không thay đổi).
2. Bằng chứng validation.
3. Rủi ro mở và những điều chưa biết.
4. Hành động tiếp theo được đề xuất.
Điều này giữ cho tổn thất ngữ cảnh ở mức thấp và tránh việc phải đào sâu lặp lại.
---
## 15. Tài liệu liên quan
- [README.md](README.md) — phân loại và điều hướng tài liệu.
- [ci-map.md](ci-map.md) — bản đồ quyền sở hữu và triage CI workflow.
- [reviewer-playbook.md](reviewer-playbook.md) — mô hình thực thi của reviewer.
- [actions-source-policy.md](actions-source-policy.md) — chính sách allowlist nguồn action.
---
## 16. Ghi chú bảo trì
- **Chủ sở hữu:** các maintainer chịu trách nhiệm về quản trị cộng tác và chất lượng merge.
- **Kích hoạt cập nhật:** thay đổi branch protection, thay đổi chính sách nhãn/rủi ro, cập nhật quản trị hàng đợi hoặc thay đổi quy trình review agent.
- **Lần review cuối:** 2026-02-18.
-17
View File
@@ -1,17 +0,0 @@
# Tài liệu snapshot và triage dự án
Snapshot trạng thái dự án có giới hạn thời gian cho tài liệu lập kế hoạch và công việc vận hành.
## Snapshot hiện tại
- [project-triage-snapshot-2026-02-18.md](../../../maintainers/project-triage-snapshot-2026-02-18.md)
## Phạm vi
Snapshot dự án là các đánh giá có giới hạn thời gian về PR mở, issue và tình trạng tài liệu. Dùng chúng để:
- Xác định các khoảng trống tài liệu được thúc đẩy bởi công việc tính năng
- Ưu tiên bảo trì tài liệu song song với thay đổi code
- Theo dõi áp lực PR/issue đang phát triển theo thời gian
Để phân loại tài liệu ổn định (không giới hạn thời gian), dùng [docs-inventory.md](../../../maintainers/docs-inventory.md).
-253
View File
@@ -1,253 +0,0 @@
# Tài liệu tham khảo Providers — ZeroClaw
Tài liệu này liệt kê các provider ID, alias và biến môi trường chứa thông tin xác thực.
Cập nhật lần cuối: **2026-03-10**.
## Cách liệt kê các Provider
```bash
zeroclaw providers
```
## Thứ tự ưu tiên khi giải quyết thông tin xác thực
Thứ tự ưu tiên tại runtime:
1. Thông tin xác thực tường minh từ config/CLI
2. Biến môi trường dành riêng cho provider
3. Biến môi trường dự phòng chung: `ZEROCLAW_API_KEY`, sau đó là `API_KEY`
Với chuỗi provider dự phòng (`reliability.fallback_providers`), mỗi provider dự phòng tự giải quyết thông tin xác thực của mình độc lập. Key xác thực của provider chính không tự động dùng cho provider dự phòng.
## Danh mục Provider
| Canonical ID | Alias | Cục bộ | Biến môi trường dành riêng |
|---|---|---:|---|
| `openrouter` | — | Không | `OPENROUTER_API_KEY` |
| `anthropic` | — | Không | `ANTHROPIC_OAUTH_TOKEN`, `ANTHROPIC_API_KEY` |
| `openai` | — | Không | `OPENAI_API_KEY` |
| `ollama` | — | Có | `OLLAMA_API_KEY` (tùy chọn) |
| `gemini` | `google`, `google-gemini` | Không | `GEMINI_API_KEY`, `GOOGLE_API_KEY` |
| `venice` | — | Không | `VENICE_API_KEY` |
| `vercel` | `vercel-ai` | Không | `VERCEL_API_KEY` |
| `cloudflare` | `cloudflare-ai` | Không | `CLOUDFLARE_API_KEY` |
| `moonshot` | `kimi` | Không | `MOONSHOT_API_KEY` |
| `kimi-code` | `kimi_coding`, `kimi_for_coding` | Không | `KIMI_CODE_API_KEY`, `MOONSHOT_API_KEY` |
| `synthetic` | — | Không | `SYNTHETIC_API_KEY` |
| `opencode` | `opencode-zen` | Không | `OPENCODE_API_KEY` |
| `opencode-go` | — | Không | `OPENCODE_GO_API_KEY` |
| `zai` | `z.ai` | Không | `ZAI_API_KEY` |
| `glm` | `zhipu` | Không | `GLM_API_KEY` |
| `minimax` | `minimax-intl`, `minimax-io`, `minimax-global`, `minimax-cn`, `minimaxi`, `minimax-oauth`, `minimax-oauth-cn`, `minimax-portal`, `minimax-portal-cn` | Không | `MINIMAX_OAUTH_TOKEN`, `MINIMAX_API_KEY` |
| `bedrock` | `aws-bedrock` | Không | `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` (tùy chọn: `AWS_REGION`) |
| `qianfan` | `baidu` | Không | `QIANFAN_API_KEY` |
| `qwen` | `dashscope`, `qwen-intl`, `dashscope-intl`, `qwen-us`, `dashscope-us`, `qwen-code`, `qwen-oauth`, `qwen_oauth` | Không | `QWEN_OAUTH_TOKEN`, `DASHSCOPE_API_KEY` |
| `groq` | — | Không | `GROQ_API_KEY` |
| `mistral` | — | Không | `MISTRAL_API_KEY` |
| `xai` | `grok` | Không | `XAI_API_KEY` |
| `deepseek` | — | Không | `DEEPSEEK_API_KEY` |
| `together` | `together-ai` | Không | `TOGETHER_API_KEY` |
| `fireworks` | `fireworks-ai` | Không | `FIREWORKS_API_KEY` |
| `perplexity` | — | Không | `PERPLEXITY_API_KEY` |
| `cohere` | — | Không | `COHERE_API_KEY` |
| `copilot` | `github-copilot` | Không | (dùng config/`API_KEY` fallback với GitHub token) |
| `lmstudio` | `lm-studio` | Có | (tùy chọn; mặc định là cục bộ) |
| `nvidia` | `nvidia-nim`, `build.nvidia.com` | Không | `NVIDIA_API_KEY` |
### Ghi chú về Gemini
- Provider ID: `gemini` (alias: `google`, `google-gemini`)
- Xác thực có thể dùng `GEMINI_API_KEY`, `GOOGLE_API_KEY`, hoặc Gemini CLI OAuth cache (`~/.gemini/oauth_creds.json`)
- Request bằng API key dùng endpoint `generativelanguage.googleapis.com/v1beta`
- Request OAuth qua Gemini CLI dùng endpoint `cloudcode-pa.googleapis.com/v1internal` theo chuẩn Code Assist request envelope
### Ghi chú về Ollama Vision
- Provider ID: `ollama`
- Hỗ trợ đầu vào hình ảnh qua marker nội tuyến trong tin nhắn: ``[IMAGE:<source>]``
- Sau khi chuẩn hóa multimodal, ZeroClaw gửi payload hình ảnh qua trường `messages[].images` gốc của Ollama.
- Nếu chọn provider không hỗ trợ vision, ZeroClaw trả về lỗi rõ ràng thay vì âm thầm bỏ qua hình ảnh.
### Ghi chú về Bedrock
- Provider ID: `bedrock` (alias: `aws-bedrock`)
- API: [Converse API](https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html)
- Xác thực: AWS AKSK (không phải một API key đơn lẻ). Cần đặt biến môi trường `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`.
- Tùy chọn: `AWS_SESSION_TOKEN` cho thông tin xác thực tạm thời/STS, `AWS_REGION` hoặc `AWS_DEFAULT_REGION` (mặc định: `us-east-1`).
- Model mặc định khi khởi tạo: `anthropic.claude-sonnet-4-5-20250929-v1:0`
- Hỗ trợ native tool calling và prompt caching (`cachePoint`).
- Hỗ trợ cross-region inference profiles (ví dụ: `us.anthropic.claude-*`).
- Model ID dùng định dạng Bedrock: `anthropic.claude-sonnet-4-6`, `anthropic.claude-opus-4-6-v1`, v.v.
### Bật/tắt tính năng Reasoning của Ollama
Bạn có thể kiểm soát hành vi reasoning/thinking của Ollama từ `config.toml`:
```toml
[runtime]
reasoning_enabled = false
```
Hành vi:
- `false`: gửi `think: false` đến các yêu cầu Ollama `/api/chat`.
- `true`: gửi `think: true`.
- Không đặt: bỏ qua `think` và giữ nguyên mặc định của Ollama/model.
### Ghi chú về Kimi Code
- Provider ID: `kimi-code`
- Endpoint: `https://api.kimi.com/coding/v1`
- Model mặc định khi khởi tạo: `kimi-for-coding` (thay thế: `kimi-k2.5`)
- Runtime tự động thêm `User-Agent: KimiCLI/0.77` để đảm bảo tương thích.
### Ghi chú về NVIDIA NIM
- Canonical provider ID: `nvidia`
- Alias: `nvidia-nim`, `build.nvidia.com`
- Base API URL: `https://integrate.api.nvidia.com/v1`
- Khám phá model: `zeroclaw models refresh --provider nvidia`
Các model ID khởi đầu được khuyến nghị (đã xác minh với danh mục NVIDIA API ngày 2026-02-18):
- `meta/llama-3.3-70b-instruct`
- `deepseek-ai/deepseek-v3.2`
- `nvidia/llama-3.3-nemotron-super-49b-v1.5`
- `nvidia/llama-3.1-nemotron-ultra-253b-v1`
## Endpoint Tùy chỉnh
- Endpoint tương thích OpenAI:
```toml
default_provider = "custom:https://your-api.example.com"
```
- Endpoint tương thích Anthropic:
```toml
default_provider = "anthropic-custom:https://your-api.example.com"
```
## Cấu hình MiniMax OAuth (`config.toml`)
Đặt provider MiniMax và OAuth placeholder trong config:
```toml
default_provider = "minimax-oauth"
api_key = "minimax-oauth"
```
Sau đó cung cấp một trong các thông tin xác thực sau qua biến môi trường:
- `MINIMAX_OAUTH_TOKEN` (ưu tiên, access token trực tiếp)
- `MINIMAX_API_KEY` (token tĩnh/cũ)
- `MINIMAX_OAUTH_REFRESH_TOKEN` (tự động làm mới access token khi khởi động)
Tùy chọn:
- `MINIMAX_OAUTH_REGION=global` hoặc `cn` (mặc định theo alias của provider)
- `MINIMAX_OAUTH_CLIENT_ID` để ghi đè OAuth client id mặc định
Lưu ý về tương thích channel:
- Đối với các cuộc trò chuyện channel được hỗ trợ bởi MiniMax, lịch sử runtime được chuẩn hóa để duy trì thứ tự lượt hợp lệ `user`/`assistant`.
- Hướng dẫn phân phối đặc thù của channel (ví dụ: marker đính kèm Telegram) được hợp nhất vào system prompt đầu tiên thay vì được thêm vào như một lượt `system` cuối cùng.
## Cấu hình Qwen Code OAuth (`config.toml`)
Đặt chế độ Qwen Code OAuth trong config:
```toml
default_provider = "qwen-code"
api_key = "qwen-oauth"
```
Thứ tự ưu tiên giải quyết thông tin xác thực cho `qwen-code`:
1. Giá trị `api_key` tường minh (nếu không phải placeholder `qwen-oauth`)
2. `QWEN_OAUTH_TOKEN`
3. `~/.qwen/oauth_creds.json` (tái sử dụng thông tin xác thực OAuth đã cache của Qwen Code)
4. Tùy chọn làm mới qua `QWEN_OAUTH_REFRESH_TOKEN` (hoặc refresh token đã cache)
5. Nếu không dùng OAuth placeholder, `DASHSCOPE_API_KEY` vẫn có thể được dùng làm dự phòng
Tùy chọn ghi đè endpoint:
- `QWEN_OAUTH_RESOURCE_URL` (được chuẩn hóa thành `https://.../v1` nếu cần)
- Nếu không đặt, `resource_url` từ thông tin xác thực OAuth đã cache sẽ được dùng khi có
## Định tuyến Model (`hint:<name>`)
Bạn có thể định tuyến các lời gọi model theo hint bằng cách sử dụng `[[model_routes]]`:
```toml
[[model_routes]]
hint = "reasoning"
provider = "openrouter"
model = "anthropic/claude-opus-4-20250514"
[[model_routes]]
hint = "fast"
provider = "groq"
model = "llama-3.3-70b-versatile"
```
Sau đó gọi với tên model hint (ví dụ từ tool hoặc các đường dẫn tích hợp):
```text
hint:reasoning
```
## Định tuyến Embedding (`hint:<name>`)
Bạn có thể định tuyến các lời gọi embedding theo cùng mẫu hint bằng `[[embedding_routes]]`.
Đặt `[memory].embedding_model` thành giá trị `hint:<name>` để kích hoạt định tuyến.
```toml
[memory]
embedding_model = "hint:semantic"
[[embedding_routes]]
hint = "semantic"
provider = "openai"
model = "text-embedding-3-small"
dimensions = 1536
[[embedding_routes]]
hint = "archive"
provider = "custom:https://embed.example.com/v1"
model = "your-embedding-model-id"
dimensions = 1024
```
Các embedding provider được hỗ trợ:
- `none`
- `openai`
- `custom:<url>` (endpoint embeddings tương thích OpenAI)
Tùy chọn ghi đè key theo từng route:
```toml
[[embedding_routes]]
hint = "semantic"
provider = "openai"
model = "text-embedding-3-small"
api_key = "sk-route-specific"
```
## Nâng cấp Model An toàn
Sử dụng các hint ổn định và chỉ cập nhật target route khi provider ngừng hỗ trợ model ID cũ.
Quy trình được khuyến nghị:
1. Giữ nguyên các call site (`hint:reasoning`, `hint:semantic`).
2. Chỉ thay đổi model đích trong `[[model_routes]]` hoặc `[[embedding_routes]]`.
3. Chạy:
- `zeroclaw doctor`
- `zeroclaw status`
4. Smoke test một luồng đại diện (chat + memory retrieval) trước khi triển khai.
Cách này giảm thiểu rủi ro phá vỡ vì các tích hợp và prompt không cần thay đổi khi nâng cấp model ID.
-229
View File
@@ -1,229 +0,0 @@
# Playbook Proxy Agent
Tài liệu này cung cấp các tool call có thể copy-paste để cấu hình hành vi proxy qua `proxy_config`.
Dùng tài liệu này khi bạn muốn agent chuyển đổi phạm vi proxy nhanh chóng và an toàn.
## 0. Tóm Tắt
- **Mục đích:** cung cấp tool call sẵn sàng sử dụng để quản lý phạm vi proxy và rollback.
- **Đối tượng:** operator và maintainer đang chạy ZeroClaw trong mạng có proxy.
- **Phạm vi:** các hành động `proxy_config`, lựa chọn mode, quy trình xác minh và xử lý sự cố.
- **Ngoài phạm vi:** gỡ lỗi mạng chung không liên quan đến hành vi runtime của ZeroClaw.
---
## 1. Đường Dẫn Nhanh Theo Mục Đích
Dùng mục này để định tuyến vận hành nhanh.
### 1.1 Chỉ proxy traffic nội bộ ZeroClaw
1. Dùng scope `zeroclaw`.
2. Đặt `http_proxy`/`https_proxy` hoặc `all_proxy`.
3. Xác minh bằng `{"action":"get"}`.
Xem:
- [Mục 4](#4-mode-a--chỉ-proxy-cho-nội-bộ-zeroclaw)
### 1.2 Chỉ proxy các dịch vụ được chọn
1. Dùng scope `services`.
2. Đặt các key cụ thể hoặc wildcard selector trong `services`.
3. Xác minh phủ sóng bằng `{"action":"list_services"}`.
Xem:
- [Mục 5](#5-mode-b--chỉ-proxy-cho-các-dịch-vụ-cụ-thể)
### 1.3 Xuất biến môi trường proxy cho toàn bộ process
1. Dùng scope `environment`.
2. Áp dụng bằng `{"action":"apply_env"}`.
3. Xác minh snapshot env qua `{"action":"get"}`.
Xem:
- [Mục 6](#6-mode-c--proxy-cho-toàn-bộ-môi-trường-process)
### 1.4 Rollback khẩn cấp
1. Tắt proxy.
2. Nếu cần, xóa các biến env đã xuất.
3. Kiểm tra lại snapshot runtime và môi trường.
Xem:
- [Mục 7](#7-các-mẫu-tắt--rollback)
---
## 2. Ma Trận Quyết Định Phạm Vi
| Phạm vi | Ảnh hưởng | Xuất biến env | Trường hợp dùng điển hình |
|---|---|---|---|
| `zeroclaw` | Các HTTP client nội bộ ZeroClaw | Không | Proxying runtime thông thường không có tác dụng phụ cấp process |
| `services` | Chỉ các service key/selector được chọn | Không | Định tuyến chi tiết cho provider/tool/channel cụ thể |
| `environment` | Runtime + biến môi trường proxy của process | Có | Các tích hợp yêu cầu `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY` |
---
## 3. Quy Trình An Toàn Chuẩn
Dùng trình tự này cho mọi thay đổi proxy:
1. Kiểm tra trạng thái hiện tại.
2. Khám phá các service key/selector hợp lệ.
3. Áp dụng cấu hình phạm vi mục tiêu.
4. Xác minh snapshot runtime và môi trường.
5. Rollback nếu hành vi không như kỳ vọng.
Tool call:
```json
{"action":"get"}
{"action":"list_services"}
```
---
## 4. Mode A — Chỉ Proxy Cho Nội Bộ ZeroClaw
Dùng khi traffic HTTP của provider/channel/tool ZeroClaw cần đi qua proxy mà không xuất biến env proxy cấp process.
Tool call:
```json
{"action":"set","enabled":true,"scope":"zeroclaw","http_proxy":"http://127.0.0.1:7890","https_proxy":"http://127.0.0.1:7890","no_proxy":["localhost","127.0.0.1"]}
{"action":"get"}
```
Hành vi kỳ vọng:
- Runtime proxy hoạt động cho các HTTP client của ZeroClaw.
- Không cần xuất `HTTP_PROXY` / `HTTPS_PROXY` vào env của process.
---
## 5. Mode B — Chỉ Proxy Cho Các Dịch Vụ Cụ Thể
Dùng khi chỉ một phần hệ thống cần đi qua proxy (ví dụ provider/tool/channel cụ thể).
### 5.1 Nhắm vào dịch vụ cụ thể
```json
{"action":"set","enabled":true,"scope":"services","services":["provider.openai","tool.http_request","channel.telegram"],"all_proxy":"socks5h://127.0.0.1:1080","no_proxy":["localhost","127.0.0.1",".internal"]}
{"action":"get"}
```
### 5.2 Nhắm theo selector
```json
{"action":"set","enabled":true,"scope":"services","services":["provider.*","tool.*"],"http_proxy":"http://127.0.0.1:7890"}
{"action":"get"}
```
Hành vi kỳ vọng:
- Chỉ các service khớp mới dùng proxy.
- Các service không khớp bỏ qua proxy.
---
## 6. Mode C — Proxy Cho Toàn Bộ Môi Trường Process
Dùng khi bạn cần xuất tường minh các biến env của process (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`) cho các tích hợp runtime.
### 6.1 Cấu hình và áp dụng environment scope
```json
{"action":"set","enabled":true,"scope":"environment","http_proxy":"http://127.0.0.1:7890","https_proxy":"http://127.0.0.1:7890","no_proxy":"localhost,127.0.0.1,.internal"}
{"action":"apply_env"}
{"action":"get"}
```
Hành vi kỳ vọng:
- Runtime proxy hoạt động.
- Các biến môi trường được xuất cho process.
---
## 7. Các Mẫu Tắt / Rollback
### 7.1 Tắt proxy (hành vi an toàn mặc định)
```json
{"action":"disable"}
{"action":"get"}
```
### 7.2 Tắt proxy và xóa cưỡng bức các biến env
```json
{"action":"disable","clear_env":true}
{"action":"get"}
```
### 7.3 Giữ proxy bật nhưng chỉ xóa các biến env đã xuất
```json
{"action":"clear_env"}
{"action":"get"}
```
---
## 8. Các Công Thức Vận Hành Thường Dùng
### 8.1 Chuyển từ proxy toàn environment sang proxy chỉ service
```json
{"action":"set","enabled":true,"scope":"services","services":["provider.openai","tool.http_request"],"all_proxy":"socks5://127.0.0.1:1080"}
{"action":"get"}
```
### 8.2 Thêm một dịch vụ proxied
```json
{"action":"set","scope":"services","services":["provider.openai","tool.http_request","channel.slack"]}
{"action":"get"}
```
### 8.3 Đặt lại danh sách `services` với selector
```json
{"action":"set","scope":"services","services":["provider.*","channel.telegram"]}
{"action":"get"}
```
---
## 9. Xử Lý Sự Cố
- Lỗi: `proxy.scope='services' requires a non-empty proxy.services list`
- Khắc phục: đặt ít nhất một service key cụ thể hoặc selector.
- Lỗi: invalid proxy URL scheme
- Scheme được chấp nhận: `http`, `https`, `socks5`, `socks5h`.
- Proxy không áp dụng như kỳ vọng
- Chạy `{"action":"list_services"}` và xác minh tên/selector dịch vụ.
- Chạy `{"action":"get"}` và kiểm tra giá trị snapshot `runtime_proxy``environment`.
---
## 10. Tài Liệu Liên Quan
- [README.md](./README.md) — Chỉ mục tài liệu và phân loại.
- [network-deployment.md](network-deployment.md) — Hướng dẫn triển khai mạng đầu-cuối và topology tunnel.
- [resource-limits.md](./resource-limits.md) — Giới hạn an toàn runtime cho ngữ cảnh thực thi mạng/tool.
---
## 11. Ghi Chú Bảo Trì
- **Chủ sở hữu:** maintainer runtime và tooling.
- **Điều kiện cập nhật:** các hành động `proxy_config` mới, ngữ nghĩa phạm vi proxy, hoặc thay đổi selector dịch vụ được hỗ trợ.
- **Xem xét lần cuối:** 2026-02-18.
-22
View File
@@ -1,22 +0,0 @@
# Danh mục tham chiếu
Tra cứu lệnh, provider, channel, config và tích hợp.
## Tham chiếu cốt lõi
- Lệnh theo workflow: [../commands-reference.md](../commands-reference.md)
- ID provider / alias / biến môi trường: [../providers-reference.md](../providers-reference.md)
- Thiết lập channel + allowlist: [../channels-reference.md](../channels-reference.md)
- Giá trị mặc định và khóa config: [../config-reference.md](../config-reference.md)
## Mở rộng provider và tích hợp
- Endpoint provider tùy chỉnh: [../custom-providers.md](../custom-providers.md)
- Tích hợp provider Z.AI / GLM: [../zai-glm-setup.md](../zai-glm-setup.md)
- Các mẫu tích hợp dựa trên LangGraph: [../langgraph-integration.md](../langgraph-integration.md)
## Cách dùng
Sử dụng bộ sưu tập này khi bạn cần chi tiết CLI/config chính xác hoặc các mẫu tích hợp provider thay vì hướng dẫn từng bước.
Khi thêm tài liệu tham chiếu/tích hợp mới, hãy đảm bảo nó được liên kết trong cả [../SUMMARY.md](../SUMMARY.md) và [docs-inventory.md](../../../maintainers/docs-inventory.md).
-133
View File
@@ -1,133 +0,0 @@
# Quy trình Release ZeroClaw
Runbook này định nghĩa quy trình release tiêu chuẩn của maintainer.
Cập nhật lần cuối: **2026-02-20**.
## Mục tiêu release
- Đảm bảo release có thể dự đoán và lặp lại.
- Chỉ publish từ code đã có trên `master`.
- Xác minh các artifact đa nền tảng trước khi publish.
- Duy trì nhịp release đều đặn ngay cả khi PR volume cao.
## Chu kỳ tiêu chuẩn
- Release patch/minor: hàng tuần hoặc hai tuần một lần.
- Bản vá bảo mật khẩn cấp: out-of-band.
- Không bao giờ chờ tích lũy quá nhiều commit lớn.
## Hợp đồng workflow
Automation release nằm tại:
- `.github/workflows/pub-release.yml`
- `.github/workflows/pub-homebrew-core.yml` (PR formula Homebrew thủ công, do bot sở hữu)
Các chế độ:
- Tag push `v*`: chế độ publish.
- Manual dispatch: chế độ chỉ xác minh hoặc publish.
- Lịch hàng tuần: chế độ chỉ xác minh.
Các guardrail ở chế độ publish:
- Tag phải khớp định dạng semver-like `vX.Y.Z[-suffix]`.
- Tag phải đã tồn tại trên origin.
- Commit của tag phải có thể truy vết được từ `origin/master`.
- GHCR image tag tương ứng (`ghcr.io/<owner>/<repo>:<tag>`) phải sẵn sàng trước khi GitHub Release publish hoàn tất.
- Artifact được xác minh trước khi publish.
## Quy trình maintainer
### 1) Preflight trên `master`
1. Đảm bảo các required check đều xanh trên `master` mới nhất.
2. Xác nhận không có sự cố ưu tiên cao hoặc regression đã biết nào đang mở.
3. Xác nhận các workflow installer và Docker đều khoẻ mạnh trên các commit `master` gần đây.
### 2) Chạy verification build (không publish)
Chạy `Pub Release` thủ công:
- `publish_release`: `false`
- `release_ref`: `master`
Kết quả mong đợi:
- Ma trận target đầy đủ build thành công.
- `verify-artifacts` xác nhận tất cả archive mong đợi đều tồn tại.
- Không có GitHub Release nào được publish.
### 3) Cut release tag
Từ một checkout cục bộ sạch đã sync với `origin/master`:
```bash
scripts/release/cut_release_tag.sh vX.Y.Z --push
```
Script này đảm bảo:
- working tree sạch
- `HEAD == origin/master`
- tag không bị trùng lặp
- định dạng tag semver-like
### 4) Theo dõi publish run
Sau khi push tag, theo dõi:
1. Chế độ publish `Pub Release`
2. Job publish `Pub Docker Img`
Kết quả publish mong đợi:
- release archive
- `SHA256SUMS`
- SBOM `CycloneDX``SPDX`
- chữ ký/chứng chỉ cosign
- GitHub Release notes + asset
### 5) Xác minh sau release
1. Xác minh GitHub Release asset có thể tải xuống.
2. Xác minh GHCR tag cho phiên bản đã release (`vX.Y.Z`) và tag SHA commit release (`sha-<12>`).
3. Xác minh các đường dẫn cài đặt phụ thuộc vào release asset (ví dụ tải xuống binary bootstrap).
### 6) Publish formula Homebrew Core (do bot sở hữu)
Chạy `Pub Homebrew Core` thủ công:
- `release_tag`: `vX.Y.Z`
- `dry_run`: `true` trước, sau đó `false`
Cài đặt repository bắt buộc cho non-dry-run:
- secret: `HOMEBREW_CORE_BOT_TOKEN` (token từ tài khoản bot chuyên dụng, không phải tài khoản maintainer cá nhân)
- variable: `HOMEBREW_CORE_BOT_FORK_REPO` (ví dụ `zeroclaw-release-bot/homebrew-core`)
- variable tùy chọn: `HOMEBREW_CORE_BOT_EMAIL`
Các guardrail workflow:
- release tag phải khớp version `Cargo.toml`
- URL nguồn và SHA256 của formula được cập nhật từ tagged tarball
- license formula được chuẩn hóa thành `Apache-2.0 OR MIT`
- PR được mở từ bot fork vào `Homebrew/homebrew-core:master`
## Đường dẫn khẩn cấp / khôi phục
Nếu release push tag thất bại sau khi artifact đã được xác minh:
1. Sửa vấn đề workflow hoặc packaging trên `master`.
2. Chạy lại `Pub Release` thủ công ở chế độ publish với:
- `publish_release=true`
- `release_tag=<existing tag>`
- `release_ref` tự động được pin vào `release_tag` ở chế độ publish
3. Xác minh lại asset đã release.
## Ghi chú vận hành
- Giữ các thay đổi release nhỏ và có thể đảo ngược.
- Dùng một issue/checklist release cho mỗi phiên bản để bàn giao rõ ràng.
- Tránh publish từ các feature branch ad-hoc.
-109
View File
@@ -1,109 +0,0 @@
# Giới hạn tài nguyên
> ⚠️ **Trạng thái: Đề xuất / Lộ trình**
>
> Tài liệu này mô tả các hướng tiếp cận đề xuất và có thể bao gồm các lệnh hoặc cấu hình giả định.
> Để biết hành vi runtime hiện tại, xem [config-reference.md](config-reference.md), [operations-runbook.md](operations-runbook.md), và [troubleshooting.md](troubleshooting.md).
## Vấn đề
ZeroClaw có rate limiting (20 actions/hour) nhưng chưa có giới hạn tài nguyên. Một agent bị lỗi lặp vòng có thể:
- Làm cạn kiệt bộ nhớ khả dụng
- Quay CPU liên tục ở 100%
- Lấp đầy ổ đĩa bằng log/output
---
## Các giải pháp đề xuất
### Tùy chọn 1: cgroups v2 (Linux, khuyến nghị)
Tự động tạo cgroup cho zeroclaw với các giới hạn.
```bash
# Tạo systemd service với giới hạn
[Service]
MemoryMax=512M
CPUQuota=100%
IOReadBandwidthMax=/dev/sda 10M
IOWriteBandwidthMax=/dev/sda 10M
TasksMax=100
```
### Tùy chọn 2: phát hiện deadlock với tokio::task
Ngăn task starvation.
```rust
use tokio::time::{timeout, Duration};
pub async fn execute_with_timeout<F, T>(
fut: F,
cpu_time_limit: Duration,
memory_limit: usize,
) -> Result<T>
where
F: Future<Output = Result<T>>,
{
// CPU timeout
timeout(cpu_time_limit, fut).await?
}
```
### Tùy chọn 3: memory monitoring
Theo dõi sử dụng heap và kill nếu vượt giới hạn.
```rust
use std::alloc::{GlobalAlloc, Layout, System};
struct LimitedAllocator<A> {
inner: A,
max_bytes: usize,
used: std::sync::atomic::AtomicUsize,
}
unsafe impl<A: GlobalAlloc> GlobalAlloc for LimitedAllocator<A> {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let current = self.used.fetch_add(layout.size(), std::sync::atomic::Ordering::Relaxed);
if current + layout.size() > self.max_bytes {
std::process::abort();
}
self.inner.alloc(layout)
}
}
```
---
## Config schema
```toml
[resources]
# Giới hạn bộ nhớ (tính bằng MB)
max_memory_mb = 512
max_memory_per_command_mb = 128
# Giới hạn CPU
max_cpu_percent = 50
max_cpu_time_seconds = 60
# Giới hạn Disk I/O
max_log_size_mb = 100
max_temp_storage_mb = 500
# Giới hạn process
max_subprocesses = 10
max_open_files = 100
```
---
## Thứ tự triển khai
| Giai đoạn | Tính năng | Công sức | Tác động |
|-------|---------|--------|--------|
| **P0** | Memory monitoring + kill | Thấp | Cao |
| **P1** | CPU timeout mỗi lệnh | Thấp | Cao |
| **P2** | Tích hợp cgroups (Linux) | Trung bình | Rất cao |
| **P3** | Giới hạn Disk I/O | Trung bình | Trung bình |
-191
View File
@@ -1,191 +0,0 @@
# Sổ tay Reviewer
Tài liệu này là người bạn đồng hành vận hành của [`docs/pr-workflow.md`](pr-workflow.md).
Để điều hướng tài liệu rộng hơn, xem [`docs/README.md`](README.md).
## 0. Tóm tắt
- **Mục đích:** định nghĩa mô hình vận hành reviewer mang tính quyết định, duy trì chất lượng review cao khi khối lượng PR lớn.
- **Đối tượng:** maintainer, reviewer và reviewer có hỗ trợ agent.
- **Phạm vi:** triage intake, phân tuyến rủi ro-sang-độ-sâu, kiểm tra review sâu, ghi đè tự động hóa và giao thức bàn giao.
- **Ngoài phạm vi:** thay thế thẩm quyền chính sách PR trong `CONTRIBUTING.md` hoặc thẩm quyền workflow trong các file CI.
---
## 1. Lối tắt theo tình huống review
Dùng phần này để phân tuyến nhanh trước khi đọc chi tiết đầy đủ.
### 1.1 Intake thất bại trong 5 phút đầu
1. Để lại một comment dạng checklist hành động được.
2. Dừng review sâu cho đến khi các vấn đề intake được sửa.
Xem tiếp:
- [Mục 3.1](#31-triage-intake-năm-phút)
### 1.2 Rủi ro cao hoặc không rõ ràng
1. Mặc định coi là `risk: high`.
2. Yêu cầu review sâu và bằng chứng rollback rõ ràng.
Xem tiếp:
- [Mục 2](#2-ma-trận-quyết-định-độ-sâu-review)
- [Mục 3.3](#33-checklist-review-sâu-rủi-ro-cao)
### 1.3 Kết quả tự động hóa sai/ồn ào
1. Áp dụng giao thức ghi đè (`risk: manual`, loại bỏ trùng lặp comment/nhãn).
2. Tiếp tục review với lý do rõ ràng.
Xem tiếp:
- [Mục 5](#5-giao-thức-ghi-đè-tự-động-hóa)
### 1.4 Cần bàn giao review
1. Bàn giao với phạm vi/rủi ro/validation/vấn đề chặn.
2. Giao hành động tiếp theo cụ thể.
Xem tiếp:
- [Mục 6](#6-giao-thức-bàn-giao)
---
## 2. Ma trận quyết định độ sâu review
| Nhãn rủi ro | Đường dẫn thường gặp | Độ sâu review tối thiểu | Bằng chứng bắt buộc |
|---|---|---|---|
| `risk: low` | docs/tests/chore, thay đổi không ảnh hưởng runtime | 1 reviewer + CI gate | validation cục bộ nhất quán + không mơ hồ hành vi |
| `risk: medium` | `src/providers/**`, `src/channels/**`, `src/memory/**`, `src/config/**` | 1 reviewer có hiểu biết về hệ thống con + xác minh hành vi | bằng chứng kịch bản tập trung + tác dụng phụ rõ ràng |
| `risk: high` | `src/security/**`, `src/runtime/**`, `src/gateway/**`, `src/tools/**`, `.github/workflows/**` | triage nhanh + review sâu + sẵn sàng rollback | kiểm tra bảo mật/failure mode + rõ ràng về rollback |
Khi không chắc chắn, coi là `risk: high`.
Nếu việc gán nhãn rủi ro tự động không đúng ngữ cảnh, maintainer có thể áp dụng `risk: manual` và đặt nhãn `risk:*` cuối cùng một cách tường minh.
---
## 3. Quy trình review tiêu chuẩn
### 3.1 Triage intake năm phút
Cho mỗi PR mới:
1. Xác nhận độ đầy đủ template (`summary`, `validation`, `security`, `rollback`).
2. Xác nhận nhãn hiện diện và hợp lý:
- `size:*`, `risk:*`
- nhãn phạm vi (ví dụ `provider`, `channel`, `security`)
- nhãn có phạm vi module (`channel:*`, `provider:*`, `tool:*`)
- nhãn bậc contributor khi áp dụng được
3. Xác nhận trạng thái tín hiệu CI (`CI Required Gate`).
4. Xác nhận phạm vi là một mối quan tâm (từ chối mega-PR hỗn hợp trừ khi có lý do).
5. Xác nhận các yêu cầu tính riêng tư/vệ sinh dữ liệu và diễn đạt test trung lập đã được thỏa mãn.
Nếu bất kỳ yêu cầu intake nào thất bại, để lại một comment dạng checklist hành động được thay vì review sâu.
### 3.2 Checklist fast-lane (tất cả PR)
- Ranh giới phạm vi rõ ràng và đáng tin cậy.
- Các lệnh validation hiện diện và kết quả nhất quán.
- Các thay đổi hành vi hướng người dùng đã được ghi lại.
- Tác giả thể hiện hiểu biết về hành vi và blast radius (đặc biệt với PR có hỗ trợ agent).
- Đường dẫn rollback cụ thể (không chỉ là "revert").
- Tác động tương thích/migration rõ ràng.
- Không có rò rỉ dữ liệu cá nhân/nhạy cảm trong diff artifact; ví dụ/test giữ trung lập và theo phạm vi dự án.
- Nếu có ngôn ngữ giống danh tính, nó sử dụng vai trò gốc ZeroClaw/dự án (không phải danh tính cá nhân hay thực tế).
- Quy ước đặt tên và ranh giới kiến trúc tuân theo hợp đồng dự án (`AGENTS.md`, `CONTRIBUTING.md`).
### 3.3 Checklist review sâu (rủi ro cao)
Với PR rủi ro cao, xác minh ít nhất một ví dụ cụ thể trong mỗi hạng mục:
- **Ranh giới bảo mật:** hành vi deny-by-default được bảo tồn, không mở rộng phạm vi ngẫu nhiên.
- **Failure mode:** xử lý lỗi rõ ràng và suy giảm an toàn.
- **Ổn định hợp đồng:** tương thích CLI/config/API được bảo tồn hoặc migration được ghi lại.
- **Observability:** lỗi có thể chẩn đoán mà không rò rỉ secret.
- **An toàn rollback:** đường dẫn revert và blast radius rõ ràng.
### 3.4 Phong cách kết quả comment review
Ưu tiên comment dạng checklist với một kết quả rõ ràng:
- **Sẵn sàng merge** (giải thích lý do).
- **Cần tác giả hành động** (danh sách vấn đề chặn có thứ tự).
- **Cần review bảo mật/runtime sâu hơn** (nêu rõ rủi ro và bằng chứng yêu cầu).
Tránh comment mơ hồ tạo ra độ trễ qua lại không cần thiết.
---
## 4. Triage issue và quản trị backlog
### 4.1 Sổ tay nhãn triage issue
Dùng nhãn để giữ backlog có thể hành động:
- `r:needs-repro` cho báo cáo lỗi chưa đầy đủ.
- `r:support` cho câu hỏi sử dụng/hỗ trợ nên chuyển hướng ngoài bug backlog.
- `duplicate` / `invalid` cho trùng lặp/nhiễu không thể hành động.
- `no-stale` cho công việc đã được chấp nhận đang chờ vấn đề chặn bên ngoài.
- Yêu cầu biên tập khi log/payload chứa định danh cá nhân hoặc dữ liệu nhạy cảm.
### 4.2 Giao thức cắt tỉa backlog PR
Khi nhu cầu review vượt quá năng lực, áp dụng thứ tự này:
1. Giữ PR bug/security đang hoạt động (`size: XS/S`) ở đầu hàng đợi.
2. Yêu cầu các PR chồng chéo hợp nhất; đóng các PR cũ hơn là `superseded` sau khi xác nhận.
3. Đánh dấu PR ngủ đông là `stale-candidate` trước khi cửa sổ đóng stale bắt đầu.
4. Yêu cầu rebase + validation mới trước khi mở lại công việc kỹ thuật stale/superseded.
---
## 5. Giao thức ghi đè tự động hóa
Dùng khi kết quả tự động hóa tạo ra tác dụng phụ cho review:
1. **Nhãn rủi ro sai:** thêm `risk: manual`, rồi đặt nhãn `risk:*` mong muốn.
2. **Tự đóng sai trên triage issue:** mở lại issue, xóa nhãn route, để lại một comment làm rõ.
3. **Spam/nhiễu nhãn:** giữ một comment maintainer chuẩn tắc và xóa nhãn route dư thừa.
4. **Phạm vi PR mơ hồ:** yêu cầu chia nhỏ trước khi review sâu.
---
## 6. Giao thức bàn giao
Nếu bàn giao review cho maintainer/agent khác, bao gồm:
1. Tóm tắt phạm vi.
2. Phân loại rủi ro hiện tại và lý do.
3. Những gì đã được validate.
4. Các vấn đề chặn mở.
5. Hành động tiếp theo được đề xuất.
---
## 7. Vệ sinh hàng đợi hàng tuần
- Review hàng đợi stale và chỉ áp dụng `no-stale` cho công việc đã được chấp nhận nhưng bị chặn.
- Ưu tiên PR bug/security `size: XS/S` trước.
- Chuyển đổi các issue hỗ trợ tái diễn thành cập nhật tài liệu và hướng dẫn auto-response.
---
## 8. Tài liệu liên quan
- [README.md](README.md) — phân loại và điều hướng tài liệu.
- [pr-workflow.md](pr-workflow.md) — workflow quản trị và hợp đồng merge.
- [ci-map.md](ci-map.md) — bản đồ quyền sở hữu và triage CI.
- [actions-source-policy.md](actions-source-policy.md) — chính sách allowlist nguồn action.
---
## 9. Ghi chú bảo trì
- **Chủ sở hữu:** các maintainer chịu trách nhiệm về chất lượng review và thông lượng hàng đợi.
- **Kích hoạt cập nhật:** thay đổi chính sách PR, thay đổi mô hình phân tuyến rủi ro hoặc thay đổi hành vi ghi đè tự động hóa.
- **Lần review cuối:** 2026-02-18.
-200
View File
@@ -1,200 +0,0 @@
# Chiến lược sandboxing
> ⚠️ **Trạng thái: Đề xuất / Lộ trình**
>
> Tài liệu này mô tả các hướng tiếp cận đề xuất và có thể bao gồm các lệnh hoặc cấu hình giả định.
> Để biết hành vi runtime hiện tại, xem [config-reference.md](config-reference.md), [operations-runbook.md](operations-runbook.md), và [troubleshooting.md](troubleshooting.md).
## Vấn đề
ZeroClaw hiện có application-layer security (allowlists, path blocking, command injection protection) nhưng thiếu cơ chế cách ly cấp hệ điều hành. Nếu kẻ tấn công nằm trong allowlist, họ có thể chạy bất kỳ lệnh nào được cho phép với quyền của user zeroclaw.
## Các giải pháp đề xuất
### Tùy chọn 1: tích hợp Firejail (khuyến nghị cho Linux)
Firejail cung cấp sandboxing ở user-space với overhead tối thiểu.
```rust
// src/security/firejail.rs
use std::process::Command;
pub struct FirejailSandbox {
enabled: bool,
}
impl FirejailSandbox {
pub fn new() -> Self {
let enabled = which::which("firejail").is_ok();
Self { enabled }
}
pub fn wrap_command(&self, cmd: &mut Command) -> &mut Command {
if !self.enabled {
return cmd;
}
// Firejail bọc bất kỳ lệnh nào với sandboxing
let mut jail = Command::new("firejail");
jail.args([
"--private=home", // Thư mục home mới
"--private-dev", // /dev tối giản
"--nosound", // Không âm thanh
"--no3d", // Không tăng tốc 3D
"--novideo", // Không thiết bị video
"--nowheel", // Không thiết bị nhập liệu
"--notv", // Không thiết bị TV
"--noprofile", // Bỏ qua tải profile
"--quiet", // Tắt cảnh báo
]);
// Gắn thêm lệnh gốc
if let Some(program) = cmd.get_program().to_str() {
jail.arg(program);
}
for arg in cmd.get_args() {
if let Some(s) = arg.to_str() {
jail.arg(s);
}
}
// Thay thế lệnh gốc bằng firejail wrapper
*cmd = jail;
cmd
}
}
```
**Tùy chọn config:**
```toml
[security]
enable_sandbox = true
sandbox_backend = "firejail" # hoặc "none", "bubblewrap", "docker"
```
---
### Tùy chọn 2: Bubblewrap (di động, không cần root)
Bubblewrap dùng user namespaces để tạo container.
```bash
# Cài bubblewrap
sudo apt install bubblewrap
# Bọc lệnh:
bwrap --ro-bind /usr /usr \
--dev /dev \
--proc /proc \
--bind /workspace /workspace \
--unshare-all \
--share-net \
--die-with-parent \
-- /bin/sh -c "command"
```
---
### Tùy chọn 3: Docker-in-Docker (nặng nhưng cách ly hoàn toàn)
Chạy các công cụ agent trong container tạm thời.
```rust
pub struct DockerSandbox {
image: String,
}
impl DockerSandbox {
pub async fn execute(&self, command: &str, workspace: &Path) -> Result<String> {
let output = Command::new("docker")
.args([
"run", "--rm",
"--memory", "512m",
"--cpus", "1.0",
"--network", "none",
"--volume", &format!("{}:/workspace", workspace.display()),
&self.image,
"sh", "-c", command
])
.output()
.await?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
}
```
---
### Tùy chọn 4: Landlock (Linux kernel LSM, Rust native)
Landlock cung cấp kiểm soát truy cập hệ thống file mà không cần container.
```rust
use landlock::{Ruleset, AccessFS};
pub fn apply_landlock() -> Result<()> {
let ruleset = Ruleset::new()
.set_access_fs(AccessFS::read_file | AccessFS::write_file)
.add_path(Path::new("/workspace"), AccessFS::read_file | AccessFS::write_file)?
.add_path(Path::new("/tmp"), AccessFS::read_file | AccessFS::write_file)?
.restrict_self()?;
Ok(())
}
```
---
## Thứ tự triển khai ưu tiên
| Giai đoạn | Giải pháp | Công sức | Tăng cường bảo mật |
|-------|----------|--------|---------------|
| **P0** | Landlock (chỉ Linux, native) | Thấp | Cao (filesystem) |
| **P1** | Tích hợp Firejail | Thấp | Rất cao |
| **P2** | Bubblewrap wrapper | Trung bình | Rất cao |
| **P3** | Docker sandbox mode | Cao | Hoàn toàn |
## Mở rộng config schema
```toml
[security.sandbox]
enabled = true
backend = "auto" # auto | firejail | bubblewrap | landlock | docker | none
# Dành riêng cho Firejail
[security.sandbox.firejail]
extra_args = ["--seccomp", "--caps.drop=all"]
# Dành riêng cho Landlock
[security.sandbox.landlock]
readonly_paths = ["/usr", "/bin", "/lib"]
readwrite_paths = ["$HOME/workspace", "/tmp/zeroclaw"]
```
## Chiến lược kiểm thử
```rust
#[cfg(test)]
mod tests {
#[test]
fn sandbox_blocks_path_traversal() {
// Thử đọc /etc/passwd qua sandbox
let result = sandboxed_execute("cat /etc/passwd");
assert!(result.is_err());
}
#[test]
fn sandbox_allows_workspace_access() {
let result = sandboxed_execute("ls /workspace");
assert!(result.is_ok());
}
#[test]
fn sandbox_no_network_isolation() {
// Đảm bảo mạng bị chặn khi được cấu hình
let result = sandboxed_execute("curl http://example.com");
assert!(result.is_err());
}
}
```
-188
View File
@@ -1,188 +0,0 @@
# Lộ trình cải tiến bảo mật
> ⚠️ **Trạng thái: Đề xuất / Lộ trình**
>
> Tài liệu này mô tả các hướng tiếp cận đề xuất và có thể bao gồm các lệnh hoặc cấu hình giả định.
> Để biết hành vi runtime hiện tại, xem [config-reference.md](config-reference.md), [operations-runbook.md](operations-runbook.md), và [troubleshooting.md](troubleshooting.md).
## Tình trạng bảo mật hiện tại: nền tảng vững chắc
ZeroClaw đã có **application-layer security xuất sắc**:
✅ Command allowlist (không phải blocklist)
✅ Bảo vệ path traversal
✅ Chặn command injection (`$(...)`, backticks, `&&`, `>`)
✅ Cách ly secret (API key không bị rò rỉ ra shell)
✅ Rate limiting (20 actions/hour)
✅ Channel authorization (rỗng = từ chối tất cả, `*` = cho phép tất cả)
✅ Phân loại rủi ro (Low/Medium/High)
✅ Làm sạch biến môi trường
✅ Chặn forbidden paths
✅ Độ phủ kiểm thử toàn diện (1.017 test)
## Những gì còn thiếu: cách ly cấp hệ điều hành
🔴 Chưa có sandboxing cấp OS (chroot, containers, namespaces)
🔴 Chưa có giới hạn tài nguyên (giới hạn CPU, memory, disk I/O)
🔴 Chưa có audit logging chống giả mạo
🔴 Chưa có syscall filtering (seccomp)
---
## So sánh: ZeroClaw vs PicoClaw vs production grade
| Tính năng | PicoClaw | ZeroClaw hiện tại | ZeroClaw + lộ trình | Mục tiêu production |
|---------|----------|--------------|-------------------|-------------------|
| **Kích thước binary** | ~8MB | **3.4MB** ✅ | 3.5-4MB | < 5MB |
| **RAM** | < 10MB | **< 5MB** ✅ | < 10MB | < 20MB |
| **Thời gian startup** | < 1s | **< 10ms** ✅ | < 50ms | < 100ms |
| **Command allowlist** | Không rõ | ✅ Có | ✅ Có | ✅ Có |
| **Path blocking** | Không rõ | ✅ Có | ✅ Có | ✅ Có |
| **Injection protection** | Không rõ | ✅ Có | ✅ Có | ✅ Có |
| **OS sandbox** | Không | ❌ Không | ✅ Firejail/Landlock | ✅ Container/namespaces |
| **Resource limits** | Không | ❌ Không | ✅ cgroups/Monitor | ✅ Full cgroups |
| **Audit logging** | Không | ❌ Không | ✅ Ký HMAC | ✅ Tích hợp SIEM |
| **Điểm bảo mật** | C | **B+** | **A-** | **A+** |
---
## Lộ trình triển khai
### Giai đoạn 1: kết quả nhanh (1-2 tuần)
**Mục tiêu**: giải quyết các thiếu sót nghiêm trọng với độ phức tạp tối thiểu
| Nhiệm vụ | File | Công sức | Tác động |
|------|------|--------|-------|
| Landlock filesystem sandbox | `src/security/landlock.rs` | 2 ngày | Cao |
| Memory monitoring + OOM kill | `src/resources/memory.rs` | 1 ngày | Cao |
| CPU timeout mỗi lệnh | `src/tools/shell.rs` | 1 ngày | Cao |
| Audit logging cơ bản | `src/security/audit.rs` | 2 ngày | Trung bình |
| Cập nhật config schema | `src/config/schema.rs` | 1 ngày | - |
**Kết quả bàn giao**:
- Linux: truy cập filesystem bị giới hạn trong workspace
- Tất cả nền tảng: bảo vệ memory/CPU chống lệnh chạy vô hạn
- Tất cả nền tảng: audit trail chống giả mạo
---
### Giai đoạn 2: tích hợp nền tảng (2-3 tuần)
**Mục tiêu**: tích hợp sâu với OS để cách ly cấp production
| Nhiệm vụ | Công sức | Tác động |
|------|--------|-------|
| Tự phát hiện Firejail + wrapping | 3 ngày | Rất cao |
| Bubblewrap wrapper cho macOS/*nix | 4 ngày | Rất cao |
| Tích hợp cgroups v2 systemd | 3 ngày | Cao |
| Syscall filtering với seccomp | 5 ngày | Cao |
| Audit log query CLI | 2 ngày | Trung bình |
**Kết quả bàn giao**:
- Linux: cách ly hoàn toàn như container qua Firejail
- macOS: cách ly filesystem với Bubblewrap
- Linux: thực thi giới hạn tài nguyên qua cgroups
- Linux: allowlist syscall
---
### Giai đoạn 3: hardening production (1-2 tuần)
**Mục tiêu**: các tính năng bảo mật doanh nghiệp
| Nhiệm vụ | Công sức | Tác động |
|------|--------|-------|
| Docker sandbox mode | 3 ngày | Cao |
| Certificate pinning cho channels | 2 ngày | Trung bình |
| Xác minh config đã ký | 2 ngày | Trung bình |
| Xuất audit tương thích SIEM | 2 ngày | Trung bình |
| Tự kiểm tra bảo mật (`zeroclaw audit --check`) | 1 ngày | Thấp |
**Kết quả bàn giao**:
- Tùy chọn cách ly thực thi dựa trên Docker
- HTTPS certificate pinning cho channel webhooks
- Xác minh chữ ký file config
- Xuất audit JSON/CSV cho phân tích ngoài
---
## Xem trước config schema mới
```toml
[security]
level = "strict" # relaxed | default | strict | paranoid
# Cấu hình sandbox
[security.sandbox]
enabled = true
backend = "auto" # auto | firejail | bubblewrap | landlock | docker | none
# Giới hạn tài nguyên
[resources]
max_memory_mb = 512
max_memory_per_command_mb = 128
max_cpu_percent = 50
max_cpu_time_seconds = 60
max_subprocesses = 10
# Audit logging
[security.audit]
enabled = true
log_path = "~/.config/zeroclaw/audit.log"
sign_events = true
max_size_mb = 100
# Autonomy (hiện có, được cải thiện)
[autonomy]
level = "supervised" # readonly | supervised | full
allowed_commands = ["git", "ls", "cat", "grep", "find"]
forbidden_paths = ["/etc", "/root", "~/.ssh"]
require_approval_for_medium_risk = true
block_high_risk_commands = true
max_actions_per_hour = 20
```
---
## Xem trước lệnh CLI
```bash
# Kiểm tra trạng thái bảo mật
zeroclaw security --check
# → ✓ Sandbox: Firejail active
# → ✓ Audit logging enabled (42 events today)
# → → Resource limits: 512MB mem, 50% CPU
# Truy vấn audit log
zeroclaw audit --user @alice --since 24h
zeroclaw audit --risk high --violations-only
zeroclaw audit --verify-signatures
# Kiểm tra sandbox
zeroclaw sandbox --test
# → Testing isolation...
# ✓ Cannot read /etc/passwd
# ✓ Cannot access ~/.ssh
# ✓ Can read /workspace
```
---
## Tóm tắt
**ZeroClaw đã an toàn hơn PicoClaw** với:
- Binary nhỏ hơn 50% (3.4MB so với 8MB)
- RAM ít hơn 50% (< 5MB so với < 10MB)
- Startup nhanh hơn 100 lần (< 10ms so với < 1s)
- Policy engine bảo mật toàn diện
- Độ phủ kiểm thử rộng
**Khi triển khai lộ trình này**, ZeroClaw sẽ trở thành:
- Cấp production với OS-level sandboxing
- Nhận biết tài nguyên với bảo vệ memory/CPU
- Sẵn sàng audit với logging chống giả mạo
- Sẵn sàng doanh nghiệp với các cấp độ bảo mật có thể cấu hình
**Công sức ước tính**: 4-7 tuần để triển khai đầy đủ
**Giá trị**: biến ZeroClaw từ "an toàn để kiểm thử" thành "an toàn cho production"
-22
View File
@@ -1,22 +0,0 @@
# Tài liệu bảo mật
Hướng dẫn bảo mật hiện tại và đề xuất cải tiến.
## Hành vi hiện tại trước tiên
Để biết hành vi runtime hiện tại, bắt đầu tại đây:
- Tham chiếu config: [../config-reference.md](../config-reference.md)
- Sổ tay vận hành: [../operations-runbook.md](../operations-runbook.md)
- Xử lý sự cố: [../troubleshooting.md](../troubleshooting.md)
## Tài liệu đề xuất / Lộ trình
Các tài liệu sau theo định hướng đề xuất rõ ràng và có thể bao gồm các ví dụ CLI/config chưa triển khai:
- [../agnostic-security.md](../agnostic-security.md)
- [../frictionless-security.md](../frictionless-security.md)
- [../sandboxing.md](../sandboxing.md)
- [../resource-limits.md](../resource-limits.md)
- [../audit-logging.md](../audit-logging.md)
- [../security-roadmap.md](../security-roadmap.md)
-236
View File
@@ -1,236 +0,0 @@
# Khắc phục sự cố ZeroClaw
Các lỗi thường gặp khi cài đặt và chạy, kèm cách khắc phục.
Xác minh lần cuối: **2026-02-20**.
## Cài đặt / Bootstrap
### Không tìm thấy `cargo`
Triệu chứng:
- bootstrap thoát với lỗi `cargo is not installed`
Khắc phục:
```bash
./install.sh --install-rust
```
Hoặc cài từ <https://rustup.rs/>.
### Thiếu thư viện hệ thống để build
Triệu chứng:
- build thất bại do lỗi trình biên dịch hoặc `pkg-config`
Khắc phục:
```bash
./install.sh --install-system-deps
```
### Build thất bại trên máy ít RAM / ít dung lượng
Triệu chứng:
- `cargo build --release` bị kill (`signal: 9`, OOM killer, hoặc `cannot allocate memory`)
- Build vẫn lỗi sau khi thêm swap vì hết dung lượng ổ đĩa
Nguyên nhân:
- RAM lúc chạy (<5MB) khác xa RAM lúc biên dịch.
- Build đầy đủ từ mã nguồn có thể cần **2 GB RAM + swap****6+ GB dung lượng trống**.
- Bật swap trên ổ nhỏ có thể tránh OOM RAM nhưng vẫn lỗi vì hết dung lượng.
Cách tốt nhất cho máy hạn chế tài nguyên:
```bash
./install.sh --prefer-prebuilt
```
Chế độ chỉ dùng binary (không build từ nguồn):
```bash
./install.sh --prebuilt-only
```
Nếu bắt buộc phải build từ nguồn trên máy yếu:
1. Chỉ thêm swap nếu còn đủ dung lượng cho cả swap lẫn kết quả build.
1. Giới hạn số luồng build:
```bash
CARGO_BUILD_JOBS=1 cargo build --release --locked
```
1. Bỏ bớt feature nặng khi không cần Matrix:
```bash
cargo build --release --locked --no-default-features --features hardware
```
1. Cross-compile trên máy mạnh hơn rồi copy binary sang máy đích.
### Build rất chậm hoặc có vẻ bị treo
Triệu chứng:
- `cargo check` / `cargo build` dừng lâu ở `Checking zeroclaw`
- Lặp lại thông báo `Blocking waiting for file lock on package cache` hoặc `build directory`
Nguyên nhân:
- Thư viện Matrix E2EE (`matrix-sdk`, `ruma`, `vodozemac`) lớn và tốn thời gian kiểm tra kiểu.
- TLS + crypto native build script (`aws-lc-sys`, `ring`) tăng thời gian biên dịch đáng kể.
- `rusqlite` với SQLite tích hợp biên dịch mã C cục bộ.
- Chạy nhiều cargo job/worktree song song gây tranh chấp file lock.
Kiểm tra nhanh:
```bash
cargo check --timings
cargo tree -d
```
Báo cáo thời gian được ghi tại `target/cargo-timings/cargo-timing.html`.
Lặp nhanh hơn khi không cần kênh Matrix:
```bash
cargo check --no-default-features --features hardware
```
Lệnh này bỏ qua `channel-matrix` và giảm đáng kể thời gian biên dịch.
Build với Matrix:
```bash
cargo check --no-default-features --features hardware,channel-matrix
```
Giảm tranh chấp lock:
```bash
pgrep -af "cargo (check|build|test)|cargo check|cargo build|cargo test"
```
Dừng các cargo job không liên quan trước khi build.
### Không tìm thấy lệnh `zeroclaw` sau cài đặt
Triệu chứng:
- Cài đặt thành công nhưng shell không tìm thấy `zeroclaw`
Khắc phục:
```bash
export PATH="$HOME/.cargo/bin:$PATH"
which zeroclaw
```
Thêm vào shell profile nếu cần giữ lâu dài.
## Runtime / Gateway
### Không kết nối được gateway
Kiểm tra:
```bash
zeroclaw status
zeroclaw doctor
```
Xác minh `~/.zeroclaw/config.toml`:
- `[gateway].host` (mặc định `127.0.0.1`)
- `[gateway].port` (mặc định `3000`)
- `allow_public_bind` chỉ bật khi cố ý mở truy cập LAN/public
### Lỗi ghép nối / xác thực webhook
Kiểm tra:
1. Đảm bảo đã hoàn tất ghép nối (luồng `/pair`)
2. Đảm bảo bearer token còn hiệu lực
3. Chạy lại chẩn đoán:
```bash
zeroclaw doctor
```
## Sự cố kênh
### Telegram xung đột: `terminated by other getUpdates request`
Nguyên nhân:
- Nhiều poller dùng chung bot token
Khắc phục:
- Chỉ giữ một runtime đang chạy cho token đó
- Dừng các tiến trình `zeroclaw daemon` / `zeroclaw channel start` thừa
### Kênh không khỏe trong `channel doctor`
Kiểm tra:
```bash
zeroclaw channel doctor
```
Sau đó xác minh thông tin xác thực và trường allowlist cho từng kênh trong config.
## Chế độ dịch vụ
### Dịch vụ đã cài nhưng không chạy
Kiểm tra:
```bash
zeroclaw service status
```
Khôi phục:
```bash
zeroclaw service stop
zeroclaw service start
```
Xem log trên Linux:
```bash
journalctl --user -u zeroclaw.service -f
```
## URL cài đặt
```bash
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/install.sh | bash
```
## Vẫn chưa giải quyết được?
Thu thập và đính kèm các thông tin sau khi tạo issue:
```bash
zeroclaw --version
zeroclaw status
zeroclaw doctor
zeroclaw channel doctor
```
Kèm thêm: hệ điều hành, cách cài đặt, và đoạn config đã ẩn bí mật.
## Tài liệu liên quan
- [operations-runbook.md](operations-runbook.md)
- [one-click-bootstrap.md](one-click-bootstrap.md)
- [channels-reference.md](channels-reference.md)
- [network-deployment.md](network-deployment.md)
-142
View File
@@ -1,142 +0,0 @@
# Thiết lập Z.AI GLM
ZeroClaw hỗ trợ các model GLM của Z.AI thông qua các endpoint tương thích OpenAI.
Hướng dẫn cấu hình thực tế theo provider hiện tại của ZeroClaw.
## Tổng quan
ZeroClaw hỗ trợ sẵn các alias và endpoint Z.AI sau đây:
| Alias | Endpoint | Ghi chú |
|-------|----------|---------|
| `zai` | `https://api.z.ai/api/coding/paas/v4` | Endpoint toàn cầu |
| `zai-cn` | `https://open.bigmodel.cn/api/paas/v4` | Endpoint Trung Quốc |
Nếu bạn cần base URL tùy chỉnh, xem `docs/custom-providers.md`.
## Thiết lập
### Bắt đầu nhanh
```bash
zeroclaw onboard \
--provider "zai" \
--api-key "YOUR_ZAI_API_KEY"
```
### Cấu hình thủ công
Chỉnh sửa `~/.zeroclaw/config.toml`:
```toml
api_key = "YOUR_ZAI_API_KEY"
default_provider = "zai"
default_model = "glm-5"
default_temperature = 0.7
```
## Các model hiện có
| Model | Mô tả |
|-------|-------|
| `glm-5` | Mặc định khi onboarding; khả năng suy luận mạnh nhất |
| `glm-4.7` | Chất lượng đa năng cao |
| `glm-4.6` | Mức cơ bản cân bằng |
| `glm-4.5-air` | Tùy chọn độ trễ thấp hơn |
Khả năng khả dụng của model có thể thay đổi theo tài khoản/khu vực, hãy dùng API `/models` khi không chắc chắn.
## Xác minh thiết lập
### Kiểm tra bằng curl
```bash
# Test OpenAI-compatible endpoint
curl -X POST "https://api.z.ai/api/coding/paas/v4/chat/completions" \
-H "Authorization: Bearer YOUR_ZAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "glm-5",
"messages": [{"role": "user", "content": "Hello"}]
}'
```
Phản hồi mong đợi:
```json
{
"choices": [{
"message": {
"content": "Hello! How can I help you today?",
"role": "assistant"
}
}]
}
```
### Kiểm tra bằng ZeroClaw CLI
```bash
# Test agent directly
echo "Hello" | zeroclaw agent
# Check status
zeroclaw status
```
## Biến môi trường
Thêm vào file `.env` của bạn:
```bash
# Z.AI API Key
ZAI_API_KEY=your-id.secret
# Optional generic key (used by many providers)
# API_KEY=your-id.secret
```
Định dạng key là `id.secret` (ví dụ: `abc123.xyz789`).
## Xử lý sự cố
### Rate Limiting
**Triệu chứng:** Lỗi `rate_limited`
**Giải pháp:**
- Chờ và thử lại
- Kiểm tra giới hạn gói Z.AI của bạn
- Thử `glm-4.5-air` để có độ trễ thấp hơn và khả năng chịu đựng quota cao hơn
### Lỗi xác thực
**Triệu chứng:** Lỗi 401 hoặc 403
**Giải pháp:**
- Xác minh định dạng API key là `id.secret`
- Kiểm tra key chưa hết hạn
- Đảm bảo không có khoảng trắng thừa trong key
### Model không tìm thấy
**Triệu chứng:** Lỗi model không khả dụng
**Giải pháp:**
- Liệt kê các model có sẵn:
```bash
curl -s "https://api.z.ai/api/coding/paas/v4/models" \
-H "Authorization: Bearer YOUR_ZAI_API_KEY" | jq '.data[].id'
```
## Lấy API Key
1. Truy cập [Z.AI](https://z.ai)
2. Đăng ký Coding Plan
3. Tạo API key từ dashboard
4. Định dạng key: `id.secret` (ví dụ: `abc123.xyz789`)
## Tài liệu liên quan
- [ZeroClaw README](README.md)
- [Custom Provider Endpoints](./custom-providers.md)
- [Contributing Guide](../../../CONTRIBUTING.md)
+73
View File
@@ -0,0 +1,73 @@
# OpenAI Temperature Compatibility Reference
This document provides empirical evidence for temperature parameter compatibility across OpenAI models.
## Summary
Different OpenAI model families have different temperature requirements:
- **Reasoning models** (o-series, gpt-5 base variants): Only accept `temperature=1.0`
- **Search models**: Do not accept temperature parameter (must be omitted)
- **Standard models** (gpt-3.5, gpt-4, gpt-4o): Accept flexible temperature values (0.0-2.0)
## Tested Models
### Models Requiring temperature=1.0
| Model | Accepts 0.7 | Accepts 1.0 | Recommendation |
|-------|-------------|-------------|----------------|
| o1 | ❌ | ✅ | USE_1.0 |
| o1-2024-12-17 | ❌ | ✅ | USE_1.0 |
| o3 | ❌ | ✅ | USE_1.0 |
| o3-2025-04-16 | ❌ | ✅ | USE_1.0 |
| o3-mini | ❌ | ✅ | USE_1.0 |
| o3-mini-2025-01-31 | ❌ | ✅ | USE_1.0 |
| o4-mini | ❌ | ✅ | USE_1.0 |
| o4-mini-2025-04-16 | ❌ | ✅ | USE_1.0 |
| gpt-5 | ❌ | ✅ | USE_1.0 |
| gpt-5-2025-08-07 | ❌ | ✅ | USE_1.0 |
| gpt-5-mini | ❌ | ✅ | USE_1.0 |
| gpt-5-mini-2025-08-07 | ❌ | ✅ | USE_1.0 |
| gpt-5-nano | ❌ | ✅ | USE_1.0 |
| gpt-5-nano-2025-08-07 | ❌ | ✅ | USE_1.0 |
| gpt-5.1-chat-latest | ❌ | ✅ | USE_1.0 |
| gpt-5.2-chat-latest | ❌ | ✅ | USE_1.0 |
| gpt-5.3-chat-latest | ❌ | ✅ | USE_1.0 |
### Models Accepting Flexible Temperature (0.7 works)
All standard GPT models accept flexible temperature values:
- gpt-3.5-turbo (all variants)
- gpt-4 (all variants)
- gpt-4-turbo (all variants)
- gpt-4o (all variants)
- gpt-4o-mini (all variants)
- gpt-4.1 (all variants)
- gpt-5-chat-latest
- gpt-5.2, gpt-5.2-2025-12-11
- gpt-5.4, gpt-5.4-2026-03-05
### Models Requiring Temperature Omission
Search-preview models do not accept temperature parameter:
- gpt-4o-mini-search-preview
- gpt-4o-search-preview
- gpt-5-search-api
## Implementation
The `adjust_temperature_for_model()` function in `src/providers/openai.rs` automatically adjusts temperature to 1.0 for reasoning models while preserving user-specified values for standard models.
## Testing Methodology
Models were tested with:
1. No temperature parameter (baseline)
2. temperature=0.7 (common default)
3. temperature=1.0 (reasoning model requirement)
Results were validated against actual OpenAI API responses.
## References
- OpenAI API Documentation: https://platform.openai.com/docs/api-reference/chat
- Related Issue: Temperature errors with o1/o3/gpt-5 models
+58
View File
@@ -22,6 +22,64 @@ For first-time installation, start from [one-click-bootstrap.md](../setup-guides
| Foreground runtime | `zeroclaw daemon` | local debugging, short-lived sessions |
| Foreground gateway only | `zeroclaw gateway` | webhook endpoint testing |
| User service | `zeroclaw service install && zeroclaw service start` | persistent operator-managed runtime |
| Docker / Podman | `docker compose up -d` | containerized deployment |
## Docker / Podman Runtime
If you installed via `./install.sh --docker`, the container exits after onboarding. To run
ZeroClaw as a long-lived container, use the repository `docker-compose.yml` or start a
container manually against the persisted data directory.
### Recommended: docker-compose
```bash
# Start (detached, auto-restarts on reboot)
docker compose up -d
# Stop
docker compose down
# Restart
docker compose up -d
```
Replace `docker` with `podman` if using Podman.
### Manual container lifecycle
```bash
# Start a new container from the bootstrap image
docker run -d --name zeroclaw \
--restart unless-stopped \
-v "$PWD/.zeroclaw-docker/.zeroclaw:/zeroclaw-data/.zeroclaw" \
-v "$PWD/.zeroclaw-docker/workspace:/zeroclaw-data/workspace" \
-e HOME=/zeroclaw-data \
-e ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace \
-p 42617:42617 \
zeroclaw-bootstrap:local \
gateway
# Stop (preserves config and workspace)
docker stop zeroclaw
# Restart a stopped container
docker start zeroclaw
# View logs
docker logs -f zeroclaw
# Health check
docker exec zeroclaw zeroclaw status
```
For Podman, add `--userns keep-id --user "$(id -u):$(id -g)"` and append `:Z` to volume mounts.
### Key detail: do not re-run install.sh to restart
Re-running `install.sh --docker` rebuilds the image and re-runs onboarding. To simply
restart, use `docker start`, `docker compose up -d`, or `podman start`.
For full setup instructions, see [one-click-bootstrap.md](../setup-guides/one-click-bootstrap.md#stopping-and-restarting-a-dockerpodman-container).
## Baseline Operator Checklist
+97
View File
@@ -98,6 +98,103 @@ If you add `--skip-build`, the installer skips local image build. It first tries
Docker tag (`ZEROCLAW_DOCKER_IMAGE`, default: `zeroclaw-bootstrap:local`); if missing,
it pulls `ghcr.io/zeroclaw-labs/zeroclaw:latest` and tags it locally before running.
### Stopping and restarting a Docker/Podman container
After `./install.sh --docker` finishes, the container exits. Your config and workspace
are persisted in the data directory (default: `./.zeroclaw-docker`, or `~/.zeroclaw-docker`
when bootstrapping via `curl | bash`). You can override this path with `ZEROCLAW_DOCKER_DATA_DIR`.
**Do not re-run `install.sh`** to restart -- it will rebuild the image and re-run onboarding.
Instead, start a new container from the existing image and mount the persisted data directory.
#### Using the repository docker-compose.yml
The simplest way to run ZeroClaw long-term in Docker/Podman is with the provided
`docker-compose.yml` at the repository root. It uses a named volume (`zeroclaw-data`)
and sets `restart: unless-stopped` so the container survives reboots.
```bash
# Start (detached)
docker compose up -d
# Stop
docker compose down
# Restart after stopping
docker compose up -d
```
Replace `docker` with `podman` if you use Podman.
#### Manual container run (using install.sh data directory)
If you installed via `./install.sh --docker` and want to reuse the `.zeroclaw-docker`
data directory without compose:
```bash
# Docker
docker run -d --name zeroclaw \
--restart unless-stopped \
-v "$PWD/.zeroclaw-docker/.zeroclaw:/zeroclaw-data/.zeroclaw" \
-v "$PWD/.zeroclaw-docker/workspace:/zeroclaw-data/workspace" \
-e HOME=/zeroclaw-data \
-e ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace \
-p 42617:42617 \
zeroclaw-bootstrap:local \
gateway
# Podman (add --userns keep-id and :Z volume labels)
podman run -d --name zeroclaw \
--restart unless-stopped \
--userns keep-id \
--user "$(id -u):$(id -g)" \
-v "$PWD/.zeroclaw-docker/.zeroclaw:/zeroclaw-data/.zeroclaw:Z" \
-v "$PWD/.zeroclaw-docker/workspace:/zeroclaw-data/workspace:Z" \
-e HOME=/zeroclaw-data \
-e ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace \
-p 42617:42617 \
zeroclaw-bootstrap:local \
gateway
```
#### Common lifecycle commands
```bash
# Stop the container (preserves data)
docker stop zeroclaw
# Start a stopped container (config and workspace are intact)
docker start zeroclaw
# View logs
docker logs -f zeroclaw
# Remove the container (data in volumes/.zeroclaw-docker is preserved)
docker rm zeroclaw
# Check health
docker exec zeroclaw zeroclaw status
```
#### Environment variables
When running manually, pass provider configuration as environment variables
or ensure they are already saved in the persisted `config.toml`:
```bash
docker run -d --name zeroclaw \
-e API_KEY="sk-..." \
-e PROVIDER="openrouter" \
-v "$PWD/.zeroclaw-docker/.zeroclaw:/zeroclaw-data/.zeroclaw" \
-v "$PWD/.zeroclaw-docker/workspace:/zeroclaw-data/workspace" \
-p 42617:42617 \
zeroclaw-bootstrap:local \
gateway
```
If you already ran `onboard` during the initial install, your API key and provider are
saved in `.zeroclaw-docker/.zeroclaw/config.toml` and do not need to be passed again.
### Quick onboarding (non-interactive)
```bash
@@ -0,0 +1,314 @@
# LinkedIn Tool — Design Spec
**Date:** 2026-03-13
**Status:** Approved
**Risk tier:** Medium (new tool, external API, credential handling)
## Summary
Native LinkedIn integration tool for ZeroClaw. Enables the agent to create posts,
list its own posts, comment, react, delete posts, view post engagement, and retrieve
profile info — all through LinkedIn's official REST API with OAuth2 authentication.
## Motivation
Enable ZeroClaw to autonomously publish LinkedIn content on a schedule (via cron),
drawing from the user's memory, project history, and Medium feed. Removes dependency
on third-party platforms like Composio for social media posting.
## Required OAuth2 scopes
Users must grant these scopes when creating their LinkedIn Developer App:
| Scope | Required for |
|---|---|
| `w_member_social` | `create_post`, `comment`, `react`, `delete_post` |
| `r_liteprofile` | `get_profile` |
| `r_member_social` | `list_posts`, `get_engagement` |
The "Share on LinkedIn" and "Sign In with LinkedIn using OpenID Connect" products
must be requested in the LinkedIn Developer App dashboard (both auto-approve).
## Architecture
### File structure
| File | Role |
|---|---|
| `src/tools/linkedin.rs` | `Tool` trait impl, action dispatch, parameter validation |
| `src/tools/linkedin_client.rs` | OAuth2 token management, LinkedIn REST API wrappers |
| `src/tools/mod.rs` | Module declaration, pub use, registration in `all_tools_with_runtime` |
| `src/config/schema.rs` | `[linkedin]` config section (`LinkedInConfig`) |
| `src/config/mod.rs` | Add `LinkedInConfig` to pub use exports |
### No new dependencies
All required crates are already in `Cargo.toml`: `reqwest` (HTTP), `serde`/`serde_json`
(serialization), `chrono` (timestamps), `tokio` (async fs for .env reading).
## Config
### `config.toml`
```toml
[linkedin]
enabled = false
```
### `.env` credentials
```bash
LINKEDIN_CLIENT_ID=your_client_id
LINKEDIN_CLIENT_SECRET=your_client_secret
LINKEDIN_ACCESS_TOKEN=your_access_token
LINKEDIN_REFRESH_TOKEN=your_refresh_token
LINKEDIN_PERSON_ID=your_person_urn_id
```
Token format: `LINKEDIN_PERSON_ID` is the bare ID (e.g., `dXNlcjpA...`), not the
full URN. The client prefixes `urn:li:person:` internally.
## Tool design
### Single tool, action-dispatched
Tool name: `linkedin`
The LLM calls it with an `action` field and action-specific parameters:
```json
{ "action": "create_post", "text": "...", "visibility": "PUBLIC" }
```
### Actions
| Action | Params | API | Write? |
|---|---|---|---|
| `create_post` | `text`, `visibility?` (PUBLIC/CONNECTIONS, default PUBLIC), `article_url?`, `article_title?` | `POST /rest/posts` | Yes |
| `list_posts` | `count?` (default 10, max 50) | `GET /rest/posts?author={personUrn}&q=author` | No |
| `comment` | `post_id`, `text` | `POST /rest/socialActions/{id}/comments` | Yes |
| `react` | `post_id`, `reaction_type` (LIKE/CELEBRATE/SUPPORT/LOVE/INSIGHTFUL/FUNNY) | `POST /rest/reactions?actor={actorUrn}` | Yes |
| `delete_post` | `post_id` | `DELETE /rest/posts/{id}` | Yes |
| `get_engagement` | `post_id` | `GET /rest/socialActions/{id}` | No |
| `get_profile` | (none) | `GET /rest/me` | No |
Note: `list_posts` queries posts authored by the authenticated user (not a home feed —
LinkedIn does not expose a home feed API). `get_engagement` returns likes/comments/shares
counts for a specific post via the socialActions endpoint.
### Security enforcement
- Write actions (`create_post`, `comment`, `react`, `delete_post`): check `security.can_act()` + `security.record_action()`
- Read actions (`list_posts`, `get_engagement`, `get_profile`): still call `record_action()` for rate tracking
### Parameter validation
- `article_title` without `article_url` returns error: "article_title requires article_url"
- `react` requires both `post_id` and `reaction_type`
- `comment` requires both `post_id` and `text`
- `create_post` requires `text` (non-empty)
### Parameter schema
```json
{
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["create_post", "list_posts", "comment", "react", "delete_post", "get_engagement", "get_profile"],
"description": "The LinkedIn action to perform"
},
"text": {
"type": "string",
"description": "Post or comment text content"
},
"visibility": {
"type": "string",
"enum": ["PUBLIC", "CONNECTIONS"],
"description": "Post visibility (default: PUBLIC)"
},
"article_url": {
"type": "string",
"description": "URL to attach as article/link preview"
},
"article_title": {
"type": "string",
"description": "Title for the attached article (requires article_url)"
},
"post_id": {
"type": "string",
"description": "LinkedIn post URN for comment/react/delete/engagement"
},
"reaction_type": {
"type": "string",
"enum": ["LIKE", "CELEBRATE", "SUPPORT", "LOVE", "INSIGHTFUL", "FUNNY"],
"description": "Reaction type for the react action"
},
"count": {
"type": "integer",
"description": "Number of posts to retrieve (default 10, max 50)"
}
},
"required": ["action"]
}
```
## LinkedIn client
### `LinkedInClient` struct
```rust
pub struct LinkedInClient {
workspace_dir: PathBuf,
}
```
Uses `crate::config::build_runtime_proxy_client_with_timeouts("tool.linkedin", 30, 10)`
per request (same pattern as Pushover), respecting runtime proxy configuration.
### Credential loading
Same pattern as `PushoverTool`: reads `.env` from `workspace_dir`, parses key-value
pairs, supports `export` prefix and quoted values.
### Token refresh
1. All API calls use `LINKEDIN_ACCESS_TOKEN` in `Authorization: Bearer` header
2. On 401 response, attempt token refresh:
- `POST https://www.linkedin.com/oauth/v2/accessToken`
- Body: `grant_type=refresh_token&refresh_token=...&client_id=...&client_secret=...`
3. On successful refresh, update `LINKEDIN_ACCESS_TOKEN` in `.env` file via
line-targeted replacement (read all lines, replace the matching key line, write back).
Preserves `export` prefixes, quoting style, comments, and all other keys.
4. Retry the original request once
5. If refresh also fails, return error with clear message about re-authentication
### API versioning
All requests include:
- `LinkedIn-Version: 202402` header (stable version)
- `X-Restli-Protocol-Version: 2.0.0` header
- `Content-Type: application/json`
### React endpoint details
The `react` action sends:
- `POST /rest/reactions?actor=urn:li:person:{personId}`
- Body: `{"reactionType": "LIKE", "object": "urn:li:ugcPost:{postId}"}`
The actor URN is derived from `LINKEDIN_PERSON_ID` in `.env`.
### Response parsing
The client returns structured data types:
```rust
pub struct PostSummary {
pub id: String,
pub text: String,
pub created_at: String,
pub visibility: String,
}
pub struct ProfileInfo {
pub id: String,
pub name: String,
pub headline: String,
}
pub struct EngagementSummary {
pub likes: u64,
pub comments: u64,
pub shares: u64,
}
```
## Registration
In `src/tools/mod.rs` (follows `security_ops` config-gated pattern):
```rust
// Module declarations
pub mod linkedin;
pub mod linkedin_client;
// Re-exports
pub use linkedin::LinkedInTool;
// In all_tools_with_runtime():
if root_config.linkedin.enabled {
tool_arcs.push(Arc::new(LinkedInTool::new(
security.clone(),
workspace_dir.to_path_buf(),
)));
}
```
## Config schema
In `src/config/schema.rs`:
```rust
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct LinkedInConfig {
pub enabled: bool,
}
impl Default for LinkedInConfig {
fn default() -> Self {
Self { enabled: false }
}
}
```
Added as field `pub linkedin: LinkedInConfig` on the `Config` struct.
Added to `pub use` exports in `src/config/mod.rs`.
## Testing
### Unit tests (in `linkedin.rs`)
- Tool name, description, schema validation
- Action dispatch routes correctly
- Write actions blocked in read-only mode
- Write actions blocked by rate limiting
- Missing required params return clear errors
- Unknown action returns error
- `article_title` without `article_url` returns validation error
### Unit tests (in `linkedin_client.rs`)
- Credential parsing from `.env` (plain, quoted, export prefix, comments)
- Missing credential fields produce specific errors
- Token refresh writes updated token back to `.env` preserving other keys
- Post creation builds correct request body with URN formatting
- React builds correct query param with actor URN
- Visibility defaults to PUBLIC when omitted
### Registry tests (in `mod.rs`)
- `all_tools` excludes `linkedin` when `linkedin.enabled = false`
- `all_tools` includes `linkedin` when `linkedin.enabled = true`
### Integration tests
Not added in this PR — would require live LinkedIn API credentials.
A `#[cfg(feature = "test-linkedin-live")]` gate can be added later.
## Error handling
- Missing `.env` file: "LinkedIn credentials not found. Add LINKEDIN_* keys to .env"
- Missing specific key: "LINKEDIN_ACCESS_TOKEN not found in .env"
- Expired token + no refresh token: "LinkedIn token expired. Re-authenticate or add LINKEDIN_REFRESH_TOKEN to .env"
- `article_title` without `article_url`: "article_title requires article_url to be set"
- API errors: pass through LinkedIn's error message with status code
- Rate limited by LinkedIn: "LinkedIn API rate limit exceeded. Try again later."
- Missing scope: "LinkedIn API returned 403. Ensure your app has the required scopes: w_member_social, r_liteprofile, r_member_social"
## PR metadata
- **Branch:** `feature/linkedin-tool`
- **Title:** `feat(tools): add native LinkedIn integration tool`
- **Risk:** Medium — new tool, external API, no security boundary changes
- **Size target:** M (2 new files ~200-300 lines each, 3-4 modified files)
+67 -6
View File
@@ -177,11 +177,29 @@ get_available_disk_mb() {
fi
}
is_musl_linux() {
[[ "$(uname -s)" == "Linux" ]] || return 1
if [[ -f /etc/alpine-release ]]; then
return 0
fi
if have_cmd ldd && ldd --version 2>&1 | grep -qi 'musl'; then
return 0
fi
return 1
}
detect_release_target() {
local os arch
os="$(uname -s)"
arch="$(uname -m)"
if is_musl_linux; then
return 1
fi
case "$os:$arch" in
Linux:x86_64)
echo "x86_64-unknown-linux-gnu"
@@ -283,6 +301,12 @@ install_prebuilt_binary() {
return 1
fi
if is_musl_linux; then
warn "Pre-built release binaries are not published for musl/Alpine yet."
warn "Falling back to source build."
return 1
fi
target="$(detect_release_target || true)"
if [[ -z "$target" ]]; then
warn "No pre-built binary target mapping for $(uname -s)/$(uname -m)."
@@ -517,7 +541,7 @@ install_system_deps() {
fi
elif have_cmd apt-get; then
run_privileged apt-get update -qq
run_privileged apt-get install -y build-essential pkg-config git curl
run_privileged apt-get install -y build-essential pkg-config git curl libssl-dev
elif have_cmd dnf; then
run_privileged dnf install -y \
gcc \
@@ -1145,7 +1169,11 @@ if [[ "$FORCE_SOURCE_BUILD" == false ]]; then
SKIP_BUILD=true
SKIP_INSTALL=true
elif [[ "$PREBUILT_ONLY" == true ]]; then
error "Pre-built-only mode requested, but no compatible release asset is available."
if is_musl_linux; then
error "Pre-built-only mode is not supported on musl/Alpine because releases do not include musl assets yet."
else
error "Pre-built-only mode requested, but no compatible release asset is available."
fi
error "Try again later, or run with --force-source-build on a machine with enough RAM/disk."
exit 1
else
@@ -1212,17 +1240,23 @@ if [[ "$SKIP_INSTALL" == false ]]; then
cargo install --path "$WORK_DIR" --force --locked
step_ok "ZeroClaw installed"
# Sync binary to ~/.local/bin so PATH lookups find the fresh version
if [[ -d "$HOME/.local/bin" ]]; then
cp -f "$HOME/.cargo/bin/zeroclaw" "$HOME/.local/bin/zeroclaw" 2>/dev/null && \
step_ok "Synced binary to ~/.local/bin" || true
fi
else
step_dot "Skipping install"
fi
ZEROCLAW_BIN=""
if have_cmd zeroclaw; then
ZEROCLAW_BIN="zeroclaw"
elif [[ -x "$HOME/.cargo/bin/zeroclaw" ]]; then
if [[ -x "$HOME/.cargo/bin/zeroclaw" ]]; then
ZEROCLAW_BIN="$HOME/.cargo/bin/zeroclaw"
elif [[ -x "$WORK_DIR/target/release/zeroclaw" ]]; then
ZEROCLAW_BIN="$WORK_DIR/target/release/zeroclaw"
elif have_cmd zeroclaw; then
ZEROCLAW_BIN="zeroclaw"
fi
echo
@@ -1282,6 +1316,26 @@ if [[ -n "$ZEROCLAW_BIN" ]]; then
step_ok "Gateway service installed"
if "$ZEROCLAW_BIN" service restart 2>/dev/null; then
step_ok "Gateway service restarted"
# Fetch and display pairing code from running gateway
PAIR_CODE=""
for i in 1 2 3 4 5; do
sleep 2
if PAIR_CODE=$("$ZEROCLAW_BIN" gateway get-paircode 2>/dev/null | grep -oE '[0-9]{6}'); then
break
fi
done
if [[ -n "$PAIR_CODE" ]]; then
echo
echo -e " ${BOLD_BLUE}🔐 Gateway Pairing Code${RESET}"
echo
echo -e " ${BOLD_BLUE}┌──────────────┐${RESET}"
echo -e " ${BOLD_BLUE}${RESET} ${BOLD}${PAIR_CODE}${RESET} ${BOLD_BLUE}${RESET}"
echo -e " ${BOLD_BLUE}└──────────────┘${RESET}"
echo
echo -e " ${DIM}Enter this code in the dashboard to pair your device.${RESET}"
echo -e " ${DIM}Run 'zeroclaw gateway get-paircode --new' anytime to generate a fresh code.${RESET}"
fi
else
step_fail "Gateway service restart failed — re-run with zeroclaw service start"
fi
@@ -1312,6 +1366,13 @@ else
echo -e "${BOLD_BLUE}${CRAB} ZeroClaw installed successfully!${RESET}"
fi
if [[ -x "$HOME/.cargo/bin/zeroclaw" ]] && ! have_cmd zeroclaw; then
echo
warn "zeroclaw is installed in $HOME/.cargo/bin, but that directory is not in PATH for this shell."
warn 'Run: export PATH="$HOME/.cargo/bin:$PATH"'
step_dot "To persist it, add that export line to ~/.bashrc, ~/.zshrc, or your shell profile, then open a new shell."
fi
if [[ "$INSTALL_MODE" == "upgrade" ]]; then
step_dot "Upgrade complete"
fi
@@ -1321,7 +1382,7 @@ GATEWAY_PORT=42617
DASHBOARD_URL="http://127.0.0.1:${GATEWAY_PORT}"
echo
echo -e "${BOLD}Dashboard URL:${RESET} ${BLUE}${DASHBOARD_URL}${RESET}"
echo -e "${DIM} Enter the pairing code shown above to connect.${RESET}"
echo -e "${DIM} Run 'zeroclaw gateway get-paircode' to get your pairing code.${RESET}"
# --- Copy to clipboard ---
COPIED_TO_CLIPBOARD=false
+335 -4
View File
@@ -33,10 +33,13 @@ pub struct Agent {
skills: Vec<crate::skills::Skill>,
skills_prompt_mode: crate::config::SkillsPromptInjectionMode,
auto_save: bool,
memory_session_id: Option<String>,
history: Vec<ConversationMessage>,
classification_config: crate::config::QueryClassificationConfig,
available_hints: Vec<String>,
route_model_by_hint: HashMap<String, String>,
allowed_tools: Option<Vec<String>>,
response_cache: Option<Arc<crate::memory::response_cache::ResponseCache>>,
}
pub struct AgentBuilder {
@@ -55,9 +58,12 @@ pub struct AgentBuilder {
skills: Option<Vec<crate::skills::Skill>>,
skills_prompt_mode: Option<crate::config::SkillsPromptInjectionMode>,
auto_save: Option<bool>,
memory_session_id: Option<String>,
classification_config: Option<crate::config::QueryClassificationConfig>,
available_hints: Option<Vec<String>>,
route_model_by_hint: Option<HashMap<String, String>>,
allowed_tools: Option<Vec<String>>,
response_cache: Option<Arc<crate::memory::response_cache::ResponseCache>>,
}
impl AgentBuilder {
@@ -78,9 +84,12 @@ impl AgentBuilder {
skills: None,
skills_prompt_mode: None,
auto_save: None,
memory_session_id: None,
classification_config: None,
available_hints: None,
route_model_by_hint: None,
allowed_tools: None,
response_cache: None,
}
}
@@ -162,6 +171,11 @@ impl AgentBuilder {
self
}
pub fn memory_session_id(mut self, memory_session_id: Option<String>) -> Self {
self.memory_session_id = memory_session_id;
self
}
pub fn classification_config(
mut self,
classification_config: crate::config::QueryClassificationConfig,
@@ -180,10 +194,27 @@ impl AgentBuilder {
self
}
pub fn allowed_tools(mut self, allowed_tools: Option<Vec<String>>) -> Self {
self.allowed_tools = allowed_tools;
self
}
pub fn response_cache(
mut self,
cache: Option<Arc<crate::memory::response_cache::ResponseCache>>,
) -> Self {
self.response_cache = cache;
self
}
pub fn build(self) -> Result<Agent> {
let tools = self
let mut tools = self
.tools
.ok_or_else(|| anyhow::anyhow!("tools are required"))?;
let allowed = self.allowed_tools.clone();
if let Some(ref allow_list) = allowed {
tools.retain(|t| allow_list.iter().any(|name| name == t.name()));
}
let tool_specs = tools.iter().map(|tool| tool.spec()).collect();
Ok(Agent {
@@ -219,10 +250,13 @@ impl AgentBuilder {
skills: self.skills.unwrap_or_default(),
skills_prompt_mode: self.skills_prompt_mode.unwrap_or_default(),
auto_save: self.auto_save.unwrap_or(false),
memory_session_id: self.memory_session_id,
history: Vec::new(),
classification_config: self.classification_config.unwrap_or_default(),
available_hints: self.available_hints.unwrap_or_default(),
route_model_by_hint: self.route_model_by_hint.unwrap_or_default(),
allowed_tools: allowed,
response_cache: self.response_cache,
})
}
}
@@ -240,6 +274,29 @@ impl Agent {
self.history.clear();
}
pub fn set_memory_session_id(&mut self, session_id: Option<String>) {
self.memory_session_id = session_id;
}
/// Hydrate the agent with prior chat messages (e.g. from a session backend).
///
/// Ensures a system prompt is prepended if history is empty, then appends all
/// non-system messages from the seed. System messages in the seed are skipped
/// to avoid duplicating the system prompt.
pub fn seed_history(&mut self, messages: &[ChatMessage]) {
if self.history.is_empty() {
if let Ok(sys) = self.build_system_prompt() {
self.history
.push(ConversationMessage::Chat(ChatMessage::system(sys)));
}
}
for msg in messages {
if msg.role != "system" {
self.history.push(ConversationMessage::Chat(msg.clone()));
}
}
}
pub fn from_config(config: &Config) -> Result<Self> {
let observer: Arc<dyn Observer> =
Arc::from(observability::create_observer(&config.observability));
@@ -293,13 +350,16 @@ impl Agent {
.unwrap_or("anthropic/claude-sonnet-4-20250514")
.to_string();
let provider: Box<dyn Provider> = providers::create_routed_provider(
let provider_runtime_options = providers::provider_runtime_options_from_config(config);
let provider: Box<dyn Provider> = providers::create_routed_provider_with_options(
provider_name,
config.api_key.as_deref(),
config.api_url.as_deref(),
&config.reliability,
&config.model_routes,
&model_name,
&provider_runtime_options,
)?;
let dispatcher_choice = config.agent.tool_dispatcher.as_str();
@@ -317,11 +377,25 @@ impl Agent {
.collect();
let available_hints: Vec<String> = route_model_by_hint.keys().cloned().collect();
let response_cache = if config.memory.response_cache_enabled {
crate::memory::response_cache::ResponseCache::with_hot_cache(
&config.workspace_dir,
config.memory.response_cache_ttl_minutes,
config.memory.response_cache_max_entries,
config.memory.response_cache_hot_entries,
)
.ok()
.map(Arc::new)
} else {
None
};
Agent::builder()
.provider(provider)
.tools(tools)
.memory(memory)
.observer(observer)
.response_cache(response_cache)
.tool_dispatcher(tool_dispatcher)
.memory_loader(Box::new(DefaultMemoryLoader::new(
5,
@@ -476,13 +550,22 @@ impl Agent {
if self.auto_save {
let _ = self
.memory
.store("user_msg", user_message, MemoryCategory::Conversation, None)
.store(
"user_msg",
user_message,
MemoryCategory::Conversation,
self.memory_session_id.as_deref(),
)
.await;
}
let context = self
.memory_loader
.load_context(self.memory.as_ref(), user_message)
.load_context(
self.memory.as_ref(),
user_message,
self.memory_session_id.as_deref(),
)
.await
.unwrap_or_default();
@@ -500,6 +583,47 @@ impl Agent {
for _ in 0..self.config.max_tool_iterations {
let messages = self.tool_dispatcher.to_provider_messages(&self.history);
// Response cache: check before LLM call (only for deterministic, text-only prompts)
let cache_key = if self.temperature == 0.0 {
self.response_cache.as_ref().map(|_| {
let last_user = messages
.iter()
.rfind(|m| m.role == "user")
.map(|m| m.content.as_str())
.unwrap_or("");
let system = messages
.iter()
.find(|m| m.role == "system")
.map(|m| m.content.as_str());
crate::memory::response_cache::ResponseCache::cache_key(
&effective_model,
system,
last_user,
)
})
} else {
None
};
if let (Some(ref cache), Some(ref key)) = (&self.response_cache, &cache_key) {
if let Ok(Some(cached)) = cache.get(key) {
self.observer.record_event(&ObserverEvent::CacheHit {
cache_type: "response".into(),
tokens_saved: 0,
});
self.history
.push(ConversationMessage::Chat(ChatMessage::assistant(
cached.clone(),
)));
self.trim_history();
return Ok(cached);
}
self.observer.record_event(&ObserverEvent::CacheMiss {
cache_type: "response".into(),
});
}
let response = match self
.provider
.chat(
@@ -528,6 +652,17 @@ impl Agent {
text
};
// Store in response cache (text-only, no tool calls)
if let (Some(ref cache), Some(ref key)) = (&self.response_cache, &cache_key) {
let token_count = response
.usage
.as_ref()
.and_then(|u| u.output_tokens)
.unwrap_or(0);
#[allow(clippy::cast_possible_truncation)]
let _ = cache.put(key, &effective_model, &final_text, token_count as u32);
}
self.history
.push(ConversationMessage::Chat(ChatMessage::assistant(
final_text.clone(),
@@ -892,4 +1027,200 @@ mod tests {
let seen = seen_models.lock();
assert_eq!(seen.as_slice(), &["hint:fast".to_string()]);
}
#[tokio::test]
async fn from_config_passes_extra_headers_to_custom_provider() {
use axum::{http::HeaderMap, routing::post, Json, Router};
use tempfile::TempDir;
use tokio::net::TcpListener;
let captured_headers: Arc<std::sync::Mutex<Option<HashMap<String, String>>>> =
Arc::new(std::sync::Mutex::new(None));
let captured_headers_clone = captured_headers.clone();
let app = Router::new().route(
"/chat/completions",
post(
move |headers: HeaderMap, Json(_body): Json<serde_json::Value>| {
let captured_headers = captured_headers_clone.clone();
async move {
let collected = headers
.iter()
.filter_map(|(name, value)| {
value
.to_str()
.ok()
.map(|value| (name.as_str().to_string(), value.to_string()))
})
.collect();
*captured_headers.lock().unwrap() = Some(collected);
Json(serde_json::json!({
"choices": [{
"message": {
"content": "hello from mock"
}
}]
}))
}
},
),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server_handle = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let tmp = TempDir::new().expect("temp dir");
let workspace_dir = tmp.path().join("workspace");
std::fs::create_dir_all(&workspace_dir).unwrap();
let mut config = crate::config::Config::default();
config.workspace_dir = workspace_dir;
config.config_path = tmp.path().join("config.toml");
config.api_key = Some("test-key".to_string());
config.default_provider = Some(format!("custom:http://{addr}"));
config.default_model = Some("test-model".to_string());
config.memory.backend = "none".to_string();
config.memory.auto_save = false;
config.extra_headers.insert(
"User-Agent".to_string(),
"zeroclaw-web-test/1.0".to_string(),
);
config
.extra_headers
.insert("X-Title".to_string(), "zeroclaw-web".to_string());
let mut agent = Agent::from_config(&config).expect("agent from config");
let response = agent.turn("hello").await.expect("agent turn");
assert_eq!(response, "hello from mock");
let headers = captured_headers
.lock()
.unwrap()
.clone()
.expect("captured headers");
assert_eq!(
headers.get("user-agent").map(String::as_str),
Some("zeroclaw-web-test/1.0")
);
assert_eq!(
headers.get("x-title").map(String::as_str),
Some("zeroclaw-web")
);
server_handle.abort();
}
#[test]
fn builder_allowed_tools_none_keeps_all_tools() {
let provider = Box::new(MockProvider {
responses: Mutex::new(vec![]),
});
let memory_cfg = crate::config::MemoryConfig {
backend: "none".into(),
..crate::config::MemoryConfig::default()
};
let mem: Arc<dyn Memory> = Arc::from(
crate::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None)
.expect("memory creation should succeed with valid config"),
);
let observer: Arc<dyn Observer> = Arc::from(crate::observability::NoopObserver {});
let agent = Agent::builder()
.provider(provider)
.tools(vec![Box::new(MockTool)])
.memory(mem)
.observer(observer)
.tool_dispatcher(Box::new(NativeToolDispatcher))
.workspace_dir(std::path::PathBuf::from("/tmp"))
.allowed_tools(None)
.build()
.expect("agent builder should succeed with valid config");
assert_eq!(agent.tool_specs.len(), 1);
assert_eq!(agent.tool_specs[0].name, "echo");
}
#[test]
fn builder_allowed_tools_some_filters_tools() {
let provider = Box::new(MockProvider {
responses: Mutex::new(vec![]),
});
let memory_cfg = crate::config::MemoryConfig {
backend: "none".into(),
..crate::config::MemoryConfig::default()
};
let mem: Arc<dyn Memory> = Arc::from(
crate::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None)
.expect("memory creation should succeed with valid config"),
);
let observer: Arc<dyn Observer> = Arc::from(crate::observability::NoopObserver {});
let agent = Agent::builder()
.provider(provider)
.tools(vec![Box::new(MockTool)])
.memory(mem)
.observer(observer)
.tool_dispatcher(Box::new(NativeToolDispatcher))
.workspace_dir(std::path::PathBuf::from("/tmp"))
.allowed_tools(Some(vec!["nonexistent".to_string()]))
.build()
.expect("agent builder should succeed with valid config");
assert!(
agent.tool_specs.is_empty(),
"No tools should match a non-existent allowlist entry"
);
}
#[test]
fn seed_history_prepends_system_and_skips_system_from_seed() {
let provider = Box::new(MockProvider {
responses: Mutex::new(vec![]),
});
let memory_cfg = crate::config::MemoryConfig {
backend: "none".into(),
..crate::config::MemoryConfig::default()
};
let mem: Arc<dyn Memory> = Arc::from(
crate::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None)
.expect("memory creation should succeed with valid config"),
);
let observer: Arc<dyn Observer> = Arc::from(crate::observability::NoopObserver {});
let mut agent = Agent::builder()
.provider(provider)
.tools(vec![Box::new(MockTool)])
.memory(mem)
.observer(observer)
.tool_dispatcher(Box::new(NativeToolDispatcher))
.workspace_dir(std::path::PathBuf::from("/tmp"))
.build()
.expect("agent builder should succeed with valid config");
let seed = vec![
ChatMessage::system("old system prompt"),
ChatMessage::user("hello"),
ChatMessage::assistant("hi there"),
];
agent.seed_history(&seed);
let history = agent.history();
// First message should be a freshly built system prompt (not the seed one)
assert!(matches!(&history[0], ConversationMessage::Chat(m) if m.role == "system"));
// System message from seed should be skipped, so next is user
assert!(
matches!(&history[1], ConversationMessage::Chat(m) if m.role == "user" && m.content == "hello")
);
assert!(
matches!(&history[2], ConversationMessage::Chat(m) if m.role == "assistant" && m.content == "hi there")
);
assert_eq!(history.len(), 3);
}
}
+1 -12
View File
@@ -128,7 +128,7 @@ impl ToolDispatcher for XmlToolDispatcher {
ConversationMessage::Chat(ChatMessage::user(format!("[Tool results]\n{content}")))
}
fn prompt_instructions(&self, tools: &[Box<dyn Tool>]) -> String {
fn prompt_instructions(&self, _tools: &[Box<dyn Tool>]) -> String {
let mut instructions = String::new();
instructions.push_str("## Tool Use Protocol\n\n");
instructions
@@ -136,17 +136,6 @@ impl ToolDispatcher for XmlToolDispatcher {
instructions.push_str(
"```\n<tool_call>\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n```\n\n",
);
instructions.push_str("### Available Tools\n\n");
for tool in tools {
let _ = writeln!(
instructions,
"- **{}**: {}\n Parameters: `{}`",
tool.name(),
tool.description(),
tool.parameters_schema()
);
}
instructions
}
+625 -88
View File
File diff suppressed because it is too large Load Diff
+19 -5
View File
@@ -4,8 +4,12 @@ use std::fmt::Write;
#[async_trait]
pub trait MemoryLoader: Send + Sync {
async fn load_context(&self, memory: &dyn Memory, user_message: &str)
-> anyhow::Result<String>;
async fn load_context(
&self,
memory: &dyn Memory,
user_message: &str,
session_id: Option<&str>,
) -> anyhow::Result<String>;
}
pub struct DefaultMemoryLoader {
@@ -37,8 +41,9 @@ impl MemoryLoader for DefaultMemoryLoader {
&self,
memory: &dyn Memory,
user_message: &str,
session_id: Option<&str>,
) -> anyhow::Result<String> {
let entries = memory.recall(user_message, self.limit, None).await?;
let entries = memory.recall(user_message, self.limit, session_id).await?;
if entries.is_empty() {
return Ok(String::new());
}
@@ -48,6 +53,9 @@ impl MemoryLoader for DefaultMemoryLoader {
if memory::is_assistant_autosave_key(&entry.key) {
continue;
}
if memory::should_skip_autosave_content(&entry.content) {
continue;
}
if let Some(score) = entry.score {
if score < self.min_relevance_score {
continue;
@@ -191,7 +199,10 @@ mod tests {
#[tokio::test]
async fn default_loader_formats_context() {
let loader = DefaultMemoryLoader::default();
let context = loader.load_context(&MockMemory, "hello").await.unwrap();
let context = loader
.load_context(&MockMemory, "hello", None)
.await
.unwrap();
assert!(context.contains("[Memory context]"));
assert!(context.contains("- k: v"));
}
@@ -222,7 +233,10 @@ mod tests {
]),
};
let context = loader.load_context(&memory, "answer style").await.unwrap();
let context = loader
.load_context(&memory, "answer style", None)
.await
.unwrap();
assert!(context.contains("user_fact"));
assert!(!context.contains("assistant_resp_legacy"));
assert!(!context.contains("fabricated detail"));
+6 -2
View File
@@ -1282,8 +1282,12 @@ fn xml_dispatcher_generates_tool_instructions() {
assert!(instructions.contains("## Tool Use Protocol"));
assert!(instructions.contains("<tool_call>"));
assert!(instructions.contains("echo"));
assert!(instructions.contains("Echoes the input"));
// Tool listing is handled by ToolsSection in prompt.rs, not by the
// dispatcher. prompt_instructions() must only emit the protocol envelope.
assert!(
!instructions.contains("echo"),
"dispatcher should not duplicate tool listing"
);
}
#[test]
+143 -4
View File
@@ -44,11 +44,18 @@ pub struct ApprovalLogEntry {
// ── ApprovalManager ──────────────────────────────────────────────
/// Manages the interactive approval workflow.
/// Manages the approval workflow for tool calls.
///
/// - Checks config-level `auto_approve` / `always_ask` lists
/// - Maintains a session-scoped "always" allowlist
/// - Records an audit trail of all decisions
///
/// Two modes:
/// - **Interactive** (CLI): tools needing approval trigger a stdin prompt.
/// - **Non-interactive** (channels): tools needing approval are auto-denied
/// because there is no interactive operator to approve them. `auto_approve`
/// policy is still enforced, and `always_ask` / supervised-default tools are
/// denied rather than silently allowed.
pub struct ApprovalManager {
/// Tools that never need approval (from config).
auto_approve: HashSet<String>,
@@ -56,6 +63,9 @@ pub struct ApprovalManager {
always_ask: HashSet<String>,
/// Autonomy level from config.
autonomy_level: AutonomyLevel,
/// When `true`, tools that would require interactive approval are
/// auto-denied instead. Used for channel-driven (non-CLI) runs.
non_interactive: bool,
/// Session-scoped allowlist built from "Always" responses.
session_allowlist: Mutex<HashSet<String>>,
/// Audit trail of approval decisions.
@@ -63,17 +73,40 @@ pub struct ApprovalManager {
}
impl ApprovalManager {
/// Create from autonomy config.
/// Create an interactive (CLI) approval manager from autonomy config.
pub fn from_config(config: &AutonomyConfig) -> Self {
Self {
auto_approve: config.auto_approve.iter().cloned().collect(),
always_ask: config.always_ask.iter().cloned().collect(),
autonomy_level: config.level,
non_interactive: false,
session_allowlist: Mutex::new(HashSet::new()),
audit_log: Mutex::new(Vec::new()),
}
}
/// Create a non-interactive approval manager for channel-driven runs.
///
/// Enforces the same `auto_approve` / `always_ask` / supervised policies
/// as the CLI manager, but tools that would require interactive approval
/// are auto-denied instead of prompting (since there is no operator).
pub fn for_non_interactive(config: &AutonomyConfig) -> Self {
Self {
auto_approve: config.auto_approve.iter().cloned().collect(),
always_ask: config.always_ask.iter().cloned().collect(),
autonomy_level: config.level,
non_interactive: true,
session_allowlist: Mutex::new(HashSet::new()),
audit_log: Mutex::new(Vec::new()),
}
}
/// Returns `true` when this manager operates in non-interactive mode
/// (i.e. for channel-driven runs where no operator can approve).
pub fn is_non_interactive(&self) -> bool {
self.non_interactive
}
/// Check whether a tool call requires interactive approval.
///
/// Returns `true` if the call needs a prompt, `false` if it can proceed.
@@ -93,6 +126,15 @@ impl ApprovalManager {
return true;
}
// Channel-driven shell execution is still guarded by the shell tool's
// own command allowlist and risk policy. Skipping the outer approval
// gate here lets low-risk allowlisted commands (e.g. `ls`) work in
// non-interactive channels without silently allowing medium/high-risk
// commands.
if self.non_interactive && tool_name == "shell" {
return false;
}
// auto_approve skips the prompt.
if self.auto_approve.contains(tool_name) {
return false;
@@ -147,8 +189,8 @@ impl ApprovalManager {
/// Prompt the user on the CLI and return their decision.
///
/// For non-CLI channels, returns `Yes` automatically (interactive
/// approval is only supported on CLI for now).
/// Only called for interactive (CLI) managers. Non-interactive managers
/// auto-deny in the tool-call loop before reaching this point.
pub fn prompt_cli(&self, request: &ApprovalRequest) -> ApprovalResponse {
prompt_cli_interactive(request)
}
@@ -401,6 +443,103 @@ mod tests {
assert!(summary.contains("just a string"));
}
// ── non-interactive (channel) mode ────────────────────────
#[test]
fn non_interactive_manager_reports_non_interactive() {
let mgr = ApprovalManager::for_non_interactive(&supervised_config());
assert!(mgr.is_non_interactive());
}
#[test]
fn interactive_manager_reports_interactive() {
let mgr = ApprovalManager::from_config(&supervised_config());
assert!(!mgr.is_non_interactive());
}
#[test]
fn non_interactive_auto_approve_tools_skip_approval() {
let mgr = ApprovalManager::for_non_interactive(&supervised_config());
// auto_approve tools (file_read, memory_recall) should not need approval.
assert!(!mgr.needs_approval("file_read"));
assert!(!mgr.needs_approval("memory_recall"));
}
#[test]
fn non_interactive_shell_skips_outer_approval_by_default() {
let mgr = ApprovalManager::for_non_interactive(&AutonomyConfig::default());
assert!(!mgr.needs_approval("shell"));
}
#[test]
fn non_interactive_always_ask_tools_need_approval() {
let mgr = ApprovalManager::for_non_interactive(&supervised_config());
// always_ask tools (shell) still report as needing approval,
// so the tool-call loop will auto-deny them in non-interactive mode.
assert!(mgr.needs_approval("shell"));
}
#[test]
fn non_interactive_unknown_tools_need_approval_in_supervised() {
let mgr = ApprovalManager::for_non_interactive(&supervised_config());
// Unknown tools in supervised mode need approval (will be auto-denied
// by the tool-call loop for non-interactive managers).
assert!(mgr.needs_approval("file_write"));
assert!(mgr.needs_approval("http_request"));
}
#[test]
fn non_interactive_full_autonomy_never_needs_approval() {
let mgr = ApprovalManager::for_non_interactive(&full_config());
// Full autonomy means no approval needed, even in non-interactive mode.
assert!(!mgr.needs_approval("shell"));
assert!(!mgr.needs_approval("file_write"));
assert!(!mgr.needs_approval("anything"));
}
#[test]
fn non_interactive_readonly_never_needs_approval() {
let config = AutonomyConfig {
level: AutonomyLevel::ReadOnly,
..AutonomyConfig::default()
};
let mgr = ApprovalManager::for_non_interactive(&config);
// ReadOnly blocks execution elsewhere; approval manager does not prompt.
assert!(!mgr.needs_approval("shell"));
}
#[test]
fn non_interactive_session_allowlist_still_works() {
let mgr = ApprovalManager::for_non_interactive(&supervised_config());
assert!(mgr.needs_approval("file_write"));
// Simulate an "Always" decision (would come from a prior channel run
// if the tool was auto-approved somehow, e.g. via config change).
mgr.record_decision(
"file_write",
&serde_json::json!({"path": "test.txt"}),
ApprovalResponse::Always,
"telegram",
);
assert!(!mgr.needs_approval("file_write"));
}
#[test]
fn non_interactive_always_ask_overrides_session_allowlist() {
let mgr = ApprovalManager::for_non_interactive(&supervised_config());
mgr.record_decision(
"shell",
&serde_json::json!({"command": "ls"}),
ApprovalResponse::Always,
"telegram",
);
// shell is in always_ask, so it still needs approval even after "Always".
assert!(mgr.needs_approval("shell"));
}
// ── ApprovalResponse serde ───────────────────────────────
#[test]
+571
View File
@@ -0,0 +1,571 @@
use super::traits::{Channel, ChannelMessage, SendMessage};
use anyhow::{bail, Result};
use async_trait::async_trait;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
/// Bluesky channel — polls for mentions via AT Protocol and replies as posts.
pub struct BlueskyChannel {
handle: String,
app_password: String,
auth: Mutex<BlueskyAuth>,
}
struct BlueskyAuth {
access_jwt: String,
refresh_jwt: String,
did: String,
expires_at: Instant,
}
const BSKY_API_BASE: &str = "https://bsky.social/xrpc";
const POLL_INTERVAL: Duration = Duration::from_secs(5);
#[derive(Deserialize)]
struct CreateSessionResponse {
#[serde(rename = "accessJwt")]
access_jwt: String,
#[serde(rename = "refreshJwt")]
refresh_jwt: String,
did: String,
}
#[derive(Deserialize)]
struct RefreshSessionResponse {
#[serde(rename = "accessJwt")]
access_jwt: String,
#[serde(rename = "refreshJwt")]
refresh_jwt: String,
}
#[derive(Deserialize)]
struct NotificationListResponse {
notifications: Vec<Notification>,
cursor: Option<String>,
}
#[allow(dead_code)]
#[derive(Deserialize)]
struct Notification {
uri: String,
cid: String,
author: NotificationAuthor,
reason: String,
record: Option<serde_json::Value>,
#[serde(rename = "isRead")]
is_read: bool,
#[serde(rename = "indexedAt")]
indexed_at: String,
}
#[allow(dead_code)]
#[derive(Deserialize)]
struct NotificationAuthor {
did: String,
handle: String,
#[serde(rename = "displayName")]
display_name: Option<String>,
}
/// AT Protocol record for creating a post.
#[derive(Serialize)]
struct CreateRecordRequest {
repo: String,
collection: String,
record: PostRecord,
}
#[derive(Serialize)]
struct PostRecord {
#[serde(rename = "$type")]
record_type: String,
text: String,
#[serde(rename = "createdAt")]
created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
reply: Option<ReplyRef>,
}
#[derive(Serialize)]
struct ReplyRef {
root: PostRef,
parent: PostRef,
}
#[derive(Serialize)]
struct PostRef {
uri: String,
cid: String,
}
impl BlueskyChannel {
pub fn new(handle: String, app_password: String) -> Self {
Self {
handle,
app_password,
auth: Mutex::new(BlueskyAuth {
access_jwt: String::new(),
refresh_jwt: String::new(),
did: String::new(),
expires_at: Instant::now(),
}),
}
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.bluesky")
}
/// Create a new session with handle + app password.
async fn create_session(&self) -> Result<()> {
let client = self.http_client();
let resp = client
.post(format!("{BSKY_API_BASE}/com.atproto.server.createSession"))
.json(&serde_json::json!({
"identifier": self.handle,
"password": self.app_password,
}))
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let body = resp
.text()
.await
.unwrap_or_else(|e| format!("<failed to read response: {e}>"));
bail!("Bluesky createSession failed ({status}): {body}");
}
let session: CreateSessionResponse = resp.json().await?;
let mut auth = self.auth.lock();
auth.access_jwt = session.access_jwt;
auth.refresh_jwt = session.refresh_jwt;
auth.did = session.did;
// AT Protocol JWTs typically last ~2 hours; refresh well before that.
auth.expires_at = Instant::now() + Duration::from_secs(90 * 60);
Ok(())
}
/// Refresh an existing session.
async fn refresh_session(&self) -> Result<()> {
let refresh_jwt = {
let auth = self.auth.lock();
auth.refresh_jwt.clone()
};
if refresh_jwt.is_empty() {
return self.create_session().await;
}
let client = self.http_client();
let resp = client
.post(format!("{BSKY_API_BASE}/com.atproto.server.refreshSession"))
.bearer_auth(&refresh_jwt)
.send()
.await?;
if !resp.status().is_success() {
// Refresh failed — fall back to full re-auth
tracing::warn!("Bluesky session refresh failed, re-authenticating");
return self.create_session().await;
}
let refreshed: RefreshSessionResponse = resp.json().await?;
let mut auth = self.auth.lock();
auth.access_jwt = refreshed.access_jwt;
auth.refresh_jwt = refreshed.refresh_jwt;
auth.expires_at = Instant::now() + Duration::from_secs(90 * 60);
Ok(())
}
/// Get a valid access JWT, refreshing if expired.
async fn get_access_jwt(&self) -> Result<String> {
{
let auth = self.auth.lock();
if !auth.access_jwt.is_empty() && Instant::now() < auth.expires_at {
return Ok(auth.access_jwt.clone());
}
}
self.refresh_session().await?;
let auth = self.auth.lock();
Ok(auth.access_jwt.clone())
}
/// Get the DID for the authenticated account.
fn get_did(&self) -> String {
self.auth.lock().did.clone()
}
/// Parse a notification into a ChannelMessage (only processes mentions).
fn parse_notification(&self, notif: &Notification) -> Option<ChannelMessage> {
// Only process mentions
if notif.reason != "mention" && notif.reason != "reply" {
return None;
}
// Skip already-read notifications
if notif.is_read {
return None;
}
// Skip own posts
if notif.author.did == self.get_did() {
return None;
}
// Extract text from the record
let text = notif
.record
.as_ref()
.and_then(|r| r.get("text"))
.and_then(|t| t.as_str())
.unwrap_or("");
if text.is_empty() {
return None;
}
// Parse timestamp from indexedAt (ISO 8601)
let timestamp = chrono::DateTime::parse_from_rfc3339(&notif.indexed_at)
.map(|dt| dt.timestamp().cast_unsigned())
.unwrap_or(0);
// Extract CID from the record for reply references
let cid = notif
.record
.as_ref()
.and_then(|r| r.get("cid"))
.and_then(|c| c.as_str())
.unwrap_or(&notif.cid);
// The reply target encodes the URI and CID needed for threading
let reply_target = format!("{}|{}", notif.uri, cid);
Some(ChannelMessage {
id: format!("bluesky_{}", notif.cid),
sender: notif.author.handle.clone(),
reply_target,
content: text.to_string(),
channel: "bluesky".to_string(),
timestamp,
thread_ts: Some(notif.uri.clone()),
})
}
/// Mark notifications as read up to a given timestamp.
async fn update_seen(&self, seen_at: &str) -> Result<()> {
let token = self.get_access_jwt().await?;
let client = self.http_client();
let resp = client
.post(format!("{BSKY_API_BASE}/app.bsky.notification.updateSeen"))
.bearer_auth(&token)
.json(&serde_json::json!({ "seenAt": seen_at }))
.send()
.await?;
if !resp.status().is_success() {
tracing::warn!("Bluesky updateSeen failed: {}", resp.status());
}
Ok(())
}
}
#[async_trait]
impl Channel for BlueskyChannel {
fn name(&self) -> &str {
"bluesky"
}
async fn send(&self, message: &SendMessage) -> Result<()> {
let token = self.get_access_jwt().await?;
let did = self.get_did();
let client = self.http_client();
let now = chrono::Utc::now().to_rfc3339();
// Parse reply reference from recipient if present (format: "uri|cid")
let reply = if message.recipient.contains('|') {
let parts: Vec<&str> = message.recipient.splitn(2, '|').collect();
if parts.len() == 2 {
let uri = parts[0];
let cid = parts[1];
Some(ReplyRef {
root: PostRef {
uri: uri.to_string(),
cid: cid.to_string(),
},
parent: PostRef {
uri: uri.to_string(),
cid: cid.to_string(),
},
})
} else {
None
}
} else {
None
};
// Bluesky posts have a 300-character limit (grapheme clusters).
// For longer content, truncate with an indicator.
let text = if message.content.len() > 300 {
format!("{}...", &message.content[..297])
} else {
message.content.clone()
};
let request = CreateRecordRequest {
repo: did,
collection: "app.bsky.feed.post".to_string(),
record: PostRecord {
record_type: "app.bsky.feed.post".to_string(),
text,
created_at: now,
reply,
},
};
let resp = client
.post(format!("{BSKY_API_BASE}/com.atproto.repo.createRecord"))
.bearer_auth(&token)
.json(&request)
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let body = resp
.text()
.await
.unwrap_or_else(|e| format!("<failed to read response: {e}>"));
bail!("Bluesky post failed ({status}): {body}");
}
Ok(())
}
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> Result<()> {
// Initial auth
self.create_session().await?;
tracing::info!("Bluesky channel listening as @{}...", self.handle);
loop {
tokio::time::sleep(POLL_INTERVAL).await;
let token = match self.get_access_jwt().await {
Ok(t) => t,
Err(e) => {
tracing::warn!("Bluesky auth error: {e}");
continue;
}
};
let client = self.http_client();
let resp = match client
.get(format!(
"{BSKY_API_BASE}/app.bsky.notification.listNotifications"
))
.bearer_auth(&token)
.query(&[("limit", "25")])
.send()
.await
{
Ok(r) => r,
Err(e) => {
tracing::warn!("Bluesky poll error: {e}");
continue;
}
};
if !resp.status().is_success() {
tracing::warn!("Bluesky notifications failed: {}", resp.status());
continue;
}
let listing: NotificationListResponse = match resp.json().await {
Ok(l) => l,
Err(e) => {
tracing::warn!("Bluesky parse error: {e}");
continue;
}
};
let mut latest_indexed_at: Option<String> = None;
for notif in &listing.notifications {
if let Some(msg) = self.parse_notification(notif) {
latest_indexed_at = Some(notif.indexed_at.clone());
if tx.send(msg).await.is_err() {
return Ok(());
}
}
}
// Mark as seen
if let Some(ref seen_at) = latest_indexed_at {
if let Err(e) = self.update_seen(seen_at).await {
tracing::warn!("Bluesky updateSeen error: {e}");
}
}
let _ = &listing.cursor; // cursor available for pagination if needed
}
}
async fn health_check(&self) -> bool {
self.get_access_jwt().await.is_ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_channel() -> BlueskyChannel {
let ch = BlueskyChannel::new("testbot.bsky.social".into(), "app-password".into());
// Seed auth with a DID for tests
{
let mut auth = ch.auth.lock();
auth.did = "did:plc:test123".into();
}
ch
}
fn make_notification(
reason: &str,
handle: &str,
did: &str,
text: &str,
is_read: bool,
) -> Notification {
Notification {
uri: format!("at://{did}/app.bsky.feed.post/abc123"),
cid: "bafyreitest123".into(),
author: NotificationAuthor {
did: did.into(),
handle: handle.into(),
display_name: None,
},
reason: reason.into(),
record: Some(serde_json::json!({ "text": text })),
is_read,
indexed_at: "2026-01-15T10:00:00.000Z".into(),
}
}
#[test]
fn parse_mention_notification() {
let ch = make_channel();
let notif = make_notification(
"mention",
"user1.bsky.social",
"did:plc:user1",
"@testbot hello",
false,
);
let msg = ch.parse_notification(&notif).unwrap();
assert_eq!(msg.sender, "user1.bsky.social");
assert_eq!(msg.content, "@testbot hello");
assert_eq!(msg.channel, "bluesky");
assert!(msg.id.starts_with("bluesky_"));
}
#[test]
fn parse_reply_notification() {
let ch = make_channel();
let notif = make_notification(
"reply",
"user2.bsky.social",
"did:plc:user2",
"thanks for the info!",
false,
);
let msg = ch.parse_notification(&notif).unwrap();
assert_eq!(msg.sender, "user2.bsky.social");
assert_eq!(msg.content, "thanks for the info!");
}
#[test]
fn skip_read_notifications() {
let ch = make_channel();
let notif = make_notification(
"mention",
"user1.bsky.social",
"did:plc:user1",
"old message",
true,
);
assert!(ch.parse_notification(&notif).is_none());
}
#[test]
fn skip_own_notifications() {
let ch = make_channel();
let notif = make_notification(
"mention",
"testbot.bsky.social",
"did:plc:test123", // same as seeded DID
"self message",
false,
);
assert!(ch.parse_notification(&notif).is_none());
}
#[test]
fn skip_like_notifications() {
let ch = make_channel();
let notif = make_notification(
"like",
"user1.bsky.social",
"did:plc:user1",
"liked post",
false,
);
assert!(ch.parse_notification(&notif).is_none());
}
#[test]
fn skip_empty_text() {
let ch = make_channel();
let notif = make_notification("mention", "user1.bsky.social", "did:plc:user1", "", false);
assert!(ch.parse_notification(&notif).is_none());
}
#[test]
fn reply_target_encoding() {
let ch = make_channel();
let notif = make_notification(
"mention",
"user1.bsky.social",
"did:plc:user1",
"hello",
false,
);
let msg = ch.parse_notification(&notif).unwrap();
// reply_target should contain URI|CID
assert!(msg.reply_target.contains('|'));
let parts: Vec<&str> = msg.reply_target.splitn(2, '|').collect();
assert_eq!(parts.len(), 2);
assert!(parts[0].starts_with("at://"));
}
#[test]
fn send_message_formatting() {
// Verify reply target parsing
let reply_target = "at://did:plc:user1/app.bsky.feed.post/abc|bafyreitest";
let parts: Vec<&str> = reply_target.splitn(2, '|').collect();
assert_eq!(parts.len(), 2);
assert_eq!(parts[0], "at://did:plc:user1/app.bsky.feed.post/abc");
assert_eq!(parts[1], "bafyreitest");
}
}
+41 -1
View File
@@ -711,8 +711,13 @@ impl Channel for DiscordChannel {
}
let content = d.get("content").and_then(|c| c.as_str()).unwrap_or("");
// DMs carry no guild_id in the Discord gateway payload. They are
// inherently private and implicitly addressed to the bot, so bypass
// the mention gate — requiring a @mention in a DM is never correct.
let is_dm = d.get("guild_id").is_none();
let effective_mention_only = self.mention_only && !is_dm;
let Some(clean_content) =
normalize_incoming_content(content, self.mention_only, &bot_user_id)
normalize_incoming_content(content, effective_mention_only, &bot_user_id)
else {
continue;
};
@@ -1027,6 +1032,41 @@ mod tests {
assert!(cleaned.is_none());
}
// mention_only DM-bypass tests
#[test]
fn mention_only_dm_bypasses_mention_gate() {
// DMs (no guild_id) must pass through even when mention_only is true
// and the message contains no @mention. Mirrors the listen call-site logic.
let mention_only = true;
let is_dm = true;
let effective = mention_only && !is_dm;
let cleaned = normalize_incoming_content("hello without mention", effective, "12345");
assert_eq!(cleaned.as_deref(), Some("hello without mention"));
}
#[test]
fn mention_only_guild_message_without_mention_is_rejected() {
// Guild messages (has guild_id, so is_dm = false) must still be rejected
// when mention_only is true and the message contains no @mention.
let mention_only = true;
let is_dm = false;
let effective = mention_only && !is_dm;
let cleaned = normalize_incoming_content("hello without mention", effective, "12345");
assert!(cleaned.is_none());
}
#[test]
fn mention_only_guild_message_with_mention_passes_and_strips() {
// Guild messages that do carry a @mention pass through and have the
// mention tag stripped, consistent with pre-existing behaviour.
let mention_only = true;
let is_dm = false;
let effective = mention_only && !is_dm;
let cleaned = normalize_incoming_content("<@12345> run status", effective, "12345");
assert_eq!(cleaned.as_deref(), Some("run status"));
}
// Message splitting tests
#[test]
+2 -2
View File
@@ -746,7 +746,7 @@ impl Channel for MatrixChannel {
MessageType::Notice(content) => (content.body.clone(), None),
MessageType::Image(content) => {
let dl = media_info(&content.source, &content.body);
(format!("[image: {}]", content.body), dl)
(format!("[IMAGE:{}]", content.body), dl)
}
MessageType::File(content) => {
let dl = media_info(&content.source, &content.body);
@@ -888,7 +888,7 @@ impl Channel for MatrixChannel {
sender: sender.clone(),
reply_target: format!("{}||{}", sender, room.room_id()),
content: body,
channel: format!("matrix:{}", room.room_id()),
channel: "matrix".to_string(),
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
+326
View File
@@ -0,0 +1,326 @@
use super::traits::{Channel, ChannelMessage, SendMessage};
use async_trait::async_trait;
use serde_json::json;
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::RwLock;
use uuid::Uuid;
/// Deduplication set capacity — evict half of entries when full.
const DEDUP_CAPACITY: usize = 10_000;
/// Mochat customer service channel.
///
/// Integrates with the Mochat open-source customer service platform API
/// for receiving and sending messages through its HTTP endpoints.
pub struct MochatChannel {
api_url: String,
api_token: String,
allowed_users: Vec<String>,
poll_interval_secs: u64,
/// Message deduplication set.
dedup: Arc<RwLock<HashSet<String>>>,
}
impl MochatChannel {
pub fn new(
api_url: String,
api_token: String,
allowed_users: Vec<String>,
poll_interval_secs: u64,
) -> Self {
Self {
api_url: api_url.trim_end_matches('/').to_string(),
api_token,
allowed_users,
poll_interval_secs,
dedup: Arc::new(RwLock::new(HashSet::new())),
}
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.mochat")
}
fn is_user_allowed(&self, user_id: &str) -> bool {
self.allowed_users.iter().any(|u| u == "*" || u == user_id)
}
/// Check and insert message ID for deduplication.
async fn is_duplicate(&self, msg_id: &str) -> bool {
if msg_id.is_empty() {
return false;
}
let mut dedup = self.dedup.write().await;
if dedup.contains(msg_id) {
return true;
}
if dedup.len() >= DEDUP_CAPACITY {
let to_remove: Vec<String> = dedup.iter().take(DEDUP_CAPACITY / 2).cloned().collect();
for key in to_remove {
dedup.remove(&key);
}
}
dedup.insert(msg_id.to_string());
false
}
}
#[async_trait]
impl Channel for MochatChannel {
fn name(&self) -> &str {
"mochat"
}
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
let body = json!({
"toUserId": message.recipient,
"msgType": "text",
"content": {
"text": message.content,
}
});
let resp = self
.http_client()
.post(format!("{}/api/message/send", self.api_url))
.header("Authorization", format!("Bearer {}", self.api_token))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let err = resp.text().await.unwrap_or_default();
anyhow::bail!("Mochat send message failed ({status}): {err}");
}
let result: serde_json::Value = resp.json().await?;
let code = result.get("code").and_then(|v| v.as_i64()).unwrap_or(-1);
if code != 0 && code != 200 {
let msg = result
.get("msg")
.or_else(|| result.get("message"))
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
anyhow::bail!("Mochat API error (code={code}): {msg}");
}
Ok(())
}
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
tracing::info!("Mochat: starting message poller");
let poll_interval = std::time::Duration::from_secs(self.poll_interval_secs);
let mut last_message_id: Option<String> = None;
loop {
let mut url = format!("{}/api/message/receive", self.api_url);
if let Some(ref id) = last_message_id {
use std::fmt::Write;
let _ = write!(url, "?since_id={id}");
}
match self
.http_client()
.get(&url)
.header("Authorization", format!("Bearer {}", self.api_token))
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
let data: serde_json::Value = match resp.json().await {
Ok(d) => d,
Err(e) => {
tracing::warn!("Mochat: failed to parse response: {e}");
tokio::time::sleep(poll_interval).await;
continue;
}
};
let messages = data
.get("data")
.or_else(|| data.get("messages"))
.and_then(|d| d.as_array());
if let Some(messages) = messages {
for msg in messages {
let msg_id = msg
.get("messageId")
.or_else(|| msg.get("id"))
.and_then(|i| i.as_str())
.unwrap_or("");
if self.is_duplicate(msg_id).await {
continue;
}
let sender = msg
.get("fromUserId")
.or_else(|| msg.get("sender"))
.and_then(|s| s.as_str())
.unwrap_or("unknown");
if !self.is_user_allowed(sender) {
tracing::debug!(
"Mochat: ignoring message from unauthorized user: {sender}"
);
continue;
}
let content = msg
.get("content")
.and_then(|c| {
c.get("text")
.and_then(|t| t.as_str())
.or_else(|| c.as_str())
})
.unwrap_or("")
.trim();
if content.is_empty() {
continue;
}
let channel_msg = ChannelMessage {
id: Uuid::new_v4().to_string(),
sender: sender.to_string(),
reply_target: sender.to_string(),
content: content.to_string(),
channel: "mochat".to_string(),
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
thread_ts: None,
};
if tx.send(channel_msg).await.is_err() {
tracing::warn!("Mochat: message channel closed");
return Ok(());
}
if !msg_id.is_empty() {
last_message_id = Some(msg_id.to_string());
}
}
}
}
Ok(resp) => {
let status = resp.status();
let err = resp.text().await.unwrap_or_default();
tracing::warn!("Mochat: poll request failed ({status}): {err}");
}
Err(e) => {
tracing::warn!("Mochat: poll request error: {e}");
}
}
tokio::time::sleep(poll_interval).await;
}
}
async fn health_check(&self) -> bool {
let resp = self
.http_client()
.get(format!("{}/api/health", self.api_url))
.header("Authorization", format!("Bearer {}", self.api_token))
.send()
.await;
match resp {
Ok(r) => r.status().is_success(),
Err(_) => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_name() {
let ch = MochatChannel::new("https://mochat.example.com".into(), "tok".into(), vec![], 5);
assert_eq!(ch.name(), "mochat");
}
#[test]
fn test_api_url_trailing_slash_stripped() {
let ch = MochatChannel::new(
"https://mochat.example.com/".into(),
"tok".into(),
vec![],
5,
);
assert_eq!(ch.api_url, "https://mochat.example.com");
}
#[test]
fn test_user_allowed_wildcard() {
let ch = MochatChannel::new("https://m.test".into(), "tok".into(), vec!["*".into()], 5);
assert!(ch.is_user_allowed("anyone"));
}
#[test]
fn test_user_allowed_specific() {
let ch = MochatChannel::new(
"https://m.test".into(),
"tok".into(),
vec!["user123".into()],
5,
);
assert!(ch.is_user_allowed("user123"));
assert!(!ch.is_user_allowed("other"));
}
#[test]
fn test_user_denied_empty() {
let ch = MochatChannel::new("https://m.test".into(), "tok".into(), vec![], 5);
assert!(!ch.is_user_allowed("anyone"));
}
#[tokio::test]
async fn test_dedup() {
let ch = MochatChannel::new("https://m.test".into(), "tok".into(), vec![], 5);
assert!(!ch.is_duplicate("msg1").await);
assert!(ch.is_duplicate("msg1").await);
assert!(!ch.is_duplicate("msg2").await);
}
#[tokio::test]
async fn test_dedup_empty_id() {
let ch = MochatChannel::new("https://m.test".into(), "tok".into(), vec![], 5);
assert!(!ch.is_duplicate("").await);
assert!(!ch.is_duplicate("").await);
}
#[test]
fn test_config_serde() {
let toml_str = r#"
api_url = "https://mochat.example.com"
api_token = "secret"
allowed_users = ["user1"]
"#;
let config: crate::config::schema::MochatConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.api_url, "https://mochat.example.com");
assert_eq!(config.api_token, "secret");
assert_eq!(config.allowed_users, vec!["user1"]);
}
#[test]
fn test_config_serde_defaults() {
let toml_str = r#"
api_url = "https://mochat.example.com"
api_token = "secret"
"#;
let config: crate::config::schema::MochatConfig = toml::from_str(toml_str).unwrap();
assert!(config.allowed_users.is_empty());
assert_eq!(config.poll_interval_secs, 5);
}
}
+1035 -20
View File
File diff suppressed because it is too large Load Diff
+211 -32
View File
@@ -62,24 +62,146 @@ impl NextcloudTalkChannel {
/// Parse a Nextcloud Talk webhook payload into channel messages.
///
/// Relevant payload fields:
/// - `type` (accepts `message` or `Create`)
/// - `object.token` (room token for reply routing)
/// - `message.actorType`, `message.actorId`, `message.message`, `message.timestamp`
/// Two payload formats are supported:
///
/// **Format A — legacy/custom** (`type: "message"`):
/// ```json
/// {
/// "type": "message",
/// "object": { "token": "<room>" },
/// "message": { "actorId": "...", "message": "...", ... }
/// }
/// ```
///
/// **Format B — Activity Streams 2.0** (`type: "Create"`):
/// This is the format actually sent by Nextcloud Talk bot webhooks.
/// ```json
/// {
/// "type": "Create",
/// "actor": { "type": "Person", "id": "users/alice", "name": "Alice" },
/// "object": { "type": "Note", "id": "177", "content": "{\"message\":\"hi\",\"parameters\":[]}", "mediaType": "text/markdown" },
/// "target": { "type": "Collection", "id": "<room_token>", "name": "Room Name" }
/// }
/// ```
pub fn parse_webhook_payload(&self, payload: &serde_json::Value) -> Vec<ChannelMessage> {
let messages = Vec::new();
let event_type = match payload.get("type").and_then(|v| v.as_str()) {
Some(t) => t,
None => return messages,
};
// Activity Streams 2.0 format sent by Nextcloud Talk bot webhooks.
if event_type.eq_ignore_ascii_case("create") {
return self.parse_as2_payload(payload);
}
// Legacy/custom format.
if !event_type.eq_ignore_ascii_case("message") {
tracing::debug!("Nextcloud Talk: skipping non-message event: {event_type}");
return messages;
}
self.parse_message_payload(payload)
}
/// Parse Activity Streams 2.0 `Create` payload (real Nextcloud Talk bot webhook format).
fn parse_as2_payload(&self, payload: &serde_json::Value) -> Vec<ChannelMessage> {
let mut messages = Vec::new();
if let Some(event_type) = payload.get("type").and_then(|v| v.as_str()) {
// Nextcloud Talk bot webhooks send "Create" for new chat messages,
// but some setups may use "message". Accept both.
let is_message_event = event_type.eq_ignore_ascii_case("message")
|| event_type.eq_ignore_ascii_case("create");
if !is_message_event {
tracing::debug!("Nextcloud Talk: skipping non-message event: {event_type}");
return messages;
}
let obj = match payload.get("object") {
Some(o) => o,
None => return messages,
};
// Only handle Note objects (= chat messages). Ignore reactions, etc.
let object_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
if !object_type.eq_ignore_ascii_case("note") {
tracing::debug!("Nextcloud Talk: skipping AS2 Create with object.type={object_type}");
return messages;
}
// Room token is in target.id.
let room_token = payload
.get("target")
.and_then(|t| t.get("id"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|t| !t.is_empty());
let Some(room_token) = room_token else {
tracing::warn!("Nextcloud Talk: missing target.id (room token) in AS2 payload");
return messages;
};
// Actor — skip bot-originated messages to prevent feedback loops.
let actor = payload.get("actor").cloned().unwrap_or_default();
let actor_type = actor.get("type").and_then(|v| v.as_str()).unwrap_or("");
if actor_type.eq_ignore_ascii_case("application") {
tracing::debug!("Nextcloud Talk: skipping bot-originated AS2 message");
return messages;
}
// actor.id is "users/<id>" — strip the prefix.
let actor_id = actor
.get("id")
.and_then(|v| v.as_str())
.map(|id| id.trim_start_matches("users/").trim())
.filter(|id| !id.is_empty());
let Some(actor_id) = actor_id else {
tracing::warn!("Nextcloud Talk: missing actor.id in AS2 payload");
return messages;
};
if !self.is_user_allowed(actor_id) {
tracing::warn!(
"Nextcloud Talk: ignoring message from unauthorized actor: {actor_id}. \
Add to channels.nextcloud_talk.allowed_users in config.toml, \
or run `zeroclaw onboard --channels-only` to configure interactively."
);
return messages;
}
// Message text is JSON-encoded inside object.content.
// e.g. content = "{\"message\":\"hello\",\"parameters\":[]}"
let content = obj
.get("content")
.and_then(|v| v.as_str())
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| {
v.get("message")
.and_then(|m| m.as_str())
.map(str::trim)
.map(str::to_string)
})
.filter(|s| !s.is_empty());
let Some(content) = content else {
tracing::debug!("Nextcloud Talk: empty or unparseable AS2 message content");
return messages;
};
let message_id =
Self::value_to_string(obj.get("id")).unwrap_or_else(|| Uuid::new_v4().to_string());
messages.push(ChannelMessage {
id: message_id,
reply_target: room_token.to_string(),
sender: actor_id.to_string(),
content,
channel: "nextcloud_talk".to_string(),
timestamp: Self::now_unix_secs(),
thread_ts: None,
});
messages
}
/// Parse legacy `type: "message"` payload format.
fn parse_message_payload(&self, payload: &serde_json::Value) -> Vec<ChannelMessage> {
let mut messages = Vec::new();
let Some(message_obj) = payload.get("message") else {
return messages;
};
@@ -343,33 +465,90 @@ mod tests {
}
#[test]
fn nextcloud_talk_parse_create_event_type() {
let channel = make_channel();
fn nextcloud_talk_parse_as2_create_payload() {
let channel = NextcloudTalkChannel::new(
"https://cloud.example.com".into(),
"app-token".into(),
vec!["*".into()],
);
// Real payload format sent by Nextcloud Talk bot webhooks.
let payload = serde_json::json!({
"type": "Create",
"object": {
"id": "42",
"token": "room-token-123",
"name": "Team Room",
"type": "room"
"actor": {
"type": "Person",
"id": "users/user_a",
"name": "User A",
"talkParticipantType": "1"
},
"message": {
"id": 88,
"token": "room-token-123",
"actorType": "users",
"actorId": "user_a",
"actorDisplayName": "User A",
"timestamp": 1_735_701_300,
"messageType": "comment",
"systemMessage": "",
"message": "Hello via Create event"
"object": {
"type": "Note",
"id": "177",
"name": "message",
"content": "{\"message\":\"hallo, bist du da?\",\"parameters\":[]}",
"mediaType": "text/markdown"
},
"target": {
"type": "Collection",
"id": "room-token-123",
"name": "HOME"
}
});
let messages = channel.parse_webhook_payload(&payload);
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].id, "88");
assert_eq!(messages[0].content, "Hello via Create event");
assert_eq!(messages[0].reply_target, "room-token-123");
assert_eq!(messages[0].sender, "user_a");
assert_eq!(messages[0].content, "hallo, bist du da?");
assert_eq!(messages[0].channel, "nextcloud_talk");
}
#[test]
fn nextcloud_talk_parse_as2_skips_bot_originated() {
let channel = NextcloudTalkChannel::new(
"https://cloud.example.com".into(),
"app-token".into(),
vec!["*".into()],
);
let payload = serde_json::json!({
"type": "Create",
"actor": {
"type": "Application",
"id": "bots/jarvis",
"name": "jarvis"
},
"object": {
"type": "Note",
"id": "178",
"content": "{\"message\":\"I am the bot\",\"parameters\":[]}",
"mediaType": "text/markdown"
},
"target": {
"type": "Collection",
"id": "room-token-123",
"name": "HOME"
}
});
let messages = channel.parse_webhook_payload(&payload);
assert!(messages.is_empty());
}
#[test]
fn nextcloud_talk_parse_as2_skips_non_note_objects() {
let channel = NextcloudTalkChannel::new(
"https://cloud.example.com".into(),
"app-token".into(),
vec!["*".into()],
);
let payload = serde_json::json!({
"type": "Create",
"actor": { "type": "Person", "id": "users/user_a" },
"object": { "type": "Reaction", "id": "5" },
"target": { "type": "Collection", "id": "room-token-123" }
});
let messages = channel.parse_webhook_payload(&payload);
assert!(messages.is_empty());
}
#[test]
+614
View File
@@ -0,0 +1,614 @@
use super::traits::{Channel, ChannelMessage, SendMessage};
use anyhow::{bail, Result};
use async_trait::async_trait;
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::RwLock;
const NOTION_API_BASE: &str = "https://api.notion.com/v1";
const NOTION_VERSION: &str = "2022-06-28";
const MAX_RESULT_LENGTH: usize = 2000;
const MAX_RETRIES: u32 = 3;
const RETRY_BASE_DELAY_MS: u64 = 2000;
/// Maximum number of characters to include from an error response body.
const MAX_ERROR_BODY_CHARS: usize = 500;
/// Find the largest byte index <= `max_bytes` that falls on a UTF-8 char boundary.
fn floor_utf8_char_boundary(s: &str, max_bytes: usize) -> usize {
if max_bytes >= s.len() {
return s.len();
}
let mut idx = max_bytes;
while idx > 0 && !s.is_char_boundary(idx) {
idx -= 1;
}
idx
}
/// Notion channel — polls a Notion database for pending tasks and writes results back.
///
/// The channel connects to the Notion API, queries a database for rows with a "pending"
/// status, dispatches them as channel messages, and writes results back when processing
/// completes. It supports crash recovery by resetting stale "running" tasks on startup.
pub struct NotionChannel {
api_key: String,
database_id: String,
poll_interval_secs: u64,
status_property: String,
input_property: String,
result_property: String,
max_concurrent: usize,
status_type: Arc<RwLock<String>>,
inflight: Arc<RwLock<HashSet<String>>>,
http: reqwest::Client,
recover_stale: bool,
}
impl NotionChannel {
/// Create a new Notion channel with the given configuration.
pub fn new(
api_key: String,
database_id: String,
poll_interval_secs: u64,
status_property: String,
input_property: String,
result_property: String,
max_concurrent: usize,
recover_stale: bool,
) -> Self {
Self {
api_key,
database_id,
poll_interval_secs,
status_property,
input_property,
result_property,
max_concurrent,
status_type: Arc::new(RwLock::new("select".to_string())),
inflight: Arc::new(RwLock::new(HashSet::new())),
http: reqwest::Client::new(),
recover_stale,
}
}
/// Build the standard Notion API headers (Authorization, version, content-type).
fn headers(&self) -> Result<reqwest::header::HeaderMap> {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
"Authorization",
format!("Bearer {}", self.api_key)
.parse()
.map_err(|e| anyhow::anyhow!("Invalid Notion API key header value: {e}"))?,
);
headers.insert("Notion-Version", NOTION_VERSION.parse().unwrap());
headers.insert("Content-Type", "application/json".parse().unwrap());
Ok(headers)
}
/// Make a Notion API call with automatic retry on rate-limit (429) and server errors (5xx).
async fn api_call(
&self,
method: reqwest::Method,
url: &str,
body: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let mut last_err = None;
for attempt in 0..MAX_RETRIES {
let mut req = self
.http
.request(method.clone(), url)
.headers(self.headers()?);
if let Some(ref b) = body {
req = req.json(b);
}
match req.send().await {
Ok(resp) => {
let status = resp.status();
if status.is_success() {
return resp
.json()
.await
.map_err(|e| anyhow::anyhow!("Failed to parse response: {e}"));
}
let status_code = status.as_u16();
// Only retry on 429 (rate limit) or 5xx (server errors)
if status_code != 429 && (400..500).contains(&status_code) {
let body_text = resp.text().await.unwrap_or_default();
let truncated =
crate::util::truncate_with_ellipsis(&body_text, MAX_ERROR_BODY_CHARS);
bail!("Notion API error {status_code}: {truncated}");
}
last_err = Some(anyhow::anyhow!("Notion API error: {status_code}"));
}
Err(e) => {
last_err = Some(anyhow::anyhow!("HTTP request failed: {e}"));
}
}
let delay = RETRY_BASE_DELAY_MS * 2u64.pow(attempt);
tracing::warn!(
"Notion API call failed (attempt {}/{}), retrying in {}ms",
attempt + 1,
MAX_RETRIES,
delay
);
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("Notion API call failed after retries")))
}
/// Query the database schema and detect whether Status uses "select" or "status" type.
async fn detect_status_type(&self) -> Result<String> {
let url = format!("{NOTION_API_BASE}/databases/{}", self.database_id);
let resp = self.api_call(reqwest::Method::GET, &url, None).await?;
let status_type = resp
.get("properties")
.and_then(|p| p.get(&self.status_property))
.and_then(|s| s.get("type"))
.and_then(|t| t.as_str())
.unwrap_or("select")
.to_string();
Ok(status_type)
}
/// Query for rows where Status = "pending".
async fn query_pending(&self) -> Result<Vec<serde_json::Value>> {
let url = format!("{NOTION_API_BASE}/databases/{}/query", self.database_id);
let status_type = self.status_type.read().await.clone();
let filter = build_status_filter(&self.status_property, &status_type, "pending");
let resp = self
.api_call(
reqwest::Method::POST,
&url,
Some(serde_json::json!({ "filter": filter })),
)
.await?;
Ok(resp
.get("results")
.and_then(|r| r.as_array())
.cloned()
.unwrap_or_default())
}
/// Atomically claim a task. Returns true if this caller got it.
async fn claim_task(&self, page_id: &str) -> bool {
let mut inflight = self.inflight.write().await;
if inflight.contains(page_id) {
return false;
}
if inflight.len() >= self.max_concurrent {
return false;
}
inflight.insert(page_id.to_string());
true
}
/// Release a task from the inflight set.
async fn release_task(&self, page_id: &str) {
let mut inflight = self.inflight.write().await;
inflight.remove(page_id);
}
/// Update a row's status.
async fn set_status(&self, page_id: &str, status_value: &str) -> Result<()> {
let url = format!("{NOTION_API_BASE}/pages/{page_id}");
let status_type = self.status_type.read().await.clone();
let payload = serde_json::json!({
"properties": {
&self.status_property: build_status_payload(&status_type, status_value),
}
});
self.api_call(reqwest::Method::PATCH, &url, Some(payload))
.await?;
Ok(())
}
/// Write result text to the Result column.
async fn set_result(&self, page_id: &str, result_text: &str) -> Result<()> {
let url = format!("{NOTION_API_BASE}/pages/{page_id}");
let payload = serde_json::json!({
"properties": {
&self.result_property: build_rich_text_payload(result_text),
}
});
self.api_call(reqwest::Method::PATCH, &url, Some(payload))
.await?;
Ok(())
}
/// On startup, reset "running" tasks back to "pending" for crash recovery.
async fn recover_stale(&self) -> Result<()> {
let url = format!("{NOTION_API_BASE}/databases/{}/query", self.database_id);
let status_type = self.status_type.read().await.clone();
let filter = build_status_filter(&self.status_property, &status_type, "running");
let resp = self
.api_call(
reqwest::Method::POST,
&url,
Some(serde_json::json!({ "filter": filter })),
)
.await?;
let stale = resp
.get("results")
.and_then(|r| r.as_array())
.cloned()
.unwrap_or_default();
if stale.is_empty() {
return Ok(());
}
tracing::warn!(
"Found {} stale task(s) in 'running' state, resetting to 'pending'",
stale.len()
);
for task in &stale {
if let Some(page_id) = task.get("id").and_then(|v| v.as_str()) {
let page_url = format!("{NOTION_API_BASE}/pages/{page_id}");
let payload = serde_json::json!({
"properties": {
&self.status_property: build_status_payload(&status_type, "pending"),
&self.result_property: build_rich_text_payload(
"Reset: poller restarted while task was running"
),
}
});
let short_id_end = floor_utf8_char_boundary(page_id, 8);
let short_id = &page_id[..short_id_end];
if let Err(e) = self
.api_call(reqwest::Method::PATCH, &page_url, Some(payload))
.await
{
tracing::error!("Could not reset stale task {short_id}: {e}");
} else {
tracing::info!("Reset stale task {short_id} to pending");
}
}
}
Ok(())
}
}
#[async_trait]
impl Channel for NotionChannel {
fn name(&self) -> &str {
"notion"
}
async fn send(&self, message: &SendMessage) -> Result<()> {
// recipient is the page_id for Notion
let page_id = &message.recipient;
let status_type = self.status_type.read().await.clone();
let url = format!("{NOTION_API_BASE}/pages/{page_id}");
let payload = serde_json::json!({
"properties": {
&self.status_property: build_status_payload(&status_type, "done"),
&self.result_property: build_rich_text_payload(&message.content),
}
});
self.api_call(reqwest::Method::PATCH, &url, Some(payload))
.await?;
self.release_task(page_id).await;
Ok(())
}
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> Result<()> {
// Detect status property type
match self.detect_status_type().await {
Ok(st) => {
tracing::info!("Notion status property type: {st}");
*self.status_type.write().await = st;
}
Err(e) => {
bail!("Failed to detect Notion database schema: {e}");
}
}
// Crash recovery
if self.recover_stale {
if let Err(e) = self.recover_stale().await {
tracing::error!("Notion stale task recovery failed: {e}");
}
}
// Polling loop
loop {
match self.query_pending().await {
Ok(tasks) => {
if !tasks.is_empty() {
tracing::info!("Notion: found {} pending task(s)", tasks.len());
}
for task in tasks {
let page_id = match task.get("id").and_then(|v| v.as_str()) {
Some(id) => id.to_string(),
None => continue,
};
let input_text = extract_text_from_property(
task.get("properties")
.and_then(|p| p.get(&self.input_property)),
);
if input_text.trim().is_empty() {
let short_end = floor_utf8_char_boundary(&page_id, 8);
tracing::warn!(
"Notion: empty input for task {}, skipping",
&page_id[..short_end]
);
continue;
}
if !self.claim_task(&page_id).await {
continue;
}
// Set status to running
if let Err(e) = self.set_status(&page_id, "running").await {
tracing::error!("Notion: failed to set running status: {e}");
self.release_task(&page_id).await;
continue;
}
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if tx
.send(ChannelMessage {
id: page_id.clone(),
sender: "notion".into(),
reply_target: page_id,
content: input_text,
channel: "notion".into(),
timestamp,
thread_ts: None,
})
.await
.is_err()
{
tracing::info!("Notion channel shutting down");
return Ok(());
}
}
}
Err(e) => {
tracing::error!("Notion poll error: {e}");
}
}
tokio::time::sleep(std::time::Duration::from_secs(self.poll_interval_secs)).await;
}
}
async fn health_check(&self) -> bool {
let url = format!("{NOTION_API_BASE}/databases/{}", self.database_id);
self.api_call(reqwest::Method::GET, &url, None)
.await
.is_ok()
}
}
// ── Helper functions ──────────────────────────────────────────────
/// Build a Notion API filter object for the given status property.
fn build_status_filter(property: &str, status_type: &str, value: &str) -> serde_json::Value {
if status_type == "status" {
serde_json::json!({
"property": property,
"status": { "equals": value }
})
} else {
serde_json::json!({
"property": property,
"select": { "equals": value }
})
}
}
/// Build a Notion API property-update payload for a status field.
fn build_status_payload(status_type: &str, value: &str) -> serde_json::Value {
if status_type == "status" {
serde_json::json!({ "status": { "name": value } })
} else {
serde_json::json!({ "select": { "name": value } })
}
}
/// Build a Notion API rich-text property payload, truncating if necessary.
fn build_rich_text_payload(value: &str) -> serde_json::Value {
let truncated = truncate_result(value);
serde_json::json!({
"rich_text": [{
"text": { "content": truncated }
}]
})
}
/// Truncate result text to fit within the Notion rich-text content limit.
fn truncate_result(value: &str) -> String {
if value.len() <= MAX_RESULT_LENGTH {
return value.to_string();
}
let cut = MAX_RESULT_LENGTH.saturating_sub(30);
// Ensure we cut on a char boundary
let end = floor_utf8_char_boundary(value, cut);
format!("{}\n\n... [output truncated]", &value[..end])
}
/// Extract plain text from a Notion property (title or rich_text type).
fn extract_text_from_property(prop: Option<&serde_json::Value>) -> String {
let Some(prop) = prop else {
return String::new();
};
let ptype = prop.get("type").and_then(|t| t.as_str()).unwrap_or("");
let array_key = match ptype {
"title" => "title",
"rich_text" => "rich_text",
_ => return String::new(),
};
prop.get(array_key)
.and_then(|arr| arr.as_array())
.map(|items| {
items
.iter()
.filter_map(|item| item.get("plain_text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn claim_task_deduplication() {
let channel = NotionChannel::new(
"test-key".into(),
"test-db".into(),
5,
"Status".into(),
"Input".into(),
"Result".into(),
4,
false,
);
assert!(channel.claim_task("page-1").await);
// Second claim for same page should fail
assert!(!channel.claim_task("page-1").await);
// Different page should succeed
assert!(channel.claim_task("page-2").await);
// After release, can claim again
channel.release_task("page-1").await;
assert!(channel.claim_task("page-1").await);
}
#[test]
fn result_truncation_within_limit() {
let short = "hello world";
assert_eq!(truncate_result(short), short);
}
#[test]
fn result_truncation_over_limit() {
let long = "a".repeat(MAX_RESULT_LENGTH + 100);
let truncated = truncate_result(&long);
assert!(truncated.len() <= MAX_RESULT_LENGTH);
assert!(truncated.ends_with("... [output truncated]"));
}
#[test]
fn result_truncation_multibyte_safe() {
// Build a string that would cut in the middle of a multibyte char
let mut s = String::new();
for _ in 0..700 {
s.push('\u{6E2C}'); // 3-byte UTF-8 char
}
let truncated = truncate_result(&s);
// Should not panic and should be valid UTF-8
assert!(truncated.len() <= MAX_RESULT_LENGTH);
assert!(truncated.ends_with("... [output truncated]"));
}
#[test]
fn status_payload_select_type() {
let payload = build_status_payload("select", "pending");
assert_eq!(
payload,
serde_json::json!({ "select": { "name": "pending" } })
);
}
#[test]
fn status_payload_status_type() {
let payload = build_status_payload("status", "done");
assert_eq!(payload, serde_json::json!({ "status": { "name": "done" } }));
}
#[test]
fn rich_text_payload_construction() {
let payload = build_rich_text_payload("test output");
let text = payload["rich_text"][0]["text"]["content"].as_str().unwrap();
assert_eq!(text, "test output");
}
#[test]
fn status_filter_select_type() {
let filter = build_status_filter("Status", "select", "pending");
assert_eq!(
filter,
serde_json::json!({
"property": "Status",
"select": { "equals": "pending" }
})
);
}
#[test]
fn status_filter_status_type() {
let filter = build_status_filter("Status", "status", "running");
assert_eq!(
filter,
serde_json::json!({
"property": "Status",
"status": { "equals": "running" }
})
);
}
#[test]
fn extract_text_from_title_property() {
let prop = serde_json::json!({
"type": "title",
"title": [
{ "plain_text": "Hello " },
{ "plain_text": "World" }
]
});
assert_eq!(extract_text_from_property(Some(&prop)), "Hello World");
}
#[test]
fn extract_text_from_rich_text_property() {
let prop = serde_json::json!({
"type": "rich_text",
"rich_text": [{ "plain_text": "task content" }]
});
assert_eq!(extract_text_from_property(Some(&prop)), "task content");
}
#[test]
fn extract_text_from_none() {
assert_eq!(extract_text_from_property(None), "");
}
#[test]
fn extract_text_from_unknown_type() {
let prop = serde_json::json!({ "type": "number", "number": 42 });
assert_eq!(extract_text_from_property(Some(&prop)), "");
}
#[tokio::test]
async fn claim_task_respects_max_concurrent() {
let channel = NotionChannel::new(
"test-key".into(),
"test-db".into(),
5,
"Status".into(),
"Input".into(),
"Result".into(),
2, // max_concurrent = 2
false,
);
assert!(channel.claim_task("page-1").await);
assert!(channel.claim_task("page-2").await);
// Third claim should be rejected (at capacity)
assert!(!channel.claim_task("page-3").await);
// After releasing one, can claim again
channel.release_task("page-1").await;
assert!(channel.claim_task("page-3").await);
}
}
+39 -4
View File
@@ -257,8 +257,10 @@ impl Channel for QQChannel {
(
format!("{QQ_API_BASE}/v2/groups/{group_id}/messages"),
json!({
"content": &message.content,
"msg_type": 0,
"markdown": {
"content": &message.content,
},
"msg_type": 2,
}),
)
} else {
@@ -273,8 +275,10 @@ impl Channel for QQChannel {
(
format!("{QQ_API_BASE}/v2/users/{user_id}/messages"),
json!({
"content": &message.content,
"msg_type": 0,
"markdown": {
"content": &message.content,
},
"msg_type": 2,
}),
)
};
@@ -667,4 +671,35 @@ allowed_users = ["user1"]
assert_eq!(compose_message_content(&payload), None);
}
#[test]
fn test_send_body_uses_markdown_msg_type() {
// Verify the expected JSON shape for both group and user send paths.
// msg_type 2 with a nested markdown object is required by the QQ API
// for markdown rendering; msg_type 0 (plain text) causes markdown
// syntax to appear literally in the client.
let content = "**bold** and `code`";
let group_body = json!({
"markdown": { "content": content },
"msg_type": 2,
});
assert_eq!(group_body["msg_type"], 2);
assert_eq!(group_body["markdown"]["content"], content);
assert!(
group_body.get("content").is_none(),
"top-level 'content' must not be present"
);
let user_body = json!({
"markdown": { "content": content },
"msg_type": 2,
});
assert_eq!(user_body["msg_type"], 2);
assert_eq!(user_body["markdown"]["content"], content);
assert!(
user_body.get("content").is_none(),
"top-level 'content' must not be present"
);
}
}
+504
View File
@@ -0,0 +1,504 @@
use super::traits::{Channel, ChannelMessage, SendMessage};
use anyhow::{bail, Result};
use async_trait::async_trait;
use parking_lot::Mutex;
use serde::Deserialize;
use std::time::{Duration, Instant};
/// Reddit channel — polls for mentions, DMs, and comment replies via Reddit OAuth2 API.
pub struct RedditChannel {
client_id: String,
client_secret: String,
refresh_token: String,
username: String,
subreddit: Option<String>,
auth: Mutex<RedditAuth>,
}
struct RedditAuth {
access_token: String,
expires_at: Instant,
}
#[derive(Deserialize)]
struct RedditTokenResponse {
access_token: String,
expires_in: u64,
}
#[derive(Deserialize)]
struct RedditListing {
data: RedditListingData,
}
#[derive(Deserialize)]
struct RedditListingData {
children: Vec<RedditChild>,
}
#[derive(Deserialize)]
struct RedditChild {
data: RedditItemData,
}
#[allow(dead_code)]
#[derive(Deserialize)]
struct RedditItemData {
name: Option<String>,
author: Option<String>,
body: Option<String>,
subject: Option<String>,
parent_id: Option<String>,
link_id: Option<String>,
subreddit: Option<String>,
created_utc: Option<f64>,
new: Option<bool>,
#[serde(rename = "type")]
message_type: Option<String>,
context: Option<String>,
}
const REDDIT_API_BASE: &str = "https://oauth.reddit.com";
const REDDIT_TOKEN_URL: &str = "https://www.reddit.com/api/v1/access_token";
const USER_AGENT: &str = "zeroclaw:channel:v0.1.0 (by /u/zeroclaw-bot)";
/// Reddit enforces 60 requests per minute.
const POLL_INTERVAL: Duration = Duration::from_secs(5);
impl RedditChannel {
pub fn new(
client_id: String,
client_secret: String,
refresh_token: String,
username: String,
subreddit: Option<String>,
) -> Self {
Self {
client_id,
client_secret,
refresh_token,
username,
subreddit,
auth: Mutex::new(RedditAuth {
access_token: String::new(),
expires_at: Instant::now(),
}),
}
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.reddit")
}
/// Refresh the OAuth2 access token using the refresh token.
async fn refresh_access_token(&self) -> Result<()> {
let client = self.http_client();
let resp = client
.post(REDDIT_TOKEN_URL)
.basic_auth(&self.client_id, Some(&self.client_secret))
.header("User-Agent", USER_AGENT)
.form(&[
("grant_type", "refresh_token"),
("refresh_token", &self.refresh_token),
])
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let body = resp
.text()
.await
.unwrap_or_else(|e| format!("<failed to read response: {e}>"));
bail!("Reddit token refresh failed ({status}): {body}");
}
let token_resp: RedditTokenResponse = resp.json().await?;
let mut auth = self.auth.lock();
auth.access_token = token_resp.access_token;
auth.expires_at =
Instant::now() + Duration::from_secs(token_resp.expires_in.saturating_sub(60));
Ok(())
}
/// Get a valid access token, refreshing if expired.
async fn get_access_token(&self) -> Result<String> {
{
let auth = self.auth.lock();
if !auth.access_token.is_empty() && Instant::now() < auth.expires_at {
return Ok(auth.access_token.clone());
}
}
self.refresh_access_token().await?;
let auth = self.auth.lock();
Ok(auth.access_token.clone())
}
/// Fetch unread inbox items (mentions, DMs, comment replies).
async fn fetch_inbox(&self) -> Result<Vec<RedditChild>> {
let token = self.get_access_token().await?;
let client = self.http_client();
let resp = client
.get(format!("{REDDIT_API_BASE}/message/unread"))
.bearer_auth(&token)
.header("User-Agent", USER_AGENT)
.query(&[("limit", "25")])
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let body = resp
.text()
.await
.unwrap_or_else(|e| format!("<failed to read response: {e}>"));
tracing::warn!("Reddit inbox fetch failed ({status}): {body}");
return Ok(Vec::new());
}
let listing: RedditListing = resp.json().await?;
Ok(listing.data.children)
}
/// Mark inbox items as read.
async fn mark_read(&self, fullnames: &[String]) -> Result<()> {
if fullnames.is_empty() {
return Ok(());
}
let token = self.get_access_token().await?;
let client = self.http_client();
let ids = fullnames.join(",");
let resp = client
.post(format!("{REDDIT_API_BASE}/api/read_message"))
.bearer_auth(&token)
.header("User-Agent", USER_AGENT)
.form(&[("id", ids.as_str())])
.send()
.await?;
if !resp.status().is_success() {
tracing::warn!("Reddit mark_read failed: {}", resp.status());
}
Ok(())
}
/// Parse a Reddit inbox item into a ChannelMessage.
fn parse_item(&self, item: &RedditItemData) -> Option<ChannelMessage> {
let author = item.author.as_deref().unwrap_or("");
let body = item.body.as_deref().unwrap_or("");
let name = item.name.as_deref().unwrap_or("");
// Skip messages from ourselves
if author.eq_ignore_ascii_case(&self.username) || author.is_empty() || body.is_empty() {
return None;
}
// If a subreddit filter is set, skip items from other subreddits
if let Some(ref sub) = self.subreddit {
if let Some(ref item_sub) = item.subreddit {
if !item_sub.eq_ignore_ascii_case(sub) {
return None;
}
}
}
// Determine reply target: for comment replies use the parent thing name,
// for DMs reply to the author.
let reply_target =
if item.message_type.as_deref() == Some("comment_reply") || item.parent_id.is_some() {
// For comment replies, the recipient is the parent fullname
item.parent_id.clone().unwrap_or_else(|| name.to_string())
} else {
// For DMs, reply to the author
author.to_string()
};
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let timestamp = item.created_utc.unwrap_or(0.0) as u64;
Some(ChannelMessage {
id: format!("reddit_{name}"),
sender: author.to_string(),
reply_target,
content: body.to_string(),
channel: "reddit".to_string(),
timestamp,
thread_ts: item.parent_id.clone(),
})
}
}
#[async_trait]
impl Channel for RedditChannel {
fn name(&self) -> &str {
"reddit"
}
async fn send(&self, message: &SendMessage) -> Result<()> {
let token = self.get_access_token().await?;
let client = self.http_client();
// If recipient looks like a Reddit fullname (t1_, t3_, t4_), it's a comment reply.
// Otherwise treat it as a DM to a username.
if message.recipient.starts_with("t1_")
|| message.recipient.starts_with("t3_")
|| message.recipient.starts_with("t4_")
{
// Comment reply
let resp = client
.post(format!("{REDDIT_API_BASE}/api/comment"))
.bearer_auth(&token)
.header("User-Agent", USER_AGENT)
.form(&[
("thing_id", message.recipient.as_str()),
("text", &message.content),
])
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let body = resp
.text()
.await
.unwrap_or_else(|e| format!("<failed to read response: {e}>"));
bail!("Reddit comment reply failed ({status}): {body}");
}
} else {
// Direct message
let subject = message
.subject
.as_deref()
.unwrap_or("Message from ZeroClaw");
let resp = client
.post(format!("{REDDIT_API_BASE}/api/compose"))
.bearer_auth(&token)
.header("User-Agent", USER_AGENT)
.form(&[
("to", message.recipient.as_str()),
("subject", subject),
("text", &message.content),
])
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let body = resp
.text()
.await
.unwrap_or_else(|e| format!("<failed to read response: {e}>"));
bail!("Reddit DM failed ({status}): {body}");
}
}
Ok(())
}
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> Result<()> {
// Initial auth
self.refresh_access_token().await?;
tracing::info!(
"Reddit channel listening as u/{} {}...",
self.username,
self.subreddit
.as_ref()
.map(|s| format!("in r/{s}"))
.unwrap_or_default()
);
loop {
tokio::time::sleep(POLL_INTERVAL).await;
let items = match self.fetch_inbox().await {
Ok(items) => items,
Err(e) => {
tracing::warn!("Reddit poll error: {e}");
continue;
}
};
let mut read_ids = Vec::new();
for child in &items {
if let Some(ref name) = child.data.name {
read_ids.push(name.clone());
}
if let Some(msg) = self.parse_item(&child.data) {
if tx.send(msg).await.is_err() {
return Ok(());
}
}
}
if let Err(e) = self.mark_read(&read_ids).await {
tracing::warn!("Reddit mark_read error: {e}");
}
}
}
async fn health_check(&self) -> bool {
self.get_access_token().await.is_ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_channel() -> RedditChannel {
RedditChannel::new(
"client_id".into(),
"client_secret".into(),
"refresh_token".into(),
"testbot".into(),
None,
)
}
fn make_channel_with_sub(sub: &str) -> RedditChannel {
RedditChannel::new(
"client_id".into(),
"client_secret".into(),
"refresh_token".into(),
"testbot".into(),
Some(sub.into()),
)
}
#[test]
fn parse_comment_reply() {
let ch = make_channel();
let item = RedditItemData {
name: Some("t1_abc123".into()),
author: Some("user1".into()),
body: Some("hello bot".into()),
subject: None,
parent_id: Some("t1_parent1".into()),
link_id: Some("t3_post1".into()),
subreddit: Some("rust".into()),
created_utc: Some(1_700_000_000.0),
new: Some(true),
message_type: Some("comment_reply".into()),
context: None,
};
let msg = ch.parse_item(&item).unwrap();
assert_eq!(msg.sender, "user1");
assert_eq!(msg.content, "hello bot");
assert_eq!(msg.reply_target, "t1_parent1");
assert_eq!(msg.channel, "reddit");
assert_eq!(msg.id, "reddit_t1_abc123");
}
#[test]
fn parse_dm() {
let ch = make_channel();
let item = RedditItemData {
name: Some("t4_dm456".into()),
author: Some("user2".into()),
body: Some("private message".into()),
subject: Some("Hello".into()),
parent_id: None,
link_id: None,
subreddit: None,
created_utc: Some(1_700_000_100.0),
new: Some(true),
message_type: None,
context: None,
};
let msg = ch.parse_item(&item).unwrap();
assert_eq!(msg.sender, "user2");
assert_eq!(msg.content, "private message");
assert_eq!(msg.reply_target, "user2"); // DM reply goes to author
}
#[test]
fn skip_self_messages() {
let ch = make_channel();
let item = RedditItemData {
name: Some("t1_self".into()),
author: Some("testbot".into()),
body: Some("my own message".into()),
subject: None,
parent_id: None,
link_id: None,
subreddit: None,
created_utc: Some(1_700_000_000.0),
new: Some(true),
message_type: None,
context: None,
};
assert!(ch.parse_item(&item).is_none());
}
#[test]
fn skip_empty_body() {
let ch = make_channel();
let item = RedditItemData {
name: Some("t1_empty".into()),
author: Some("user1".into()),
body: Some(String::new()),
subject: None,
parent_id: None,
link_id: None,
subreddit: None,
created_utc: Some(1_700_000_000.0),
new: Some(true),
message_type: None,
context: None,
};
assert!(ch.parse_item(&item).is_none());
}
#[test]
fn subreddit_filter() {
let ch = make_channel_with_sub("rust");
let item = RedditItemData {
name: Some("t1_other".into()),
author: Some("user1".into()),
body: Some("hello".into()),
subject: None,
parent_id: None,
link_id: None,
subreddit: Some("python".into()),
created_utc: Some(1_700_000_000.0),
new: Some(true),
message_type: None,
context: None,
};
assert!(ch.parse_item(&item).is_none());
let matching_item = RedditItemData {
name: Some("t1_match".into()),
author: Some("user1".into()),
body: Some("hello".into()),
subject: None,
parent_id: None,
link_id: None,
subreddit: Some("rust".into()),
created_utc: Some(1_700_000_000.0),
new: Some(true),
message_type: None,
context: None,
};
assert!(ch.parse_item(&matching_item).is_some());
}
#[test]
fn send_message_formatting() {
// Verify SendMessage can be constructed for both DM and comment reply
let dm = SendMessage::new("hello", "user1");
assert_eq!(dm.recipient, "user1");
assert_eq!(dm.content, "hello");
let reply = SendMessage::new("response", "t1_abc123");
assert!(reply.recipient.starts_with("t1_"));
}
}
+108
View File
@@ -0,0 +1,108 @@
//! Trait abstraction for session persistence backends.
//!
//! Backends store per-sender conversation histories. The trait is intentionally
//! minimal — load, append, remove_last, list — so that JSONL and SQLite (and
//! future backends) share a common interface.
use crate::providers::traits::ChatMessage;
use chrono::{DateTime, Utc};
/// Metadata about a persisted session.
#[derive(Debug, Clone)]
pub struct SessionMetadata {
/// Session key (e.g. `telegram_user123`).
pub key: String,
/// When the session was first created.
pub created_at: DateTime<Utc>,
/// When the last message was appended.
pub last_activity: DateTime<Utc>,
/// Total number of messages in the session.
pub message_count: usize,
}
/// Query parameters for listing sessions.
#[derive(Debug, Clone, Default)]
pub struct SessionQuery {
/// Keyword to search in session messages (FTS5 if available).
pub keyword: Option<String>,
/// Maximum number of sessions to return.
pub limit: Option<usize>,
}
/// Trait for session persistence backends.
///
/// Implementations must be `Send + Sync` for sharing across async tasks.
pub trait SessionBackend: Send + Sync {
/// Load all messages for a session. Returns empty vec if session doesn't exist.
fn load(&self, session_key: &str) -> Vec<ChatMessage>;
/// Append a single message to a session.
fn append(&self, session_key: &str, message: &ChatMessage) -> std::io::Result<()>;
/// Remove the last message from a session. Returns `true` if a message was removed.
fn remove_last(&self, session_key: &str) -> std::io::Result<bool>;
/// List all session keys.
fn list_sessions(&self) -> Vec<String>;
/// List sessions with metadata.
fn list_sessions_with_metadata(&self) -> Vec<SessionMetadata> {
// Default: construct metadata from messages (backends can override for efficiency)
self.list_sessions()
.into_iter()
.map(|key| {
let messages = self.load(&key);
SessionMetadata {
key,
created_at: Utc::now(),
last_activity: Utc::now(),
message_count: messages.len(),
}
})
.collect()
}
/// Compact a session file (remove duplicates/corruption). No-op by default.
fn compact(&self, _session_key: &str) -> std::io::Result<()> {
Ok(())
}
/// Remove sessions that haven't been active within the given TTL hours.
fn cleanup_stale(&self, _ttl_hours: u32) -> std::io::Result<usize> {
Ok(0)
}
/// Search sessions by keyword. Default returns empty (backends with FTS override).
fn search(&self, _query: &SessionQuery) -> Vec<SessionMetadata> {
Vec::new()
}
/// Delete all messages for a session. Returns `true` if the session existed.
fn delete_session(&self, _session_key: &str) -> std::io::Result<bool> {
Ok(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_metadata_is_constructible() {
let meta = SessionMetadata {
key: "test".into(),
created_at: Utc::now(),
last_activity: Utc::now(),
message_count: 5,
};
assert_eq!(meta.key, "test");
assert_eq!(meta.message_count, 5);
}
#[test]
fn session_query_defaults() {
let q = SessionQuery::default();
assert!(q.keyword.is_none());
assert!(q.limit.is_none());
}
}
+558
View File
@@ -0,0 +1,558 @@
//! SQLite-backed session persistence with FTS5 search.
//!
//! Stores sessions in `{workspace}/sessions/sessions.db` using WAL mode.
//! Provides full-text search via FTS5 and automatic TTL-based cleanup.
//! Designed as the default backend, replacing JSONL for new installations.
use crate::channels::session_backend::{SessionBackend, SessionMetadata, SessionQuery};
use crate::providers::traits::ChatMessage;
use anyhow::{Context, Result};
use chrono::{DateTime, Duration, Utc};
use parking_lot::Mutex;
use rusqlite::{params, Connection};
use std::path::{Path, PathBuf};
/// SQLite-backed session store with FTS5 and WAL mode.
pub struct SqliteSessionBackend {
conn: Mutex<Connection>,
#[allow(dead_code)]
db_path: PathBuf,
}
impl SqliteSessionBackend {
/// Open or create the sessions database.
pub fn new(workspace_dir: &Path) -> Result<Self> {
let sessions_dir = workspace_dir.join("sessions");
std::fs::create_dir_all(&sessions_dir).context("Failed to create sessions directory")?;
let db_path = sessions_dir.join("sessions.db");
let conn = Connection::open(&db_path)
.with_context(|| format!("Failed to open session DB: {}", db_path.display()))?;
conn.execute_batch(
"PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA temp_store = MEMORY;
PRAGMA mmap_size = 4194304;",
)?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_key TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_key ON sessions(session_key);
CREATE INDEX IF NOT EXISTS idx_sessions_key_id ON sessions(session_key, id);
CREATE TABLE IF NOT EXISTS session_metadata (
session_key TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
last_activity TEXT NOT NULL,
message_count INTEGER NOT NULL DEFAULT 0
);
CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5(
session_key, content, content=sessions, content_rowid=id
);
CREATE TRIGGER IF NOT EXISTS sessions_ai AFTER INSERT ON sessions BEGIN
INSERT INTO sessions_fts(rowid, session_key, content)
VALUES (new.id, new.session_key, new.content);
END;
CREATE TRIGGER IF NOT EXISTS sessions_ad AFTER DELETE ON sessions BEGIN
INSERT INTO sessions_fts(sessions_fts, rowid, session_key, content)
VALUES ('delete', old.id, old.session_key, old.content);
END;",
)
.context("Failed to initialize session schema")?;
Ok(Self {
conn: Mutex::new(conn),
db_path,
})
}
/// Migrate JSONL session files into SQLite. Renames migrated files to `.jsonl.migrated`.
pub fn migrate_from_jsonl(&self, workspace_dir: &Path) -> Result<usize> {
let sessions_dir = workspace_dir.join("sessions");
let entries = match std::fs::read_dir(&sessions_dir) {
Ok(e) => e,
Err(_) => return Ok(0),
};
let mut migrated = 0;
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let name = match entry.file_name().into_string() {
Ok(n) => n,
Err(_) => continue,
};
let Some(key) = name.strip_suffix(".jsonl") else {
continue;
};
let path = entry.path();
let file = match std::fs::File::open(&path) {
Ok(f) => f,
Err(_) => continue,
};
let reader = std::io::BufReader::new(file);
let mut count = 0;
for line in std::io::BufRead::lines(reader) {
let Ok(line) = line else { continue };
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Ok(msg) = serde_json::from_str::<ChatMessage>(trimmed) {
if self.append(key, &msg).is_ok() {
count += 1;
}
}
}
if count > 0 {
let migrated_path = path.with_extension("jsonl.migrated");
let _ = std::fs::rename(&path, &migrated_path);
migrated += 1;
}
}
Ok(migrated)
}
}
impl SessionBackend for SqliteSessionBackend {
fn load(&self, session_key: &str) -> Vec<ChatMessage> {
let conn = self.conn.lock();
let mut stmt = match conn
.prepare("SELECT role, content FROM sessions WHERE session_key = ?1 ORDER BY id ASC")
{
Ok(s) => s,
Err(_) => return Vec::new(),
};
let rows = match stmt.query_map(params![session_key], |row| {
Ok(ChatMessage {
role: row.get(0)?,
content: row.get(1)?,
})
}) {
Ok(r) => r,
Err(_) => return Vec::new(),
};
rows.filter_map(|r| r.ok()).collect()
}
fn append(&self, session_key: &str, message: &ChatMessage) -> std::io::Result<()> {
let conn = self.conn.lock();
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO sessions (session_key, role, content, created_at)
VALUES (?1, ?2, ?3, ?4)",
params![session_key, message.role, message.content, now],
)
.map_err(std::io::Error::other)?;
// Upsert metadata
conn.execute(
"INSERT INTO session_metadata (session_key, created_at, last_activity, message_count)
VALUES (?1, ?2, ?3, 1)
ON CONFLICT(session_key) DO UPDATE SET
last_activity = excluded.last_activity,
message_count = message_count + 1",
params![session_key, now, now],
)
.map_err(std::io::Error::other)?;
Ok(())
}
fn remove_last(&self, session_key: &str) -> std::io::Result<bool> {
let conn = self.conn.lock();
let last_id: Option<i64> = conn
.query_row(
"SELECT id FROM sessions WHERE session_key = ?1 ORDER BY id DESC LIMIT 1",
params![session_key],
|row| row.get(0),
)
.ok();
let Some(id) = last_id else {
return Ok(false);
};
conn.execute("DELETE FROM sessions WHERE id = ?1", params![id])
.map_err(std::io::Error::other)?;
// Update metadata count
conn.execute(
"UPDATE session_metadata SET message_count = MAX(0, message_count - 1)
WHERE session_key = ?1",
params![session_key],
)
.map_err(std::io::Error::other)?;
Ok(true)
}
fn list_sessions(&self) -> Vec<String> {
let conn = self.conn.lock();
let mut stmt = match conn
.prepare("SELECT session_key FROM session_metadata ORDER BY last_activity DESC")
{
Ok(s) => s,
Err(_) => return Vec::new(),
};
let rows = match stmt.query_map([], |row| row.get(0)) {
Ok(r) => r,
Err(_) => return Vec::new(),
};
rows.filter_map(|r| r.ok()).collect()
}
fn list_sessions_with_metadata(&self) -> Vec<SessionMetadata> {
let conn = self.conn.lock();
let mut stmt = match conn.prepare(
"SELECT session_key, created_at, last_activity, message_count
FROM session_metadata ORDER BY last_activity DESC",
) {
Ok(s) => s,
Err(_) => return Vec::new(),
};
let rows = match stmt.query_map([], |row| {
let key: String = row.get(0)?;
let created_str: String = row.get(1)?;
let activity_str: String = row.get(2)?;
let count: i64 = row.get(3)?;
let created = DateTime::parse_from_rfc3339(&created_str)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now());
let activity = DateTime::parse_from_rfc3339(&activity_str)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now());
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
Ok(SessionMetadata {
key,
created_at: created,
last_activity: activity,
message_count: count as usize,
})
}) {
Ok(r) => r,
Err(_) => return Vec::new(),
};
rows.filter_map(|r| r.ok()).collect()
}
fn cleanup_stale(&self, ttl_hours: u32) -> std::io::Result<usize> {
let conn = self.conn.lock();
let cutoff = (Utc::now() - Duration::hours(i64::from(ttl_hours))).to_rfc3339();
// Find stale sessions
let stale_keys: Vec<String> = {
let mut stmt = conn
.prepare("SELECT session_key FROM session_metadata WHERE last_activity < ?1")
.map_err(std::io::Error::other)?;
let rows = stmt
.query_map(params![cutoff], |row| row.get(0))
.map_err(std::io::Error::other)?;
rows.filter_map(|r| r.ok()).collect()
};
let count = stale_keys.len();
for key in &stale_keys {
let _ = conn.execute("DELETE FROM sessions WHERE session_key = ?1", params![key]);
let _ = conn.execute(
"DELETE FROM session_metadata WHERE session_key = ?1",
params![key],
);
}
Ok(count)
}
fn delete_session(&self, session_key: &str) -> std::io::Result<bool> {
let conn = self.conn.lock();
// Check if session exists
let exists: bool = conn
.query_row(
"SELECT COUNT(*) > 0 FROM session_metadata WHERE session_key = ?1",
params![session_key],
|row| row.get(0),
)
.unwrap_or(false);
if !exists {
return Ok(false);
}
// Delete messages (FTS5 trigger handles sessions_fts cleanup)
conn.execute(
"DELETE FROM sessions WHERE session_key = ?1",
params![session_key],
)
.map_err(std::io::Error::other)?;
// Delete metadata
conn.execute(
"DELETE FROM session_metadata WHERE session_key = ?1",
params![session_key],
)
.map_err(std::io::Error::other)?;
Ok(true)
}
fn search(&self, query: &SessionQuery) -> Vec<SessionMetadata> {
let Some(keyword) = &query.keyword else {
return self.list_sessions_with_metadata();
};
let conn = self.conn.lock();
#[allow(clippy::cast_possible_wrap)]
let limit = query.limit.unwrap_or(50) as i64;
// FTS5 search
let mut stmt = match conn.prepare(
"SELECT DISTINCT f.session_key
FROM sessions_fts f
WHERE sessions_fts MATCH ?1
LIMIT ?2",
) {
Ok(s) => s,
Err(_) => return Vec::new(),
};
// Quote each word for FTS5
let fts_query: String = keyword
.split_whitespace()
.map(|w| format!("\"{w}\""))
.collect::<Vec<_>>()
.join(" OR ");
let keys: Vec<String> = match stmt.query_map(params![fts_query, limit], |row| row.get(0)) {
Ok(r) => r.filter_map(|r| r.ok()).collect(),
Err(_) => return Vec::new(),
};
// Look up metadata for matched sessions
keys.iter()
.filter_map(|key| {
conn.query_row(
"SELECT created_at, last_activity, message_count FROM session_metadata WHERE session_key = ?1",
params![key],
|row| {
let created_str: String = row.get(0)?;
let activity_str: String = row.get(1)?;
let count: i64 = row.get(2)?;
Ok(SessionMetadata {
key: key.clone(),
created_at: DateTime::parse_from_rfc3339(&created_str)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now()),
last_activity: DateTime::parse_from_rfc3339(&activity_str)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now()),
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
message_count: count as usize,
})
},
)
.ok()
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn round_trip_sqlite() {
let tmp = TempDir::new().unwrap();
let backend = SqliteSessionBackend::new(tmp.path()).unwrap();
backend
.append("user1", &ChatMessage::user("hello"))
.unwrap();
backend
.append("user1", &ChatMessage::assistant("hi"))
.unwrap();
let msgs = backend.load("user1");
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].role, "user");
assert_eq!(msgs[1].role, "assistant");
}
#[test]
fn remove_last_sqlite() {
let tmp = TempDir::new().unwrap();
let backend = SqliteSessionBackend::new(tmp.path()).unwrap();
backend.append("u", &ChatMessage::user("a")).unwrap();
backend.append("u", &ChatMessage::user("b")).unwrap();
assert!(backend.remove_last("u").unwrap());
let msgs = backend.load("u");
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].content, "a");
}
#[test]
fn remove_last_empty_sqlite() {
let tmp = TempDir::new().unwrap();
let backend = SqliteSessionBackend::new(tmp.path()).unwrap();
assert!(!backend.remove_last("nonexistent").unwrap());
}
#[test]
fn list_sessions_sqlite() {
let tmp = TempDir::new().unwrap();
let backend = SqliteSessionBackend::new(tmp.path()).unwrap();
backend.append("a", &ChatMessage::user("hi")).unwrap();
backend.append("b", &ChatMessage::user("hey")).unwrap();
let sessions = backend.list_sessions();
assert_eq!(sessions.len(), 2);
}
#[test]
fn metadata_tracks_counts() {
let tmp = TempDir::new().unwrap();
let backend = SqliteSessionBackend::new(tmp.path()).unwrap();
backend.append("s1", &ChatMessage::user("a")).unwrap();
backend.append("s1", &ChatMessage::user("b")).unwrap();
backend.append("s1", &ChatMessage::user("c")).unwrap();
let meta = backend.list_sessions_with_metadata();
assert_eq!(meta.len(), 1);
assert_eq!(meta[0].message_count, 3);
}
#[test]
fn fts5_search_finds_content() {
let tmp = TempDir::new().unwrap();
let backend = SqliteSessionBackend::new(tmp.path()).unwrap();
backend
.append(
"code_chat",
&ChatMessage::user("How do I parse JSON in Rust?"),
)
.unwrap();
backend
.append("weather", &ChatMessage::user("What's the weather today?"))
.unwrap();
let results = backend.search(&SessionQuery {
keyword: Some("Rust".into()),
limit: Some(10),
});
assert_eq!(results.len(), 1);
assert_eq!(results[0].key, "code_chat");
}
#[test]
fn cleanup_stale_removes_old_sessions() {
let tmp = TempDir::new().unwrap();
let backend = SqliteSessionBackend::new(tmp.path()).unwrap();
// Insert a session with old timestamp
{
let conn = backend.conn.lock();
let old_time = (Utc::now() - Duration::hours(100)).to_rfc3339();
conn.execute(
"INSERT INTO sessions (session_key, role, content, created_at) VALUES (?1, ?2, ?3, ?4)",
params!["old_session", "user", "ancient", old_time],
).unwrap();
conn.execute(
"INSERT INTO session_metadata (session_key, created_at, last_activity, message_count) VALUES (?1, ?2, ?3, 1)",
params!["old_session", old_time, old_time],
).unwrap();
}
backend
.append("new_session", &ChatMessage::user("fresh"))
.unwrap();
let cleaned = backend.cleanup_stale(48).unwrap(); // 48h TTL
assert_eq!(cleaned, 1);
let sessions = backend.list_sessions();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0], "new_session");
}
#[test]
fn delete_session_removes_all_data() {
let tmp = TempDir::new().unwrap();
let backend = SqliteSessionBackend::new(tmp.path()).unwrap();
backend.append("s1", &ChatMessage::user("hello")).unwrap();
backend.append("s1", &ChatMessage::assistant("hi")).unwrap();
backend.append("s2", &ChatMessage::user("other")).unwrap();
assert!(backend.delete_session("s1").unwrap());
assert!(backend.load("s1").is_empty());
assert_eq!(backend.list_sessions().len(), 1);
assert_eq!(backend.list_sessions()[0], "s2");
}
#[test]
fn delete_session_returns_false_for_missing() {
let tmp = TempDir::new().unwrap();
let backend = SqliteSessionBackend::new(tmp.path()).unwrap();
assert!(!backend.delete_session("nonexistent").unwrap());
}
#[test]
fn migrate_from_jsonl_imports_and_renames() {
let tmp = TempDir::new().unwrap();
let sessions_dir = tmp.path().join("sessions");
std::fs::create_dir_all(&sessions_dir).unwrap();
// Create a JSONL file
let jsonl_path = sessions_dir.join("test_user.jsonl");
std::fs::write(
&jsonl_path,
"{\"role\":\"user\",\"content\":\"hello\"}\n{\"role\":\"assistant\",\"content\":\"hi\"}\n",
)
.unwrap();
let backend = SqliteSessionBackend::new(tmp.path()).unwrap();
let migrated = backend.migrate_from_jsonl(tmp.path()).unwrap();
assert_eq!(migrated, 1);
// JSONL should be renamed
assert!(!jsonl_path.exists());
assert!(sessions_dir.join("test_user.jsonl.migrated").exists());
// Messages should be in SQLite
let msgs = backend.load("test_user");
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].content, "hello");
}
}
+311
View File
@@ -0,0 +1,311 @@
//! JSONL-based session persistence for channel conversations.
//!
//! Each session (keyed by `channel_sender` or `channel_thread_sender`) is stored
//! as an append-only JSONL file in `{workspace}/sessions/`. Messages are appended
//! one-per-line as JSON, never modifying old lines. On daemon restart, sessions
//! are loaded from disk to restore conversation context.
use crate::channels::session_backend::SessionBackend;
use crate::providers::traits::ChatMessage;
use std::io::{BufRead, Write};
use std::path::{Path, PathBuf};
/// Append-only JSONL session store for channel conversations.
pub struct SessionStore {
sessions_dir: PathBuf,
}
impl SessionStore {
/// Create a new session store, ensuring the sessions directory exists.
pub fn new(workspace_dir: &Path) -> std::io::Result<Self> {
let sessions_dir = workspace_dir.join("sessions");
std::fs::create_dir_all(&sessions_dir)?;
Ok(Self { sessions_dir })
}
/// Compute the file path for a session key, sanitizing for filesystem safety.
fn session_path(&self, session_key: &str) -> PathBuf {
let safe_key: String = session_key
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect();
self.sessions_dir.join(format!("{safe_key}.jsonl"))
}
/// Load all messages for a session from its JSONL file.
/// Returns an empty vec if the file does not exist or is unreadable.
pub fn load(&self, session_key: &str) -> Vec<ChatMessage> {
let path = self.session_path(session_key);
let file = match std::fs::File::open(&path) {
Ok(f) => f,
Err(_) => return Vec::new(),
};
let reader = std::io::BufReader::new(file);
let mut messages = Vec::new();
for line in reader.lines() {
let Ok(line) = line else { continue };
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Ok(msg) = serde_json::from_str::<ChatMessage>(trimmed) {
messages.push(msg);
}
}
messages
}
/// Append a single message to the session JSONL file.
pub fn append(&self, session_key: &str, message: &ChatMessage) -> std::io::Result<()> {
let path = self.session_path(session_key);
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
let json = serde_json::to_string(message)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
writeln!(file, "{json}")?;
Ok(())
}
/// Remove the last message from a session's JSONL file.
///
/// Rewrite approach: load all messages, drop the last, rewrite. This is
/// O(n) but rollbacks are rare.
pub fn remove_last(&self, session_key: &str) -> std::io::Result<bool> {
let mut messages = self.load(session_key);
if messages.is_empty() {
return Ok(false);
}
messages.pop();
self.rewrite(session_key, &messages)?;
Ok(true)
}
/// Compact a session file by rewriting only valid messages (removes corrupt lines).
pub fn compact(&self, session_key: &str) -> std::io::Result<()> {
let messages = self.load(session_key);
self.rewrite(session_key, &messages)
}
fn rewrite(&self, session_key: &str, messages: &[ChatMessage]) -> std::io::Result<()> {
let path = self.session_path(session_key);
let mut file = std::fs::File::create(&path)?;
for msg in messages {
let json = serde_json::to_string(msg)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
writeln!(file, "{json}")?;
}
Ok(())
}
/// List all session keys that have files on disk.
pub fn list_sessions(&self) -> Vec<String> {
let entries = match std::fs::read_dir(&self.sessions_dir) {
Ok(e) => e,
Err(_) => return Vec::new(),
};
entries
.filter_map(|entry| {
let entry = entry.ok()?;
let name = entry.file_name().into_string().ok()?;
name.strip_suffix(".jsonl").map(String::from)
})
.collect()
}
}
impl SessionBackend for SessionStore {
fn load(&self, session_key: &str) -> Vec<ChatMessage> {
self.load(session_key)
}
fn append(&self, session_key: &str, message: &ChatMessage) -> std::io::Result<()> {
self.append(session_key, message)
}
fn remove_last(&self, session_key: &str) -> std::io::Result<bool> {
self.remove_last(session_key)
}
fn list_sessions(&self) -> Vec<String> {
self.list_sessions()
}
fn compact(&self, session_key: &str) -> std::io::Result<()> {
self.compact(session_key)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn round_trip_append_and_load() {
let tmp = TempDir::new().unwrap();
let store = SessionStore::new(tmp.path()).unwrap();
store
.append("telegram_user123", &ChatMessage::user("hello"))
.unwrap();
store
.append("telegram_user123", &ChatMessage::assistant("hi there"))
.unwrap();
let messages = store.load("telegram_user123");
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].role, "user");
assert_eq!(messages[0].content, "hello");
assert_eq!(messages[1].role, "assistant");
assert_eq!(messages[1].content, "hi there");
}
#[test]
fn load_nonexistent_session_returns_empty() {
let tmp = TempDir::new().unwrap();
let store = SessionStore::new(tmp.path()).unwrap();
let messages = store.load("nonexistent");
assert!(messages.is_empty());
}
#[test]
fn key_sanitization() {
let tmp = TempDir::new().unwrap();
let store = SessionStore::new(tmp.path()).unwrap();
// Keys with special chars should be sanitized
store
.append("slack/thread:123/user", &ChatMessage::user("test"))
.unwrap();
let messages = store.load("slack/thread:123/user");
assert_eq!(messages.len(), 1);
}
#[test]
fn list_sessions_returns_keys() {
let tmp = TempDir::new().unwrap();
let store = SessionStore::new(tmp.path()).unwrap();
store
.append("telegram_alice", &ChatMessage::user("hi"))
.unwrap();
store
.append("discord_bob", &ChatMessage::user("hey"))
.unwrap();
let mut sessions = store.list_sessions();
sessions.sort();
assert_eq!(sessions.len(), 2);
assert!(sessions.contains(&"discord_bob".to_string()));
assert!(sessions.contains(&"telegram_alice".to_string()));
}
#[test]
fn append_is_truly_append_only() {
let tmp = TempDir::new().unwrap();
let store = SessionStore::new(tmp.path()).unwrap();
let key = "test_session";
store.append(key, &ChatMessage::user("msg1")).unwrap();
store.append(key, &ChatMessage::user("msg2")).unwrap();
// Read raw file to verify append-only format
let path = store.session_path(key);
let content = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = content.trim().lines().collect();
assert_eq!(lines.len(), 2);
}
#[test]
fn remove_last_drops_final_message() {
let tmp = TempDir::new().unwrap();
let store = SessionStore::new(tmp.path()).unwrap();
store
.append("rm_test", &ChatMessage::user("first"))
.unwrap();
store
.append("rm_test", &ChatMessage::user("second"))
.unwrap();
assert!(store.remove_last("rm_test").unwrap());
let messages = store.load("rm_test");
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].content, "first");
}
#[test]
fn remove_last_empty_returns_false() {
let tmp = TempDir::new().unwrap();
let store = SessionStore::new(tmp.path()).unwrap();
assert!(!store.remove_last("nonexistent").unwrap());
}
#[test]
fn compact_removes_corrupt_lines() {
let tmp = TempDir::new().unwrap();
let store = SessionStore::new(tmp.path()).unwrap();
let key = "compact_test";
let path = store.session_path(key);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut file = std::fs::File::create(&path).unwrap();
writeln!(file, r#"{{"role":"user","content":"ok"}}"#).unwrap();
writeln!(file, "corrupt line").unwrap();
writeln!(file, r#"{{"role":"assistant","content":"hi"}}"#).unwrap();
store.compact(key).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert_eq!(raw.trim().lines().count(), 2);
}
#[test]
fn session_backend_trait_works_via_dyn() {
let tmp = TempDir::new().unwrap();
let store = SessionStore::new(tmp.path()).unwrap();
let backend: &dyn SessionBackend = &store;
backend
.append("trait_test", &ChatMessage::user("hello"))
.unwrap();
let msgs = backend.load("trait_test");
assert_eq!(msgs.len(), 1);
}
#[test]
fn handles_corrupt_lines_gracefully() {
let tmp = TempDir::new().unwrap();
let store = SessionStore::new(tmp.path()).unwrap();
let key = "corrupt_test";
// Write valid message + corrupt line + valid message
let path = store.session_path(key);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut file = std::fs::File::create(&path).unwrap();
writeln!(file, r#"{{"role":"user","content":"hello"}}"#).unwrap();
writeln!(file, "this is not valid json").unwrap();
writeln!(file, r#"{{"role":"assistant","content":"world"}}"#).unwrap();
let messages = store.load(key);
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].content, "hello");
assert_eq!(messages[1].content, "world");
}
}
+69 -11
View File
@@ -334,6 +334,13 @@ pub struct TelegramChannel {
workspace_dir: Option<std::path::PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EditMessageResult {
Success,
NotModified,
Failed(reqwest::StatusCode),
}
impl TelegramChannel {
pub fn new(bot_token: String, allowed_users: Vec<String>, mention_only: bool) -> Self {
let normalized_allowed = Self::normalize_allowed_users(allowed_users);
@@ -540,6 +547,20 @@ impl TelegramChannel {
format!("{}/bot{}/{method}", self.api_base, self.bot_token)
}
async fn classify_edit_message_response(resp: reqwest::Response) -> EditMessageResult {
if resp.status().is_success() {
return EditMessageResult::Success;
}
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
if body.contains("message is not modified") {
return EditMessageResult::NotModified;
}
EditMessageResult::Failed(status)
}
async fn fetch_bot_username(&self) -> anyhow::Result<String> {
let resp = self.http_client().get(self.api_url("getMe")).send().await?;
@@ -730,7 +751,7 @@ impl TelegramChannel {
if let Some(identity) = bind_identity {
self.add_allowed_identity_runtime(&identity);
match self.persist_allowed_identity(&identity).await {
match Box::pin(self.persist_allowed_identity(&identity)).await {
Ok(()) => {
let _ = self
.send(&SendMessage::new(
@@ -2374,11 +2395,17 @@ impl Channel for TelegramChannel {
.send()
.await?;
if resp.status().is_success() {
return Ok(());
match Self::classify_edit_message_response(resp).await {
EditMessageResult::Success | EditMessageResult::NotModified => return Ok(()),
EditMessageResult::Failed(status) => {
tracing::debug!(
status = ?status,
"Telegram finalize_draft HTML edit failed; retrying without parse_mode"
);
}
}
// Markdown failed — retry without parse_mode
// HTML failed — retry without parse_mode
let plain_body = serde_json::json!({
"chat_id": chat_id,
"message_id": id,
@@ -2392,14 +2419,45 @@ impl Channel for TelegramChannel {
.send()
.await?;
if resp.status().is_success() {
return Ok(());
match Self::classify_edit_message_response(resp).await {
EditMessageResult::Success | EditMessageResult::NotModified => return Ok(()),
EditMessageResult::Failed(status) => {
tracing::warn!(
status = ?status,
"Telegram finalize_draft plain edit failed; attempting delete+send fallback"
);
}
}
// Edit failed entirely — fall back to new message
tracing::warn!("Telegram finalize_draft edit failed; falling back to sendMessage");
self.send_text_chunks(text, &chat_id, thread_id.as_deref())
.await
let delete_resp = self
.client
.post(self.api_url("deleteMessage"))
.json(&serde_json::json!({
"chat_id": chat_id,
"message_id": id,
}))
.send()
.await;
match delete_resp {
Ok(resp) if resp.status().is_success() => {
self.send_text_chunks(text, &chat_id, thread_id.as_deref())
.await
}
Ok(resp) => {
tracing::warn!(
status = ?resp.status(),
"Telegram finalize_draft delete failed; skipping sendMessage to avoid duplicate"
);
Ok(())
}
Err(err) => {
tracing::warn!(
"Telegram finalize_draft delete request failed: {err}; skipping sendMessage to avoid duplicate"
);
Ok(())
}
}
}
async fn cancel_draft(&self, recipient: &str, message_id: &str) -> anyhow::Result<()> {
@@ -2627,7 +2685,7 @@ Ensure only one `zeroclaw` process is using this bot token."
} else if let Some(m) = self.try_parse_attachment_message(update).await {
m
} else {
self.handle_unauthorized_message(update).await;
Box::pin(self.handle_unauthorized_message(update)).await;
continue;
};
+802 -32
View File
@@ -1,11 +1,19 @@
use std::collections::HashMap;
use anyhow::{bail, Context, Result};
use async_trait::async_trait;
use reqwest::multipart::{Form, Part};
use crate::config::TranscriptionConfig;
/// Maximum upload size accepted by the Groq Whisper API (25 MB).
/// Maximum upload size accepted by most Whisper-compatible APIs (25 MB).
const MAX_AUDIO_BYTES: usize = 25 * 1024 * 1024;
/// Request timeout for transcription API calls (seconds).
const TRANSCRIPTION_TIMEOUT_SECS: u64 = 120;
// ── Audio utilities ─────────────────────────────────────────────
/// Map file extension to MIME type for Whisper-compatible transcription APIs.
fn mime_for_audio(extension: &str) -> Option<&'static str> {
match extension.to_ascii_lowercase().as_str() {
@@ -31,16 +39,51 @@ fn normalize_audio_filename(file_name: &str) -> String {
}
}
/// Transcribe audio bytes via a Whisper-compatible transcription API.
/// Resolve the API key for voice transcription.
///
/// Returns the transcribed text on success. Requires `GROQ_API_KEY` in the
/// environment. The caller is responsible for enforcing duration limits
/// *before* downloading the file; this function enforces the byte-size cap.
pub async fn transcribe_audio(
audio_data: Vec<u8>,
file_name: &str,
config: &TranscriptionConfig,
) -> Result<String> {
/// Priority order:
/// 1. Explicit `config.api_key` (if set and non-empty).
/// 2. Provider-specific env var based on `api_url`:
/// - URL contains "openai.com" -> `OPENAI_API_KEY`
/// - URL contains "groq.com" -> `GROQ_API_KEY`
/// 3. Fallback chain: `TRANSCRIPTION_API_KEY` -> `GROQ_API_KEY` -> `OPENAI_API_KEY`.
fn resolve_transcription_api_key(config: &TranscriptionConfig) -> Result<String> {
// 1. Explicit config key
if let Some(ref key) = config.api_key {
let trimmed = key.trim();
if !trimmed.is_empty() {
return Ok(trimmed.to_string());
}
}
// 2. Provider-specific env var based on API URL
if config.api_url.contains("openai.com") {
if let Ok(key) = std::env::var("OPENAI_API_KEY") {
return Ok(key);
}
} else if config.api_url.contains("groq.com") {
if let Ok(key) = std::env::var("GROQ_API_KEY") {
return Ok(key);
}
}
// 3. Fallback chain
for var in ["TRANSCRIPTION_API_KEY", "GROQ_API_KEY", "OPENAI_API_KEY"] {
if let Ok(key) = std::env::var(var) {
return Ok(key);
}
}
bail!(
"No API key found for voice transcription — set one of: \
transcription.api_key in config, TRANSCRIPTION_API_KEY, GROQ_API_KEY, or OPENAI_API_KEY"
);
}
/// Validate audio data and resolve MIME type from file name.
///
/// Returns `(normalized_filename, mime_type)` on success.
fn validate_audio(audio_data: &[u8], file_name: &str) -> Result<(String, &'static str)> {
if audio_data.len() > MAX_AUDIO_BYTES {
bail!(
"Audio file too large ({} bytes, max {MAX_AUDIO_BYTES})",
@@ -59,33 +102,494 @@ pub async fn transcribe_audio(
)
})?;
let api_key = std::env::var("GROQ_API_KEY").context(
"GROQ_API_KEY environment variable is not set — required for voice transcription",
)?;
Ok((normalized_name, mime))
}
let client = crate::config::build_runtime_proxy_client("transcription.groq");
// ── TranscriptionProvider trait ─────────────────────────────────
let file_part = Part::bytes(audio_data)
.file_name(normalized_name)
.mime_str(mime)?;
/// Trait for speech-to-text provider implementations.
#[async_trait]
pub trait TranscriptionProvider: Send + Sync {
/// Human-readable provider name (e.g. "groq", "openai").
fn name(&self) -> &str;
let mut form = Form::new()
.part("file", file_part)
.text("model", config.model.clone())
.text("response_format", "json");
/// Transcribe raw audio bytes. `file_name` includes the extension for
/// format detection (e.g. "voice.ogg").
async fn transcribe(&self, audio_data: &[u8], file_name: &str) -> Result<String>;
if let Some(ref lang) = config.language {
form = form.text("language", lang.clone());
/// List of supported audio file extensions.
fn supported_formats(&self) -> Vec<String> {
vec![
"flac", "mp3", "mpeg", "mpga", "mp4", "m4a", "ogg", "oga", "opus", "wav", "webm",
]
.into_iter()
.map(String::from)
.collect()
}
}
// ── GroqProvider ────────────────────────────────────────────────
/// Groq Whisper API provider (default, backward-compatible with existing config).
pub struct GroqProvider {
api_url: String,
model: String,
api_key: String,
language: Option<String>,
}
impl GroqProvider {
/// Build from the existing `TranscriptionConfig` fields.
///
/// Credential resolution order:
/// 1. `config.api_key`
/// 2. `GROQ_API_KEY` environment variable (backward compatibility)
pub fn from_config(config: &TranscriptionConfig) -> Result<Self> {
let api_key = config
.api_key
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
std::env::var("GROQ_API_KEY")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
})
.context(
"Missing transcription API key: set [transcription].api_key or GROQ_API_KEY environment variable",
)?;
Ok(Self {
api_url: config.api_url.clone(),
model: config.model.clone(),
api_key,
language: config.language.clone(),
})
}
}
#[async_trait]
impl TranscriptionProvider for GroqProvider {
fn name(&self) -> &str {
"groq"
}
let resp = client
.post(&config.api_url)
.bearer_auth(&api_key)
.multipart(form)
.send()
.await
.context("Failed to send transcription request")?;
async fn transcribe(&self, audio_data: &[u8], file_name: &str) -> Result<String> {
let (normalized_name, mime) = validate_audio(audio_data, file_name)?;
let client = crate::config::build_runtime_proxy_client("transcription.groq");
let file_part = Part::bytes(audio_data.to_vec())
.file_name(normalized_name)
.mime_str(mime)?;
let mut form = Form::new()
.part("file", file_part)
.text("model", self.model.clone())
.text("response_format", "json");
if let Some(ref lang) = self.language {
form = form.text("language", lang.clone());
}
let resp = client
.post(&self.api_url)
.bearer_auth(&self.api_key)
.multipart(form)
.timeout(std::time::Duration::from_secs(TRANSCRIPTION_TIMEOUT_SECS))
.send()
.await
.context("Failed to send transcription request to Groq")?;
parse_whisper_response(resp).await
}
}
// ── OpenAiWhisperProvider ───────────────────────────────────────
/// OpenAI Whisper API provider.
pub struct OpenAiWhisperProvider {
api_key: String,
model: String,
}
impl OpenAiWhisperProvider {
pub fn from_config(config: &crate::config::OpenAiSttConfig) -> Result<Self> {
let api_key = config
.api_key
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.map(ToOwned::to_owned)
.context("Missing OpenAI STT API key: set [transcription.openai].api_key")?;
Ok(Self {
api_key,
model: config.model.clone(),
})
}
}
#[async_trait]
impl TranscriptionProvider for OpenAiWhisperProvider {
fn name(&self) -> &str {
"openai"
}
async fn transcribe(&self, audio_data: &[u8], file_name: &str) -> Result<String> {
let (normalized_name, mime) = validate_audio(audio_data, file_name)?;
let client = crate::config::build_runtime_proxy_client("transcription.openai");
let file_part = Part::bytes(audio_data.to_vec())
.file_name(normalized_name)
.mime_str(mime)?;
let form = Form::new()
.part("file", file_part)
.text("model", self.model.clone())
.text("response_format", "json");
let resp = client
.post("https://api.openai.com/v1/audio/transcriptions")
.bearer_auth(&self.api_key)
.multipart(form)
.timeout(std::time::Duration::from_secs(TRANSCRIPTION_TIMEOUT_SECS))
.send()
.await
.context("Failed to send transcription request to OpenAI")?;
parse_whisper_response(resp).await
}
}
// ── DeepgramProvider ────────────────────────────────────────────
/// Deepgram STT API provider.
pub struct DeepgramProvider {
api_key: String,
model: String,
}
impl DeepgramProvider {
pub fn from_config(config: &crate::config::DeepgramSttConfig) -> Result<Self> {
let api_key = config
.api_key
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.map(ToOwned::to_owned)
.context("Missing Deepgram API key: set [transcription.deepgram].api_key")?;
Ok(Self {
api_key,
model: config.model.clone(),
})
}
}
#[async_trait]
impl TranscriptionProvider for DeepgramProvider {
fn name(&self) -> &str {
"deepgram"
}
async fn transcribe(&self, audio_data: &[u8], file_name: &str) -> Result<String> {
let (_, mime) = validate_audio(audio_data, file_name)?;
let client = crate::config::build_runtime_proxy_client("transcription.deepgram");
let url = format!(
"https://api.deepgram.com/v1/listen?model={}&punctuate=true",
self.model
);
let resp = client
.post(&url)
.header("Authorization", format!("Token {}", self.api_key))
.header("Content-Type", mime)
.body(audio_data.to_vec())
.timeout(std::time::Duration::from_secs(TRANSCRIPTION_TIMEOUT_SECS))
.send()
.await
.context("Failed to send transcription request to Deepgram")?;
let status = resp.status();
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse Deepgram response")?;
if !status.is_success() {
let error_msg = body["err_msg"]
.as_str()
.or_else(|| body["error"].as_str())
.unwrap_or("unknown error");
bail!("Deepgram API error ({}): {}", status, error_msg);
}
let text = body["results"]["channels"][0]["alternatives"][0]["transcript"]
.as_str()
.context("Deepgram response missing transcript field")?
.to_string();
Ok(text)
}
}
// ── AssemblyAiProvider ──────────────────────────────────────────
/// AssemblyAI STT API provider.
pub struct AssemblyAiProvider {
api_key: String,
}
impl AssemblyAiProvider {
pub fn from_config(config: &crate::config::AssemblyAiSttConfig) -> Result<Self> {
let api_key = config
.api_key
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.map(ToOwned::to_owned)
.context("Missing AssemblyAI API key: set [transcription.assemblyai].api_key")?;
Ok(Self { api_key })
}
}
#[async_trait]
impl TranscriptionProvider for AssemblyAiProvider {
fn name(&self) -> &str {
"assemblyai"
}
async fn transcribe(&self, audio_data: &[u8], file_name: &str) -> Result<String> {
let (_, _) = validate_audio(audio_data, file_name)?;
let client = crate::config::build_runtime_proxy_client("transcription.assemblyai");
// Step 1: Upload the audio file.
let upload_resp = client
.post("https://api.assemblyai.com/v2/upload")
.header("Authorization", &self.api_key)
.header("Content-Type", "application/octet-stream")
.body(audio_data.to_vec())
.timeout(std::time::Duration::from_secs(TRANSCRIPTION_TIMEOUT_SECS))
.send()
.await
.context("Failed to upload audio to AssemblyAI")?;
let upload_status = upload_resp.status();
let upload_body: serde_json::Value = upload_resp
.json()
.await
.context("Failed to parse AssemblyAI upload response")?;
if !upload_status.is_success() {
let error_msg = upload_body["error"].as_str().unwrap_or("unknown error");
bail!("AssemblyAI upload error ({}): {}", upload_status, error_msg);
}
let upload_url = upload_body["upload_url"]
.as_str()
.context("AssemblyAI upload response missing 'upload_url'")?;
// Step 2: Create transcription job.
let transcript_req = serde_json::json!({
"audio_url": upload_url,
});
let create_resp = client
.post("https://api.assemblyai.com/v2/transcript")
.header("Authorization", &self.api_key)
.json(&transcript_req)
.timeout(std::time::Duration::from_secs(TRANSCRIPTION_TIMEOUT_SECS))
.send()
.await
.context("Failed to create AssemblyAI transcription")?;
let create_status = create_resp.status();
let create_body: serde_json::Value = create_resp
.json()
.await
.context("Failed to parse AssemblyAI create response")?;
if !create_status.is_success() {
let error_msg = create_body["error"].as_str().unwrap_or("unknown error");
bail!(
"AssemblyAI transcription error ({}): {}",
create_status,
error_msg
);
}
let transcript_id = create_body["id"]
.as_str()
.context("AssemblyAI response missing 'id'")?;
// Step 3: Poll for completion.
let poll_url = format!("https://api.assemblyai.com/v2/transcript/{transcript_id}");
let poll_interval = std::time::Duration::from_secs(3);
let poll_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(180);
while tokio::time::Instant::now() < poll_deadline {
tokio::time::sleep(poll_interval).await;
let poll_resp = client
.get(&poll_url)
.header("Authorization", &self.api_key)
.timeout(std::time::Duration::from_secs(30))
.send()
.await
.context("Failed to poll AssemblyAI transcription")?;
let poll_status = poll_resp.status();
let poll_body: serde_json::Value = poll_resp
.json()
.await
.context("Failed to parse AssemblyAI poll response")?;
if !poll_status.is_success() {
let error_msg = poll_body["error"].as_str().unwrap_or("unknown poll error");
bail!("AssemblyAI poll error ({}): {}", poll_status, error_msg);
}
let status_str = poll_body["status"].as_str().unwrap_or("unknown");
match status_str {
"completed" => {
let text = poll_body["text"]
.as_str()
.context("AssemblyAI response missing 'text'")?
.to_string();
return Ok(text);
}
"error" => {
let error_msg = poll_body["error"]
.as_str()
.unwrap_or("unknown transcription error");
bail!("AssemblyAI transcription failed: {}", error_msg);
}
_ => {}
}
}
bail!("AssemblyAI transcription timed out after 180s")
}
}
// ── GoogleSttProvider ───────────────────────────────────────────
/// Google Cloud Speech-to-Text API provider.
pub struct GoogleSttProvider {
api_key: String,
language_code: String,
}
impl GoogleSttProvider {
pub fn from_config(config: &crate::config::GoogleSttConfig) -> Result<Self> {
let api_key = config
.api_key
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.map(ToOwned::to_owned)
.context("Missing Google STT API key: set [transcription.google].api_key")?;
Ok(Self {
api_key,
language_code: config.language_code.clone(),
})
}
}
#[async_trait]
impl TranscriptionProvider for GoogleSttProvider {
fn name(&self) -> &str {
"google"
}
fn supported_formats(&self) -> Vec<String> {
// Google Cloud STT supports a subset of formats.
vec!["flac", "wav", "ogg", "opus", "mp3", "webm"]
.into_iter()
.map(String::from)
.collect()
}
async fn transcribe(&self, audio_data: &[u8], file_name: &str) -> Result<String> {
let (normalized_name, _) = validate_audio(audio_data, file_name)?;
let client = crate::config::build_runtime_proxy_client("transcription.google");
let encoding = match normalized_name
.rsplit_once('.')
.map(|(_, e)| e.to_ascii_lowercase())
.as_deref()
{
Some("flac") => "FLAC",
Some("wav") => "LINEAR16",
Some("ogg" | "opus") => "OGG_OPUS",
Some("mp3") => "MP3",
Some("webm") => "WEBM_OPUS",
Some(ext) => bail!("Google STT does not support '.{ext}' input"),
None => bail!("Google STT requires a file extension"),
};
let audio_content =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, audio_data);
let request_body = serde_json::json!({
"config": {
"encoding": encoding,
"languageCode": &self.language_code,
"enableAutomaticPunctuation": true,
},
"audio": {
"content": audio_content,
}
});
let url = format!(
"https://speech.googleapis.com/v1/speech:recognize?key={}",
self.api_key
);
let resp = client
.post(&url)
.json(&request_body)
.timeout(std::time::Duration::from_secs(TRANSCRIPTION_TIMEOUT_SECS))
.send()
.await
.context("Failed to send transcription request to Google STT")?;
let status = resp.status();
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse Google STT response")?;
if !status.is_success() {
let error_msg = body["error"]["message"].as_str().unwrap_or("unknown error");
bail!("Google STT API error ({}): {}", status, error_msg);
}
let text = body["results"][0]["alternatives"][0]["transcript"]
.as_str()
.unwrap_or("")
.to_string();
Ok(text)
}
}
// ── Shared response parsing ─────────────────────────────────────
/// Parse a standard Whisper-compatible JSON response (`{ "text": "..." }`).
async fn parse_whisper_response(resp: reqwest::Response) -> Result<String> {
let status = resp.status();
let body: serde_json::Value = resp
.json()
@@ -105,6 +609,128 @@ pub async fn transcribe_audio(
Ok(text)
}
// ── TranscriptionManager ────────────────────────────────────────
/// Manages multiple STT providers and routes transcription requests.
pub struct TranscriptionManager {
providers: HashMap<String, Box<dyn TranscriptionProvider>>,
default_provider: String,
}
impl TranscriptionManager {
/// Build a `TranscriptionManager` from config.
///
/// Always attempts to register the Groq provider from existing config fields.
/// Additional providers are registered when their config sections are present.
///
/// Provider keys with missing API keys are silently skipped — the error
/// surfaces at transcribe-time so callers that target a different default
/// provider are not blocked.
pub fn new(config: &TranscriptionConfig) -> Result<Self> {
let mut providers: HashMap<String, Box<dyn TranscriptionProvider>> = HashMap::new();
if let Ok(groq) = GroqProvider::from_config(config) {
providers.insert("groq".to_string(), Box::new(groq));
}
if let Some(ref openai_cfg) = config.openai {
if let Ok(p) = OpenAiWhisperProvider::from_config(openai_cfg) {
providers.insert("openai".to_string(), Box::new(p));
}
}
if let Some(ref deepgram_cfg) = config.deepgram {
if let Ok(p) = DeepgramProvider::from_config(deepgram_cfg) {
providers.insert("deepgram".to_string(), Box::new(p));
}
}
if let Some(ref assemblyai_cfg) = config.assemblyai {
if let Ok(p) = AssemblyAiProvider::from_config(assemblyai_cfg) {
providers.insert("assemblyai".to_string(), Box::new(p));
}
}
if let Some(ref google_cfg) = config.google {
if let Ok(p) = GoogleSttProvider::from_config(google_cfg) {
providers.insert("google".to_string(), Box::new(p));
}
}
let default_provider = config.default_provider.clone();
if config.enabled && !providers.contains_key(&default_provider) {
let available: Vec<&str> = providers.keys().map(|k| k.as_str()).collect();
bail!(
"Default transcription provider '{}' is not configured. Available: {available:?}",
default_provider
);
}
Ok(Self {
providers,
default_provider,
})
}
/// Transcribe audio using the default provider.
pub async fn transcribe(&self, audio_data: &[u8], file_name: &str) -> Result<String> {
self.transcribe_with_provider(audio_data, file_name, &self.default_provider)
.await
}
/// Transcribe audio using a specific named provider.
pub async fn transcribe_with_provider(
&self,
audio_data: &[u8],
file_name: &str,
provider: &str,
) -> Result<String> {
let p = self.providers.get(provider).ok_or_else(|| {
let available: Vec<&str> = self.providers.keys().map(|k| k.as_str()).collect();
anyhow::anyhow!(
"Transcription provider '{provider}' not configured. Available: {available:?}"
)
})?;
p.transcribe(audio_data, file_name).await
}
/// List registered provider names.
pub fn available_providers(&self) -> Vec<&str> {
self.providers.keys().map(|k| k.as_str()).collect()
}
}
// ── Backward-compatible convenience function ────────────────────
/// Transcribe audio bytes via a Whisper-compatible transcription API.
///
/// Returns the transcribed text on success.
///
/// This is the backward-compatible entry point that preserves the original
/// function signature. It uses the Groq provider directly, matching the
/// original single-provider behavior.
///
/// Credential resolution order:
/// 1. `config.transcription.api_key`
/// 2. `GROQ_API_KEY` environment variable (backward compatibility)
///
/// The caller is responsible for enforcing duration limits *before* downloading
/// the file; this function enforces the byte-size cap.
pub async fn transcribe_audio(
audio_data: Vec<u8>,
file_name: &str,
config: &TranscriptionConfig,
) -> Result<String> {
// Validate audio before resolving credentials so that size/format errors
// are reported before missing-key errors (preserves original behavior).
validate_audio(&audio_data, file_name)?;
let groq = GroqProvider::from_config(config)?;
groq.transcribe(&audio_data, file_name).await
}
#[cfg(test)]
mod tests {
use super::*;
@@ -125,8 +751,10 @@ mod tests {
#[tokio::test]
async fn rejects_missing_api_key() {
// Ensure the key is absent for this test
// Ensure all candidate keys are absent for this test.
std::env::remove_var("GROQ_API_KEY");
std::env::remove_var("OPENAI_API_KEY");
std::env::remove_var("TRANSCRIPTION_API_KEY");
let data = vec![0u8; 100];
let config = TranscriptionConfig::default();
@@ -135,11 +763,29 @@ mod tests {
.await
.unwrap_err();
assert!(
err.to_string().contains("GROQ_API_KEY"),
err.to_string().contains("transcription API key"),
"expected missing-key error, got: {err}"
);
}
#[tokio::test]
async fn uses_config_api_key_without_groq_env() {
std::env::remove_var("GROQ_API_KEY");
let data = vec![0u8; 100];
let mut config = TranscriptionConfig::default();
config.api_key = Some("transcription-key".to_string());
// Keep invalid extension so we fail before network, but after key resolution.
let err = transcribe_audio(data, "recording.aac", &config)
.await
.unwrap_err();
assert!(
err.to_string().contains("Unsupported audio format"),
"expected unsupported-format error, got: {err}"
);
}
#[test]
fn mime_for_audio_maps_accepted_formats() {
let cases = [
@@ -215,4 +861,128 @@ mod tests {
"error should mention the rejected extension, got: {msg}"
);
}
// ── TranscriptionManager tests ──────────────────────────────
#[test]
fn manager_creation_with_default_config() {
std::env::remove_var("GROQ_API_KEY");
let config = TranscriptionConfig::default();
let manager = TranscriptionManager::new(&config).unwrap();
assert_eq!(manager.default_provider, "groq");
// Groq won't be registered without a key.
assert!(manager.providers.is_empty());
}
#[test]
fn manager_registers_groq_with_key() {
std::env::remove_var("GROQ_API_KEY");
let mut config = TranscriptionConfig::default();
config.api_key = Some("test-groq-key".to_string());
let manager = TranscriptionManager::new(&config).unwrap();
assert!(manager.providers.contains_key("groq"));
assert_eq!(manager.providers["groq"].name(), "groq");
}
#[test]
fn manager_registers_multiple_providers() {
std::env::remove_var("GROQ_API_KEY");
let mut config = TranscriptionConfig::default();
config.api_key = Some("test-groq-key".to_string());
config.openai = Some(crate::config::OpenAiSttConfig {
api_key: Some("test-openai-key".to_string()),
model: "whisper-1".to_string(),
});
config.deepgram = Some(crate::config::DeepgramSttConfig {
api_key: Some("test-deepgram-key".to_string()),
model: "nova-2".to_string(),
});
let manager = TranscriptionManager::new(&config).unwrap();
assert!(manager.providers.contains_key("groq"));
assert!(manager.providers.contains_key("openai"));
assert!(manager.providers.contains_key("deepgram"));
assert_eq!(manager.available_providers().len(), 3);
}
#[tokio::test]
async fn manager_rejects_unconfigured_provider() {
std::env::remove_var("GROQ_API_KEY");
let mut config = TranscriptionConfig::default();
config.api_key = Some("test-groq-key".to_string());
let manager = TranscriptionManager::new(&config).unwrap();
let err = manager
.transcribe_with_provider(&[0u8; 100], "test.ogg", "nonexistent")
.await
.unwrap_err();
assert!(
err.to_string().contains("not configured"),
"expected not-configured error, got: {err}"
);
}
#[test]
fn manager_default_provider_from_config() {
std::env::remove_var("GROQ_API_KEY");
let mut config = TranscriptionConfig::default();
config.default_provider = "openai".to_string();
config.openai = Some(crate::config::OpenAiSttConfig {
api_key: Some("test-openai-key".to_string()),
model: "whisper-1".to_string(),
});
let manager = TranscriptionManager::new(&config).unwrap();
assert_eq!(manager.default_provider, "openai");
}
#[test]
fn validate_audio_rejects_oversized() {
let big = vec![0u8; MAX_AUDIO_BYTES + 1];
let err = validate_audio(&big, "test.ogg").unwrap_err();
assert!(err.to_string().contains("too large"));
}
#[test]
fn validate_audio_rejects_unsupported_format() {
let data = vec![0u8; 100];
let err = validate_audio(&data, "test.aac").unwrap_err();
assert!(err.to_string().contains("Unsupported audio format"));
}
#[test]
fn validate_audio_accepts_supported_format() {
let data = vec![0u8; 100];
let (name, mime) = validate_audio(&data, "test.ogg").unwrap();
assert_eq!(name, "test.ogg");
assert_eq!(mime, "audio/ogg");
}
#[test]
fn validate_audio_normalizes_oga() {
let data = vec![0u8; 100];
let (name, mime) = validate_audio(&data, "voice.oga").unwrap();
assert_eq!(name, "voice.ogg");
assert_eq!(mime, "audio/ogg");
}
#[test]
fn backward_compat_config_defaults_unchanged() {
let config = TranscriptionConfig::default();
assert!(!config.enabled);
assert!(config.api_key.is_none());
assert!(config.api_url.contains("groq.com"));
assert_eq!(config.model, "whisper-large-v3-turbo");
assert_eq!(config.default_provider, "groq");
assert!(config.openai.is_none());
assert!(config.deepgram.is_none());
assert!(config.assemblyai.is_none());
assert!(config.google.is_none());
}
}
+1
View File
@@ -85,6 +85,7 @@ impl TtsProvider for OpenAiTtsProvider {
"input": text,
"voice": voice,
"speed": self.speed,
"response_format": "opus",
});
let resp = self
+485
View File
@@ -0,0 +1,485 @@
use super::traits::{Channel, ChannelMessage, SendMessage};
use async_trait::async_trait;
use serde_json::json;
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::RwLock;
use uuid::Uuid;
const TWITTER_API_BASE: &str = "https://api.x.com/2";
/// X/Twitter channel — uses the Twitter API v2 with OAuth 2.0 Bearer Token
/// for sending tweets/DMs and filtered stream for receiving mentions.
pub struct TwitterChannel {
bearer_token: String,
allowed_users: Vec<String>,
/// Message deduplication set.
dedup: Arc<RwLock<HashSet<String>>>,
}
/// Deduplication set capacity — evict half of entries when full.
const DEDUP_CAPACITY: usize = 10_000;
impl TwitterChannel {
pub fn new(bearer_token: String, allowed_users: Vec<String>) -> Self {
Self {
bearer_token,
allowed_users,
dedup: Arc::new(RwLock::new(HashSet::new())),
}
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.twitter")
}
fn is_user_allowed(&self, user_id: &str) -> bool {
self.allowed_users.iter().any(|u| u == "*" || u == user_id)
}
/// Check and insert tweet ID for deduplication.
async fn is_duplicate(&self, tweet_id: &str) -> bool {
if tweet_id.is_empty() {
return false;
}
let mut dedup = self.dedup.write().await;
if dedup.contains(tweet_id) {
return true;
}
if dedup.len() >= DEDUP_CAPACITY {
let to_remove: Vec<String> = dedup.iter().take(DEDUP_CAPACITY / 2).cloned().collect();
for key in to_remove {
dedup.remove(&key);
}
}
dedup.insert(tweet_id.to_string());
false
}
/// Get the authenticated user's ID for filtered stream rules.
async fn get_authenticated_user_id(&self) -> anyhow::Result<String> {
let resp = self
.http_client()
.get(format!("{TWITTER_API_BASE}/users/me"))
.bearer_auth(&self.bearer_token)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let err = resp.text().await.unwrap_or_default();
anyhow::bail!("Twitter users/me failed ({status}): {err}");
}
let data: serde_json::Value = resp.json().await?;
let user_id = data
.get("data")
.and_then(|d| d.get("id"))
.and_then(|id| id.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing user id in Twitter response"))?
.to_string();
Ok(user_id)
}
/// Send a reply tweet.
async fn create_tweet(
&self,
text: &str,
reply_tweet_id: Option<&str>,
) -> anyhow::Result<String> {
let mut body = json!({ "text": text });
if let Some(reply_id) = reply_tweet_id {
body["reply"] = json!({ "in_reply_to_tweet_id": reply_id });
}
let resp = self
.http_client()
.post(format!("{TWITTER_API_BASE}/tweets"))
.bearer_auth(&self.bearer_token)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let err = resp.text().await.unwrap_or_default();
anyhow::bail!("Twitter create tweet failed ({status}): {err}");
}
let data: serde_json::Value = resp.json().await?;
let tweet_id = data
.get("data")
.and_then(|d| d.get("id"))
.and_then(|id| id.as_str())
.unwrap_or("")
.to_string();
Ok(tweet_id)
}
/// Send a DM to a user.
async fn send_dm(&self, recipient_id: &str, text: &str) -> anyhow::Result<()> {
let body = json!({
"text": text,
});
let resp = self
.http_client()
.post(format!(
"{TWITTER_API_BASE}/dm_conversations/with/{recipient_id}/messages"
))
.bearer_auth(&self.bearer_token)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let err = resp.text().await.unwrap_or_default();
anyhow::bail!("Twitter DM send failed ({status}): {err}");
}
Ok(())
}
}
#[async_trait]
impl Channel for TwitterChannel {
fn name(&self) -> &str {
"twitter"
}
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
// recipient format: "dm:{user_id}" for DMs, "tweet:{tweet_id}" for replies
if let Some(user_id) = message.recipient.strip_prefix("dm:") {
// Twitter API enforces a 280 char limit on tweets but DMs can be up to 10000.
self.send_dm(user_id, &message.content).await
} else if let Some(tweet_id) = message.recipient.strip_prefix("tweet:") {
// Split long replies into tweet threads (280 char limit).
let chunks = split_tweet_text(&message.content, 280);
let mut reply_to = tweet_id.to_string();
for chunk in chunks {
reply_to = self.create_tweet(&chunk, Some(&reply_to)).await?;
}
Ok(())
} else {
// Default: treat as tweet reply
let chunks = split_tweet_text(&message.content, 280);
let mut reply_to = message.recipient.clone();
for chunk in chunks {
reply_to = self.create_tweet(&chunk, Some(&reply_to)).await?;
}
Ok(())
}
}
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
tracing::info!("Twitter: authenticating...");
let bot_user_id = self.get_authenticated_user_id().await?;
tracing::info!("Twitter: authenticated as user {bot_user_id}");
// Poll mentions timeline (filtered stream requires elevated access).
// Using mentions timeline polling as a more accessible approach.
let mut since_id: Option<String> = None;
let poll_interval = std::time::Duration::from_secs(15);
loop {
let mut url = format!(
"{TWITTER_API_BASE}/users/{bot_user_id}/mentions?tweet.fields=author_id,conversation_id,created_at&expansions=author_id&max_results=20"
);
if let Some(ref id) = since_id {
use std::fmt::Write;
let _ = write!(url, "&since_id={id}");
}
match self
.http_client()
.get(&url)
.bearer_auth(&self.bearer_token)
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
let data: serde_json::Value = match resp.json().await {
Ok(d) => d,
Err(e) => {
tracing::warn!("Twitter: failed to parse mentions response: {e}");
tokio::time::sleep(poll_interval).await;
continue;
}
};
if let Some(tweets) = data.get("data").and_then(|d| d.as_array()) {
// Build user lookup map from includes
let user_map: std::collections::HashMap<String, String> = data
.get("includes")
.and_then(|i| i.get("users"))
.and_then(|u| u.as_array())
.map(|users| {
users
.iter()
.filter_map(|u| {
let id = u.get("id")?.as_str()?.to_string();
let username = u.get("username")?.as_str()?.to_string();
Some((id, username))
})
.collect()
})
.unwrap_or_default();
// Process tweets in chronological order (oldest first)
for tweet in tweets.iter().rev() {
let tweet_id = tweet.get("id").and_then(|i| i.as_str()).unwrap_or("");
let author_id = tweet
.get("author_id")
.and_then(|a| a.as_str())
.unwrap_or("");
let text = tweet.get("text").and_then(|t| t.as_str()).unwrap_or("");
// Skip own tweets
if author_id == bot_user_id {
continue;
}
if self.is_duplicate(tweet_id).await {
continue;
}
let username = user_map
.get(author_id)
.cloned()
.unwrap_or_else(|| author_id.to_string());
if !self.is_user_allowed(&username) && !self.is_user_allowed(author_id)
{
tracing::debug!(
"Twitter: ignoring mention from unauthorized user: {username}"
);
continue;
}
// Strip the @mention from the text
let clean_text = strip_at_mention(text, &bot_user_id);
if clean_text.trim().is_empty() {
continue;
}
let reply_target = format!("tweet:{tweet_id}");
let channel_msg = ChannelMessage {
id: Uuid::new_v4().to_string(),
sender: username,
reply_target,
content: clean_text,
channel: "twitter".to_string(),
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
thread_ts: tweet
.get("conversation_id")
.and_then(|c| c.as_str())
.map(|s| s.to_string()),
};
if tx.send(channel_msg).await.is_err() {
tracing::warn!("Twitter: message channel closed");
return Ok(());
}
// Track newest ID for pagination
if since_id.as_deref().map_or(true, |s| tweet_id > s) {
since_id = Some(tweet_id.to_string());
}
}
}
// Update newest_id from meta
if let Some(newest) = data
.get("meta")
.and_then(|m| m.get("newest_id"))
.and_then(|n| n.as_str())
{
since_id = Some(newest.to_string());
}
}
Ok(resp) => {
let status = resp.status();
if status.as_u16() == 429 {
// Rate limited — back off
tracing::warn!("Twitter: rate limited, backing off 60s");
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
continue;
}
let err = resp.text().await.unwrap_or_default();
tracing::warn!("Twitter: mentions request failed ({status}): {err}");
}
Err(e) => {
tracing::warn!("Twitter: mentions request error: {e}");
}
}
tokio::time::sleep(poll_interval).await;
}
}
async fn health_check(&self) -> bool {
self.get_authenticated_user_id().await.is_ok()
}
}
/// Strip @mention from the beginning of a tweet text.
fn strip_at_mention(text: &str, _bot_user_id: &str) -> String {
// Remove all leading @mentions (Twitter includes @bot_name at start of replies)
let mut result = text;
while let Some(rest) = result.strip_prefix('@') {
// Skip past the username (until whitespace or end)
match rest.find(char::is_whitespace) {
Some(idx) => result = rest[idx..].trim_start(),
None => return String::new(),
}
}
result.to_string()
}
/// Split text into tweet-sized chunks, breaking at word boundaries.
fn split_tweet_text(text: &str, max_len: usize) -> Vec<String> {
if text.len() <= max_len {
return vec![text.to_string()];
}
let mut chunks = Vec::new();
let mut remaining = text;
while !remaining.is_empty() {
if remaining.len() <= max_len {
chunks.push(remaining.to_string());
break;
}
// Find last space within limit
let split_at = remaining[..max_len].rfind(' ').unwrap_or(max_len);
chunks.push(remaining[..split_at].to_string());
remaining = remaining[split_at..].trim_start();
}
chunks
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_name() {
let ch = TwitterChannel::new("token".into(), vec![]);
assert_eq!(ch.name(), "twitter");
}
#[test]
fn test_user_allowed_wildcard() {
let ch = TwitterChannel::new("token".into(), vec!["*".into()]);
assert!(ch.is_user_allowed("anyone"));
}
#[test]
fn test_user_allowed_specific() {
let ch = TwitterChannel::new("token".into(), vec!["user123".into()]);
assert!(ch.is_user_allowed("user123"));
assert!(!ch.is_user_allowed("other"));
}
#[test]
fn test_user_denied_empty() {
let ch = TwitterChannel::new("token".into(), vec![]);
assert!(!ch.is_user_allowed("anyone"));
}
#[tokio::test]
async fn test_dedup() {
let ch = TwitterChannel::new("token".into(), vec![]);
assert!(!ch.is_duplicate("tweet1").await);
assert!(ch.is_duplicate("tweet1").await);
assert!(!ch.is_duplicate("tweet2").await);
}
#[tokio::test]
async fn test_dedup_empty_id() {
let ch = TwitterChannel::new("token".into(), vec![]);
assert!(!ch.is_duplicate("").await);
assert!(!ch.is_duplicate("").await);
}
#[test]
fn test_strip_at_mention_single() {
assert_eq!(strip_at_mention("@bot hello world", "123"), "hello world");
}
#[test]
fn test_strip_at_mention_multiple() {
assert_eq!(strip_at_mention("@bot @other hello", "123"), "hello");
}
#[test]
fn test_strip_at_mention_only() {
assert_eq!(strip_at_mention("@bot", "123"), "");
}
#[test]
fn test_strip_at_mention_no_mention() {
assert_eq!(strip_at_mention("hello world", "123"), "hello world");
}
#[test]
fn test_split_tweet_text_short() {
let chunks = split_tweet_text("hello", 280);
assert_eq!(chunks, vec!["hello"]);
}
#[test]
fn test_split_tweet_text_long() {
let text = "a ".repeat(200);
let chunks = split_tweet_text(text.trim(), 280);
assert!(chunks.len() > 1);
for chunk in &chunks {
assert!(chunk.len() <= 280);
}
}
#[test]
fn test_split_tweet_text_no_spaces() {
let text = "a".repeat(300);
let chunks = split_tweet_text(&text, 280);
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0].len(), 280);
}
#[test]
fn test_config_serde() {
let toml_str = r#"
bearer_token = "AAAA"
allowed_users = ["user1"]
"#;
let config: crate::config::schema::TwitterConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.bearer_token, "AAAA");
assert_eq!(config.allowed_users, vec!["user1"]);
}
#[test]
fn test_config_serde_defaults() {
let toml_str = r#"
bearer_token = "tok"
"#;
let config: crate::config::schema::TwitterConfig = toml::from_str(toml_str).unwrap();
assert!(config.allowed_users.is_empty());
}
}
+409
View File
@@ -0,0 +1,409 @@
use super::traits::{Channel, ChannelMessage, SendMessage};
use anyhow::{bail, Result};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
/// Generic Webhook channel — receives messages via HTTP POST and sends replies
/// to a configurable outbound URL. This is the "universal adapter" for any system
/// that supports webhooks.
pub struct WebhookChannel {
listen_port: u16,
listen_path: String,
send_url: Option<String>,
send_method: String,
auth_header: Option<String>,
secret: Option<String>,
}
/// Incoming webhook payload format.
#[derive(Debug, Deserialize)]
struct IncomingWebhook {
sender: String,
content: String,
#[serde(default)]
thread_id: Option<String>,
}
/// Outgoing webhook payload format.
#[derive(Debug, Serialize)]
struct OutgoingWebhook {
content: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
recipient: Option<String>,
}
impl WebhookChannel {
pub fn new(
listen_port: u16,
listen_path: Option<String>,
send_url: Option<String>,
send_method: Option<String>,
auth_header: Option<String>,
secret: Option<String>,
) -> Self {
let path = listen_path.unwrap_or_else(|| "/webhook".to_string());
// Ensure path starts with /
let listen_path = if path.starts_with('/') {
path
} else {
format!("/{path}")
};
Self {
listen_port,
listen_path,
send_url,
send_method: send_method
.unwrap_or_else(|| "POST".to_string())
.to_uppercase(),
auth_header,
secret,
}
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.webhook")
}
/// Verify an incoming request's signature if a secret is configured.
fn verify_signature(&self, body: &[u8], signature: Option<&str>) -> bool {
let Some(ref secret) = self.secret else {
return true; // No secret configured, accept all
};
let Some(sig) = signature else {
return false; // Secret is set but no signature header provided
};
// HMAC-SHA256 verification
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else {
return false;
};
mac.update(body);
// Signature should be hex-encoded
let Ok(expected) = hex::decode(sig.trim_start_matches("sha256=")) else {
return false;
};
mac.verify_slice(&expected).is_ok()
}
}
#[async_trait]
impl Channel for WebhookChannel {
fn name(&self) -> &str {
"webhook"
}
async fn send(&self, message: &SendMessage) -> Result<()> {
let Some(ref send_url) = self.send_url else {
tracing::debug!("Webhook channel: no send_url configured, skipping outbound message");
return Ok(());
};
let client = self.http_client();
let payload = OutgoingWebhook {
content: message.content.clone(),
thread_id: message.thread_ts.clone(),
recipient: if message.recipient.is_empty() {
None
} else {
Some(message.recipient.clone())
},
};
let mut request = match self.send_method.as_str() {
"PUT" => client.put(send_url),
_ => client.post(send_url),
};
if let Some(ref auth) = self.auth_header {
request = request.header("Authorization", auth);
}
let resp = request.json(&payload).send().await?;
let status = resp.status();
if !status.is_success() {
let body = resp
.text()
.await
.unwrap_or_else(|e| format!("<failed to read response: {e}>"));
bail!("Webhook send failed ({status}): {body}");
}
Ok(())
}
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> Result<()> {
use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode},
routing::post,
Router,
};
use portable_atomic::{AtomicU64, Ordering};
use std::sync::Arc;
let counter = Arc::new(AtomicU64::new(0));
struct WebhookState {
tx: tokio::sync::mpsc::Sender<ChannelMessage>,
secret: Option<String>,
counter: Arc<AtomicU64>,
}
let state = Arc::new(WebhookState {
tx: tx.clone(),
secret: self.secret.clone(),
counter: counter.clone(),
});
let listen_path = self.listen_path.clone();
async fn handle_webhook(
State(state): State<Arc<WebhookState>>,
headers: HeaderMap,
body: Bytes,
) -> StatusCode {
// Verify signature if secret is configured
if let Some(ref secret) = state.secret {
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let signature = headers
.get("x-webhook-signature")
.and_then(|v| v.to_str().ok());
let valid = if let Some(sig) = signature {
if let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) {
mac.update(&body);
let expected =
hex::decode(sig.trim_start_matches("sha256=")).unwrap_or_default();
mac.verify_slice(&expected).is_ok()
} else {
false
}
} else {
false
};
if !valid {
tracing::warn!("Webhook: invalid signature, rejecting request");
return StatusCode::UNAUTHORIZED;
}
}
let payload: IncomingWebhook = match serde_json::from_slice(&body) {
Ok(p) => p,
Err(e) => {
tracing::warn!("Webhook: invalid JSON payload: {e}");
return StatusCode::BAD_REQUEST;
}
};
if payload.content.is_empty() {
return StatusCode::BAD_REQUEST;
}
let seq = state.counter.fetch_add(1, Ordering::Relaxed);
#[allow(clippy::cast_possible_truncation)]
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let reply_target = payload
.thread_id
.clone()
.unwrap_or_else(|| payload.sender.clone());
let msg = ChannelMessage {
id: format!("webhook_{seq}"),
sender: payload.sender,
reply_target,
content: payload.content,
channel: "webhook".to_string(),
timestamp,
thread_ts: payload.thread_id,
};
if state.tx.send(msg).await.is_err() {
return StatusCode::SERVICE_UNAVAILABLE;
}
StatusCode::OK
}
let app = Router::new()
.route(&listen_path, post(handle_webhook))
.with_state(state);
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], self.listen_port));
tracing::info!(
"Webhook channel listening on http://0.0.0.0:{}{} ...",
self.listen_port,
self.listen_path
);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app)
.await
.map_err(|e| anyhow::anyhow!("Webhook server error: {e}"))?;
Ok(())
}
async fn health_check(&self) -> bool {
// Webhook channel is healthy if the port can be bound (basic check).
// In practice, once listen() starts the server is running.
true
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_channel() -> WebhookChannel {
WebhookChannel::new(
8080,
Some("/webhook".into()),
Some("https://example.com/callback".into()),
None,
None,
None,
)
}
fn make_channel_with_secret() -> WebhookChannel {
WebhookChannel::new(
8080,
None,
Some("https://example.com/callback".into()),
None,
None,
Some("mysecret".into()),
)
}
#[test]
fn default_path() {
let ch = WebhookChannel::new(8080, None, None, None, None, None);
assert_eq!(ch.listen_path, "/webhook");
}
#[test]
fn path_normalized() {
let ch = WebhookChannel::new(8080, Some("hooks/incoming".into()), None, None, None, None);
assert_eq!(ch.listen_path, "/hooks/incoming");
}
#[test]
fn send_method_default() {
let ch = make_channel();
assert_eq!(ch.send_method, "POST");
}
#[test]
fn send_method_put() {
let ch = WebhookChannel::new(
8080,
None,
Some("https://example.com".into()),
Some("put".into()),
None,
None,
);
assert_eq!(ch.send_method, "PUT");
}
#[test]
fn incoming_payload_deserializes_all_fields() {
let json = r#"{"sender": "zeroclaw_user", "content": "hello", "thread_id": "t1"}"#;
let payload: IncomingWebhook = serde_json::from_str(json).unwrap();
assert_eq!(payload.sender, "zeroclaw_user");
assert_eq!(payload.content, "hello");
assert_eq!(payload.thread_id.as_deref(), Some("t1"));
}
#[test]
fn incoming_payload_without_thread() {
let json = r#"{"sender": "bob", "content": "hi"}"#;
let payload: IncomingWebhook = serde_json::from_str(json).unwrap();
assert_eq!(payload.sender, "bob");
assert_eq!(payload.content, "hi");
assert!(payload.thread_id.is_none());
}
#[test]
fn outgoing_payload_serializes_content() {
let payload = OutgoingWebhook {
content: "response".into(),
thread_id: Some("t1".into()),
recipient: Some("zeroclaw_user".into()),
};
let json = serde_json::to_value(&payload).unwrap();
assert_eq!(json["content"], "response");
assert_eq!(json["thread_id"], "t1");
assert_eq!(json["recipient"], "zeroclaw_user");
}
#[test]
fn outgoing_payload_omits_none_fields() {
let payload = OutgoingWebhook {
content: "response".into(),
thread_id: None,
recipient: None,
};
let json = serde_json::to_value(&payload).unwrap();
assert_eq!(json["content"], "response");
assert!(json.get("thread_id").is_none());
assert!(json.get("recipient").is_none());
}
#[test]
fn verify_signature_no_secret() {
let ch = make_channel();
assert!(ch.verify_signature(b"body", None));
}
#[test]
fn verify_signature_missing_header() {
let ch = make_channel_with_secret();
assert!(!ch.verify_signature(b"body", None));
}
#[test]
fn verify_signature_valid() {
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let ch = make_channel_with_secret();
let body = b"test body";
let mut mac = HmacSha256::new_from_slice(b"mysecret").unwrap();
mac.update(body);
let sig = hex::encode(mac.finalize().into_bytes());
assert!(ch.verify_signature(body, Some(&sig)));
}
#[test]
fn verify_signature_invalid() {
let ch = make_channel_with_secret();
assert!(!ch.verify_signature(b"body", Some("badhex")));
}
}
+350 -38
View File
@@ -64,6 +64,17 @@ pub struct WhatsAppWebChannel {
client: Arc<Mutex<Option<Arc<wa_rs::Client>>>>,
/// Message sender channel
tx: Arc<Mutex<Option<tokio::sync::mpsc::Sender<ChannelMessage>>>>,
/// Voice transcription (STT) config
transcription: Option<crate::config::TranscriptionConfig>,
/// Text-to-speech config for voice replies
tts_config: Option<crate::config::TtsConfig>,
/// Chats awaiting a voice reply — maps chat JID to the latest substantive
/// reply text. A background task debounces and sends the voice note after
/// the agent finishes its turn (no new send() for 3 seconds).
pending_voice:
Arc<std::sync::Mutex<std::collections::HashMap<String, (String, std::time::Instant)>>>,
/// Chats whose last incoming message was a voice note.
voice_chats: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
}
impl WhatsAppWebChannel {
@@ -90,9 +101,31 @@ impl WhatsAppWebChannel {
bot_handle: Arc::new(Mutex::new(None)),
client: Arc::new(Mutex::new(None)),
tx: Arc::new(Mutex::new(None)),
transcription: None,
tts_config: None,
pending_voice: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
voice_chats: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
}
}
/// Configure voice transcription (STT) for incoming voice notes.
#[cfg(feature = "whatsapp-web")]
pub fn with_transcription(mut self, config: crate::config::TranscriptionConfig) -> Self {
if config.enabled {
self.transcription = Some(config);
}
self
}
/// Configure text-to-speech for outgoing voice replies.
#[cfg(feature = "whatsapp-web")]
pub fn with_tts(mut self, config: crate::config::TtsConfig) -> Self {
if config.enabled {
self.tts_config = Some(config);
}
self
}
/// Check if a phone number is allowed (E.164 format: +1234567890)
#[cfg(feature = "whatsapp-web")]
fn is_number_allowed(&self, phone: &str) -> bool {
@@ -275,6 +308,134 @@ impl WhatsAppWebChannel {
format!("{expanded_session_path}-shm"),
]
}
/// Attempt to download and transcribe a WhatsApp voice note.
///
/// Returns `None` if transcription is disabled, download fails, or
/// transcription fails (all logged as warnings).
#[cfg(feature = "whatsapp-web")]
async fn try_transcribe_voice_note(
client: &wa_rs::Client,
audio: &wa_rs_proto::whatsapp::message::AudioMessage,
transcription_config: Option<&crate::config::TranscriptionConfig>,
) -> Option<String> {
let config = transcription_config?;
// Enforce duration limit
if let Some(seconds) = audio.seconds {
if u64::from(seconds) > config.max_duration_secs {
tracing::info!(
"WhatsApp Web: skipping voice note ({}s exceeds {}s limit)",
seconds,
config.max_duration_secs
);
return None;
}
}
// Download the encrypted audio
use wa_rs::download::Downloadable;
let audio_data = match client.download(audio as &dyn Downloadable).await {
Ok(data) => data,
Err(e) => {
tracing::warn!("WhatsApp Web: failed to download voice note: {e}");
return None;
}
};
// Determine filename from mimetype for transcription API
let file_name = match audio.mimetype.as_deref() {
Some(m) if m.contains("opus") || m.contains("ogg") => "voice.ogg",
Some(m) if m.contains("mp4") || m.contains("m4a") => "voice.m4a",
Some(m) if m.contains("mpeg") || m.contains("mp3") => "voice.mp3",
Some(m) if m.contains("webm") => "voice.webm",
_ => "voice.ogg", // WhatsApp default
};
tracing::info!(
"WhatsApp Web: transcribing voice note ({} bytes, file={})",
audio_data.len(),
file_name
);
match super::transcription::transcribe_audio(audio_data, file_name, config).await {
Ok(text) if text.trim().is_empty() => {
tracing::info!("WhatsApp Web: voice transcription returned empty text, skipping");
None
}
Ok(text) => {
tracing::info!(
"WhatsApp Web: voice note transcribed ({} chars)",
text.len()
);
Some(text)
}
Err(e) => {
tracing::warn!("WhatsApp Web: voice transcription failed: {e}");
None
}
}
}
/// Synthesize text to speech and send as a WhatsApp voice note (static version for spawned tasks).
#[cfg(feature = "whatsapp-web")]
async fn synthesize_voice_static(
client: &wa_rs::Client,
to: &wa_rs_binary::jid::Jid,
text: &str,
tts_config: &crate::config::TtsConfig,
) -> Result<()> {
let tts_manager = super::tts::TtsManager::new(tts_config)?;
let audio_bytes = tts_manager.synthesize(text).await?;
let audio_len = audio_bytes.len();
tracing::info!("WhatsApp Web TTS: synthesized {} bytes of audio", audio_len);
if audio_bytes.is_empty() {
anyhow::bail!("TTS returned empty audio");
}
use wa_rs_core::download::MediaType;
let upload = client
.upload(audio_bytes, MediaType::Audio)
.await
.map_err(|e| anyhow!("Failed to upload TTS audio: {e}"))?;
tracing::info!(
"WhatsApp Web TTS: uploaded audio (url_len={}, file_length={})",
upload.url.len(),
upload.file_length
);
// Estimate duration: Opus at ~32kbps → bytes / 4000 ≈ seconds
#[allow(clippy::cast_possible_truncation)]
let estimated_seconds = std::cmp::max(1, (upload.file_length / 4000) as u32);
let voice_msg = wa_rs_proto::whatsapp::Message {
audio_message: Some(Box::new(wa_rs_proto::whatsapp::message::AudioMessage {
url: Some(upload.url),
direct_path: Some(upload.direct_path),
media_key: Some(upload.media_key),
file_enc_sha256: Some(upload.file_enc_sha256),
file_sha256: Some(upload.file_sha256),
file_length: Some(upload.file_length),
mimetype: Some("audio/ogg; codecs=opus".to_string()),
ptt: Some(true),
seconds: Some(estimated_seconds),
..Default::default()
})),
..Default::default()
};
Box::pin(client.send_message(to.clone(), voice_msg))
.await
.map_err(|e| anyhow!("Failed to send voice note: {e}"))?;
tracing::info!(
"WhatsApp Web TTS: sent voice note ({} bytes, ~{}s)",
audio_len,
estimated_seconds
);
Ok(())
}
}
#[cfg(feature = "whatsapp-web")]
@@ -303,6 +464,88 @@ impl Channel for WhatsAppWebChannel {
}
let to = self.recipient_to_jid(&message.recipient)?;
// Voice chat mode: send text normally AND queue a voice note of the
// final answer. Only substantive messages (not tool outputs) are queued.
// A debounce task waits 10s after the last substantive message, then
// sends ONE voice note. Text in → text out. Voice in → text + voice out.
let is_voice_chat = self
.voice_chats
.lock()
.map(|vs| vs.contains(&message.recipient))
.unwrap_or(false);
if is_voice_chat && self.tts_config.is_some() {
let content = &message.content;
// Only queue substantive natural-language replies for voice.
// Skip tool outputs: URLs, JSON, code blocks, errors, short status.
let is_substantive = content.len() > 40
&& !content.starts_with("http")
&& !content.starts_with('{')
&& !content.starts_with('[')
&& !content.starts_with("Error")
&& !content.contains("```")
&& !content.contains("tool_call")
&& !content.contains("wttr.in");
if is_substantive {
if let Ok(mut pv) = self.pending_voice.lock() {
pv.insert(
message.recipient.clone(),
(content.clone(), std::time::Instant::now()),
);
}
let pending = self.pending_voice.clone();
let voice_chats = self.voice_chats.clone();
let client_clone = client.clone();
let to_clone = to.clone();
let recipient = message.recipient.clone();
let tts_config = self.tts_config.clone().unwrap();
tokio::spawn(async move {
// Wait 10 seconds — long enough for the agent to finish its
// full tool chain and send the final answer.
tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;
// Atomic check-and-remove: only one task gets the value
let to_voice = pending.lock().ok().and_then(|mut pv| {
if let Some((_, ts)) = pv.get(&recipient) {
if ts.elapsed().as_secs() >= 8 {
return pv.remove(&recipient).map(|(text, _)| text);
}
}
None
});
if let Some(text) = to_voice {
if let Ok(mut vc) = voice_chats.lock() {
vc.remove(&recipient);
}
match Box::pin(WhatsAppWebChannel::synthesize_voice_static(
&client_clone,
&to_clone,
&text,
&tts_config,
))
.await
{
Ok(()) => {
tracing::info!(
"WhatsApp Web: voice reply sent ({} chars)",
text.len()
);
}
Err(e) => {
tracing::warn!("WhatsApp Web: TTS voice reply failed: {e}");
}
}
}
});
}
// Fall through to send text normally (voice chat gets BOTH)
}
// Send text message
let outgoing = wa_rs_proto::whatsapp::Message {
conversation: Some(message.content.clone()),
..Default::default()
@@ -310,7 +553,7 @@ impl Channel for WhatsAppWebChannel {
let message_id = client.send_message(to, outgoing).await?;
tracing::debug!(
"WhatsApp Web: sent message to {} (id: {})",
"WhatsApp Web: sent text to {} (id: {})",
message.recipient,
message_id
);
@@ -380,40 +623,33 @@ impl Channel for WhatsAppWebChannel {
let logout_tx_clone = logout_tx.clone();
let retry_count_clone = retry_count.clone();
let session_revoked_clone = session_revoked.clone();
let transcription_config = self.transcription.clone();
let transcription_config = self.transcription.clone();
let voice_chats = self.voice_chats.clone();
let mut builder = Bot::builder()
.with_backend(backend)
.with_transport_factory(transport_factory)
.with_http_client(http_client)
.on_event(move |event, _client| {
.on_event(move |event, client| {
let tx_inner = tx_clone.clone();
let allowed_numbers = allowed_numbers.clone();
let logout_tx = logout_tx_clone.clone();
let retry_count = retry_count_clone.clone();
let session_revoked = session_revoked_clone.clone();
let transcription_config = transcription_config.clone();
let voice_chats = voice_chats.clone();
async move {
match event {
Event::Message(msg, info) => {
// Extract message content
let text = msg.text_content().unwrap_or("");
let sender_jid = info.source.sender.clone();
let sender_alt = info.source.sender_alt.clone();
let sender = sender_jid.user().to_string();
let chat = info.source.chat.to_string();
tracing::info!(
"WhatsApp Web message received (sender_len={}, chat_len={}, text_len={})",
sender.len(),
chat.len(),
text.len()
);
tracing::debug!(
"WhatsApp Web message content: {}",
text
);
let mapped_phone = if sender_jid.is_lid() {
_client.get_phone_number_from_lid(&sender_jid.user).await
client.get_phone_number_from_lid(&sender_jid.user).await
} else {
None
};
@@ -423,42 +659,92 @@ impl Channel for WhatsAppWebChannel {
mapped_phone.as_deref(),
);
if let Some(normalized) = sender_candidates
let normalized = match sender_candidates
.iter()
.find(|candidate| {
Self::is_number_allowed_for_list(&allowed_numbers, candidate)
})
.cloned()
{
let trimmed = text.trim();
if trimmed.is_empty() {
tracing::debug!(
"WhatsApp Web: ignoring empty or non-text message from {}",
normalized
Some(n) => n,
None => {
tracing::warn!(
"WhatsApp Web: message from unrecognized sender not in allowed list (candidates_count={})",
sender_candidates.len()
);
return;
}
};
if let Err(e) = tx_inner
.send(ChannelMessage {
id: uuid::Uuid::new_v4().to_string(),
channel: "whatsapp".to_string(),
sender: normalized.clone(),
// Reply to the originating chat JID (DM or group).
reply_target: chat,
content: trimmed.to_string(),
timestamp: chrono::Utc::now().timestamp() as u64,
thread_ts: None,
})
// Attempt voice note transcription (ptt = push-to-talk = voice note)
let voice_text = if let Some(ref audio) = msg.audio_message {
if audio.ptt == Some(true) {
Self::try_transcribe_voice_note(
&client,
audio,
transcription_config.as_ref(),
)
.await
{
tracing::error!("Failed to send message to channel: {}", e);
} else {
tracing::debug!(
"WhatsApp Web: ignoring non-PTT audio message from {}",
normalized
);
None
}
} else {
tracing::warn!(
"WhatsApp Web: message from unrecognized sender not in allowed list (candidates_count={})",
sender_candidates.len()
None
};
// Use transcribed voice text, or fall back to text content.
// Track whether this chat used a voice note so we reply in kind.
// We store the chat JID (reply_target) since that's what send() receives.
let content = if let Some(ref vt) = voice_text {
if let Ok(mut vs) = voice_chats.lock() {
vs.insert(chat.clone());
}
format!("[Voice] {vt}")
} else {
if let Ok(mut vs) = voice_chats.lock() {
vs.remove(&chat);
}
let text = msg.text_content().unwrap_or("");
text.trim().to_string()
};
tracing::info!(
"WhatsApp Web message received (sender_len={}, chat_len={}, content_len={})",
sender.len(),
chat.len(),
content.len()
);
tracing::debug!(
"WhatsApp Web message content: {}",
content
);
if content.is_empty() {
tracing::debug!(
"WhatsApp Web: ignoring empty or non-text message from {}",
normalized
);
return;
}
if let Err(e) = tx_inner
.send(ChannelMessage {
id: uuid::Uuid::new_v4().to_string(),
channel: "whatsapp".to_string(),
sender: normalized.clone(),
// Reply to the originating chat JID (DM or group).
reply_target: chat,
content,
timestamp: chrono::Utc::now().timestamp() as u64,
thread_ts: None,
})
.await
{
tracing::error!("Failed to send message to channel: {}", e);
}
}
Event::Connected(_) => {
@@ -695,6 +981,14 @@ impl WhatsAppWebChannel {
) -> Self {
Self { _private: () }
}
pub fn with_transcription(self, _config: crate::config::TranscriptionConfig) -> Self {
self
}
pub fn with_tts(self, _config: crate::config::TtsConfig) -> Self {
self
}
}
#[cfg(not(feature = "whatsapp-web"))]
@@ -936,6 +1230,24 @@ mod tests {
assert!(WhatsAppWebChannel::should_purge_session(&flag));
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn with_transcription_sets_config_when_enabled() {
let mut tc = crate::config::TranscriptionConfig::default();
tc.enabled = true;
let ch = make_channel().with_transcription(tc);
assert!(ch.transcription.is_some());
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn with_transcription_ignores_when_disabled() {
let tc = crate::config::TranscriptionConfig::default(); // enabled = false
let ch = make_channel().with_transcription(tc);
assert!(ch.transcription.is_none());
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn session_file_paths_includes_wal_and_shm() {
+21 -14
View File
@@ -1,24 +1,31 @@
pub mod schema;
pub mod traits;
pub mod workspace;
#[allow(unused_imports)]
pub use schema::{
apply_runtime_proxy_to_builder, build_runtime_proxy_client,
build_runtime_proxy_client_with_timeouts, runtime_proxy_config, set_runtime_proxy_config,
AgentConfig, AuditConfig, AutonomyConfig, BrowserComputerUseConfig, BrowserConfig,
BuiltinHooksConfig, ChannelsConfig, ClassificationRule, ComposioConfig, Config, CostConfig,
CronConfig, DelegateAgentConfig, DiscordConfig, DockerRuntimeConfig, EdgeTtsConfig,
ElevenLabsTtsConfig, EmbeddingRouteConfig, EstopConfig, FeishuConfig, GatewayConfig,
GoogleTtsConfig, HardwareConfig, HardwareTransport, HeartbeatConfig, HooksConfig,
HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, McpConfig,
McpServerConfig, McpTransport, MemoryConfig, ModelRouteConfig, MultimodalConfig,
NextcloudTalkConfig, NodesConfig, ObservabilityConfig, OpenAiTtsConfig, OtpConfig, OtpMethod,
PeripheralBoardConfig, PeripheralsConfig, ProxyConfig, ProxyScope, QdrantConfig,
QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig,
SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig, SkillsConfig,
SkillsPromptInjectionMode, SlackConfig, StorageConfig, StorageProviderConfig,
StorageProviderSection, StreamMode, TelegramConfig, ToolFilterGroup, ToolFilterGroupMode,
TranscriptionConfig, TtsConfig, TunnelConfig, WebFetchConfig, WebSearchConfig, WebhookConfig,
AgentConfig, AssemblyAiSttConfig, AuditConfig, AutonomyConfig, BackupConfig,
BrowserComputerUseConfig, BrowserConfig, BuiltinHooksConfig, ChannelsConfig,
ClassificationRule, CloudOpsConfig, ComposioConfig, Config, ConversationalAiConfig, CostConfig,
CronConfig, DataRetentionConfig, DeepgramSttConfig, DelegateAgentConfig, DiscordConfig,
DockerRuntimeConfig, EdgeTtsConfig, ElevenLabsTtsConfig, EmbeddingRouteConfig, EstopConfig,
FeishuConfig, GatewayConfig, GoogleSttConfig, GoogleTtsConfig, GoogleWorkspaceConfig,
HardwareConfig, HardwareTransport, HeartbeatConfig, HooksConfig, HttpRequestConfig,
IMessageConfig, IdentityConfig, ImageProviderDalleConfig, ImageProviderFluxConfig,
ImageProviderImagenConfig, ImageProviderStabilityConfig, KnowledgeConfig, LarkConfig,
LinkedInConfig, LinkedInContentConfig, LinkedInImageConfig, MatrixConfig, McpConfig,
McpServerConfig, McpTransport, MemoryConfig, Microsoft365Config, ModelRouteConfig,
MultimodalConfig, NextcloudTalkConfig, NodeTransportConfig, NodesConfig, NotionConfig,
ObservabilityConfig, OpenAiSttConfig, OpenAiTtsConfig, OpenVpnTunnelConfig, OtpConfig,
OtpMethod, PeripheralBoardConfig, PeripheralsConfig, ProjectIntelConfig, ProxyConfig,
ProxyScope, QdrantConfig, QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig,
RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig,
SecurityOpsConfig, SkillsConfig, SkillsPromptInjectionMode, SlackConfig, StorageConfig,
StorageProviderConfig, StorageProviderSection, StreamMode, SwarmConfig, SwarmStrategy,
TelegramConfig, ToolFilterGroup, ToolFilterGroupMode, TranscriptionConfig, TtsConfig,
TunnelConfig, WebFetchConfig, WebSearchConfig, WebhookConfig, WorkspaceConfig,
};
pub fn name_and_presence<T: traits::ChannelConfig>(channel: Option<&T>) -> (&'static str, bool) {
+2421 -22
View File
File diff suppressed because it is too large Load Diff
+382
View File
@@ -0,0 +1,382 @@
//! Workspace profile management for multi-client isolation.
//!
//! Each workspace represents an isolated client engagement with its own
//! memory namespace, audit trail, secrets scope, and tool restrictions.
//! Profiles are stored under `~/.zeroclaw/workspaces/<client_name>/`.
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
/// A single client workspace profile.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceProfile {
/// Human-readable workspace name (also used as directory name).
pub name: String,
/// Allowed domains for network access within this workspace.
#[serde(default)]
pub allowed_domains: Vec<String>,
/// Credential profile name scoped to this workspace.
#[serde(default)]
pub credential_profile: Option<String>,
/// Memory namespace prefix for isolation.
#[serde(default)]
pub memory_namespace: Option<String>,
/// Audit namespace prefix for isolation.
#[serde(default)]
pub audit_namespace: Option<String>,
/// Tool names denied in this workspace (e.g. `["shell"]` to block shell access).
#[serde(default)]
pub tool_restrictions: Vec<String>,
}
impl WorkspaceProfile {
/// Effective memory namespace (falls back to workspace name).
pub fn effective_memory_namespace(&self) -> &str {
self.memory_namespace
.as_deref()
.unwrap_or(self.name.as_str())
}
/// Effective audit namespace (falls back to workspace name).
pub fn effective_audit_namespace(&self) -> &str {
self.audit_namespace
.as_deref()
.unwrap_or(self.name.as_str())
}
/// Returns true if the given tool name is restricted in this workspace.
pub fn is_tool_restricted(&self, tool_name: &str) -> bool {
self.tool_restrictions
.iter()
.any(|r| r.eq_ignore_ascii_case(tool_name))
}
/// Returns true if the given domain is allowed for this workspace.
/// An empty allowlist means all domains are allowed.
pub fn is_domain_allowed(&self, domain: &str) -> bool {
if self.allowed_domains.is_empty() {
return true;
}
let domain_lower = domain.to_ascii_lowercase();
self.allowed_domains
.iter()
.any(|d| domain_lower == d.to_ascii_lowercase())
}
}
/// Manages loading and switching between client workspace profiles.
#[derive(Debug, Clone)]
pub struct WorkspaceManager {
/// Base directory containing all workspace subdirectories.
workspaces_dir: PathBuf,
/// Loaded workspace profiles keyed by name.
profiles: HashMap<String, WorkspaceProfile>,
/// Currently active workspace name.
active: Option<String>,
}
impl WorkspaceManager {
/// Create a new workspace manager rooted at the given directory.
pub fn new(workspaces_dir: PathBuf) -> Self {
Self {
workspaces_dir,
profiles: HashMap::new(),
active: None,
}
}
/// Load all workspace profiles from disk.
///
/// Each subdirectory of `workspaces_dir` that contains a `profile.toml`
/// is treated as a workspace.
pub async fn load_profiles(&mut self) -> Result<()> {
self.profiles.clear();
let dir = &self.workspaces_dir;
if !dir.exists() {
return Ok(());
}
let mut entries = tokio::fs::read_dir(dir)
.await
.with_context(|| format!("reading workspaces directory: {}", dir.display()))?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if !path.is_dir() {
continue;
}
let profile_path = path.join("profile.toml");
if !profile_path.exists() {
continue;
}
match tokio::fs::read_to_string(&profile_path).await {
Ok(contents) => match toml::from_str::<WorkspaceProfile>(&contents) {
Ok(profile) => {
self.profiles.insert(profile.name.clone(), profile);
}
Err(e) => {
tracing::warn!(
"skipping malformed workspace profile {}: {e}",
profile_path.display()
);
}
},
Err(e) => {
tracing::warn!(
"skipping unreadable workspace profile {}: {e}",
profile_path.display()
);
}
}
}
Ok(())
}
/// Switch to the named workspace. Returns an error if it does not exist.
pub fn switch(&mut self, name: &str) -> Result<&WorkspaceProfile> {
if !self.profiles.contains_key(name) {
bail!("workspace '{}' not found", name);
}
self.active = Some(name.to_string());
Ok(&self.profiles[name])
}
/// Get the currently active workspace profile, if any.
pub fn active_profile(&self) -> Option<&WorkspaceProfile> {
self.active
.as_deref()
.and_then(|name| self.profiles.get(name))
}
/// Get the active workspace name.
pub fn active_name(&self) -> Option<&str> {
self.active.as_deref()
}
/// List all loaded workspace names.
pub fn list(&self) -> Vec<&str> {
let mut names: Vec<&str> = self.profiles.keys().map(String::as_str).collect();
names.sort_unstable();
names
}
/// Get a workspace profile by name.
pub fn get(&self, name: &str) -> Option<&WorkspaceProfile> {
self.profiles.get(name)
}
/// Create a new workspace on disk and register it.
pub async fn create(&mut self, name: &str) -> Result<&WorkspaceProfile> {
if name.is_empty() {
bail!("workspace name must not be empty");
}
// Validate name: alphanumeric, hyphens, underscores only
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
bail!(
"workspace name must contain only alphanumeric characters, hyphens, or underscores"
);
}
if self.profiles.contains_key(name) {
bail!("workspace '{}' already exists", name);
}
let ws_dir = self.workspaces_dir.join(name);
tokio::fs::create_dir_all(&ws_dir)
.await
.with_context(|| format!("creating workspace directory: {}", ws_dir.display()))?;
let profile = WorkspaceProfile {
name: name.to_string(),
allowed_domains: Vec::new(),
credential_profile: None,
memory_namespace: Some(name.to_string()),
audit_namespace: Some(name.to_string()),
tool_restrictions: Vec::new(),
};
let toml_str = toml::to_string_pretty(&profile).context("serializing workspace profile")?;
let profile_path = ws_dir.join("profile.toml");
tokio::fs::write(&profile_path, toml_str)
.await
.with_context(|| format!("writing workspace profile: {}", profile_path.display()))?;
self.profiles.insert(name.to_string(), profile);
Ok(&self.profiles[name])
}
/// Export a workspace profile as a sanitized TOML string (no secrets).
pub fn export(&self, name: &str) -> Result<String> {
let profile = self
.profiles
.get(name)
.with_context(|| format!("workspace '{}' not found", name))?;
// Create an export-safe copy with credential_profile redacted
let export = WorkspaceProfile {
credential_profile: profile
.credential_profile
.as_ref()
.map(|_| "***".to_string()),
..profile.clone()
};
toml::to_string_pretty(&export).context("serializing workspace profile for export")
}
/// Directory for a specific workspace.
pub fn workspace_dir(&self, name: &str) -> PathBuf {
self.workspaces_dir.join(name)
}
/// Base workspaces directory.
pub fn workspaces_dir(&self) -> &Path {
&self.workspaces_dir
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn sample_profile(name: &str) -> WorkspaceProfile {
WorkspaceProfile {
name: name.to_string(),
allowed_domains: vec!["example.com".to_string()],
credential_profile: Some("test-creds".to_string()),
memory_namespace: Some(format!("{name}_mem")),
audit_namespace: Some(format!("{name}_audit")),
tool_restrictions: vec!["shell".to_string()],
}
}
#[test]
fn workspace_profile_tool_restriction_check() {
let profile = sample_profile("client_a");
assert!(profile.is_tool_restricted("shell"));
assert!(profile.is_tool_restricted("Shell"));
assert!(!profile.is_tool_restricted("file_read"));
}
#[test]
fn workspace_profile_domain_allowlist_empty_allows_all() {
let mut profile = sample_profile("client_a");
profile.allowed_domains.clear();
assert!(profile.is_domain_allowed("anything.com"));
}
#[test]
fn workspace_profile_domain_allowlist_enforced() {
let profile = sample_profile("client_a");
assert!(profile.is_domain_allowed("example.com"));
assert!(!profile.is_domain_allowed("other.com"));
}
#[test]
fn workspace_profile_effective_namespaces() {
let profile = sample_profile("client_a");
assert_eq!(profile.effective_memory_namespace(), "client_a_mem");
assert_eq!(profile.effective_audit_namespace(), "client_a_audit");
let fallback = WorkspaceProfile {
name: "test_ws".to_string(),
memory_namespace: None,
audit_namespace: None,
..sample_profile("test_ws")
};
assert_eq!(fallback.effective_memory_namespace(), "test_ws");
assert_eq!(fallback.effective_audit_namespace(), "test_ws");
}
#[tokio::test]
async fn workspace_manager_create_and_list() {
let tmp = TempDir::new().unwrap();
let mut mgr = WorkspaceManager::new(tmp.path().to_path_buf());
mgr.create("client_alpha").await.unwrap();
mgr.create("client_beta").await.unwrap();
let names = mgr.list();
assert_eq!(names, vec!["client_alpha", "client_beta"]);
}
#[tokio::test]
async fn workspace_manager_create_rejects_duplicate() {
let tmp = TempDir::new().unwrap();
let mut mgr = WorkspaceManager::new(tmp.path().to_path_buf());
mgr.create("client_a").await.unwrap();
let result = mgr.create("client_a").await;
assert!(result.is_err());
}
#[tokio::test]
async fn workspace_manager_create_rejects_invalid_name() {
let tmp = TempDir::new().unwrap();
let mut mgr = WorkspaceManager::new(tmp.path().to_path_buf());
assert!(mgr.create("").await.is_err());
assert!(mgr.create("bad name").await.is_err());
assert!(mgr.create("../escape").await.is_err());
}
#[tokio::test]
async fn workspace_manager_switch_and_active() {
let tmp = TempDir::new().unwrap();
let mut mgr = WorkspaceManager::new(tmp.path().to_path_buf());
mgr.create("ws_one").await.unwrap();
assert!(mgr.active_profile().is_none());
mgr.switch("ws_one").unwrap();
assert_eq!(mgr.active_name(), Some("ws_one"));
assert!(mgr.active_profile().is_some());
}
#[test]
fn workspace_manager_switch_nonexistent_fails() {
let mgr = WorkspaceManager::new(PathBuf::from("/tmp/nonexistent"));
let mut mgr = mgr;
assert!(mgr.switch("no_such_ws").is_err());
}
#[tokio::test]
async fn workspace_manager_load_profiles_from_disk() {
let tmp = TempDir::new().unwrap();
let mut mgr = WorkspaceManager::new(tmp.path().to_path_buf());
// Create a workspace via the manager
mgr.create("loaded_ws").await.unwrap();
// Create a fresh manager and load from disk
let mut mgr2 = WorkspaceManager::new(tmp.path().to_path_buf());
mgr2.load_profiles().await.unwrap();
assert_eq!(mgr2.list(), vec!["loaded_ws"]);
let profile = mgr2.get("loaded_ws").unwrap();
assert_eq!(profile.name, "loaded_ws");
}
#[tokio::test]
async fn workspace_manager_export_redacts_credentials() {
let tmp = TempDir::new().unwrap();
let mut mgr = WorkspaceManager::new(tmp.path().to_path_buf());
mgr.create("export_test").await.unwrap();
// Manually set a credential profile
if let Some(profile) = mgr.profiles.get_mut("export_test") {
profile.credential_profile = Some("secret-cred-id".to_string());
}
let exported = mgr.export("export_test").unwrap();
assert!(exported.contains("***"));
assert!(!exported.contains("secret-cred-id"));
}
}

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