Compare commits

...

78 Commits

Author SHA1 Message Date
Argenis d9cea87fae Merge pull request #3998 from zeroclaw-labs/fix/images-2
fix(docs): absolute banner URLs + web dashboard logo update
2026-03-19 16:47:09 -04:00
argenis de la rosa 6213bcab07 fix(docs): use absolute URLs for banner in all READMEs + update web dashboard logo
- Replace relative docs/assets/zeroclaw-banner.png paths with absolute
  raw.githubusercontent.com URLs in all 31 README files so the banner
  renders correctly regardless of where the README is viewed
- Switch web dashboard favicon and logos from logo.png to zeroclaw-trans.png
- Add zeroclaw-trans.png and zeroclaw-banner.png assets
- Update build.rs to track new dashboard asset
- Fix missing autonomy_level in new test + Box::pin large future

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 16:34:32 -04:00
argenis de la rosa fe9f58f917 fix(docs): use absolute URLs for banner in all READMEs + update web dashboard logo
- Replace relative docs/assets/zeroclaw-banner.png paths with absolute
  raw.githubusercontent.com URLs in all 31 README files so the banner
  renders correctly regardless of where the README is viewed
- Switch web dashboard favicon and logos from logo.png to zeroclaw-trans.png
- Add zeroclaw-trans.png and zeroclaw-banner.png assets
- Update build.rs to track new dashboard asset

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 16:31:21 -04:00
Argenis 04c7ce4488 Merge pull request #3955 from Alix-007/issue-3952-full-autonomy-channel-prompt
fix(prompt): respect autonomy level in channel prompts
2026-03-19 16:12:13 -04:00
Argenis 5eea95ef2a Merge pull request #3899 from Alix-007/issue-3842-openrouter-timeout
fix(openrouter): respect provider_timeout_secs on slow responses
2026-03-19 16:06:57 -04:00
argenis de la rosa af1c37c2fb fix: pass autonomy_level through to prompt builder in wrapper function
build_system_prompt_with_mode was discarding the autonomy_level
parameter, passing None to build_system_prompt_with_mode_and_autonomy.
This caused full-autonomy prompts to still include "ask before acting"
instructions. Convert the level to an AutonomyConfig and pass it through.
2026-03-19 15:56:37 -04:00
argenis de la rosa e3e4aef21c fix: box-pin large future in config init test to satisfy clippy
Config::load_or_init() produces a future >16KB, triggering
clippy::large_futures. Wrap with Box::pin() as recommended.
2026-03-19 15:44:41 -04:00
argenis de la rosa a48e335be9 fix: box-pin large future in config init test to satisfy clippy
Config::load_or_init() produces a future >16KB, triggering
clippy::large_futures. Wrap with Box::pin() as recommended.
2026-03-19 15:44:19 -04:00
argenis de la rosa fba15520dc fix: add missing autonomy_level field to test ChannelRuntimeContext
The full_autonomy_prompt test was missing the autonomy_level field
added to ChannelRuntimeContext by a recently merged PR.
2026-03-19 15:32:23 -04:00
argenis de la rosa 7504da1117 fix: add missing autonomy_level arg to test after merge with master
The refresh-skills test was missing the autonomy_level parameter
added to build_system_prompt_with_mode and ChannelRuntimeContext
by a recently merged PR.
2026-03-19 15:31:18 -04:00
argenis de la rosa 6292cdfe1c Merge origin/master into issue-3952-full-autonomy-channel-prompt
Resolve conflict in src/channels/mod.rs Safety section. Keeps the
PR's AutonomyConfig-based prompt construction (build_system_prompt_with_mode_and_autonomy)
while incorporating master's granular safety rules (conditional
destructive-command and ask-before-acting lines based on autonomy level).
Also fixes missing autonomy_level arg in refresh-skills test and removes
duplicate autonomy.level args from auto-merged call sites.
2026-03-19 15:27:43 -04:00
argenis de la rosa 693661b564 Merge origin/master into issue-3842-openrouter-timeout
Resolve merge conflicts keeping the PR's changes:
- timeout_secs parameter in OpenRouterProvider::new()
- read_response_body + parse_response_body pattern
- OPENROUTER_CONNECT_TIMEOUT_SECS and DEFAULT_OPENROUTER_TIMEOUT_SECS constants
- Update master's new tests to use two-arg new() signature
2026-03-19 15:19:32 -04:00
Argenis 4daec8c0df Merge pull request #3288 from Alix-007/fix-2400-block-config-self-mutation
fix(security): block agent writes to runtime config state
2026-03-19 15:16:48 -04:00
Argenis 3cf609cb38 Merge pull request #3959 from Alix-007/issue-3706-read-skill
feat(skills): add read_skill for compact mode
2026-03-19 15:16:42 -04:00
Argenis e1b7d29f1b Merge pull request #3940 from Alix-007/issue-3845-refresh-skills-on-new
channel: refresh available skills after /new
2026-03-19 15:16:35 -04:00
Argenis fef69a4128 Merge pull request #3787 from Alix-007/issue-3774-path-normalization
fix(tools): normalize workspace-prefixed paths
2026-03-19 15:16:17 -04:00
Argenis 643b683c39 Merge pull request #3954 from Alix-007/issue-2901-zai-tool-stream
fix(zai): enable tool_stream for tool-capable requests
2026-03-19 15:15:59 -04:00
Argenis 74c93b0ebc Merge pull request #3943 from Alix-007/issue-3902-claude-code-test-race
test(claude_code): isolate echo script per test run
2026-03-19 15:15:52 -04:00
Argenis a7bf69d279 Merge pull request #3356 from Alix-007/fix/config-load-initialized-state
fix(config): report existing configs as initialized on load
2026-03-19 15:15:47 -04:00
Argenis f68af9a4c7 Merge pull request #3994 from zeroclaw-labs/fix/images
fix(docs): update banner image and add Instagram to all READMEs
2026-03-19 15:14:02 -04:00
Argenis cca3d66955 fix: add Node.js build dependency to Homebrew formula template (#3996)
The Homebrew publish workflow downloads a source tarball but never
ensures Node.js is available during the build. Without it, build.rs
cannot run npm to compile the web frontend, so Homebrew-installed
ZeroClaw ships without the web dashboard.

Add `depends_on "node" => :build` to the formula by inserting it
after the existing `depends_on "rust" => :build` line. This lets
build.rs detect npm and automatically run `npm ci && npm run build`
to produce the web/dist assets.

Fixes #3991
2026-03-19 15:13:56 -04:00
Argenis 95bf229225 fix(config): enable compact_context by default (#3995)
* fix: change compact_context default to true

Local LLMs with limited context windows immediately run out of context
when compact_context defaults to false. The system prompt alone can
consume 25K+ tokens, exceeding even 55K context windows with history.

Setting compact_context=true by default limits system prompt injection
to 6000 chars and RAG results to 2 chunks, making the agent usable
with smaller models out of the box.

Fixes #3987

* docs: update compact_context default to true in config reference

Update all locale variants (en, zh-CN, vi) to reflect the new default.

* test: update tests to expect compact_context default of true

Update assertions in schema.rs unit tests and config_persistence.rs
component tests to match the new default value.
2026-03-19 15:13:14 -04:00
argenis de la rosa ebe19147f2 fix(docs): update banner image and add Instagram badge to all READMEs
- Replace zeroclaw.png (broken/outdated) with zeroclaw-banner.png
  across all 30 translated README files
- Add Instagram social badge (@therealzeroclaw) to all translations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 14:57:28 -04:00
Argenis e387f58579 Merge pull request #3951 from Alix-007/issue-3946-release-memory-postgres
build(release): include memory-postgres in published artifacts
2026-03-19 14:42:15 -04:00
Argenis fa06798926 fix(cron): persist allowed_tools for agent jobs (#3993)
Persist allowed_tools in cron_jobs table, threading it through CLI add/update and cron_add/cron_update tool APIs. Add regression coverage for store, tool, and CLI roundtrip paths.

Fixups over original PR #3929: add allowed_tools to all_overdue_jobs SELECT (merge gap), resolve merge conflicts.

Closes #3920
Supersedes #3929
2026-03-19 14:37:55 -04:00
Alix-007 a4cd4b287e feat(slack): add thread_replies channel option (#3930)
Add a thread_replies option to Slack channel config (default true). When false, replies go to channel root instead of the originating thread.

Closes #3888
2026-03-19 14:32:02 -04:00
argenis de la rosa 3ce7f2345e merge: resolve conflicts with master, include channel-lark in RELEASE_CARGO_FEATURES
Add channel-lark (merged to master separately) to RELEASE_CARGO_FEATURES
env var. Keep the DRY env-var approach and remove stale Docker build-args.
2026-03-19 14:24:30 -04:00
Argenis eb9dfc04b4 fix(anthropic): always apply cache_control to system prompts (#3990)
* fix: always use Blocks format for system prompts with cache_control

System prompts under 3KB were wrapped in SystemPrompt::String which
cannot carry cache_control headers, resulting in 0% cache hit rate
on Haiku 4.5. Always use SystemPrompt::Blocks with ephemeral
cache_control regardless of prompt size.

Fixes #3977

* fix: lower conversation caching threshold from >4 to >1 messages

The previous threshold of >4 non-system messages was too restrictive,
delaying cache benefits until 5+ turns. Lower to >1 so caching kicks
in after the first user+assistant exchange.

Fixes #3977

* test: update anthropic cache tests for new thresholds and Blocks format

- convert_messages_small_system_prompt now expects Blocks with
  cache_control instead of String variant
- should_cache_conversation tests updated for >1 threshold
- backward_compatibility test replaced with blocks-system test
2026-03-19 14:21:45 -04:00
Argenis 9cc74a2698 fix(security): wire sandbox into shell command execution (#3989)
* fix: add sandbox field to ShellTool struct

Add `sandbox: Arc<dyn Sandbox>` field to `ShellTool` and a
`new_with_sandbox()` constructor so callers can inject the configured
sandbox backend. The existing `new()` constructor defaults to
`NoopSandbox` for backward compatibility.

Ref: #3983

* fix: apply sandbox wrapping in ShellTool::execute()

Call `self.sandbox.wrap_command()` on the underlying std::process::Command
(via `as_std_mut()`) after building the shell command and before clearing
the environment. This ensures every shell command passes through the
configured sandbox backend before execution.

Ref: #3983

* fix: wire up sandbox creation at ShellTool callsites

In `all_tools_with_runtime()`, create a sandbox from
`root_config.security` via `create_sandbox()` and pass it to
`ShellTool::new_with_sandbox()`. The `default_tools_with_runtime()`
path retains `ShellTool::new()` which defaults to `NoopSandbox`.

Ref: #3983

* test: add sandbox integration tests for ShellTool

Verify that ShellTool can be constructed with a sandbox via
`new_with_sandbox()`, that NoopSandbox leaves commands unmodified,
and that command execution works end-to-end with a sandbox attached.

Ref: #3983
2026-03-19 14:21:42 -04:00
Argenis 133dc46b41 fix(web): restore accidentally deleted logo file (#3988)
* fix: restore accidentally deleted logo file

The logo.png was removed in commit 48bdbde2 but is still referenced
by the web UI components. Restore it from git history.

Fixes #3984

* fix: copy logo to web/dist for rust-embed

The Rust binary embeds files from web/dist/ via rust-embed, so the
logo must also be present there to be served without a rebuild.

Fixes #3984
2026-03-19 14:21:15 -04:00
Argenis ad03605cad Merge pull request #3949 from Alix-007/issue-3817-cron-delivery-context
fix: default cron delivery to the active channel context
2026-03-19 14:20:59 -04:00
Argenis ae1acf9b9c Merge pull request #3950 from Alix-007/issue-3466-homebrew-service-workspace
fix(onboard): warn when Homebrew services use another workspace
2026-03-19 14:20:56 -04:00
Alix-007 cc91f22e9b fix(skills): narrow shell shebang detection (#3944)
Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
2026-03-19 14:10:51 -04:00
Martin 030f5fe288 fix(install): fix guided installer in LXC/container environments (#3947)
Replace subshell-based /dev/stdin probing in guided_input_stream with a
file-descriptor approach (guided_open_input) that works reliably in LXC
containers accessed over SSH.

The previous implementation probed /dev/stdin and /proc/self/fd/0 via
subshells before falling back to /dev/tty. In LXC containers these
probes fail even when FD 0 is perfectly usable, causing the guided
installer to exit with "requires an interactive terminal".

The fix:
- When stdin is a terminal (-t 0), assign GUIDED_FD=0 directly without
  any subshell probing — trusting the kernel's own tty check
- Otherwise, open /dev/tty as an explicit fd (exec {GUIDED_FD}</dev/tty)
- guided_read uses `read -u "$GUIDED_FD"` instead of `< "$file_path"`
- Add echo after silent reads (password prompts) for correct line handling

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 14:10:48 -04:00
Giulio V c47bbcc972 fix(cron): add startup catch-up and drop login shell flag (#3948)
* fix(cron): add startup catch-up and drop login shell flag

Problems:
1. When ZeroClaw started after downtime (late boot, daemon restart),
   overdue jobs were picked up via `due_jobs()` but limited by
   `max_tasks` per poll cycle — with many overdue jobs, catch-up
   could take many cycles.
2. Cron shell jobs used `sh -lc` (login shell), which loads the
   full user profile on every execution — slow and may cause
   unexpected side effects.

Fixes:
- Add `all_overdue_jobs()` store query without `max_tasks` limit
- Add `catch_up_overdue_jobs()` startup phase that runs ALL overdue
  jobs once before entering the normal polling loop
- Extract `build_cron_shell_command()` helper using `sh -c` (non-login)
- Add structured tracing for catch-up progress
- Add tests for all new functions

* feat(cron): make catch-up configurable via API and control panel

Add `catch_up_on_startup` boolean to `[cron]` config (default: true).
When enabled, the scheduler runs all overdue jobs at startup before
entering the normal polling loop. Users can toggle this from:

- The Cron page toggle switch in the control panel
- PATCH /api/cron/settings { "catch_up_on_startup": false }
- The `[cron]` section of the TOML config editor

Also adds GET /api/cron/settings endpoint to read cron subsystem
settings without parsing the full config.

* fix(config): add catch_up_on_startup to CronConfig test constructors

The CI Lint job failed because the `cron_config_serde_roundtrip` test
constructs CronConfig directly and was missing the new field.
2026-03-19 14:10:37 -04:00
Argenis 72fbb22059 Merge pull request #3985 from zeroclaw-labs/fix/aur-ssh-publish
fix(ci): harden AUR SSH key setup and add diagnostics
2026-03-19 13:44:01 -04:00
argenis de la rosa cbb3d9ae92 fix(ci): harden AUR SSH key setup and add diagnostics (#3952)
The AUR publish step fails with "Permission denied (publickey)".
Root cause is likely key formatting (Windows line endings from
GitHub secrets UI) or missing public key registration on AUR.

Changes:
- Normalize line endings (strip \r) when writing SSH key
- Set correct permissions on ~/.ssh (700) and ~/.ssh/config (600)
- Validate key with ssh-keygen before attempting clone
- Add SSH connectivity test for clearer error diagnostics

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:28:58 -04:00
Argenis 8d1eebad4d Merge pull request #3980 from zeroclaw-labs/version-bump-0.5.1
chore: bump version to 0.5.1
2026-03-19 09:56:36 -04:00
argenis de la rosa 0fdd1ad490 chore: bump version to 0.5.1
Release highlights:
- Autonomy enforcement in gateway and channel paths (#3952)
- conversational_ai startup warning for unimplemented config (#3958)
- Heartbeat default interval 30→5min (#3938)
- Provider timeout and error handling improvements (#3973, #3978)
- Docker/CI postgres and Lark feature fixes (#3971, #3933)
- Tool path resolution fix (#3937)
- OTP config fix (#3936)
- README: Instagram badge + banner image (#3979)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 09:45:32 -04:00
Argenis 86bc60fcd1 Merge pull request #3979 from zeroclaw-labs/readme
docs: add Instagram badge and banner to README
2026-03-19 09:42:00 -04:00
argenis de la rosa 4837e1fe73 docs(readme): add Instagram social badge and switch to banner image
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 09:30:54 -04:00
Argenis 985977ae0c fix(providers): exempt tool schema errors from non-retryable classification (#3978)
* fix: exempt tool schema validation errors from non-retryable classification

Groq returns 400 "tool call validation failed" which was classified as
non-retryable by is_non_retryable(), preventing the provider-level
fallback in compatible.rs from executing. Add is_tool_schema_error()
to detect these errors and return false from is_non_retryable(), allowing
the retry loop to pass control back to the provider's built-in fallback.

Fixes #3757

* test: add unit tests for tool schema error detection in reliable.rs

Verify is_tool_schema_error detects Groq-style validation failures and
that is_non_retryable returns false for tool schema 400s while still
returning true for other 400 errors like invalid API key.

* fix: escape format braces in test string literals for cargo check

The anyhow::anyhow! macro interprets curly braces as format
placeholders. Use explicit format argument to pass JSON-containing
strings in tests.
2026-03-19 09:25:49 -04:00
Argenis 72b10f12dd Merge pull request #3975 from zeroclaw-labs/agent-loop
fix: enforce autonomy level in gateway/channel paths + conversational_ai warning
2026-03-19 09:20:21 -04:00
Argenis 3239f5ea07 fix(ci): include channel-lark feature in precompiled release binaries (#3933) (#3972)
Add channel-lark to the cargo --features flag in all release and
cross-platform build workflows, and to the Docker build-args.  This
gives users Feishu/Lark channel support out of the box without needing
to compile from source.

The channel-lark feature depends only on dep:prost (pure Rust protobuf),
so it is safe to enable on all platforms (Linux, macOS, Windows, Android).
2026-03-19 09:15:10 -04:00
Argenis 3353729b01 fix(openrouter): respect provider_timeout_secs and improve error messages (#3973)
* fix(openrouter): wire provider_timeout_secs through factory

Apply the configured provider_timeout_secs to OpenRouterProvider
in the provider factory, matching the pattern used for compatible
providers.

* fix(openrouter): add timeout_secs field to OpenRouterProvider

Add a configurable timeout_secs field (default 120s) and a
with_timeout_secs() builder method so the HTTP client timeout
can be overridden via provider config instead of being hardcoded.

* refactor(openrouter): improve response decode error messages

Read the response body as text first, then parse with
serde_json::from_str so that decode failures include a truncated
snippet of the raw body for easier debugging.

* test(openrouter): add timeout_secs configuration tests

Verify that the default timeout is 120s and that with_timeout_secs
correctly overrides it.

* style: run rustfmt on openrouter.rs
2026-03-19 09:12:14 -04:00
argenis de la rosa b6c2930a70 fix(agent): enforce autonomy level in gateway and channel paths (#3952)
- Channel tool filtering (`non_cli_excluded_tools`) now respects
  `autonomy.level = "full"` — full-autonomy agents keep all tools
  available regardless of channel.
- Gateway `process_message` now creates and passes an `ApprovalManager`
  to `agent_turn`, so `ReadOnly`/`Supervised` policies are enforced
  instead of silently skipped.
- Gateway also applies `non_cli_excluded_tools` filtering with the same
  full-autonomy bypass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 08:56:45 -04:00
argenis de la rosa 181cafff70 fix(config): warn when conversational_ai.enabled is set (#3958)
The conversational_ai config section is parsed but not yet consumed by
any runtime code. Emit a startup warning so users know the setting is
ignored, and update the doc comment to mark it as reserved for future use.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 08:56:38 -04:00
Argenis d87f387111 fix(docker): add memory-postgres feature to Docker and CI builds (#3971)
The GHCR image v0.5.0 fails to start when users configure the postgres
memory backend because the binary was compiled without the
memory-postgres cargo feature. Add it to all build configurations:
Dockerfiles (default ARG), release workflows (cargo build --features),
and Docker push steps (ZEROCLAW_CARGO_FEATURES build-arg).

Fixes #3946
2026-03-19 08:51:20 -04:00
Argenis 7068079028 fix: make channel system prompt respect autonomy.level = full (#3952) (#3970)
When autonomy.level is set to "full", the channel/web system prompt no
longer includes instructions telling the model to ask for permission
before executing tools. Previously these safety lines were hardcoded
regardless of autonomy config, causing the LLM to simulate approval
dialogs in channel and web-interface modes even though the
ApprovalManager correctly allowed execution.

The fix adds an autonomy_level parameter to build_system_prompt_with_mode
and conditionally omits the "ask before acting" instructions when the
level is Full. Core safety rules (no data exfiltration, prefer trash)
are always included.
2026-03-19 08:48:38 -04:00
Argenis a9b511e6ec fix: omit experimental conversational_ai section from default config (#3969)
The [conversational_ai] config section was serialized into every
freshly-generated config.toml despite the feature being experimental
and not yet wired into the agent runtime. This confused new users who
found an undocumented section in their config.

Add skip_serializing_if = "ConversationalAiConfig::is_disabled" so the
section is only written when a user has explicitly enabled it. Existing
configs that already contain the section continue to deserialize
correctly via #[serde(default)].

Fixes #3958
2026-03-19 08:48:33 -04:00
Argenis 65cb4fe099 feat(heartbeat): default interval 30→5min + prune heartbeat from auto-save (#3938)
Lower the default heartbeat interval to 5 minutes to match the renewable
partial wake-lock cadence. Add `[heartbeat task` to the memory auto-save
skip filter so heartbeat prompts (both Phase 1 decision and Phase 2 task
execution) do not pollute persistent conversation memory.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 08:17:08 -04:00
Alix-007 1bbc159e0e style(zai): satisfy rustfmt in tool_stream request 2026-03-19 19:15:58 +08:00
Alix-007 0d28cca843 build(release): drop stale docker feature args 2026-03-19 19:14:07 +08:00
Alix-007 b1d20d38f9 feat(skills): add read_skill for compact mode 2026-03-19 17:53:40 +08:00
Alix-007 2bad6678ec fix(prompt): respect autonomy level in channel prompts 2026-03-19 16:54:51 +08:00
Alix-007 b6fe054915 fix(zai): send tool_stream for tool-capable requests 2026-03-19 16:32:06 +08:00
Alix-007 7ddd2aace3 build(release): ship postgres-capable release artifacts 2026-03-19 15:37:45 +08:00
Alix-007 c7b3b762e0 fix(onboard): warn when Homebrew service uses another workspace 2026-03-19 15:30:40 +08:00
Alix-007 4b00e8ba75 fix(cron): default channel delivery to active reply target 2026-03-19 15:11:47 +08:00
Alix-007 dd462a2b04 test(claude_code): isolate echo script per test run 2026-03-19 13:47:08 +08:00
Alix-007 2d68b880c2 Fix /new regression test lint scope 2026-03-19 12:19:31 +08:00
Alix-007 3a672a2ede Refresh skills after new channel sessions 2026-03-19 12:07:08 +08:00
Argenis 2e48cbf7c3 fix(tools): use resolve_tool_path for consistent path resolution (#3937)
Replace workspace_dir.join(path) with resolve_tool_path(path) in
file_write, file_edit, and pdf_read tools to correctly handle absolute
paths within the workspace directory, preventing path doubling.

Closes #3774
2026-03-18 23:51:35 -04:00
Argenis e4910705d1 fix(config): add missing challenge_max_attempts field to OtpConfig (#3919) (#3936)
The OtpConfig struct uses deny_unknown_fields but was missing the
challenge_max_attempts field, causing zeroclaw config schema to fail
with a TOML parse error when the field appeared in config files.

Add challenge_max_attempts as an Option<u32>-style field with a default
of 3 and a validation check ensuring it is greater than 0.
2026-03-18 23:48:53 -04:00
Argenis 1b664143c2 fix: move misplaced include key from [lib] to [package] in Cargo.toml (#3935)
The `include` array was placed after `[lib]` without a section header,
causing Cargo to parse it as `lib.include` — an invalid manifest key.
This triggered a warning during builds and caused lockfile mismatch
errors when building with --locked in Docker (Dockerfile.debian).

Move the `include` key to the `[package]` section where it belongs and
regenerate Cargo.lock to stay in sync.

Fixes #3925
2026-03-18 23:48:50 -04:00
Argenis 950f996812 Merge pull request #3926 from zeroclaw-labs/fix/pairing-code-terminal-display
fix(gateway): move pairing code below dashboard URL in terminal
2026-03-18 20:34:08 -04:00
Alix-007 e3e9db5210 fix(openrouter): respect provider timeout config 2026-03-19 01:36:57 +08:00
Alix-007 d81eeefe52 fix(tools): normalize workspace-prefixed paths 2026-03-18 10:35:25 +08:00
Alix-007 f87c7442b9 chore(pr): restore merge-base docs file for #3356 2026-03-17 12:11:35 +08:00
Alix-007 0a191fc02c chore(pr): drop unrelated docs delta from #3356 2026-03-17 12:08:20 +08:00
Argenis bb99d2b57a Merge branch 'master' into fix-2400-block-config-self-mutation 2026-03-16 09:21:57 -04:00
李龙 0668001470 81256dbf42 test(config): fix helper lint and swarms fixture 2026-03-16 17:56:30 +08:00
李龙 0668001470 eb9b26cea0 test(config): centralize backward-compat fixtures 2026-03-16 17:45:20 +08:00
李龙 0668001470 6211824f01 fix(security): block runtime config state edits 2026-03-16 17:21:22 +08:00
李龙 0668001470 b4decb40c6 Merge upstream/master into fix/config-load-initialized-state 2026-03-16 13:18:30 +08:00
李龙 0668001470 2b30f060fe test(config): move initialized log regression away from merge hotspot 2026-03-16 11:06:18 +08:00
李龙 0668001470 f994979380 fix(config): avoid clippy used_underscore_binding 2026-03-16 09:13:18 +08:00
Alix-007 04ea5093d4 fix(config): log existing config as initialized 2026-03-13 02:41:39 +08:00
87 changed files with 3321 additions and 305 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 --features channel-matrix --target ${{ matrix.target }}
cargo build --release --locked --features channel-matrix,channel-lark,memory-postgres --target ${{ matrix.target }}
+14 -2
View File
@@ -134,15 +134,27 @@ jobs:
exit 1
fi
# Set up SSH key — normalize line endings and ensure trailing newline
mkdir -p ~/.ssh
echo "$AUR_SSH_KEY" > ~/.ssh/aur
chmod 700 ~/.ssh
printf '%s\n' "$AUR_SSH_KEY" | tr -d '\r' > ~/.ssh/aur
chmod 600 ~/.ssh/aur
cat >> ~/.ssh/config <<SSH_CONFIG
cat > ~/.ssh/config <<'SSH_CONFIG'
Host aur.archlinux.org
IdentityFile ~/.ssh/aur
User aur
StrictHostKeyChecking accept-new
SSH_CONFIG
chmod 600 ~/.ssh/config
# Verify key is valid and print fingerprint for debugging
echo "::group::SSH key diagnostics"
ssh-keygen -l -f ~/.ssh/aur || { echo "::error::AUR_SSH_KEY is not a valid SSH private key"; exit 1; }
echo "::endgroup::"
# Test SSH connectivity before attempting clone
ssh -T -o BatchMode=yes -o ConnectTimeout=10 aur@aur.archlinux.org 2>&1 || true
tmp_dir="$(mktemp -d)"
git clone ssh://aur@aur.archlinux.org/zeroclaw.git "$tmp_dir/aur"
+6
View File
@@ -146,6 +146,12 @@ jobs:
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"
# Ensure Node.js build dependency is declared so that build.rs can
# run `npm ci && npm run build` to produce the web frontend assets.
if ! grep -q 'depends_on "node" => :build' "$formula_file"; then
perl -0pi -e 's|( depends_on "rust" => :build\n)|\1 depends_on "node" => :build\n|m' "$formula_file"
fi
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."
+2 -3
View File
@@ -16,6 +16,7 @@ env:
CARGO_TERM_COLOR: always
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
RELEASE_CARGO_FEATURES: channel-matrix,channel-lark,memory-postgres
jobs:
version:
@@ -213,7 +214,7 @@ jobs:
if [ -n "${{ matrix.linker_env || '' }}" ] && [ -n "${{ matrix.linker || '' }}" ]; then
export "${{ matrix.linker_env }}=${{ matrix.linker }}"
fi
cargo build --release --locked --features channel-matrix --target ${{ matrix.target }}
cargo build --release --locked --features "${{ env.RELEASE_CARGO_FEATURES }}" --target ${{ matrix.target }}
- name: Package (Unix)
if: runner.os != 'Windows'
@@ -345,8 +346,6 @@ jobs:
with:
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
+2 -3
View File
@@ -20,6 +20,7 @@ env:
CARGO_TERM_COLOR: always
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
RELEASE_CARGO_FEATURES: channel-matrix,channel-lark,memory-postgres
jobs:
validate:
@@ -214,7 +215,7 @@ jobs:
if [ -n "${{ matrix.linker_env || '' }}" ] && [ -n "${{ matrix.linker || '' }}" ]; then
export "${{ matrix.linker_env }}=${{ matrix.linker }}"
fi
cargo build --release --locked --features channel-matrix --target ${{ matrix.target }}
cargo build --release --locked --features "${{ env.RELEASE_CARGO_FEATURES }}" --target ${{ matrix.target }}
- name: Package (Unix)
if: runner.os != 'Windows'
@@ -388,8 +389,6 @@ jobs:
with:
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
Generated
+34 -25
View File
@@ -5120,7 +5120,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit 0.25.4+spec-1.1.0",
"toml_edit 0.25.5+spec-1.1.0",
]
[[package]]
@@ -6415,9 +6415,9 @@ dependencies = [
[[package]]
name = "serialport"
version = "4.7.3"
version = "4.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acaf3f973e8616d7ceac415f53fc60e190b2a686fbcf8d27d0256c741c5007b"
checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
@@ -6428,7 +6428,7 @@ dependencies = [
"nix 0.26.4",
"scopeguard",
"unescaper",
"winapi",
"windows-sys 0.52.0",
]
[[package]]
@@ -7047,9 +7047,9 @@ dependencies = [
[[package]]
name = "tokio-websockets"
version = "0.13.1"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b6aa6c8b5a31e06fd3760eb5c1b8d9072e30731f0467ee3795617fe768e7449"
checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb"
dependencies = [
"base64",
"bytes",
@@ -7057,7 +7057,7 @@ dependencies = [
"futures-sink",
"http 1.4.0",
"httparse",
"rand 0.9.2",
"rand 0.10.0",
"ring",
"rustls-pki-types",
"simdutf8",
@@ -7095,17 +7095,17 @@ dependencies = [
[[package]]
name = "toml"
version = "1.0.6+spec-1.1.0"
version = "1.0.7+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc"
checksum = "dd28d57d8a6f6e458bc0b8784f8fdcc4b99a437936056fa122cb234f18656a96"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned 1.0.4",
"toml_datetime 1.0.0+spec-1.1.0",
"toml_datetime 1.0.1+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 0.7.15",
"winnow 1.0.0",
]
[[package]]
@@ -7128,9 +7128,9 @@ dependencies = [
[[package]]
name = "toml_datetime"
version = "1.0.0+spec-1.1.0"
version = "1.0.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e"
checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9"
dependencies = [
"serde_core",
]
@@ -7151,23 +7151,23 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.25.4+spec-1.1.0"
version = "0.25.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2"
checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1"
dependencies = [
"indexmap",
"toml_datetime 1.0.0+spec-1.1.0",
"toml_datetime 1.0.1+spec-1.1.0",
"toml_parser",
"winnow 0.7.15",
"winnow 1.0.0",
]
[[package]]
name = "toml_parser"
version = "1.0.9+spec-1.1.0"
version = "1.0.10+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420"
dependencies = [
"winnow 0.7.15",
"winnow 1.0.0",
]
[[package]]
@@ -7178,9 +7178,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "toml_writer"
version = "1.0.6+spec-1.1.0"
version = "1.0.7+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d"
[[package]]
name = "tonic"
@@ -8894,6 +8894,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8"
dependencies = [
"memchr",
]
[[package]]
name = "winx"
version = "0.36.4"
@@ -9149,13 +9158,13 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tokio-test",
"toml 1.0.6+spec-1.1.0",
"toml 1.0.7+spec-1.1.0",
"tracing",
]
[[package]]
name = "zeroclawlabs"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"anyhow",
"async-imap",
@@ -9228,7 +9237,7 @@ dependencies = [
"tokio-stream",
"tokio-tungstenite 0.28.0",
"tokio-util",
"toml 1.0.6+spec-1.1.0",
"toml 1.0.7+spec-1.1.0",
"tower",
"tower-http",
"tracing",
+9 -10
View File
@@ -4,7 +4,7 @@ resolver = "2"
[package]
name = "zeroclawlabs"
version = "0.5.0"
version = "0.5.1"
edition = "2021"
authors = ["theonlyhennygod"]
license = "MIT OR Apache-2.0"
@@ -14,15 +14,6 @@ readme = "README.md"
keywords = ["ai", "agent", "cli", "assistant", "chatbot"]
categories = ["command-line-utilities", "api-bindings"]
rust-version = "1.87"
[[bin]]
name = "zeroclaw"
path = "src/main.rs"
[lib]
name = "zeroclaw"
path = "src/lib.rs"
include = [
"/src/**/*",
"/build.rs",
@@ -34,6 +25,14 @@ include = [
"/tool_descriptions/**/*",
]
[[bin]]
name = "zeroclaw"
path = "src/main.rs"
[lib]
name = "zeroclaw"
path = "src/lib.rs"
[dependencies]
# CLI - minimal and fast
clap = { version = "4.5", features = ["derive"] }
+1 -1
View File
@@ -12,7 +12,7 @@ RUN npm run build
FROM rust:1.94-slim@sha256:da9dab7a6b8dd428e71718402e97207bb3e54167d37b5708616050b1e8f60ed6 AS builder
WORKDIR /app
ARG ZEROCLAW_CARGO_FEATURES=""
ARG ZEROCLAW_CARGO_FEATURES="memory-postgres"
# Install build dependencies
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
+1 -1
View File
@@ -27,7 +27,7 @@ RUN npm run build
FROM rust:1.94-bookworm AS builder
WORKDIR /app
ARG ZEROCLAW_CARGO_FEATURES=""
ARG ZEROCLAW_CARGO_FEATURES="memory-postgres"
# Install build dependencies
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center" dir="rtl">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -18,6 +18,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -16,6 +16,7 @@
<a href="https://x.com/zeroclawlabs?s=21"><img src="https://img.shields.io/badge/X-%40zeroclawlabs-000000?style=flat&logo=x&logoColor=white" alt="X: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="docs/assets/zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -16,6 +16,7 @@
<a href="https://x.com/zeroclawlabs?s=21"><img src="https://img.shields.io/badge/X-%40zeroclawlabs-000000?style=flat&logo=x&logoColor=white" alt="X : @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit : r/zeroclawlabs" /></a>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="docs/assets/zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀(日本語)</h1>
@@ -15,6 +15,7 @@
<a href="https://x.com/zeroclawlabs?s=21"><img src="https://img.shields.io/badge/X-%40zeroclawlabs-000000?style=flat&logo=x&logoColor=white" alt="X: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="docs/assets/zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -16,6 +16,7 @@
<a href="https://x.com/zeroclawlabs?s=21"><img src="https://img.shields.io/badge/X-%40zeroclawlabs-000000?style=flat&logo=x&logoColor=white" alt="X: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="docs/assets/zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀(Русский)</h1>
@@ -15,6 +15,7 @@
<a href="https://x.com/zeroclawlabs?s=21"><img src="https://img.shields.io/badge/X-%40zeroclawlabs-000000?style=flat&logo=x&logoColor=white" alt="X: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="docs/assets/zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@@ -16,6 +16,7 @@
<a href="https://x.com/zeroclawlabs?s=21"><img src="https://img.shields.io/badge/X-%40zeroclawlabs-000000?style=flat&logo=x&logoColor=white" alt="X: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
+2 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="docs/assets/zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀(简体中文)</h1>
@@ -15,6 +15,7 @@
<a href="https://x.com/zeroclawlabs?s=21"><img src="https://img.shields.io/badge/X-%40zeroclawlabs-000000?style=flat&logo=x&logoColor=white" alt="X: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
+20
View File
@@ -11,6 +11,7 @@ fn main() {
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=docs/assets/zeroclaw-trans.png");
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");
@@ -83,6 +84,7 @@ fn main() {
}
ensure_dist_dir(dist_dir);
ensure_dashboard_assets(dist_dir);
}
fn web_build_required(web_dir: &Path, dist_dir: &Path) -> bool {
@@ -136,6 +138,24 @@ fn ensure_dist_dir(dist_dir: &Path) {
}
}
fn ensure_dashboard_assets(dist_dir: &Path) {
// The Rust gateway serves `web/dist/` via rust-embed under `/_app/*`.
// Some builds may end up with missing/blank logo assets, so we ensure the
// expected image is always present in `web/dist/` at compile time.
let src = Path::new("docs/assets/zeroclaw-trans.png");
if !src.exists() {
eprintln!(
"cargo:warning=docs/assets/zeroclaw-trans.png not found; skipping dashboard asset copy"
);
return;
}
let dst = dist_dir.join("zeroclaw-trans.png");
if let Err(e) = fs::copy(src, &dst) {
eprintln!("cargo:warning=Failed to copy zeroclaw-trans.png into web/dist/: {e}");
}
}
/// Locate the `npm` binary on the system PATH.
fn which_npm() -> Result<String, ()> {
let cmd = if cfg!(target_os = "windows") {
Binary file not shown.

After

Width:  |  Height:  |  Size: 851 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

@@ -76,7 +76,7 @@ runtime_trace_max_entries = 200
| 键 | 默认值 | 用途 |
|---|---|---|
| `compact_context` | `false` | 为 true 时:bootstrap_max_chars=6000rag_chunk_limit=2。适用于 13B 或更小的模型 |
| `compact_context` | `true` | 为 true 时:bootstrap_max_chars=6000rag_chunk_limit=2。适用于 13B 或更小的模型 |
| `max_tool_iterations` | `10` | 跨 CLI、网关和渠道的每条用户消息的最大工具调用循环轮次 |
| `max_history_messages` | `50` | 每个会话保留的最大对话历史消息数 |
| `parallel_tools` | `false` | 在单次迭代中启用并行工具执行 |
+1 -1
View File
@@ -76,7 +76,7 @@ Operational note for container users:
| Key | Default | Purpose |
|---|---|---|
| `compact_context` | `false` | When true: bootstrap_max_chars=6000, rag_chunk_limit=2. Use for 13B or smaller models |
| `compact_context` | `true` | When true: bootstrap_max_chars=6000, rag_chunk_limit=2. Use for 13B or smaller models |
| `max_tool_iterations` | `10` | Maximum tool-call loop turns per user message across CLI, gateway, and channels |
| `max_history_messages` | `50` | Maximum conversation history messages retained per session |
| `parallel_tools` | `false` | Enable parallel tool execution within a single iteration |
+1 -1
View File
@@ -65,7 +65,7 @@ Lưu ý cho người dùng container:
| 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 |
| `compact_context` | `true` | 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 |
+13 -27
View File
@@ -448,46 +448,32 @@ bool_to_word() {
fi
}
guided_input_stream() {
# Some constrained containers report interactive stdin (-t 0) but deny
# opening /dev/stdin directly. Probe readability before selecting it.
if [[ -t 0 ]] && (: </dev/stdin) 2>/dev/null; then
echo "/dev/stdin"
guided_open_input() {
# Use stdin directly when it is an interactive terminal (e.g. SSH into LXC).
# Subshell probing of /dev/stdin fails in some constrained containers even
# when FD 0 is perfectly usable, so skip the probe and trust -t 0.
if [[ -t 0 ]]; then
GUIDED_FD=0
return 0
fi
if [[ -t 0 ]] && (: </proc/self/fd/0) 2>/dev/null; then
echo "/proc/self/fd/0"
return 0
fi
if (: </dev/tty) 2>/dev/null; then
echo "/dev/tty"
return 0
fi
return 1
# Non-interactive stdin: try to open /dev/tty as an explicit fd.
exec {GUIDED_FD}</dev/tty 2>/dev/null || return 1
}
guided_read() {
local __target_var="$1"
local __prompt="$2"
local __silent="${3:-false}"
local __input_source=""
local __value=""
if ! __input_source="$(guided_input_stream)"; then
return 1
fi
[[ -n "${GUIDED_FD:-}" ]] || guided_open_input || return 1
if [[ "$__silent" == true ]]; then
if ! read -r -s -p "$__prompt" __value <"$__input_source"; then
return 1
fi
read -r -s -u "$GUIDED_FD" -p "$__prompt" __value || return 1
echo
else
if ! read -r -p "$__prompt" __value <"$__input_source"; then
return 1
fi
read -r -u "$GUIDED_FD" -p "$__prompt" __value || return 1
fi
printf -v "$__target_var" '%s' "$__value"
@@ -708,7 +694,7 @@ prompt_model() {
run_guided_installer() {
local os_name="$1"
if ! guided_input_stream >/dev/null; then
if ! guided_open_input >/dev/null; then
error "guided installer requires an interactive terminal."
error "Run from a terminal, or pass --no-guided with explicit flags."
exit 1
+328 -5
View File
@@ -8,7 +8,7 @@ use crate::providers::{
self, ChatMessage, ChatRequest, Provider, ProviderCapabilityError, ToolCall,
};
use crate::runtime;
use crate::security::SecurityPolicy;
use crate::security::{AutonomyLevel, SecurityPolicy};
use crate::tools::{self, Tool};
use crate::util::truncate_with_ellipsis;
use anyhow::Result;
@@ -2181,8 +2181,10 @@ pub(crate) async fn agent_turn(
temperature: f64,
silent: bool,
channel_name: &str,
channel_reply_target: Option<&str>,
multimodal_config: &crate::config::MultimodalConfig,
max_tool_iterations: usize,
approval: Option<&ApprovalManager>,
excluded_tools: &[String],
dedup_exempt_tools: &[String],
activated_tools: Option<&std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
@@ -2197,8 +2199,9 @@ pub(crate) async fn agent_turn(
model,
temperature,
silent,
None,
approval,
channel_name,
channel_reply_target,
multimodal_config,
max_tool_iterations,
None,
@@ -2212,6 +2215,100 @@ pub(crate) async fn agent_turn(
.await
}
fn maybe_inject_channel_delivery_defaults(
tool_name: &str,
tool_args: &mut serde_json::Value,
channel_name: &str,
channel_reply_target: Option<&str>,
) {
if tool_name != "cron_add" {
return;
}
if !matches!(
channel_name,
"telegram" | "discord" | "slack" | "mattermost" | "matrix"
) {
return;
}
let Some(reply_target) = channel_reply_target
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return;
};
let Some(args) = tool_args.as_object_mut() else {
return;
};
let is_agent_job = args
.get("job_type")
.and_then(serde_json::Value::as_str)
.is_some_and(|job_type| job_type.eq_ignore_ascii_case("agent"))
|| args
.get("prompt")
.and_then(serde_json::Value::as_str)
.is_some_and(|prompt| !prompt.trim().is_empty());
if !is_agent_job {
return;
}
let default_delivery = || {
serde_json::json!({
"mode": "announce",
"channel": channel_name,
"to": reply_target,
})
};
match args.get_mut("delivery") {
None => {
args.insert("delivery".to_string(), default_delivery());
}
Some(serde_json::Value::Null) => {
*args.get_mut("delivery").expect("delivery key exists") = default_delivery();
}
Some(serde_json::Value::Object(delivery)) => {
if delivery
.get("mode")
.and_then(serde_json::Value::as_str)
.is_some_and(|mode| mode.eq_ignore_ascii_case("none"))
{
return;
}
delivery
.entry("mode".to_string())
.or_insert_with(|| serde_json::Value::String("announce".to_string()));
let needs_channel = delivery
.get("channel")
.and_then(serde_json::Value::as_str)
.is_none_or(|value| value.trim().is_empty());
if needs_channel {
delivery.insert(
"channel".to_string(),
serde_json::Value::String(channel_name.to_string()),
);
}
let needs_target = delivery
.get("to")
.and_then(serde_json::Value::as_str)
.is_none_or(|value| value.trim().is_empty());
if needs_target {
delivery.insert(
"to".to_string(),
serde_json::Value::String(reply_target.to_string()),
);
}
}
Some(_) => {}
}
}
async fn execute_one_tool(
call_name: &str,
call_arguments: serde_json::Value,
@@ -2405,6 +2502,7 @@ pub(crate) async fn run_tool_call_loop(
silent: bool,
approval: Option<&ApprovalManager>,
channel_name: &str,
channel_reply_target: Option<&str>,
multimodal_config: &crate::config::MultimodalConfig,
max_tool_iterations: usize,
cancellation_token: Option<CancellationToken>,
@@ -2815,6 +2913,13 @@ pub(crate) async fn run_tool_call_loop(
}
}
maybe_inject_channel_delivery_defaults(
&tool_name,
&mut tool_args,
channel_name,
channel_reply_target,
);
// ── Approval hook ────────────────────────────────
if let Some(mgr) = approval {
if mgr.needs_approval(&tool_name) {
@@ -3369,6 +3474,15 @@ pub async fn run(
"Delete a memory entry. Use when: memory is incorrect/stale or explicitly requested for removal. Don't use when: impact is uncertain.",
),
];
if matches!(
config.skills.prompt_injection_mode,
crate::config::SkillsPromptInjectionMode::Compact
) {
tool_descs.push((
"read_skill",
"Load the full source for an available skill by name. Use when: compact mode only shows a summary and you need the complete skill instructions.",
));
}
tool_descs.push((
"cron_add",
"Create a cron job. Supports schedule kinds: cron, at, every; and job types: shell or agent.",
@@ -3457,13 +3571,14 @@ pub async fn run(
None
};
let native_tools = provider.supports_native_tools();
let mut system_prompt = crate::channels::build_system_prompt_with_mode(
let mut system_prompt = crate::channels::build_system_prompt_with_mode_and_autonomy(
&config.workspace_dir,
&model_name,
&tool_descs,
&skills,
Some(&config.identity),
bootstrap_max_chars,
Some(&config.autonomy),
native_tools,
config.skills.prompt_injection_mode,
);
@@ -3556,6 +3671,7 @@ pub async fn run(
false,
approval_manager.as_ref(),
channel_name,
None,
&config.multimodal,
config.agent.max_tool_iterations,
None,
@@ -3782,6 +3898,7 @@ pub async fn run(
false,
approval_manager.as_ref(),
channel_name,
None,
&config.multimodal,
config.agent.max_tool_iterations,
None,
@@ -3894,6 +4011,7 @@ pub async fn process_message(
&config.autonomy,
&config.workspace_dir,
));
let approval_manager = ApprovalManager::for_non_interactive(&config.autonomy);
let mem: Arc<dyn Memory> = Arc::from(memory::create_memory_with_storage_and_routes(
&config.memory,
&config.embedding_routes,
@@ -4052,6 +4170,15 @@ pub async fn process_message(
("screenshot", "Capture a screenshot."),
("image_info", "Read image metadata."),
];
if matches!(
config.skills.prompt_injection_mode,
crate::config::SkillsPromptInjectionMode::Compact
) {
tool_descs.push((
"read_skill",
"Load the full source for an available skill by name.",
));
}
if config.browser.enabled {
tool_descs.push(("browser_open", "Open approved URLs in browser."));
}
@@ -4085,19 +4212,30 @@ pub async fn process_message(
"Query connected hardware for reported GPIO pins and LED pin. Use when user asks what pins are available.",
));
}
// Filter out tools excluded for non-CLI channels (gateway counts as non-CLI).
// Skip when autonomy is `Full` — full-autonomy agents keep all tools.
if config.autonomy.level != AutonomyLevel::Full {
let excluded = &config.autonomy.non_cli_excluded_tools;
if !excluded.is_empty() {
tool_descs.retain(|(name, _)| !excluded.iter().any(|ex| ex == name));
}
}
let bootstrap_max_chars = if config.agent.compact_context {
Some(6000)
} else {
None
};
let native_tools = provider.supports_native_tools();
let mut system_prompt = crate::channels::build_system_prompt_with_mode(
let mut system_prompt = crate::channels::build_system_prompt_with_mode_and_autonomy(
&config.workspace_dir,
&model_name,
&tool_descs,
&skills,
Some(&config.identity),
bootstrap_max_chars,
Some(&config.autonomy),
native_tools,
config.skills.prompt_injection_mode,
);
@@ -4133,8 +4271,11 @@ pub async fn process_message(
ChatMessage::system(&system_prompt),
ChatMessage::user(&enriched),
];
let excluded_tools =
let mut excluded_tools =
compute_excluded_mcp_tools(&tools_registry, &config.agent.tool_filter_groups, message);
if config.autonomy.level != AutonomyLevel::Full {
excluded_tools.extend(config.autonomy.non_cli_excluded_tools.iter().cloned());
}
agent_turn(
provider.as_ref(),
@@ -4146,8 +4287,10 @@ pub async fn process_message(
config.default_temperature,
true,
"daemon",
None,
&config.multimodal,
config.agent.max_tool_iterations,
Some(&approval_manager),
&excluded_tools,
&config.agent.tool_call_dedup_exempt,
activated_handle_pm.as_ref(),
@@ -4463,6 +4606,57 @@ mod tests {
}
}
struct RecordingArgsTool {
name: String,
recorded_args: Arc<Mutex<Vec<serde_json::Value>>>,
}
impl RecordingArgsTool {
fn new(name: &str, recorded_args: Arc<Mutex<Vec<serde_json::Value>>>) -> Self {
Self {
name: name.to_string(),
recorded_args,
}
}
}
#[async_trait]
impl Tool for RecordingArgsTool {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
"Records tool arguments for regression tests"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"prompt": { "type": "string" },
"schedule": { "type": "object" },
"delivery": { "type": "object" }
}
})
}
async fn execute(
&self,
args: serde_json::Value,
) -> anyhow::Result<crate::tools::ToolResult> {
self.recorded_args
.lock()
.expect("recorded args lock should be valid")
.push(args.clone());
Ok(crate::tools::ToolResult {
success: true,
output: args.to_string(),
error: None,
})
}
}
struct DelayTool {
name: String,
delay_ms: u64,
@@ -4601,6 +4795,7 @@ mod tests {
true,
None,
"cli",
None,
&crate::config::MultimodalConfig::default(),
3,
None,
@@ -4650,6 +4845,7 @@ mod tests {
true,
None,
"cli",
None,
&multimodal,
3,
None,
@@ -4693,6 +4889,7 @@ mod tests {
true,
None,
"cli",
None,
&crate::config::MultimodalConfig::default(),
3,
None,
@@ -4822,6 +5019,7 @@ mod tests {
true,
Some(&approval_mgr),
"telegram",
None,
&crate::config::MultimodalConfig::default(),
4,
None,
@@ -4859,6 +5057,122 @@ mod tests {
);
}
#[tokio::test]
async fn run_tool_call_loop_injects_channel_delivery_defaults_for_cron_add() {
let provider = ScriptedProvider::from_text_responses(vec![
r#"<tool_call>
{"name":"cron_add","arguments":{"job_type":"agent","prompt":"remind me later","schedule":{"kind":"every","every_ms":60000}}}
</tool_call>"#,
"done",
]);
let recorded_args = Arc::new(Mutex::new(Vec::new()));
let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(RecordingArgsTool::new(
"cron_add",
Arc::clone(&recorded_args),
))];
let mut history = vec![
ChatMessage::system("test-system"),
ChatMessage::user("schedule a reminder"),
];
let observer = NoopObserver;
let result = run_tool_call_loop(
&provider,
&mut history,
&tools_registry,
&observer,
"mock-provider",
"mock-model",
0.0,
true,
None,
"telegram",
Some("chat-42"),
&crate::config::MultimodalConfig::default(),
4,
None,
None,
None,
&[],
&[],
None,
None,
)
.await
.expect("cron_add delivery defaults should be injected");
assert_eq!(result, "done");
let recorded = recorded_args
.lock()
.expect("recorded args lock should be valid");
let delivery = recorded[0]["delivery"].clone();
assert_eq!(
delivery,
serde_json::json!({
"mode": "announce",
"channel": "telegram",
"to": "chat-42",
})
);
}
#[tokio::test]
async fn run_tool_call_loop_preserves_explicit_cron_delivery_none() {
let provider = ScriptedProvider::from_text_responses(vec![
r#"<tool_call>
{"name":"cron_add","arguments":{"job_type":"agent","prompt":"run silently","schedule":{"kind":"every","every_ms":60000},"delivery":{"mode":"none"}}}
</tool_call>"#,
"done",
]);
let recorded_args = Arc::new(Mutex::new(Vec::new()));
let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(RecordingArgsTool::new(
"cron_add",
Arc::clone(&recorded_args),
))];
let mut history = vec![
ChatMessage::system("test-system"),
ChatMessage::user("schedule a quiet cron job"),
];
let observer = NoopObserver;
let result = run_tool_call_loop(
&provider,
&mut history,
&tools_registry,
&observer,
"mock-provider",
"mock-model",
0.0,
true,
None,
"telegram",
Some("chat-42"),
&crate::config::MultimodalConfig::default(),
4,
None,
None,
None,
&[],
&[],
None,
None,
)
.await
.expect("explicit delivery mode should be preserved");
assert_eq!(result, "done");
let recorded = recorded_args
.lock()
.expect("recorded args lock should be valid");
assert_eq!(recorded[0]["delivery"], serde_json::json!({"mode": "none"}));
}
#[tokio::test]
async fn run_tool_call_loop_deduplicates_repeated_tool_calls() {
let provider = ScriptedProvider::from_text_responses(vec![
@@ -4894,6 +5208,7 @@ mod tests {
true,
None,
"cli",
None,
&crate::config::MultimodalConfig::default(),
4,
None,
@@ -4962,6 +5277,7 @@ mod tests {
true,
Some(&approval_mgr),
"telegram",
None,
&crate::config::MultimodalConfig::default(),
4,
None,
@@ -5021,6 +5337,7 @@ mod tests {
true,
None,
"cli",
None,
&crate::config::MultimodalConfig::default(),
4,
None,
@@ -5100,6 +5417,7 @@ mod tests {
true,
None,
"cli",
None,
&crate::config::MultimodalConfig::default(),
4,
None,
@@ -5156,6 +5474,7 @@ mod tests {
true,
None,
"cli",
None,
&crate::config::MultimodalConfig::default(),
4,
None,
@@ -5228,8 +5547,10 @@ mod tests {
0.0,
true,
"daemon",
None,
&crate::config::MultimodalConfig::default(),
4,
None,
&[],
&[],
Some(&activated),
@@ -6672,6 +6993,7 @@ Let me check the result."#;
None, // no bootstrap_max_chars
true, // native_tools
crate::config::SkillsPromptInjectionMode::Full,
crate::security::AutonomyLevel::default(),
);
// Must contain zero XML protocol artifacts
@@ -7117,6 +7439,7 @@ Let me check the result."#;
true,
None,
"telegram",
None,
&crate::config::MultimodalConfig::default(),
4,
None,
+1
View File
@@ -436,6 +436,7 @@ mod tests {
assert!(output.contains("<available_skills>"));
assert!(output.contains("<name>deploy</name>"));
assert!(output.contains("<location>skills/deploy/SKILL.md</location>"));
assert!(output.contains("read_skill(name)"));
assert!(!output.contains("<instruction>Run smoke tests before deploy.</instruction>"));
assert!(!output.contains("<tools>"));
}
+593 -26
View File
File diff suppressed because it is too large Load Diff
+37 -1
View File
@@ -25,6 +25,7 @@ pub struct SlackChannel {
channel_id: Option<String>,
channel_ids: Vec<String>,
allowed_users: Vec<String>,
thread_replies: bool,
mention_only: bool,
group_reply_allowed_sender_ids: Vec<String>,
user_display_name_cache: Mutex<HashMap<String, CachedSlackDisplayName>>,
@@ -75,6 +76,7 @@ impl SlackChannel {
channel_id,
channel_ids,
allowed_users,
thread_replies: true,
mention_only: false,
group_reply_allowed_sender_ids: Vec::new(),
user_display_name_cache: Mutex::new(HashMap::new()),
@@ -94,6 +96,12 @@ impl SlackChannel {
self
}
/// Configure whether outbound replies stay in the originating Slack thread.
pub fn with_thread_replies(mut self, thread_replies: bool) -> Self {
self.thread_replies = thread_replies;
self
}
/// Configure workspace directory used for persisting inbound Slack attachments.
pub fn with_workspace_dir(mut self, dir: PathBuf) -> Self {
self.workspace_dir = Some(dir);
@@ -122,6 +130,14 @@ impl SlackChannel {
.any(|entry| entry == "*" || entry == user_id)
}
fn outbound_thread_ts<'a>(&self, message: &'a SendMessage) -> Option<&'a str> {
if self.thread_replies {
message.thread_ts.as_deref()
} else {
None
}
}
/// Get the bot's own user ID so we can ignore our own messages
async fn get_bot_user_id(&self) -> Option<String> {
let resp: serde_json::Value = self
@@ -2149,7 +2165,7 @@ impl Channel for SlackChannel {
"text": message.content
});
if let Some(ref ts) = message.thread_ts {
if let Some(ts) = self.outbound_thread_ts(message) {
body["thread_ts"] = serde_json::json!(ts);
}
@@ -2484,10 +2500,30 @@ mod tests {
#[test]
fn slack_group_reply_policy_defaults_to_all_messages() {
let ch = SlackChannel::new("xoxb-fake".into(), None, None, vec![], vec!["*".into()]);
assert!(ch.thread_replies);
assert!(!ch.mention_only);
assert!(ch.group_reply_allowed_sender_ids.is_empty());
}
#[test]
fn with_thread_replies_sets_flag() {
let ch = SlackChannel::new("xoxb-fake".into(), None, None, vec![], vec![])
.with_thread_replies(false);
assert!(!ch.thread_replies);
}
#[test]
fn outbound_thread_ts_respects_thread_replies_setting() {
let msg = SendMessage::new("hello", "C123").in_thread(Some("1741234567.100001".into()));
let threaded = SlackChannel::new("xoxb-fake".into(), None, None, vec![], vec![]);
assert_eq!(threaded.outbound_thread_ts(&msg), Some("1741234567.100001"));
let channel_root = SlackChannel::new("xoxb-fake".into(), None, None, vec![], vec![])
.with_thread_replies(false);
assert_eq!(channel_root.outbound_thread_ts(&msg), None);
}
#[test]
fn with_workspace_dir_sets_field() {
let ch = SlackChannel::new("xoxb-fake".into(), None, None, vec![], vec![])
+218 -33
View File
@@ -137,7 +137,12 @@ pub struct Config {
pub cloud_ops: CloudOpsConfig,
/// Conversational AI agent builder configuration (`[conversational_ai]`).
#[serde(default)]
///
/// Experimental / future feature — not yet wired into the agent runtime.
/// Omitted from generated config files when disabled (the default).
/// Existing configs that already contain this section will continue to
/// deserialize correctly thanks to `#[serde(default)]`.
#[serde(default, skip_serializing_if = "ConversationalAiConfig::is_disabled")]
pub conversational_ai: ConversationalAiConfig,
/// Managed cybersecurity service configuration (`[security_ops]`).
@@ -1136,7 +1141,7 @@ fn default_agent_tool_dispatcher() -> String {
impl Default for AgentConfig {
fn default() -> Self {
Self {
compact_context: false,
compact_context: true,
max_tool_iterations: default_agent_max_tool_iterations(),
max_history_messages: default_agent_max_history_messages(),
max_context_tokens: default_agent_max_context_tokens(),
@@ -4045,7 +4050,8 @@ pub struct ClassificationRule {
pub struct HeartbeatConfig {
/// Enable periodic heartbeat pings. Default: `false`.
pub enabled: bool,
/// Interval in minutes between heartbeat pings. Default: `30`.
/// Interval in minutes between heartbeat pings. Default: `5`.
#[serde(default = "default_heartbeat_interval")]
pub interval_minutes: u32,
/// Enable two-phase heartbeat: Phase 1 asks LLM whether to run, Phase 2
/// executes only when the LLM decides there is work to do. Saves API cost
@@ -4089,6 +4095,10 @@ pub struct HeartbeatConfig {
pub max_run_history: u32,
}
fn default_heartbeat_interval() -> u32 {
5
}
fn default_two_phase() -> bool {
true
}
@@ -4109,7 +4119,7 @@ impl Default for HeartbeatConfig {
fn default() -> Self {
Self {
enabled: false,
interval_minutes: 30,
interval_minutes: default_heartbeat_interval(),
two_phase: true,
message: None,
target: None,
@@ -4133,6 +4143,15 @@ pub struct CronConfig {
/// Enable the cron subsystem. Default: `true`.
#[serde(default = "default_true")]
pub enabled: bool,
/// Run all overdue jobs at scheduler startup. Default: `true`.
///
/// When the machine boots late or the daemon restarts, jobs whose
/// `next_run` is in the past are considered "missed". With this
/// option enabled the scheduler fires them once before entering
/// the normal polling loop. Disable if you prefer missed jobs to
/// simply wait for their next scheduled occurrence.
#[serde(default = "default_true")]
pub catch_up_on_startup: bool,
/// Maximum number of historical cron run records to retain. Default: `50`.
#[serde(default = "default_max_run_history")]
pub max_run_history: u32,
@@ -4146,6 +4165,7 @@ impl Default for CronConfig {
fn default() -> Self {
Self {
enabled: true,
catch_up_on_startup: true,
max_run_history: default_max_run_history(),
}
}
@@ -4620,6 +4640,10 @@ pub struct SlackConfig {
/// cancels the in-flight request and starts a fresh response with preserved history.
#[serde(default)]
pub interrupt_on_new_message: bool,
/// When true (default), replies stay in the originating Slack thread.
/// When false, replies go to the channel root instead.
#[serde(default)]
pub thread_replies: Option<bool>,
/// When true, only respond to messages that @-mention the bot in groups.
/// Direct messages remain allowed.
#[serde(default)]
@@ -5135,6 +5159,10 @@ pub struct OtpConfig {
/// Domain-category presets expanded into `gated_domains`.
#[serde(default)]
pub gated_domain_categories: Vec<String>,
/// Maximum number of OTP challenge attempts before lockout.
#[serde(default = "default_otp_challenge_max_attempts")]
pub challenge_max_attempts: u32,
}
fn default_otp_token_ttl_secs() -> u64 {
@@ -5145,6 +5173,10 @@ fn default_otp_cache_valid_secs() -> u64 {
300
}
fn default_otp_challenge_max_attempts() -> u32 {
3
}
fn default_otp_gated_actions() -> Vec<String> {
vec![
"shell".to_string(),
@@ -5165,6 +5197,7 @@ impl Default for OtpConfig {
gated_actions: default_otp_gated_actions(),
gated_domains: Vec::new(),
gated_domain_categories: Vec::new(),
challenge_max_attempts: default_otp_challenge_max_attempts(),
}
}
}
@@ -5863,8 +5896,8 @@ fn default_conversational_ai_timeout_secs() -> u64 {
/// Conversational AI agent builder configuration (`[conversational_ai]` section).
///
/// Controls language detection, escalation behavior, conversation limits, and
/// analytics for conversational agent workflows. Disabled by default.
/// **Status: Reserved for future use.** This configuration is parsed but not yet
/// consumed by the runtime. Setting `enabled = true` will produce a startup warning.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ConversationalAiConfig {
/// Enable conversational AI features. Default: false.
@@ -5896,6 +5929,17 @@ pub struct ConversationalAiConfig {
pub knowledge_base_tool: Option<String>,
}
impl ConversationalAiConfig {
/// Returns `true` when the feature is disabled (the default).
///
/// Used by `#[serde(skip_serializing_if)]` to omit the entire
/// `[conversational_ai]` section from newly-generated config files,
/// avoiding user confusion over an undocumented / experimental section.
pub fn is_disabled(&self) -> bool {
!self.enabled
}
}
impl Default for ConversationalAiConfig {
fn default() -> Self {
Self {
@@ -6875,7 +6919,7 @@ impl Config {
path = %config.config_path.display(),
workspace = %config.workspace_dir.display(),
source = resolution_source.as_str(),
initialized = false,
initialized = true,
"Config loaded"
);
Ok(config)
@@ -7036,6 +7080,9 @@ impl Config {
}
// Security OTP / estop
if self.security.otp.challenge_max_attempts == 0 {
anyhow::bail!("security.otp.challenge_max_attempts must be greater than 0");
}
if self.security.otp.token_ttl_secs == 0 {
anyhow::bail!("security.otp.token_ttl_secs must be greater than 0");
}
@@ -7716,6 +7763,13 @@ impl Config {
}
set_runtime_proxy_config(self.proxy.clone());
if self.conversational_ai.enabled {
tracing::warn!(
"conversational_ai.enabled = true but conversational AI features are not yet \
implemented; this section is reserved for future use and will be ignored"
);
}
}
async fn resolve_config_path_for_save(&self) -> Result<PathBuf> {
@@ -8183,9 +8237,11 @@ async fn sync_directory(path: &Path) -> Result<()> {
#[cfg(test)]
mod tests {
use super::*;
use std::io;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::sync::{Arc, Mutex as StdMutex};
#[cfg(unix)]
use tempfile::TempDir;
use tokio::sync::{Mutex, MutexGuard};
@@ -8195,6 +8251,37 @@ mod tests {
// ── Defaults ─────────────────────────────────────────────
fn has_test_table(raw: &str, table: &str) -> bool {
let exact = format!("[{table}]");
let nested = format!("[{table}.");
raw.lines()
.map(str::trim)
.any(|line| line == exact || line.starts_with(&nested))
}
fn parse_test_config(raw: &str) -> Config {
let mut merged = raw.trim().to_string();
for table in [
"data_retention",
"cloud_ops",
"conversational_ai",
"security",
"security_ops",
] {
if has_test_table(&merged, table) {
continue;
}
if !merged.is_empty() {
merged.push_str("\n\n");
}
merged.push('[');
merged.push_str(table);
merged.push(']');
}
merged.push('\n');
toml::from_str(&merged).unwrap()
}
#[test]
async fn http_request_config_default_has_correct_values() {
let cfg = HttpRequestConfig::default();
@@ -8221,6 +8308,36 @@ mod tests {
assert!(c.config_path.to_string_lossy().contains("config.toml"));
}
#[derive(Clone, Default)]
struct SharedLogBuffer(Arc<StdMutex<Vec<u8>>>);
struct SharedLogWriter(Arc<StdMutex<Vec<u8>>>);
impl SharedLogBuffer {
fn captured(&self) -> String {
String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SharedLogBuffer {
type Writer = SharedLogWriter;
fn make_writer(&'a self) -> Self::Writer {
SharedLogWriter(self.0.clone())
}
}
impl io::Write for SharedLogWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
async fn config_dir_creation_error_mentions_openrc_and_path() {
let msg = config_dir_creation_error(Path::new("/etc/zeroclaw"));
@@ -8323,7 +8440,7 @@ mod tests {
async fn heartbeat_config_default() {
let h = HeartbeatConfig::default();
assert!(!h.enabled);
assert_eq!(h.interval_minutes, 30);
assert_eq!(h.interval_minutes, 5);
assert!(h.message.is_none());
assert!(h.target.is_none());
assert!(h.to.is_none());
@@ -8357,11 +8474,13 @@ recipient = "42"
async fn cron_config_serde_roundtrip() {
let c = CronConfig {
enabled: false,
catch_up_on_startup: false,
max_run_history: 100,
};
let json = serde_json::to_string(&c).unwrap();
let parsed: CronConfig = serde_json::from_str(&json).unwrap();
assert!(!parsed.enabled);
assert!(!parsed.catch_up_on_startup);
assert_eq!(parsed.max_run_history, 100);
}
@@ -8373,8 +8492,9 @@ config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(toml_str).unwrap();
let parsed = parse_test_config(toml_str);
assert!(parsed.cron.enabled);
assert!(parsed.cron.catch_up_on_startup);
assert_eq!(parsed.cron.max_run_history, 50);
}
@@ -8551,7 +8671,7 @@ default_temperature = 0.7
};
let toml_str = toml::to_string_pretty(&config).unwrap();
let parsed: Config = toml::from_str(&toml_str).unwrap();
let parsed = parse_test_config(&toml_str);
assert_eq!(parsed.api_key, config.api_key);
assert_eq!(parsed.default_provider, config.default_provider);
@@ -8584,7 +8704,7 @@ workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal).unwrap();
let parsed = parse_test_config(minimal);
assert!(parsed.api_key.is_none());
assert!(parsed.default_provider.is_none());
assert_eq!(parsed.observability.backend, "none");
@@ -8607,7 +8727,7 @@ default_temperature = 0.7
default_temperature = 0.7
provider_timeout_secs = 300
"#;
let parsed: Config = toml::from_str(raw).unwrap();
let parsed = parse_test_config(raw);
assert_eq!(parsed.provider_timeout_secs, 300);
}
@@ -8687,7 +8807,7 @@ default_temperature = 0.7
User-Agent = "MyApp/1.0"
X-Title = "zeroclaw"
"#;
let parsed: Config = toml::from_str(raw).unwrap();
let parsed = parse_test_config(raw);
assert_eq!(parsed.extra_headers.len(), 2);
assert_eq!(parsed.extra_headers.get("User-Agent").unwrap(), "MyApp/1.0");
assert_eq!(parsed.extra_headers.get("X-Title").unwrap(), "zeroclaw");
@@ -8698,7 +8818,7 @@ X-Title = "zeroclaw"
let raw = r#"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(raw).unwrap();
let parsed = parse_test_config(raw);
assert!(parsed.extra_headers.is_empty());
}
@@ -8715,7 +8835,7 @@ table = "memories"
connect_timeout_secs = 12
"#;
let parsed: Config = toml::from_str(raw).unwrap();
let parsed = parse_test_config(raw);
assert_eq!(parsed.storage.provider.config.provider, "postgres");
assert_eq!(
parsed.storage.provider.config.db_url.as_deref(),
@@ -8738,7 +8858,7 @@ default_temperature = 0.7
reasoning_enabled = false
"#;
let parsed: Config = toml::from_str(raw).unwrap();
let parsed = parse_test_config(raw);
assert_eq!(parsed.runtime.reasoning_enabled, Some(false));
}
@@ -8771,7 +8891,7 @@ reasoning_effort = "turbo"
#[test]
async fn agent_config_defaults() {
let cfg = AgentConfig::default();
assert!(!cfg.compact_context);
assert!(cfg.compact_context);
assert_eq!(cfg.max_tool_iterations, 10);
assert_eq!(cfg.max_history_messages, 50);
assert!(!cfg.parallel_tools);
@@ -8789,7 +8909,7 @@ max_history_messages = 80
parallel_tools = true
tool_dispatcher = "xml"
"#;
let parsed: Config = toml::from_str(raw).unwrap();
let parsed = parse_test_config(raw);
assert!(parsed.agent.compact_context);
assert_eq!(parsed.agent.max_tool_iterations, 20);
assert_eq!(parsed.agent.max_history_messages, 80);
@@ -9330,6 +9450,7 @@ allowed_users = ["@ops:matrix.org"]
let parsed: SlackConfig = serde_json::from_str(json).unwrap();
assert!(parsed.allowed_users.is_empty());
assert!(!parsed.interrupt_on_new_message);
assert_eq!(parsed.thread_replies, None);
assert!(!parsed.mention_only);
}
@@ -9339,6 +9460,7 @@ allowed_users = ["@ops:matrix.org"]
let parsed: SlackConfig = serde_json::from_str(json).unwrap();
assert_eq!(parsed.allowed_users, vec!["U111"]);
assert!(!parsed.interrupt_on_new_message);
assert_eq!(parsed.thread_replies, None);
assert!(!parsed.mention_only);
}
@@ -9348,6 +9470,7 @@ allowed_users = ["@ops:matrix.org"]
let parsed: SlackConfig = serde_json::from_str(json).unwrap();
assert!(parsed.mention_only);
assert!(!parsed.interrupt_on_new_message);
assert_eq!(parsed.thread_replies, None);
}
#[test]
@@ -9355,6 +9478,16 @@ allowed_users = ["@ops:matrix.org"]
let json = r#"{"bot_token":"xoxb-tok","interrupt_on_new_message":true}"#;
let parsed: SlackConfig = serde_json::from_str(json).unwrap();
assert!(parsed.interrupt_on_new_message);
assert_eq!(parsed.thread_replies, None);
assert!(!parsed.mention_only);
}
#[test]
async fn slack_config_deserializes_thread_replies() {
let json = r#"{"bot_token":"xoxb-tok","thread_replies":false}"#;
let parsed: SlackConfig = serde_json::from_str(json).unwrap();
assert_eq!(parsed.thread_replies, Some(false));
assert!(!parsed.interrupt_on_new_message);
assert!(!parsed.mention_only);
}
@@ -9378,6 +9511,7 @@ channel_id = "C123"
let parsed: SlackConfig = toml::from_str(toml_str).unwrap();
assert!(parsed.allowed_users.is_empty());
assert!(!parsed.interrupt_on_new_message);
assert_eq!(parsed.thread_replies, None);
assert!(!parsed.mention_only);
assert_eq!(parsed.channel_id.as_deref(), Some("C123"));
}
@@ -9648,7 +9782,7 @@ workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal).unwrap();
let parsed = parse_test_config(minimal);
assert!(
parsed.gateway.require_pairing,
"Missing [gateway] must default to require_pairing=true"
@@ -9710,7 +9844,7 @@ workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal).unwrap();
let parsed = parse_test_config(minimal);
assert!(
!parsed.composio.enabled,
"Missing [composio] must default to disabled"
@@ -9765,7 +9899,7 @@ workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal).unwrap();
let parsed = parse_test_config(minimal);
assert!(
parsed.secrets.encrypt,
"Missing [secrets] must default to encrypt=true"
@@ -9850,7 +9984,7 @@ workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal).unwrap();
let parsed = parse_test_config(minimal);
assert!(!parsed.browser.enabled);
assert!(parsed.browser.allowed_domains.is_empty());
}
@@ -9949,7 +10083,7 @@ wire_api = "responses"
requires_openai_auth = true
"#;
let parsed: Config = toml::from_str(raw).expect("config should parse");
let parsed = parse_test_config(raw);
assert_eq!(parsed.default_provider.as_deref(), Some("sub2api"));
assert_eq!(parsed.default_model.as_deref(), Some("gpt-5.3-codex"));
let profile = parsed
@@ -10187,7 +10321,7 @@ requires_openai_auth = true
let saved = tokio::fs::read_to_string(&resolved_config_path)
.await
.unwrap();
let parsed: Config = toml::from_str(&saved).unwrap();
let parsed = parse_test_config(&saved);
assert_eq!(parsed.default_temperature, 0.5);
std::env::remove_var("ZEROCLAW_WORKSPACE");
@@ -10632,6 +10766,59 @@ default_model = "legacy-model"
let _ = fs::remove_dir_all(temp_home).await;
}
#[test]
async fn load_or_init_logs_existing_config_as_initialized() {
let _env_guard = env_override_lock().await;
let temp_home =
std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
let workspace_dir = temp_home.join("profile-a");
let config_path = workspace_dir.join("config.toml");
fs::create_dir_all(&workspace_dir).await.unwrap();
fs::write(
&config_path,
r#"default_temperature = 0.7
default_model = "persisted-profile"
"#,
)
.await
.unwrap();
let original_home = std::env::var("HOME").ok();
std::env::set_var("HOME", &temp_home);
std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir);
let capture = SharedLogBuffer::default();
let subscriber = tracing_subscriber::fmt()
.with_ansi(false)
.without_time()
.with_target(false)
.with_writer(capture.clone())
.finish();
let dispatch = tracing::Dispatch::new(subscriber);
let guard = tracing::dispatcher::set_default(&dispatch);
let config = Box::pin(Config::load_or_init()).await.unwrap();
drop(guard);
let logs = capture.captured();
assert_eq!(config.workspace_dir, workspace_dir.join("workspace"));
assert_eq!(config.config_path, config_path);
assert_eq!(config.default_model.as_deref(), Some("persisted-profile"));
assert!(logs.contains("Config loaded"), "{logs}");
assert!(logs.contains("initialized=true"), "{logs}");
assert!(!logs.contains("initialized=false"), "{logs}");
std::env::remove_var("ZEROCLAW_WORKSPACE");
if let Some(home) = original_home {
std::env::set_var("HOME", home);
} else {
std::env::remove_var("HOME");
}
let _ = fs::remove_dir_all(temp_home).await;
}
#[test]
async fn env_override_empty_values_ignored() {
let _env_guard = env_override_lock().await;
@@ -11284,7 +11471,7 @@ default_model = "legacy-model"
config.transcription.language = Some("en".into());
let toml_str = toml::to_string_pretty(&config).unwrap();
let parsed: Config = toml::from_str(&toml_str).unwrap();
let parsed = parse_test_config(&toml_str);
assert!(parsed.transcription.enabled);
assert_eq!(parsed.transcription.language.as_deref(), Some("en"));
@@ -11298,21 +11485,20 @@ default_model = "legacy-model"
default_model = "test-model"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(toml_str).unwrap();
let parsed = parse_test_config(toml_str);
assert!(!parsed.transcription.enabled);
assert_eq!(parsed.transcription.max_duration_secs, 120);
}
#[test]
async fn security_defaults_are_backward_compatible() {
let parsed: Config = toml::from_str(
let parsed = parse_test_config(
r#"
default_provider = "openrouter"
default_model = "anthropic/claude-sonnet-4.6"
default_temperature = 0.7
"#,
)
.unwrap();
);
assert!(!parsed.security.otp.enabled);
assert_eq!(parsed.security.otp.method, OtpMethod::Totp);
@@ -11322,7 +11508,7 @@ default_temperature = 0.7
#[test]
async fn security_toml_parses_otp_and_estop_sections() {
let parsed: Config = toml::from_str(
let parsed = parse_test_config(
r#"
default_provider = "openrouter"
default_model = "anthropic/claude-sonnet-4.6"
@@ -11342,8 +11528,7 @@ enabled = true
state_file = "~/.zeroclaw/estop-state.json"
require_otp_to_resume = true
"#,
)
.unwrap();
);
assert!(parsed.security.otp.enabled);
assert!(parsed.security.estop.enabled);
@@ -11749,7 +11934,7 @@ require_otp_to_resume = true
agents = ["researcher", "writer"]
strategy = "sequential"
"#;
let config: Config = toml::from_str(toml_str).expect("deserialize");
let config = parse_test_config(toml_str);
assert_eq!(config.agents.len(), 2);
assert_eq!(config.swarms.len(), 1);
assert!(config.swarms.contains_key("pipeline"));
+144 -8
View File
@@ -14,8 +14,8 @@ pub use schedule::{
};
#[allow(unused_imports)]
pub use store::{
add_agent_job, due_jobs, get_job, list_jobs, list_runs, record_last_run, record_run,
remove_job, reschedule_after_run, update_job,
add_agent_job, all_overdue_jobs, due_jobs, get_job, list_jobs, list_runs, record_last_run,
record_run, remove_job, reschedule_after_run, update_job,
};
pub use types::{
deserialize_maybe_stringified, CronJob, CronJobPatch, CronRun, DeliveryConfig, JobType,
@@ -156,6 +156,7 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
expression,
tz,
agent,
allowed_tools,
command,
} => {
let schedule = Schedule::Cron {
@@ -172,12 +173,20 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
None,
None,
false,
if allowed_tools.is_empty() {
None
} else {
Some(allowed_tools)
},
)?;
println!("✅ Added agent cron job {}", job.id);
println!(" Expr : {}", job.expression);
println!(" Next : {}", job.next_run.to_rfc3339());
println!(" Prompt: {}", job.prompt.as_deref().unwrap_or_default());
} else {
if !allowed_tools.is_empty() {
bail!("--allowed-tool is only supported with --agent cron jobs");
}
let job = add_shell_job(config, None, schedule, &command)?;
println!("✅ Added cron job {}", job.id);
println!(" Expr: {}", job.expression);
@@ -186,7 +195,12 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
}
Ok(())
}
crate::CronCommands::AddAt { at, agent, command } => {
crate::CronCommands::AddAt {
at,
agent,
allowed_tools,
command,
} => {
let at = chrono::DateTime::parse_from_rfc3339(&at)
.map_err(|e| anyhow::anyhow!("Invalid RFC3339 timestamp for --at: {e}"))?
.with_timezone(&chrono::Utc);
@@ -201,11 +215,19 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
None,
None,
true,
if allowed_tools.is_empty() {
None
} else {
Some(allowed_tools)
},
)?;
println!("✅ Added one-shot agent cron job {}", job.id);
println!(" At : {}", job.next_run.to_rfc3339());
println!(" Prompt: {}", job.prompt.as_deref().unwrap_or_default());
} else {
if !allowed_tools.is_empty() {
bail!("--allowed-tool is only supported with --agent cron jobs");
}
let job = add_shell_job(config, None, schedule, &command)?;
println!("✅ Added one-shot cron job {}", job.id);
println!(" At : {}", job.next_run.to_rfc3339());
@@ -216,6 +238,7 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
crate::CronCommands::AddEvery {
every_ms,
agent,
allowed_tools,
command,
} => {
let schedule = Schedule::Every { every_ms };
@@ -229,12 +252,20 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
None,
None,
false,
if allowed_tools.is_empty() {
None
} else {
Some(allowed_tools)
},
)?;
println!("✅ Added interval agent cron job {}", job.id);
println!(" Every(ms): {every_ms}");
println!(" Next : {}", job.next_run.to_rfc3339());
println!(" Prompt : {}", job.prompt.as_deref().unwrap_or_default());
} else {
if !allowed_tools.is_empty() {
bail!("--allowed-tool is only supported with --agent cron jobs");
}
let job = add_shell_job(config, None, schedule, &command)?;
println!("✅ Added interval cron job {}", job.id);
println!(" Every(ms): {every_ms}");
@@ -246,6 +277,7 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
crate::CronCommands::Once {
delay,
agent,
allowed_tools,
command,
} => {
if agent {
@@ -261,11 +293,19 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
None,
None,
true,
if allowed_tools.is_empty() {
None
} else {
Some(allowed_tools)
},
)?;
println!("✅ Added one-shot agent cron job {}", job.id);
println!(" At : {}", job.next_run.to_rfc3339());
println!(" Prompt: {}", job.prompt.as_deref().unwrap_or_default());
} else {
if !allowed_tools.is_empty() {
bail!("--allowed-tool is only supported with --agent cron jobs");
}
let job = add_once(config, &delay, &command)?;
println!("✅ Added one-shot cron job {}", job.id);
println!(" At : {}", job.next_run.to_rfc3339());
@@ -279,21 +319,37 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
tz,
command,
name,
allowed_tools,
} => {
if expression.is_none() && tz.is_none() && command.is_none() && name.is_none() {
bail!("At least one of --expression, --tz, --command, or --name must be provided");
if expression.is_none()
&& tz.is_none()
&& command.is_none()
&& name.is_none()
&& allowed_tools.is_empty()
{
bail!(
"At least one of --expression, --tz, --command, --name, or --allowed-tool must be provided"
);
}
let existing = if expression.is_some() || tz.is_some() || !allowed_tools.is_empty() {
Some(get_job(config, &id)?)
} else {
None
};
// Merge expression/tz with the existing schedule so that
// --tz alone updates the timezone and --expression alone
// preserves the existing timezone.
let schedule = if expression.is_some() || tz.is_some() {
let existing = get_job(config, &id)?;
let (existing_expr, existing_tz) = match existing.schedule {
let existing = existing
.as_ref()
.expect("existing job must be loaded when updating schedule");
let (existing_expr, existing_tz) = match &existing.schedule {
Schedule::Cron {
expr,
tz: existing_tz,
} => (expr, existing_tz),
} => (expr.clone(), existing_tz.clone()),
_ => bail!("Cannot update expression/tz on a non-cron schedule"),
};
Some(Schedule::Cron {
@@ -304,10 +360,24 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
None
};
if !allowed_tools.is_empty() {
let existing = existing
.as_ref()
.expect("existing job must be loaded when updating allowed tools");
if existing.job_type != JobType::Agent {
bail!("--allowed-tool is only supported for agent cron jobs");
}
}
let patch = CronJobPatch {
schedule,
command,
name,
allowed_tools: if allowed_tools.is_empty() {
None
} else {
Some(allowed_tools)
},
..CronJobPatch::default()
};
@@ -430,6 +500,7 @@ mod tests {
tz: tz.map(Into::into),
command: command.map(Into::into),
name: name.map(Into::into),
allowed_tools: vec![],
},
config,
)
@@ -778,6 +849,7 @@ mod tests {
expression: "*/15 * * * *".into(),
tz: None,
agent: true,
allowed_tools: vec![],
command: "Check server health: disk space, memory, CPU load".into(),
},
&config,
@@ -808,6 +880,7 @@ mod tests {
expression: "*/15 * * * *".into(),
tz: None,
agent: true,
allowed_tools: vec![],
command: "Check server health: disk space, memory, CPU load".into(),
},
&config,
@@ -819,6 +892,68 @@ mod tests {
assert_eq!(jobs[0].job_type, JobType::Agent);
}
#[test]
fn cli_agent_allowed_tools_persist() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
handle_command(
crate::CronCommands::Add {
expression: "*/15 * * * *".into(),
tz: None,
agent: true,
allowed_tools: vec!["file_read".into(), "web_search".into()],
command: "Check server health".into(),
},
&config,
)
.unwrap();
let jobs = list_jobs(&config).unwrap();
assert_eq!(jobs.len(), 1);
assert_eq!(
jobs[0].allowed_tools,
Some(vec!["file_read".into(), "web_search".into()])
);
}
#[test]
fn cli_update_agent_allowed_tools_persist() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = add_agent_job(
&config,
Some("agent".into()),
Schedule::Cron {
expr: "*/5 * * * *".into(),
tz: None,
},
"original prompt",
SessionTarget::Isolated,
None,
None,
false,
None,
)
.unwrap();
handle_command(
crate::CronCommands::Update {
id: job.id.clone(),
expression: None,
tz: None,
command: None,
name: None,
allowed_tools: vec!["shell".into()],
},
&config,
)
.unwrap();
let updated = get_job(&config, &job.id).unwrap();
assert_eq!(updated.allowed_tools, Some(vec!["shell".into()]));
}
#[test]
fn cli_without_agent_flag_defaults_to_shell_job() {
let tmp = TempDir::new().unwrap();
@@ -829,6 +964,7 @@ mod tests {
expression: "*/5 * * * *".into(),
tz: None,
agent: false,
allowed_tools: vec![],
command: "echo ok".into(),
},
&config,
+130 -14
View File
@@ -6,8 +6,9 @@ use crate::channels::{
};
use crate::config::Config;
use crate::cron::{
due_jobs, next_run_for_schedule, record_last_run, record_run, remove_job, reschedule_after_run,
update_job, CronJob, CronJobPatch, DeliveryConfig, JobType, Schedule, SessionTarget,
all_overdue_jobs, due_jobs, next_run_for_schedule, record_last_run, record_run, remove_job,
reschedule_after_run, update_job, CronJob, CronJobPatch, DeliveryConfig, JobType, Schedule,
SessionTarget,
};
use crate::security::SecurityPolicy;
use anyhow::Result;
@@ -33,6 +34,18 @@ pub async fn run(config: Config) -> Result<()> {
crate::health::mark_component_ok(SCHEDULER_COMPONENT);
// ── Startup catch-up: run ALL overdue jobs before entering the
// normal polling loop. The regular loop is capped by `max_tasks`,
// which could leave some overdue jobs waiting across many cycles
// if the machine was off for a while. The catch-up phase fetches
// without the `max_tasks` limit so every missed job fires once.
// Controlled by `[cron] catch_up_on_startup` (default: true).
if config.cron.catch_up_on_startup {
catch_up_overdue_jobs(&config, &security).await;
} else {
tracing::info!("Scheduler startup: catch-up disabled by config");
}
loop {
interval.tick().await;
// Keep scheduler liveness fresh even when there are no due jobs.
@@ -51,6 +64,35 @@ pub async fn run(config: Config) -> Result<()> {
}
}
/// Fetch **all** overdue jobs (ignoring `max_tasks`) and execute them.
///
/// Called once at scheduler startup so that jobs missed during downtime
/// (e.g. late boot, daemon restart) are caught up immediately.
async fn catch_up_overdue_jobs(config: &Config, security: &Arc<SecurityPolicy>) {
let now = Utc::now();
let jobs = match all_overdue_jobs(config, now) {
Ok(jobs) => jobs,
Err(e) => {
tracing::warn!("Startup catch-up query failed: {e}");
return;
}
};
if jobs.is_empty() {
tracing::info!("Scheduler startup: no overdue jobs to catch up");
return;
}
tracing::info!(
count = jobs.len(),
"Scheduler startup: catching up overdue jobs"
);
process_due_jobs(config, security, jobs, SCHEDULER_COMPONENT).await;
tracing::info!("Scheduler startup: catch-up complete");
}
pub async fn execute_job_now(config: &Config, job: &CronJob) -> (bool, String) {
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
Box::pin(execute_job_with_retry(config, &security, job)).await
@@ -506,18 +548,12 @@ async fn run_job_command_with_timeout(
);
}
let child = match Command::new("sh")
.arg("-lc")
.arg(&job.command)
.current_dir(&config.workspace_dir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
{
Ok(child) => child,
Err(e) => return (false, format!("spawn error: {e}")),
let child = match build_cron_shell_command(&job.command, &config.workspace_dir) {
Ok(mut cmd) => match cmd.spawn() {
Ok(child) => child,
Err(e) => return (false, format!("spawn error: {e}")),
},
Err(e) => return (false, format!("shell setup error: {e}")),
};
match time::timeout(timeout, child.wait_with_output()).await {
@@ -540,6 +576,35 @@ async fn run_job_command_with_timeout(
}
}
/// Build a shell `Command` for cron job execution.
///
/// Uses `sh -c <command>` (non-login shell). On Windows, ZeroClaw users
/// typically have Git Bash installed which provides `sh` in PATH, and
/// cron commands are written with Unix shell syntax. The previous `-lc`
/// (login shell) flag was dropped: login shells load the full user
/// profile on every invocation which is slow and may cause side effects.
///
/// The command is configured with:
/// - `current_dir` set to the workspace
/// - `stdin` piped to `/dev/null` (no interactive input)
/// - `stdout` and `stderr` piped for capture
/// - `kill_on_drop(true)` for safe timeout handling
fn build_cron_shell_command(
command: &str,
workspace_dir: &std::path::Path,
) -> anyhow::Result<Command> {
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg(command)
.current_dir(workspace_dir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
Ok(cmd)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -900,6 +965,7 @@ mod tests {
None,
None,
true,
None,
)
.unwrap();
let started = Utc::now();
@@ -925,6 +991,7 @@ mod tests {
None,
None,
true,
None,
)
.unwrap();
let started = Utc::now();
@@ -991,6 +1058,7 @@ mod tests {
best_effort: false,
}),
false,
None,
)
.unwrap();
let started = Utc::now();
@@ -1029,6 +1097,7 @@ mod tests {
best_effort: true,
}),
false,
None,
)
.unwrap();
let started = Utc::now();
@@ -1060,6 +1129,7 @@ mod tests {
None,
None,
false,
None,
)
.unwrap();
assert!(!job.delete_after_run);
@@ -1152,4 +1222,50 @@ mod tests {
.to_string()
.contains("matrix delivery channel requires `channel-matrix` feature"));
}
#[test]
fn build_cron_shell_command_uses_sh_non_login() {
let workspace = std::env::temp_dir();
let cmd = build_cron_shell_command("echo cron-test", &workspace).unwrap();
let debug = format!("{cmd:?}");
assert!(debug.contains("echo cron-test"));
assert!(debug.contains("\"sh\""), "should use sh: {debug}");
// Must NOT use login shell (-l) — login shells load full profile
// and are slow/unpredictable for cron jobs.
assert!(
!debug.contains("\"-lc\""),
"must not use login shell: {debug}"
);
}
#[tokio::test]
async fn build_cron_shell_command_executes_successfully() {
let workspace = std::env::temp_dir();
let mut cmd = build_cron_shell_command("echo cron-ok", &workspace).unwrap();
let output = cmd.output().await.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("cron-ok"));
}
#[tokio::test]
async fn catch_up_queries_all_overdue_jobs_ignoring_max_tasks() {
let tmp = TempDir::new().unwrap();
let mut config = test_config(&tmp).await;
config.scheduler.max_tasks = 1; // limit normal polling to 1
// Create 3 jobs with "every minute" schedule
for i in 0..3 {
let _ = cron::add_job(&config, "* * * * *", &format!("echo catchup-{i}")).unwrap();
}
// Verify normal due_jobs is limited to max_tasks=1
let far_future = Utc::now() + ChronoDuration::days(1);
let due = cron::due_jobs(&config, far_future).unwrap();
assert_eq!(due.len(), 1, "due_jobs must respect max_tasks");
// all_overdue_jobs ignores the limit
let overdue = cron::all_overdue_jobs(&config, far_future).unwrap();
assert_eq!(overdue.len(), 3, "all_overdue_jobs must return all");
}
}
+170 -8
View File
@@ -77,6 +77,7 @@ pub fn add_agent_job(
model: Option<String>,
delivery: Option<DeliveryConfig>,
delete_after_run: bool,
allowed_tools: Option<Vec<String>>,
) -> Result<CronJob> {
let now = Utc::now();
validate_schedule(&schedule, now)?;
@@ -90,8 +91,8 @@ pub fn add_agent_job(
conn.execute(
"INSERT INTO cron_jobs (
id, expression, command, schedule, job_type, prompt, name, session_target, model,
enabled, delivery, delete_after_run, created_at, next_run
) VALUES (?1, ?2, '', ?3, 'agent', ?4, ?5, ?6, ?7, 1, ?8, ?9, ?10, ?11)",
enabled, delivery, delete_after_run, allowed_tools, created_at, next_run
) VALUES (?1, ?2, '', ?3, 'agent', ?4, ?5, ?6, ?7, 1, ?8, ?9, ?10, ?11, ?12)",
params![
id,
expression,
@@ -102,6 +103,7 @@ pub fn add_agent_job(
model,
serde_json::to_string(&delivery)?,
if delete_after_run { 1 } else { 0 },
encode_allowed_tools(allowed_tools.as_ref())?,
now.to_rfc3339(),
next_run.to_rfc3339(),
],
@@ -117,7 +119,8 @@ pub fn list_jobs(config: &Config) -> Result<Vec<CronJob>> {
with_connection(config, |conn| {
let mut stmt = conn.prepare(
"SELECT id, expression, command, schedule, job_type, prompt, name, session_target, model,
enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output
enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output,
allowed_tools
FROM cron_jobs ORDER BY next_run ASC",
)?;
@@ -135,7 +138,8 @@ pub fn get_job(config: &Config, job_id: &str) -> Result<CronJob> {
with_connection(config, |conn| {
let mut stmt = conn.prepare(
"SELECT id, expression, command, schedule, job_type, prompt, name, session_target, model,
enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output
enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output,
allowed_tools
FROM cron_jobs WHERE id = ?1",
)?;
@@ -168,7 +172,8 @@ pub fn due_jobs(config: &Config, now: DateTime<Utc>) -> Result<Vec<CronJob>> {
with_connection(config, |conn| {
let mut stmt = conn.prepare(
"SELECT id, expression, command, schedule, job_type, prompt, name, session_target, model,
enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output
enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output,
allowed_tools
FROM cron_jobs
WHERE enabled = 1 AND next_run <= ?1
ORDER BY next_run ASC
@@ -188,6 +193,34 @@ pub fn due_jobs(config: &Config, now: DateTime<Utc>) -> Result<Vec<CronJob>> {
})
}
/// Return **all** enabled overdue jobs without the `max_tasks` limit.
///
/// Used by the scheduler startup catch-up to ensure every missed job is
/// executed at least once after a period of downtime (late boot, daemon
/// restart, etc.).
pub fn all_overdue_jobs(config: &Config, now: DateTime<Utc>) -> Result<Vec<CronJob>> {
with_connection(config, |conn| {
let mut stmt = conn.prepare(
"SELECT id, expression, command, schedule, job_type, prompt, name, session_target, model,
enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output, allowed_tools
FROM cron_jobs
WHERE enabled = 1 AND next_run <= ?1
ORDER BY next_run ASC",
)?;
let rows = stmt.query_map(params![now.to_rfc3339()], map_cron_job_row)?;
let mut jobs = Vec::new();
for row in rows {
match row {
Ok(job) => jobs.push(job),
Err(e) => tracing::warn!("Skipping cron job with unparseable row data: {e}"),
}
}
Ok(jobs)
})
}
pub fn update_job(config: &Config, job_id: &str, patch: CronJobPatch) -> Result<CronJob> {
let mut job = get_job(config, job_id)?;
let mut schedule_changed = false;
@@ -222,6 +255,9 @@ pub fn update_job(config: &Config, job_id: &str, patch: CronJobPatch) -> Result<
if let Some(delete_after_run) = patch.delete_after_run {
job.delete_after_run = delete_after_run;
}
if let Some(allowed_tools) = patch.allowed_tools {
job.allowed_tools = Some(allowed_tools);
}
if schedule_changed {
job.next_run = next_run_for_schedule(&job.schedule, Utc::now())?;
@@ -232,8 +268,8 @@ pub fn update_job(config: &Config, job_id: &str, patch: CronJobPatch) -> Result<
"UPDATE cron_jobs
SET expression = ?1, command = ?2, schedule = ?3, job_type = ?4, prompt = ?5, name = ?6,
session_target = ?7, model = ?8, enabled = ?9, delivery = ?10, delete_after_run = ?11,
next_run = ?12
WHERE id = ?13",
allowed_tools = ?12, next_run = ?13
WHERE id = ?14",
params![
job.expression,
job.command,
@@ -246,6 +282,7 @@ pub fn update_job(config: &Config, job_id: &str, patch: CronJobPatch) -> Result<
if job.enabled { 1 } else { 0 },
serde_json::to_string(&job.delivery)?,
if job.delete_after_run { 1 } else { 0 },
encode_allowed_tools(job.allowed_tools.as_ref())?,
job.next_run.to_rfc3339(),
job.id,
],
@@ -446,6 +483,7 @@ fn map_cron_job_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CronJob> {
let next_run_raw: String = row.get(13)?;
let last_run_raw: Option<String> = row.get(14)?;
let created_at_raw: String = row.get(12)?;
let allowed_tools_raw: Option<String> = row.get(17)?;
Ok(CronJob {
id: row.get(0)?,
@@ -468,7 +506,8 @@ fn map_cron_job_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CronJob> {
},
last_status: row.get(15)?,
last_output: row.get(16)?,
allowed_tools: None,
allowed_tools: decode_allowed_tools(allowed_tools_raw.as_deref())
.map_err(sql_conversion_error)?,
})
}
@@ -502,6 +541,25 @@ fn decode_delivery(delivery_raw: Option<&str>) -> Result<DeliveryConfig> {
Ok(DeliveryConfig::default())
}
fn encode_allowed_tools(allowed_tools: Option<&Vec<String>>) -> Result<Option<String>> {
allowed_tools
.map(serde_json::to_string)
.transpose()
.context("Failed to serialize cron allowed_tools")
}
fn decode_allowed_tools(raw: Option<&str>) -> Result<Option<Vec<String>>> {
if let Some(raw) = raw {
let trimmed = raw.trim();
if !trimmed.is_empty() {
return serde_json::from_str(trimmed)
.map(Some)
.with_context(|| format!("Failed to parse cron allowed_tools JSON: {trimmed}"));
}
}
Ok(None)
}
fn add_column_if_missing(conn: &Connection, name: &str, sql_type: &str) -> Result<()> {
let mut stmt = conn.prepare("PRAGMA table_info(cron_jobs)")?;
let mut rows = stmt.query([])?;
@@ -557,6 +615,7 @@ fn with_connection<T>(config: &Config, f: impl FnOnce(&Connection) -> Result<T>)
enabled INTEGER NOT NULL DEFAULT 1,
delivery TEXT,
delete_after_run INTEGER NOT NULL DEFAULT 0,
allowed_tools TEXT,
created_at TEXT NOT NULL,
next_run TEXT NOT NULL,
last_run TEXT,
@@ -590,6 +649,7 @@ fn with_connection<T>(config: &Config, f: impl FnOnce(&Connection) -> Result<T>)
add_column_if_missing(&conn, "enabled", "INTEGER NOT NULL DEFAULT 1")?;
add_column_if_missing(&conn, "delivery", "TEXT")?;
add_column_if_missing(&conn, "delete_after_run", "INTEGER NOT NULL DEFAULT 0")?;
add_column_if_missing(&conn, "allowed_tools", "TEXT")?;
f(&conn)
}
@@ -704,6 +764,108 @@ mod tests {
assert_eq!(due.len(), 2);
}
#[test]
fn all_overdue_jobs_ignores_max_tasks_limit() {
let tmp = TempDir::new().unwrap();
let mut config = test_config(&tmp);
config.scheduler.max_tasks = 2;
let _ = add_job(&config, "* * * * *", "echo ov-1").unwrap();
let _ = add_job(&config, "* * * * *", "echo ov-2").unwrap();
let _ = add_job(&config, "* * * * *", "echo ov-3").unwrap();
let far_future = Utc::now() + ChronoDuration::days(365);
// due_jobs respects the limit
let due = due_jobs(&config, far_future).unwrap();
assert_eq!(due.len(), 2);
// all_overdue_jobs returns everything
let overdue = all_overdue_jobs(&config, far_future).unwrap();
assert_eq!(overdue.len(), 3);
}
#[test]
fn all_overdue_jobs_excludes_disabled_jobs() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = add_job(&config, "* * * * *", "echo disabled").unwrap();
let _ = update_job(
&config,
&job.id,
CronJobPatch {
enabled: Some(false),
..CronJobPatch::default()
},
)
.unwrap();
let far_future = Utc::now() + ChronoDuration::days(365);
let overdue = all_overdue_jobs(&config, far_future).unwrap();
assert!(overdue.is_empty());
}
#[test]
fn add_agent_job_persists_allowed_tools() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = add_agent_job(
&config,
Some("agent".into()),
Schedule::Every { every_ms: 60_000 },
"do work",
SessionTarget::Isolated,
None,
None,
false,
Some(vec!["file_read".into(), "web_search".into()]),
)
.unwrap();
assert_eq!(
job.allowed_tools,
Some(vec!["file_read".into(), "web_search".into()])
);
let stored = get_job(&config, &job.id).unwrap();
assert_eq!(stored.allowed_tools, job.allowed_tools);
}
#[test]
fn update_job_persists_allowed_tools_patch() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = add_agent_job(
&config,
Some("agent".into()),
Schedule::Every { every_ms: 60_000 },
"do work",
SessionTarget::Isolated,
None,
None,
false,
None,
)
.unwrap();
let updated = update_job(
&config,
&job.id,
CronJobPatch {
allowed_tools: Some(vec!["shell".into()]),
..CronJobPatch::default()
},
)
.unwrap();
assert_eq!(updated.allowed_tools, Some(vec!["shell".into()]));
assert_eq!(
get_job(&config, &job.id).unwrap().allowed_tools,
Some(vec!["shell".into()])
);
}
#[test]
fn reschedule_after_run_persists_last_status_and_last_run() {
let tmp = TempDir::new().unwrap();
+4 -1
View File
@@ -315,7 +315,10 @@ async fn run_heartbeat_worker(config: Config) -> Result<()> {
// ── Phase 1: LLM decision (two-phase mode) ──────────────
let tasks_to_run = if two_phase {
let decision_prompt = HeartbeatEngine::build_decision_prompt(&tasks);
let decision_prompt = format!(
"[Heartbeat Task | decision] {}",
HeartbeatEngine::build_decision_prompt(&tasks),
);
match Box::pin(crate::agent::run(
config.clone(),
Some(decision_prompt),
+59
View File
@@ -357,6 +357,65 @@ pub async fn handle_api_cron_delete(
}
}
/// GET /api/cron/settings — return cron subsystem settings
pub async fn handle_api_cron_settings_get(
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let config = state.config.lock().clone();
Json(serde_json::json!({
"enabled": config.cron.enabled,
"catch_up_on_startup": config.cron.catch_up_on_startup,
"max_run_history": config.cron.max_run_history,
}))
.into_response()
}
/// PATCH /api/cron/settings — update cron subsystem settings
pub async fn handle_api_cron_settings_patch(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let mut config = state.config.lock().clone();
if let Some(v) = body.get("enabled").and_then(|v| v.as_bool()) {
config.cron.enabled = v;
}
if let Some(v) = body.get("catch_up_on_startup").and_then(|v| v.as_bool()) {
config.cron.catch_up_on_startup = v;
}
if let Some(v) = body.get("max_run_history").and_then(|v| v.as_u64()) {
config.cron.max_run_history = u32::try_from(v).unwrap_or(u32::MAX);
}
if let Err(e) = config.save().await {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Failed to save config: {e}")})),
)
.into_response();
}
*state.config.lock() = config.clone();
Json(serde_json::json!({
"status": "ok",
"enabled": config.cron.enabled,
"catch_up_on_startup": config.cron.catch_up_on_startup,
"max_run_history": config.cron.max_run_history,
}))
.into_response()
}
/// GET /api/integrations — list all integrations with status
pub async fn handle_api_integrations(
State(state): State<AppState>,
+4
View File
@@ -766,6 +766,10 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
.route("/api/tools", get(api::handle_api_tools))
.route("/api/cron", get(api::handle_api_cron_list))
.route("/api/cron", post(api::handle_api_cron_add))
.route(
"/api/cron/settings",
get(api::handle_api_cron_settings_get).patch(api::handle_api_cron_settings_patch),
)
.route("/api/cron/{id}", delete(api::handle_api_cron_delete))
.route("/api/cron/{id}/runs", get(api::handle_api_cron_runs))
.route("/api/integrations", get(api::handle_api_integrations))
+15
View File
@@ -299,6 +299,9 @@ Examples:
/// Treat the argument as an agent prompt instead of a shell command
#[arg(long)]
agent: bool,
/// Restrict agent cron jobs to the specified tool names (repeatable, agent-only)
#[arg(long = "allowed-tool")]
allowed_tools: Vec<String>,
/// Command (shell) or prompt (agent) to run
command: String,
},
@@ -317,6 +320,9 @@ Examples:
/// Treat the argument as an agent prompt instead of a shell command
#[arg(long)]
agent: bool,
/// Restrict agent cron jobs to the specified tool names (repeatable, agent-only)
#[arg(long = "allowed-tool")]
allowed_tools: Vec<String>,
/// Command (shell) or prompt (agent) to run
command: String,
},
@@ -335,6 +341,9 @@ Examples:
/// Treat the argument as an agent prompt instead of a shell command
#[arg(long)]
agent: bool,
/// Restrict agent cron jobs to the specified tool names (repeatable, agent-only)
#[arg(long = "allowed-tool")]
allowed_tools: Vec<String>,
/// Command (shell) or prompt (agent) to run
command: String,
},
@@ -355,6 +364,9 @@ Examples:
/// Treat the argument as an agent prompt instead of a shell command
#[arg(long)]
agent: bool,
/// Restrict agent cron jobs to the specified tool names (repeatable, agent-only)
#[arg(long = "allowed-tool")]
allowed_tools: Vec<String>,
/// Command (shell) or prompt (agent) to run
command: String,
},
@@ -388,6 +400,9 @@ Examples:
/// New job name
#[arg(long)]
name: Option<String>,
/// Replace the agent job allowlist with the specified tool names (repeatable)
#[arg(long = "allowed-tool")]
allowed_tools: Vec<String>,
},
/// Pause a scheduled task
Pause {
+7
View File
@@ -101,6 +101,7 @@ pub fn should_skip_autosave_content(content: &str) -> bool {
let lowered = normalized.to_ascii_lowercase();
lowered.starts_with("[cron:")
|| lowered.starts_with("[heartbeat task")
|| lowered.starts_with("[distilled_")
|| lowered.contains("distilled_index_sig:")
}
@@ -471,6 +472,12 @@ mod tests {
assert!(should_skip_autosave_content(
"[DISTILLED_MEMORY_CHUNK 1/2] DISTILLED_INDEX_SIG:abc123"
));
assert!(should_skip_autosave_content(
"[Heartbeat Task | decision] Should I run tasks?"
));
assert!(should_skip_autosave_content(
"[Heartbeat Task | high] Execute scheduled patrol"
));
assert!(!should_skip_autosave_content(
"User prefers concise answers."
));
+99 -1
View File
@@ -463,6 +463,47 @@ fn resolve_quick_setup_dirs_with_home(home: &Path) -> (PathBuf, PathBuf) {
(config_dir.clone(), config_dir.join("workspace"))
}
fn homebrew_prefix_for_exe(exe: &Path) -> Option<&'static str> {
let exe = exe.to_string_lossy();
if exe == "/opt/homebrew/bin/zeroclaw"
|| exe.starts_with("/opt/homebrew/Cellar/zeroclaw/")
|| exe.starts_with("/opt/homebrew/opt/zeroclaw/")
{
return Some("/opt/homebrew");
}
if exe == "/usr/local/bin/zeroclaw"
|| exe.starts_with("/usr/local/Cellar/zeroclaw/")
|| exe.starts_with("/usr/local/opt/zeroclaw/")
{
return Some("/usr/local");
}
None
}
fn quick_setup_homebrew_service_note(
config_path: &Path,
workspace_dir: &Path,
exe: &Path,
) -> Option<String> {
let prefix = homebrew_prefix_for_exe(exe)?;
let service_root = Path::new(prefix).join("var").join("zeroclaw");
let service_config = service_root.join("config.toml");
let service_workspace = service_root.join("workspace");
if config_path == service_config || workspace_dir == service_workspace {
return None;
}
Some(format!(
"Homebrew service note: `brew services` uses {} (config {}) by default. Your onboarding just wrote {}. If you plan to run ZeroClaw as a service, copy or link this workspace first.",
service_workspace.display(),
service_config.display(),
config_path.display(),
))
}
#[allow(clippy::too_many_lines)]
async fn run_quick_setup_with_home(
credential_override: Option<&str>,
@@ -650,6 +691,16 @@ async fn run_quick_setup_with_home(
style("Config saved:").white().bold(),
style(config_path.display()).green()
);
if cfg!(target_os = "macos") {
if let Ok(exe) = std::env::current_exe() {
if let Some(note) =
quick_setup_homebrew_service_note(&config_path, &workspace_dir, &exe)
{
println!();
println!(" {}", style(note).yellow());
}
}
}
println!();
println!(" {}", style("Next steps:").white().bold());
if credential_override.is_none() {
@@ -3913,6 +3964,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
},
allowed_users,
interrupt_on_new_message: false,
thread_replies: None,
mention_only: false,
});
}
@@ -5367,7 +5419,7 @@ async fn scaffold_workspace(workspace_dir: &Path, ctx: &ProjectContext) -> Resul
Participate, don't dominate. Respond when mentioned or when you add genuine value.\n\
Stay silent when it's casual banter or someone already answered.\n\n\
## Tools & Skills\n\n\
Skills are listed in the system prompt. Use `read` on a skill's SKILL.md for details.\n\
Skills are listed in the system prompt. Use `read_skill` when available, or `file_read` on a skill file, for full details.\n\
Keep local notes (SSH hosts, device names, etc.) in `TOOLS.md`.\n\n\
## Crash Recovery\n\n\
- If a run stops unexpectedly, recover context before acting.\n\
@@ -6066,6 +6118,52 @@ mod tests {
assert_eq!(config.config_path, expected_config_path);
}
#[test]
fn homebrew_prefix_for_exe_detects_supported_layouts() {
assert_eq!(
homebrew_prefix_for_exe(Path::new("/opt/homebrew/bin/zeroclaw")),
Some("/opt/homebrew")
);
assert_eq!(
homebrew_prefix_for_exe(Path::new(
"/opt/homebrew/Cellar/zeroclaw/0.5.0/bin/zeroclaw",
)),
Some("/opt/homebrew")
);
assert_eq!(
homebrew_prefix_for_exe(Path::new("/usr/local/bin/zeroclaw")),
Some("/usr/local")
);
assert_eq!(homebrew_prefix_for_exe(Path::new("/tmp/zeroclaw")), None);
}
#[test]
fn quick_setup_homebrew_service_note_mentions_service_workspace() {
let note = quick_setup_homebrew_service_note(
Path::new("/Users/alix/.zeroclaw/config.toml"),
Path::new("/Users/alix/.zeroclaw/workspace"),
Path::new("/opt/homebrew/bin/zeroclaw"),
)
.expect("homebrew installs should emit a service workspace note");
assert!(note.contains("/opt/homebrew/var/zeroclaw/workspace"));
assert!(note.contains("/opt/homebrew/var/zeroclaw/config.toml"));
assert!(note.contains("/Users/alix/.zeroclaw/config.toml"));
}
#[test]
fn quick_setup_homebrew_service_note_skips_matching_service_layout() {
let service_config = Path::new("/opt/homebrew/var/zeroclaw/config.toml");
let service_workspace = Path::new("/opt/homebrew/var/zeroclaw/workspace");
assert!(quick_setup_homebrew_service_note(
service_config,
service_workspace,
Path::new("/opt/homebrew/bin/zeroclaw"),
)
.is_none());
}
// ── scaffold_workspace: basic file creation ─────────────────
#[tokio::test]
+50 -41
View File
@@ -211,9 +211,9 @@ impl AnthropicProvider {
text.len() > 3072
}
/// Cache conversations with more than 4 messages (excluding system)
/// Cache conversations with more than 1 non-system message (i.e. after first exchange)
fn should_cache_conversation(messages: &[ChatMessage]) -> bool {
messages.iter().filter(|m| m.role != "system").count() > 4
messages.iter().filter(|m| m.role != "system").count() > 1
}
/// Apply cache control to the last message content block
@@ -447,17 +447,13 @@ impl AnthropicProvider {
}
}
// Convert system text to SystemPrompt with cache control if large
// Always use Blocks format with cache_control for system prompts
let system_prompt = system_text.map(|text| {
if Self::should_cache_system(&text) {
SystemPrompt::Blocks(vec![SystemBlock {
block_type: "text".to_string(),
text,
cache_control: Some(CacheControl::ephemeral()),
}])
} else {
SystemPrompt::String(text)
}
SystemPrompt::Blocks(vec![SystemBlock {
block_type: "text".to_string(),
text,
cache_control: Some(CacheControl::ephemeral()),
}])
});
(system_prompt, native_messages)
@@ -1063,12 +1059,8 @@ mod tests {
role: "user".to_string(),
content: "Hello".to_string(),
},
ChatMessage {
role: "assistant".to_string(),
content: "Hi".to_string(),
},
];
// Only 2 non-system messages
// Only 1 non-system message — should not cache
assert!(!AnthropicProvider::should_cache_conversation(&messages));
}
@@ -1078,8 +1070,8 @@ mod tests {
role: "system".to_string(),
content: "System prompt".to_string(),
}];
// Add 5 non-system messages
for i in 0..5 {
// Add 3 non-system messages
for i in 0..3 {
messages.push(ChatMessage {
role: if i % 2 == 0 { "user" } else { "assistant" }.to_string(),
content: format!("Message {i}"),
@@ -1090,21 +1082,24 @@ mod tests {
#[test]
fn should_cache_conversation_boundary() {
let mut messages = vec![];
// Add exactly 4 non-system messages
for i in 0..4 {
messages.push(ChatMessage {
role: if i % 2 == 0 { "user" } else { "assistant" }.to_string(),
content: format!("Message {i}"),
});
}
let messages = vec![ChatMessage {
role: "user".to_string(),
content: "Hello".to_string(),
}];
// Exactly 1 non-system message — should not cache
assert!(!AnthropicProvider::should_cache_conversation(&messages));
// Add one more to cross boundary
messages.push(ChatMessage {
role: "user".to_string(),
content: "One more".to_string(),
});
// Add one more to cross boundary (>1)
let messages = vec![
ChatMessage {
role: "user".to_string(),
content: "Hello".to_string(),
},
ChatMessage {
role: "assistant".to_string(),
content: "Hi".to_string(),
},
];
assert!(AnthropicProvider::should_cache_conversation(&messages));
}
@@ -1217,7 +1212,7 @@ mod tests {
}
#[test]
fn convert_messages_small_system_prompt() {
fn convert_messages_small_system_prompt_uses_blocks_with_cache() {
let messages = vec![ChatMessage {
role: "system".to_string(),
content: "Short system prompt".to_string(),
@@ -1226,10 +1221,17 @@ mod tests {
let (system_prompt, _) = AnthropicProvider::convert_messages(&messages);
match system_prompt.unwrap() {
SystemPrompt::String(s) => {
assert_eq!(s, "Short system prompt");
SystemPrompt::Blocks(blocks) => {
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].text, "Short system prompt");
assert!(
blocks[0].cache_control.is_some(),
"Small system prompts should have cache_control"
);
}
SystemPrompt::String(_) => {
panic!("Expected Blocks variant with cache_control for small prompt")
}
SystemPrompt::Blocks(_) => panic!("Expected String variant for small prompt"),
}
}
@@ -1254,12 +1256,16 @@ mod tests {
}
#[test]
fn backward_compatibility_native_chat_request() {
// Test that requests without cache_control serialize identically to old format
fn native_chat_request_with_blocks_system() {
// System prompts now always use Blocks format with cache_control
let req = NativeChatRequest {
model: "claude-3-opus".to_string(),
max_tokens: 4096,
system: Some(SystemPrompt::String("System".to_string())),
system: Some(SystemPrompt::Blocks(vec![SystemBlock {
block_type: "text".to_string(),
text: "System".to_string(),
cache_control: Some(CacheControl::ephemeral()),
}])),
messages: vec![NativeMessage {
role: "user".to_string(),
content: vec![NativeContentOut::Text {
@@ -1272,8 +1278,11 @@ mod tests {
};
let json = serde_json::to_string(&req).unwrap();
assert!(!json.contains("cache_control"));
assert!(json.contains(r#""system":"System""#));
assert!(json.contains("System"));
assert!(
json.contains(r#""cache_control":{"type":"ephemeral"}"#),
"System prompt should include cache_control"
);
}
#[tokio::test]
+27 -23
View File
@@ -279,6 +279,7 @@ impl Provider for ClaudeCodeProvider {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Mutex, OnceLock};
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
@@ -375,32 +376,35 @@ mod tests {
/// Helper: create a provider that uses a shell script echoing stdin back.
/// The script ignores CLI flags (`--print`, `--model`, `-`) and just cats stdin.
///
/// Uses `OnceLock` to write the script file exactly once, avoiding
/// "Text file busy" (ETXTBSY) races when parallel tests try to
/// overwrite a script that another test is currently executing.
fn echo_provider() -> ClaudeCodeProvider {
use std::sync::OnceLock;
use std::io::Write;
static SCRIPT_PATH: OnceLock<PathBuf> = OnceLock::new();
let script = SCRIPT_PATH.get_or_init(|| {
use std::io::Write;
let dir = std::env::temp_dir().join("zeroclaw_test_claude_code");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(format!("fake_claude_{}.sh", std::process::id()));
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "#!/bin/sh\ncat /dev/stdin").unwrap();
drop(f);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
path
});
ClaudeCodeProvider {
binary_path: script.clone(),
static SCRIPT_ID: AtomicUsize = AtomicUsize::new(0);
let dir = std::env::temp_dir().join("zeroclaw_test_claude_code");
std::fs::create_dir_all(&dir).unwrap();
let script_id = SCRIPT_ID.fetch_add(1, Ordering::Relaxed);
let path = dir.join(format!(
"fake_claude_{}_{}.sh",
std::process::id(),
script_id
));
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "#!/bin/sh\ncat /dev/stdin").unwrap();
drop(f);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
ClaudeCodeProvider { binary_path: path }
}
#[test]
fn echo_provider_uses_unique_script_paths() {
let first = echo_provider();
let second = echo_provider();
assert_ne!(first.binary_path, second.binary_path);
}
#[tokio::test]
+101
View File
@@ -335,6 +335,23 @@ impl OpenAiCompatibleProvider {
!path.is_empty() && path != "/"
}
fn requires_tool_stream(&self) -> bool {
let host_requires_tool_stream = reqwest::Url::parse(&self.base_url)
.ok()
.and_then(|url| url.host_str().map(str::to_ascii_lowercase))
.is_some_and(|host| host == "api.z.ai" || host.ends_with(".z.ai"));
host_requires_tool_stream || matches!(self.name.as_str(), "zai" | "z.ai")
}
fn tool_stream_for_tools(&self, has_tools: bool) -> Option<bool> {
if has_tools && self.requires_tool_stream() {
Some(true)
} else {
None
}
}
/// Build the full URL for responses API, detecting if base_url already includes the path.
fn responses_url(&self) -> String {
if self.path_ends_with("/responses") {
@@ -392,6 +409,8 @@ struct ApiChatRequest {
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_effort: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<String>,
@@ -590,6 +609,8 @@ struct NativeChatRequest {
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_effort: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<String>,
@@ -1264,6 +1285,7 @@ impl Provider for OpenAiCompatibleProvider {
temperature,
stream: Some(false),
reasoning_effort: self.reasoning_effort_for_model(model),
tool_stream: None,
tools: None,
tool_choice: None,
};
@@ -1387,6 +1409,7 @@ impl Provider for OpenAiCompatibleProvider {
temperature,
stream: Some(false),
reasoning_effort: self.reasoning_effort_for_model(model),
tool_stream: None,
tools: None,
tool_choice: None,
};
@@ -1498,6 +1521,7 @@ impl Provider for OpenAiCompatibleProvider {
temperature,
stream: Some(false),
reasoning_effort: self.reasoning_effort_for_model(model),
tool_stream: self.tool_stream_for_tools(!tools.is_empty()),
tools: if tools.is_empty() {
None
} else {
@@ -1604,6 +1628,8 @@ impl Provider for OpenAiCompatibleProvider {
temperature,
stream: Some(false),
reasoning_effort: self.reasoning_effort_for_model(model),
tool_stream: self
.tool_stream_for_tools(tools.as_ref().is_some_and(|tools| !tools.is_empty())),
tool_choice: tools.as_ref().map(|_| "auto".to_string()),
tools,
};
@@ -1748,6 +1774,7 @@ impl Provider for OpenAiCompatibleProvider {
temperature,
stream: Some(options.enabled),
reasoning_effort: self.reasoning_effort_for_model(model),
tool_stream: None,
tools: None,
tool_choice: None,
};
@@ -1890,6 +1917,7 @@ mod tests {
temperature: 0.4,
stream: Some(false),
reasoning_effort: None,
tool_stream: None,
tools: None,
tool_choice: None,
};
@@ -2671,6 +2699,7 @@ mod tests {
temperature: 0.7,
stream: Some(false),
reasoning_effort: None,
tool_stream: None,
tools: Some(tools),
tool_choice: Some("auto".to_string()),
};
@@ -2680,6 +2709,78 @@ mod tests {
assert!(json.contains("\"tool_choice\":\"auto\""));
}
#[test]
fn zai_tool_requests_enable_tool_stream() {
let provider = make_provider("zai", "https://api.z.ai/api/paas/v4", None);
let req = ApiChatRequest {
model: "glm-5".to_string(),
messages: vec![Message {
role: "user".to_string(),
content: MessageContent::Text("List /tmp".to_string()),
}],
temperature: 0.7,
stream: Some(false),
reasoning_effort: None,
tool_stream: provider.tool_stream_for_tools(true),
tools: Some(vec![serde_json::json!({
"type": "function",
"function": {
"name": "shell",
"description": "Run a shell command",
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string"}
}
}
}
})]),
tool_choice: Some("auto".to_string()),
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"tool_stream\":true"));
}
#[test]
fn non_zai_tool_requests_omit_tool_stream() {
let provider = make_provider("test", "https://api.example.com/v1", None);
let req = ApiChatRequest {
model: "test-model".to_string(),
messages: vec![Message {
role: "user".to_string(),
content: MessageContent::Text("List /tmp".to_string()),
}],
temperature: 0.7,
stream: Some(false),
reasoning_effort: None,
tool_stream: provider.tool_stream_for_tools(true),
tools: Some(vec![serde_json::json!({
"type": "function",
"function": {
"name": "shell",
"description": "Run a shell command",
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string"}
}
}
}
})]),
tool_choice: Some("auto".to_string()),
};
let json = serde_json::to_string(&req).unwrap();
assert!(!json.contains("\"tool_stream\""));
}
#[test]
fn z_ai_host_enables_tool_stream_for_custom_profiles() {
let provider = make_provider("custom", "https://api.z.ai/api/coding/paas/v4", None);
assert_eq!(provider.tool_stream_for_tools(true), Some(true));
}
#[test]
fn response_with_tool_calls_deserializes() {
let json = r#"{
+4 -1
View File
@@ -1119,7 +1119,10 @@ fn create_provider_with_url_and_options(
)?))
}
// ── Primary providers (custom implementations) ───────
"openrouter" => Ok(Box::new(openrouter::OpenRouterProvider::new(key))),
"openrouter" => Ok(Box::new(openrouter::OpenRouterProvider::new(
key,
options.provider_timeout_secs,
))),
"anthropic" => Ok(Box::new(anthropic::AnthropicProvider::new(key))),
"openai" => Ok(Box::new(openai::OpenAiProvider::with_base_url(api_url, key))),
// Ollama uses api_url for custom base URL (e.g. remote Ollama instance)
+133 -13
View File
@@ -6,12 +6,17 @@ use crate::providers::traits::{
use crate::tools::ToolSpec;
use async_trait::async_trait;
use reqwest::Client;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
pub struct OpenRouterProvider {
credential: Option<String>,
timeout_secs: u64,
}
const DEFAULT_OPENROUTER_TIMEOUT_SECS: u64 = 120;
const OPENROUTER_CONNECT_TIMEOUT_SECS: u64 = 10;
#[derive(Debug, Serialize)]
struct ChatRequest {
model: String,
@@ -146,12 +151,21 @@ struct NativeResponseMessage {
}
impl OpenRouterProvider {
pub fn new(credential: Option<&str>) -> Self {
pub fn new(credential: Option<&str>, timeout_secs: Option<u64>) -> Self {
Self {
credential: credential.map(ToString::to_string),
timeout_secs: timeout_secs
.filter(|secs| *secs > 0)
.unwrap_or(DEFAULT_OPENROUTER_TIMEOUT_SECS),
}
}
/// Override the HTTP request timeout for LLM API calls.
pub fn with_timeout_secs(mut self, secs: u64) -> Self {
self.timeout_secs = secs;
self
}
fn convert_tools(tools: Option<&[ToolSpec]>) -> Option<Vec<NativeToolSpec>> {
let items = tools?;
if items.is_empty() {
@@ -295,8 +309,44 @@ impl OpenRouterProvider {
}
}
fn compact_sanitized_body_snippet(body: &str) -> String {
super::sanitize_api_error(body)
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
async fn read_response_body(
provider_name: &str,
response: reqwest::Response,
) -> anyhow::Result<String> {
response.text().await.map_err(|error| {
let sanitized = super::sanitize_api_error(&error.to_string());
anyhow::anyhow!(
"{provider_name} transport error while reading response body: {sanitized}"
)
})
}
fn parse_response_body<T: DeserializeOwned>(
provider_name: &str,
body: &str,
kind: &str,
) -> anyhow::Result<T> {
serde_json::from_str::<T>(body).map_err(|error| {
let snippet = Self::compact_sanitized_body_snippet(body);
anyhow::anyhow!(
"{provider_name} API returned an unexpected {kind} payload: {error}; body={snippet}"
)
})
}
fn http_client(&self) -> Client {
crate::config::build_runtime_proxy_client_with_timeouts("provider.openrouter", 120, 10)
crate::config::build_runtime_proxy_client_with_timeouts(
"provider.openrouter",
self.timeout_secs,
OPENROUTER_CONNECT_TIMEOUT_SECS,
)
}
}
@@ -368,7 +418,9 @@ impl Provider for OpenRouterProvider {
return Err(super::api_error("OpenRouter", response).await);
}
let chat_response: ApiChatResponse = response.json().await?;
let body = Self::read_response_body("OpenRouter", response).await?;
let chat_response =
Self::parse_response_body::<ApiChatResponse>("OpenRouter", &body, "chat-completions")?;
chat_response
.choices
@@ -415,7 +467,9 @@ impl Provider for OpenRouterProvider {
return Err(super::api_error("OpenRouter", response).await);
}
let chat_response: ApiChatResponse = response.json().await?;
let body = Self::read_response_body("OpenRouter", response).await?;
let chat_response =
Self::parse_response_body::<ApiChatResponse>("OpenRouter", &body, "chat-completions")?;
chat_response
.choices
@@ -460,7 +514,9 @@ impl Provider for OpenRouterProvider {
return Err(super::api_error("OpenRouter", response).await);
}
let native_response: NativeChatResponse = response.json().await?;
let body = Self::read_response_body("OpenRouter", response).await?;
let native_response =
Self::parse_response_body::<NativeChatResponse>("OpenRouter", &body, "native chat")?;
let usage = native_response.usage.map(|u| TokenUsage {
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
@@ -552,7 +608,9 @@ impl Provider for OpenRouterProvider {
return Err(super::api_error("OpenRouter", response).await);
}
let native_response: NativeChatResponse = response.json().await?;
let body = Self::read_response_body("OpenRouter", response).await?;
let native_response =
Self::parse_response_body::<NativeChatResponse>("OpenRouter", &body, "native chat")?;
let usage = native_response.usage.map(|u| TokenUsage {
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
@@ -577,7 +635,7 @@ mod tests {
#[test]
fn capabilities_report_vision_support() {
let provider = OpenRouterProvider::new(Some("openrouter-test-credential"));
let provider = OpenRouterProvider::new(Some("openrouter-test-credential"), None);
let caps = <OpenRouterProvider as Provider>::capabilities(&provider);
assert!(caps.native_tool_calling);
assert!(caps.vision);
@@ -585,7 +643,7 @@ mod tests {
#[test]
fn creates_with_key() {
let provider = OpenRouterProvider::new(Some("openrouter-test-credential"));
let provider = OpenRouterProvider::new(Some("openrouter-test-credential"), None);
assert_eq!(
provider.credential.as_deref(),
Some("openrouter-test-credential")
@@ -594,20 +652,32 @@ mod tests {
#[test]
fn creates_without_key() {
let provider = OpenRouterProvider::new(None);
let provider = OpenRouterProvider::new(None, None);
assert!(provider.credential.is_none());
}
#[test]
fn uses_configured_timeout_when_provided() {
let provider = OpenRouterProvider::new(Some("openrouter-test-credential"), Some(1200));
assert_eq!(provider.timeout_secs, 1200);
}
#[test]
fn falls_back_to_default_timeout_for_zero() {
let provider = OpenRouterProvider::new(Some("openrouter-test-credential"), Some(0));
assert_eq!(provider.timeout_secs, DEFAULT_OPENROUTER_TIMEOUT_SECS);
}
#[tokio::test]
async fn warmup_without_key_is_noop() {
let provider = OpenRouterProvider::new(None);
let provider = OpenRouterProvider::new(None, None);
let result = provider.warmup().await;
assert!(result.is_ok());
}
#[tokio::test]
async fn chat_with_system_fails_without_key() {
let provider = OpenRouterProvider::new(None);
let provider = OpenRouterProvider::new(None, None);
let result = provider
.chat_with_system(Some("system"), "hello", "openai/gpt-4o", 0.2)
.await;
@@ -618,7 +688,7 @@ mod tests {
#[tokio::test]
async fn chat_with_history_fails_without_key() {
let provider = OpenRouterProvider::new(None);
let provider = OpenRouterProvider::new(None, None);
let messages = vec![
ChatMessage {
role: "system".into(),
@@ -713,9 +783,43 @@ mod tests {
assert!(response.choices.is_empty());
}
#[test]
fn parse_chat_response_body_reports_sanitized_snippet() {
let body = r#"{"choices":"invalid","api_key":"sk-test-secret-value"}"#;
let err = OpenRouterProvider::parse_response_body::<ApiChatResponse>(
"OpenRouter",
body,
"chat-completions",
)
.expect_err("payload should fail");
let msg = err.to_string();
assert!(msg.contains("OpenRouter API returned an unexpected chat-completions payload"));
assert!(msg.contains("body="));
assert!(msg.contains("[REDACTED]"));
assert!(!msg.contains("sk-test-secret-value"));
}
#[test]
fn parse_native_response_body_reports_sanitized_snippet() {
let body = r#"{"choices":123,"api_key":"sk-another-secret"}"#;
let err = OpenRouterProvider::parse_response_body::<NativeChatResponse>(
"OpenRouter",
body,
"native chat",
)
.expect_err("payload should fail");
let msg = err.to_string();
assert!(msg.contains("OpenRouter API returned an unexpected native chat payload"));
assert!(msg.contains("body="));
assert!(msg.contains("[REDACTED]"));
assert!(!msg.contains("sk-another-secret"));
}
#[tokio::test]
async fn chat_with_tools_fails_without_key() {
let provider = OpenRouterProvider::new(None);
let provider = OpenRouterProvider::new(None, None);
let messages = vec![ChatMessage {
role: "user".into(),
content: "What is the date?".into(),
@@ -1017,4 +1121,20 @@ mod tests {
assert!(json.contains("reasoning_content"));
assert!(json.contains("thinking..."));
}
// ═══════════════════════════════════════════════════════════════════════
// timeout_secs configuration tests
// ═══════════════════════════════════════════════════════════════════════
#[test]
fn default_timeout_is_120() {
let provider = OpenRouterProvider::new(Some("key"), None);
assert_eq!(provider.timeout_secs, 120);
}
#[test]
fn with_timeout_secs_overrides_default() {
let provider = OpenRouterProvider::new(Some("key"), None).with_timeout_secs(300);
assert_eq!(provider.timeout_secs, 300);
}
}
+74
View File
@@ -22,6 +22,13 @@ pub fn is_non_retryable(err: &anyhow::Error) -> bool {
return false;
}
// Tool schema validation errors are NOT non-retryable — the provider's
// built-in fallback in compatible.rs can recover by switching to
// prompt-guided tool instructions.
if is_tool_schema_error(err) {
return false;
}
// 4xx errors are generally non-retryable (bad request, auth failure, etc.),
// except 429 (rate-limit — transient) and 408 (timeout — worth retrying).
if let Some(reqwest_err) = err.downcast_ref::<reqwest::Error>() {
@@ -73,6 +80,22 @@ pub fn is_non_retryable(err: &anyhow::Error) -> bool {
|| msg_lower.contains("invalid"))
}
/// Check if an error is a tool schema validation failure (e.g. Groq returning
/// "tool call validation failed: attempted to call tool '...' which was not in request").
/// These errors should NOT be classified as non-retryable because the provider's
/// built-in fallback logic (`compatible.rs::is_native_tool_schema_unsupported`)
/// can recover by switching to prompt-guided tool instructions.
pub fn is_tool_schema_error(err: &anyhow::Error) -> bool {
let lower = err.to_string().to_lowercase();
let hints = [
"tool call validation failed",
"was not in request",
"not found in tool list",
"invalid_tool_call",
];
hints.iter().any(|hint| lower.contains(hint))
}
fn is_context_window_exceeded(err: &anyhow::Error) -> bool {
let lower = err.to_string().to_lowercase();
let hints = [
@@ -2189,4 +2212,55 @@ mod tests {
// Should have been called twice: once with full messages, once with truncated
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
// ── Tool schema error detection tests ───────────────────────────────
#[test]
fn tool_schema_error_detects_groq_validation_failure() {
let msg = r#"Groq API error (400 Bad Request): {"error":{"message":"tool call validation failed: attempted to call tool 'memory_recall' which was not in request"}}"#;
let err = anyhow::anyhow!("{}", msg);
assert!(is_tool_schema_error(&err));
}
#[test]
fn tool_schema_error_detects_not_in_request() {
let err = anyhow::anyhow!("tool 'search' was not in request");
assert!(is_tool_schema_error(&err));
}
#[test]
fn tool_schema_error_detects_not_found_in_tool_list() {
let err = anyhow::anyhow!("function 'foo' not found in tool list");
assert!(is_tool_schema_error(&err));
}
#[test]
fn tool_schema_error_detects_invalid_tool_call() {
let err = anyhow::anyhow!("invalid_tool_call: no matching function");
assert!(is_tool_schema_error(&err));
}
#[test]
fn tool_schema_error_ignores_unrelated_errors() {
let err = anyhow::anyhow!("invalid api key");
assert!(!is_tool_schema_error(&err));
let err = anyhow::anyhow!("model not found");
assert!(!is_tool_schema_error(&err));
}
#[test]
fn non_retryable_returns_false_for_tool_schema_400() {
// A 400 error with tool schema validation text should NOT be non-retryable.
let msg = "400 Bad Request: tool call validation failed: attempted to call tool 'x' which was not in request";
let err = anyhow::anyhow!("{}", msg);
assert!(!is_non_retryable(&err));
}
#[test]
fn non_retryable_returns_true_for_other_400_errors() {
// A regular 400 error (e.g. invalid API key) should still be non-retryable.
let err = anyhow::anyhow!("400 Bad Request: invalid api key provided");
assert!(is_non_retryable(&err));
}
}
+110
View File
@@ -234,6 +234,26 @@ fn expand_user_path(path: &str) -> PathBuf {
PathBuf::from(path)
}
fn rootless_path(path: &Path) -> Option<PathBuf> {
let mut relative = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::Prefix(_)
| std::path::Component::RootDir
| std::path::Component::CurDir => {}
std::path::Component::ParentDir => return None,
std::path::Component::Normal(part) => relative.push(part),
}
}
if relative.as_os_str().is_empty() {
None
} else {
Some(relative)
}
}
// ── Shell Command Parsing Utilities ───────────────────────────────────────
// These helpers implement a minimal quote-aware shell lexer. They exist
// because security validation must reason about the *structure* of a
@@ -1173,6 +1193,44 @@ impl SecurityPolicy {
false
}
fn runtime_config_dir(&self) -> Option<PathBuf> {
let parent = self.workspace_dir.parent()?;
Some(
parent
.canonicalize()
.unwrap_or_else(|_| parent.to_path_buf()),
)
}
pub fn is_runtime_config_path(&self, resolved: &Path) -> bool {
let Some(config_dir) = self.runtime_config_dir() else {
return false;
};
if !resolved.starts_with(&config_dir) {
return false;
}
if resolved.parent() != Some(config_dir.as_path()) {
return false;
}
let Some(file_name) = resolved.file_name().and_then(|value| value.to_str()) else {
return false;
};
file_name == "config.toml"
|| file_name == "config.toml.bak"
|| file_name == "active_workspace.toml"
|| file_name.starts_with(".config.toml.tmp-")
|| file_name.starts_with(".active_workspace.toml.tmp-")
}
pub fn runtime_config_violation_message(&self, resolved: &Path) -> String {
format!(
"Refusing to modify ZeroClaw runtime config/state file: {}. Use dedicated config tools or edit it manually outside the agent loop.",
resolved.display()
)
}
pub fn resolved_path_violation_message(&self, resolved: &Path) -> String {
let guidance = if self.allowed_roots.is_empty() {
"Add the directory to [autonomy].allowed_roots (for example: allowed_roots = [\"/absolute/path\"]), or move the file into the workspace."
@@ -1245,6 +1303,16 @@ impl SecurityPolicy {
let expanded = expand_user_path(path);
if expanded.is_absolute() {
expanded
} else if let Some(workspace_hint) = rootless_path(&self.workspace_dir) {
if let Ok(stripped) = expanded.strip_prefix(&workspace_hint) {
if stripped.as_os_str().is_empty() {
self.workspace_dir.clone()
} else {
self.workspace_dir.join(stripped)
}
} else {
self.workspace_dir.join(expanded)
}
} else {
self.workspace_dir.join(expanded)
}
@@ -2720,6 +2788,19 @@ mod tests {
assert_eq!(resolved, PathBuf::from("/workspace/relative/path.txt"));
}
#[test]
fn resolve_tool_path_normalizes_workspace_prefixed_relative_paths() {
let p = SecurityPolicy {
workspace_dir: PathBuf::from("/zeroclaw-data/workspace"),
..SecurityPolicy::default()
};
let resolved = p.resolve_tool_path("zeroclaw-data/workspace/scripts/daily.py");
assert_eq!(
resolved,
PathBuf::from("/zeroclaw-data/workspace/scripts/daily.py")
);
}
#[test]
fn is_under_allowed_root_matches_allowed_roots() {
let p = SecurityPolicy {
@@ -2744,4 +2825,33 @@ mod tests {
};
assert!(!p.is_under_allowed_root("/any/path"));
}
#[test]
fn runtime_config_paths_are_protected() {
let workspace = PathBuf::from("/tmp/zeroclaw-profile/workspace");
let policy = SecurityPolicy {
workspace_dir: workspace.clone(),
..SecurityPolicy::default()
};
let config_dir = workspace.parent().unwrap();
assert!(policy.is_runtime_config_path(&config_dir.join("config.toml")));
assert!(policy.is_runtime_config_path(&config_dir.join("config.toml.bak")));
assert!(policy.is_runtime_config_path(&config_dir.join(".config.toml.tmp-1234")));
assert!(policy.is_runtime_config_path(&config_dir.join("active_workspace.toml")));
assert!(policy.is_runtime_config_path(&config_dir.join(".active_workspace.toml.tmp-1234")));
}
#[test]
fn workspace_files_are_not_runtime_config_paths() {
let workspace = PathBuf::from("/tmp/zeroclaw-profile/workspace");
let policy = SecurityPolicy {
workspace_dir: workspace.clone(),
..SecurityPolicy::default()
};
let nested_dir = workspace.join("notes");
assert!(!policy.is_runtime_config_path(&workspace.join("notes.txt")));
assert!(!policy.is_runtime_config_path(&nested_dir.join("config.toml")));
}
}
+61 -7
View File
@@ -409,13 +409,43 @@ fn has_shell_shebang(path: &Path) -> bool {
return false;
};
let prefix = &content[..content.len().min(128)];
let shebang = String::from_utf8_lossy(prefix).to_ascii_lowercase();
shebang.starts_with("#!")
&& (shebang.contains("sh")
|| shebang.contains("bash")
|| shebang.contains("zsh")
|| shebang.contains("pwsh")
|| shebang.contains("powershell"))
let shebang_line = String::from_utf8_lossy(prefix)
.lines()
.next()
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let Some(interpreter) = shebang_interpreter(&shebang_line) else {
return false;
};
matches!(
interpreter,
"sh" | "bash" | "zsh" | "ksh" | "fish" | "pwsh" | "powershell"
)
}
fn shebang_interpreter(line: &str) -> Option<&str> {
let shebang = line.strip_prefix("#!")?.trim();
if shebang.is_empty() {
return None;
}
let mut parts = shebang.split_whitespace();
let first = parts.next()?;
let first_basename = Path::new(first).file_name()?.to_str()?;
if first_basename == "env" {
for part in parts {
if part.starts_with('-') {
continue;
}
return Path::new(part).file_name()?.to_str();
}
return None;
}
Some(first_basename)
}
fn extract_markdown_links(content: &str) -> Vec<String> {
@@ -586,6 +616,30 @@ mod tests {
);
}
#[test]
fn audit_allows_python_shebang_file_when_early_text_contains_sh() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("python-helper");
let scripts_dir = skill_dir.join("scripts");
std::fs::create_dir_all(&scripts_dir).unwrap();
std::fs::write(skill_dir.join("SKILL.md"), "# Skill\n").unwrap();
std::fs::write(
scripts_dir.join("helper.py"),
"#!/usr/bin/env python3\n\"\"\"Refresh report cache.\"\"\"\n\nprint(\"ok\")\n",
)
.unwrap();
let report = audit_skill_directory(&skill_dir).unwrap();
assert!(
!report
.findings
.iter()
.any(|finding| finding.contains("script-like files are blocked")),
"{:#?}",
report.findings
);
}
#[test]
fn audit_rejects_markdown_escape_links() {
let dir = tempfile::tempdir().unwrap();
+12 -1
View File
@@ -97,6 +97,15 @@ pub fn load_skills_with_config(workspace_dir: &Path, config: &crate::config::Con
)
}
/// Load skills using explicit open-skills settings.
pub fn load_skills_with_open_skills_settings(
workspace_dir: &Path,
open_skills_enabled: bool,
open_skills_dir: Option<&str>,
) -> Vec<Skill> {
load_skills_with_open_skills_config(workspace_dir, Some(open_skills_enabled), open_skills_dir)
}
fn load_skills_with_open_skills_config(
workspace_dir: &Path,
config_open_skills_enabled: Option<bool>,
@@ -674,7 +683,8 @@ pub fn skills_to_prompt_with_mode(
crate::config::SkillsPromptInjectionMode::Compact => String::from(
"## Available Skills\n\n\
Skill summaries are preloaded below to keep context compact.\n\
Skill instructions are loaded on demand: read the skill file in `location` only when needed.\n\n\
Skill instructions are loaded on demand: call `read_skill(name)` with the skill's `<name>` when you need the full skill file.\n\
The `location` field is included for reference.\n\n\
<available_skills>\n",
),
};
@@ -1267,6 +1277,7 @@ command = "echo hello"
assert!(prompt.contains("<name>test</name>"));
assert!(prompt.contains("<location>skills/test/SKILL.md</location>"));
assert!(prompt.contains("loaded on demand"));
assert!(prompt.contains("read_skill(name)"));
assert!(!prompt.contains("<instructions>"));
assert!(!prompt.contains("<instruction>Do the thing.</instruction>"));
assert!(!prompt.contains("<tools>"));
+47 -1
View File
@@ -130,6 +130,11 @@ impl Tool for CronAddTool {
"type": "string",
"description": "Optional model override for agent jobs, e.g. 'x-ai/grok-4-1-fast'"
},
"allowed_tools": {
"type": "array",
"items": { "type": "string" },
"description": "Optional allowlist of tool names for agent jobs. When omitted, all tools remain available."
},
"delivery": {
"type": "object",
"description": "Optional delivery config to send job output to a channel after each run. When provided, all three of mode, channel, and to are expected.",
@@ -288,6 +293,19 @@ impl Tool for CronAddTool {
.get("model")
.and_then(serde_json::Value::as_str)
.map(str::to_string);
let allowed_tools = match args.get("allowed_tools") {
Some(v) => match serde_json::from_value::<Vec<String>>(v.clone()) {
Ok(v) => Some(v),
Err(e) => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Invalid allowed_tools: {e}")),
});
}
},
None => None,
};
let delivery = match args.get("delivery") {
Some(v) => match serde_json::from_value::<DeliveryConfig>(v.clone()) {
@@ -316,6 +334,7 @@ impl Tool for CronAddTool {
model,
delivery,
delete_after_run,
allowed_tools,
)
}
};
@@ -329,7 +348,8 @@ impl Tool for CronAddTool {
"job_type": job.job_type,
"schedule": job.schedule,
"next_run": job.next_run,
"enabled": job.enabled
"enabled": job.enabled,
"allowed_tools": job.allowed_tools
}))?,
error: None,
}),
@@ -612,6 +632,32 @@ mod tests {
.contains("Missing 'prompt'"));
}
#[tokio::test]
async fn agent_job_persists_allowed_tools() {
let tmp = TempDir::new().unwrap();
let cfg = test_config(&tmp).await;
let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
let result = tool
.execute(json!({
"schedule": { "kind": "cron", "expr": "*/5 * * * *" },
"job_type": "agent",
"prompt": "check status",
"allowed_tools": ["file_read", "web_search"]
}))
.await
.unwrap();
assert!(result.success, "{:?}", result.error);
let jobs = cron::list_jobs(&cfg).unwrap();
assert_eq!(jobs.len(), 1);
assert_eq!(
jobs[0].allowed_tools,
Some(vec!["file_read".into(), "web_search".into()])
);
}
#[tokio::test]
async fn delivery_schema_includes_matrix_channel() {
let tmp = TempDir::new().unwrap();
+42
View File
@@ -89,6 +89,11 @@ impl Tool for CronUpdateTool {
"type": "string",
"description": "Model override for agent jobs, e.g. 'x-ai/grok-4-1-fast'"
},
"allowed_tools": {
"type": "array",
"items": { "type": "string" },
"description": "Optional replacement allowlist of tool names for agent jobs"
},
"session_target": {
"type": "string",
"enum": ["isolated", "main"],
@@ -403,6 +408,7 @@ mod tests {
"command",
"prompt",
"model",
"allowed_tools",
"session_target",
"delete_after_run",
"schedule",
@@ -501,4 +507,40 @@ mod tests {
.contains("Rate limit exceeded"));
assert!(cron::get_job(&cfg, &job.id).unwrap().enabled);
}
#[tokio::test]
async fn updates_agent_allowed_tools() {
let tmp = TempDir::new().unwrap();
let cfg = test_config(&tmp).await;
let job = cron::add_agent_job(
&cfg,
None,
crate::cron::Schedule::Cron {
expr: "*/5 * * * *".into(),
tz: None,
},
"check status",
crate::cron::SessionTarget::Isolated,
None,
None,
false,
None,
)
.unwrap();
let tool = CronUpdateTool::new(cfg.clone(), test_security(&cfg));
let result = tool
.execute(json!({
"job_id": job.id,
"patch": { "allowed_tools": ["file_read", "web_search"] }
}))
.await
.unwrap();
assert!(result.success, "{:?}", result.error);
assert_eq!(
cron::get_job(&cfg, &job.id).unwrap().allowed_tools,
Some(vec!["file_read".into(), "web_search".into()])
);
}
}
+1
View File
@@ -418,6 +418,7 @@ impl DelegateTool {
true,
None,
"delegate",
None,
&self.multimodal_config,
agent_config.max_iterations,
None,
+126 -1
View File
@@ -103,7 +103,7 @@ impl Tool for FileEditTool {
});
}
let full_path = self.security.workspace_dir.join(path);
let full_path = self.security.resolve_tool_path(path);
// ── 5. Canonicalize parent ─────────────────────────────────
let Some(parent) = full_path.parent() else {
@@ -147,6 +147,17 @@ impl Tool for FileEditTool {
let resolved_target = resolved_parent.join(file_name);
if self.security.is_runtime_config_path(&resolved_target) {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(
self.security
.runtime_config_violation_message(&resolved_target),
),
});
}
// ── 7. Symlink check ───────────────────────────────────────
if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await {
if meta.file_type().is_symlink() {
@@ -495,6 +506,42 @@ mod tests {
assert!(result.error.as_ref().unwrap().contains("not allowed"));
}
#[tokio::test]
async fn file_edit_normalizes_workspace_prefixed_relative_path() {
let root = std::env::temp_dir().join("zeroclaw_test_file_edit_workspace_prefixed");
let workspace = root.join("workspace");
let _ = tokio::fs::remove_dir_all(&root).await;
tokio::fs::create_dir_all(workspace.join("nested"))
.await
.unwrap();
tokio::fs::write(workspace.join("nested/target.txt"), "hello world")
.await
.unwrap();
let tool = FileEditTool::new(test_security(workspace.clone()));
let workspace_prefixed = workspace
.strip_prefix(std::path::Path::new("/"))
.unwrap()
.join("nested/target.txt");
let result = tool
.execute(json!({
"path": workspace_prefixed.to_string_lossy(),
"old_string": "world",
"new_string": "zeroclaw"
}))
.await
.unwrap();
assert!(result.success);
let content = tokio::fs::read_to_string(workspace.join("nested/target.txt"))
.await
.unwrap();
assert_eq!(content, "hello zeroclaw");
assert!(!workspace.join(workspace_prefixed).exists());
let _ = tokio::fs::remove_dir_all(&root).await;
}
#[cfg(unix)]
#[tokio::test]
async fn file_edit_blocks_symlink_escape() {
@@ -666,6 +713,46 @@ mod tests {
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn file_edit_absolute_path_in_workspace() {
let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_abs_path");
let _ = tokio::fs::remove_dir_all(&dir).await;
tokio::fs::create_dir_all(&dir).await.unwrap();
// Canonicalize so the workspace dir matches resolved paths on macOS (/private/var/…)
let dir = tokio::fs::canonicalize(&dir).await.unwrap();
tokio::fs::write(dir.join("target.txt"), "old content")
.await
.unwrap();
let tool = FileEditTool::new(test_security(dir.clone()));
// Pass an absolute path that is within the workspace
let abs_path = dir.join("target.txt");
let result = tool
.execute(json!({
"path": abs_path.to_string_lossy().to_string(),
"old_string": "old content",
"new_string": "new content"
}))
.await
.unwrap();
assert!(
result.success,
"editing via absolute workspace path should succeed, error: {:?}",
result.error
);
let content = tokio::fs::read_to_string(dir.join("target.txt"))
.await
.unwrap();
assert_eq!(content, "new content");
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn file_edit_blocks_null_byte_in_path() {
let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_null_byte");
@@ -686,4 +773,42 @@ mod tests {
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn file_edit_blocks_runtime_config_path() {
let root = std::env::temp_dir().join("zeroclaw_test_file_edit_runtime_config");
let workspace = root.join("workspace");
let config_path = root.join("config.toml");
let _ = tokio::fs::remove_dir_all(&root).await;
tokio::fs::create_dir_all(&workspace).await.unwrap();
tokio::fs::write(&config_path, "always_ask = [\"cron_add\"]")
.await
.unwrap();
let security = Arc::new(SecurityPolicy {
autonomy: AutonomyLevel::Supervised,
workspace_dir: workspace.clone(),
workspace_only: false,
allowed_roots: vec![root.clone()],
forbidden_paths: vec![],
..SecurityPolicy::default()
});
let tool = FileEditTool::new(security);
let result = tool
.execute(json!({
"path": config_path.to_string_lossy(),
"old_string": "always_ask",
"new_string": "auto_approve"
}))
.await
.unwrap();
assert!(!result.success);
assert!(result
.error
.unwrap_or_default()
.contains("runtime config/state file"));
let _ = tokio::fs::remove_dir_all(&root).await;
}
}
+110 -1
View File
@@ -78,7 +78,7 @@ impl Tool for FileWriteTool {
});
}
let full_path = self.security.workspace_dir.join(path);
let full_path = self.security.resolve_tool_path(path);
let Some(parent) = full_path.parent() else {
return Ok(ToolResult {
@@ -124,6 +124,17 @@ impl Tool for FileWriteTool {
let resolved_target = resolved_parent.join(file_name);
if self.security.is_runtime_config_path(&resolved_target) {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(
self.security
.runtime_config_violation_message(&resolved_target),
),
});
}
// If the target already exists and is a symlink, refuse to follow it
if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await {
if meta.file_type().is_symlink() {
@@ -247,6 +258,36 @@ mod tests {
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn file_write_normalizes_workspace_prefixed_relative_path() {
let root = std::env::temp_dir().join("zeroclaw_test_file_write_workspace_prefixed");
let workspace = root.join("workspace");
let _ = tokio::fs::remove_dir_all(&root).await;
tokio::fs::create_dir_all(&workspace).await.unwrap();
let tool = FileWriteTool::new(test_security(workspace.clone()));
let workspace_prefixed = workspace
.strip_prefix(std::path::Path::new("/"))
.unwrap()
.join("nested/out.txt");
let result = tool
.execute(json!({
"path": workspace_prefixed.to_string_lossy(),
"content": "written!"
}))
.await
.unwrap();
assert!(result.success);
let content = tokio::fs::read_to_string(workspace.join("nested/out.txt"))
.await
.unwrap();
assert_eq!(content, "written!");
assert!(!workspace.join(workspace_prefixed).exists());
let _ = tokio::fs::remove_dir_all(&root).await;
}
#[tokio::test]
async fn file_write_overwrites_existing() {
let dir = std::env::temp_dir().join("zeroclaw_test_file_write_overwrite");
@@ -450,6 +491,40 @@ mod tests {
let _ = tokio::fs::remove_dir_all(&root).await;
}
#[tokio::test]
async fn file_write_absolute_path_in_workspace() {
let dir = std::env::temp_dir().join("zeroclaw_test_file_write_abs_path");
let _ = tokio::fs::remove_dir_all(&dir).await;
tokio::fs::create_dir_all(&dir).await.unwrap();
// Canonicalize so the workspace dir matches resolved paths on macOS (/private/var/…)
let dir = tokio::fs::canonicalize(&dir).await.unwrap();
let tool = FileWriteTool::new(test_security(dir.clone()));
// Pass an absolute path that is within the workspace
let abs_path = dir.join("abs_test.txt");
let result = tool
.execute(
json!({"path": abs_path.to_string_lossy().to_string(), "content": "absolute!"}),
)
.await
.unwrap();
assert!(
result.success,
"writing via absolute workspace path should succeed, error: {:?}",
result.error
);
let content = tokio::fs::read_to_string(dir.join("abs_test.txt"))
.await
.unwrap();
assert_eq!(content, "absolute!");
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn file_write_blocks_null_byte_in_path() {
let dir = std::env::temp_dir().join("zeroclaw_test_file_write_null");
@@ -465,4 +540,38 @@ mod tests {
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn file_write_blocks_runtime_config_path() {
let root = std::env::temp_dir().join("zeroclaw_test_file_write_runtime_config");
let workspace = root.join("workspace");
let config_path = root.join("config.toml");
let _ = tokio::fs::remove_dir_all(&root).await;
tokio::fs::create_dir_all(&workspace).await.unwrap();
let security = Arc::new(SecurityPolicy {
autonomy: AutonomyLevel::Supervised,
workspace_dir: workspace.clone(),
workspace_only: false,
allowed_roots: vec![root.clone()],
forbidden_paths: vec![],
..SecurityPolicy::default()
});
let tool = FileWriteTool::new(security);
let result = tool
.execute(json!({
"path": config_path.to_string_lossy(),
"content": "auto_approve = [\"cron_add\"]"
}))
.await
.unwrap();
assert!(!result.success);
assert!(result
.error
.unwrap_or_default()
.contains("runtime config/state file"));
let _ = tokio::fs::remove_dir_all(&root).await;
}
}
+88 -2
View File
@@ -66,6 +66,7 @@ pub mod pdf_read;
pub mod project_intel;
pub mod proxy_config;
pub mod pushover;
pub mod read_skill;
pub mod report_templates;
pub mod schedule;
pub mod schema;
@@ -128,6 +129,7 @@ pub use pdf_read::PdfReadTool;
pub use project_intel::ProjectIntelTool;
pub use proxy_config::ProxyConfigTool;
pub use pushover::PushoverTool;
pub use read_skill::ReadSkillTool;
pub use schedule::ScheduleTool;
#[allow(unused_imports)]
pub use schema::{CleaningStrategy, SchemaCleanr};
@@ -146,7 +148,7 @@ pub use workspace_tool::WorkspaceTool;
use crate::config::{Config, DelegateAgentConfig};
use crate::memory::Memory;
use crate::runtime::{NativeRuntime, RuntimeAdapter};
use crate::security::SecurityPolicy;
use crate::security::{create_sandbox, SecurityPolicy};
use async_trait::async_trait;
use parking_lot::RwLock;
use std::collections::HashMap;
@@ -283,8 +285,13 @@ pub fn all_tools_with_runtime(
root_config: &crate::config::Config,
) -> (Vec<Box<dyn Tool>>, Option<DelegateParentToolsHandle>) {
let has_shell_access = runtime.has_shell_access();
let sandbox = create_sandbox(&root_config.security);
let mut tool_arcs: Vec<Arc<dyn Tool>> = vec![
Arc::new(ShellTool::new(security.clone(), runtime)),
Arc::new(ShellTool::new_with_sandbox(
security.clone(),
runtime,
sandbox,
)),
Arc::new(FileReadTool::new(security.clone())),
Arc::new(FileWriteTool::new(security.clone())),
Arc::new(FileEditTool::new(security.clone())),
@@ -316,6 +323,17 @@ pub fn all_tools_with_runtime(
)),
];
if matches!(
root_config.skills.prompt_injection_mode,
crate::config::SkillsPromptInjectionMode::Compact
) {
tool_arcs.push(Arc::new(ReadSkillTool::new(
workspace_dir.to_path_buf(),
root_config.skills.open_skills_enabled,
root_config.skills.open_skills_dir.clone(),
)));
}
if browser_config.enabled {
// Add legacy browser_open tool for simple URL opening
tool_arcs.push(Arc::new(BrowserOpenTool::new(
@@ -972,4 +990,72 @@ mod tests {
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(!names.contains(&"delegate"));
}
#[test]
fn all_tools_includes_read_skill_in_compact_mode() {
let tmp = TempDir::new().unwrap();
let security = Arc::new(SecurityPolicy::default());
let mem_cfg = MemoryConfig {
backend: "markdown".into(),
..MemoryConfig::default()
};
let mem: Arc<dyn Memory> =
Arc::from(crate::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
let browser = BrowserConfig::default();
let http = crate::config::HttpRequestConfig::default();
let mut cfg = test_config(&tmp);
cfg.skills.prompt_injection_mode = crate::config::SkillsPromptInjectionMode::Compact;
let (tools, _) = all_tools(
Arc::new(cfg.clone()),
&security,
mem,
None,
None,
&browser,
&http,
&crate::config::WebFetchConfig::default(),
tmp.path(),
&HashMap::new(),
None,
&cfg,
);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(names.contains(&"read_skill"));
}
#[test]
fn all_tools_excludes_read_skill_in_full_mode() {
let tmp = TempDir::new().unwrap();
let security = Arc::new(SecurityPolicy::default());
let mem_cfg = MemoryConfig {
backend: "markdown".into(),
..MemoryConfig::default()
};
let mem: Arc<dyn Memory> =
Arc::from(crate::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
let browser = BrowserConfig::default();
let http = crate::config::HttpRequestConfig::default();
let mut cfg = test_config(&tmp);
cfg.skills.prompt_injection_mode = crate::config::SkillsPromptInjectionMode::Full;
let (tools, _) = all_tools(
Arc::new(cfg.clone()),
&security,
mem,
None,
None,
&browser,
&http,
&crate::config::WebFetchConfig::default(),
tmp.path(),
&HashMap::new(),
None,
&cfg,
);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(!names.contains(&"read_skill"));
}
}
+1 -1
View File
@@ -100,7 +100,7 @@ impl Tool for PdfReadTool {
});
}
let full_path = self.security.workspace_dir.join(path);
let full_path = self.security.resolve_tool_path(path);
let resolved_path = match tokio::fs::canonicalize(&full_path).await {
Ok(p) => p,
+187
View File
@@ -0,0 +1,187 @@
use super::traits::{Tool, ToolResult};
use async_trait::async_trait;
use serde_json::json;
use std::path::PathBuf;
/// Compact-mode helper for loading a skill's source file on demand.
pub struct ReadSkillTool {
workspace_dir: PathBuf,
open_skills_enabled: bool,
open_skills_dir: Option<String>,
}
impl ReadSkillTool {
pub fn new(
workspace_dir: PathBuf,
open_skills_enabled: bool,
open_skills_dir: Option<String>,
) -> Self {
Self {
workspace_dir,
open_skills_enabled,
open_skills_dir,
}
}
}
#[async_trait]
impl Tool for ReadSkillTool {
fn name(&self) -> &str {
"read_skill"
}
fn description(&self) -> &str {
"Read the full source file for an available skill by name. Use this in compact skills mode when you need the complete skill instructions without remembering file paths."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The skill name exactly as listed in <available_skills>."
}
},
"required": ["name"]
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let requested = args
.get("name")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow::anyhow!("Missing 'name' parameter"))?;
let skills = crate::skills::load_skills_with_open_skills_settings(
&self.workspace_dir,
self.open_skills_enabled,
self.open_skills_dir.as_deref(),
);
let Some(skill) = skills
.iter()
.find(|skill| skill.name.eq_ignore_ascii_case(requested))
else {
let mut names: Vec<&str> = skills.iter().map(|skill| skill.name.as_str()).collect();
names.sort_unstable();
let available = if names.is_empty() {
"none".to_string()
} else {
names.join(", ")
};
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Unknown skill '{requested}'. Available skills: {available}"
)),
});
};
let Some(location) = skill.location.as_ref() else {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Skill '{}' has no readable source location.",
skill.name
)),
});
};
match tokio::fs::read_to_string(location).await {
Ok(output) => Ok(ToolResult {
success: true,
output,
error: None,
}),
Err(err) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Failed to read skill '{}' from {}: {err}",
skill.name,
location.display()
)),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_tool(tmp: &TempDir) -> ReadSkillTool {
ReadSkillTool::new(tmp.path().join("workspace"), false, None)
}
#[tokio::test]
async fn reads_markdown_skill_by_name() {
let tmp = TempDir::new().unwrap();
let skill_dir = tmp.path().join("workspace/skills/weather");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"# Weather\n\nUse this skill for forecast lookups.\n",
)
.unwrap();
let result = make_tool(&tmp)
.execute(json!({ "name": "weather" }))
.await
.unwrap();
assert!(result.success);
assert!(result.output.contains("# Weather"));
assert!(result.output.contains("forecast lookups"));
}
#[tokio::test]
async fn reads_toml_skill_manifest_by_name() {
let tmp = TempDir::new().unwrap();
let skill_dir = tmp.path().join("workspace/skills/deploy");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.toml"),
r#"[skill]
name = "deploy"
description = "Ship safely"
"#,
)
.unwrap();
let result = make_tool(&tmp)
.execute(json!({ "name": "deploy" }))
.await
.unwrap();
assert!(result.success);
assert!(result.output.contains("[skill]"));
assert!(result.output.contains("Ship safely"));
}
#[tokio::test]
async fn unknown_skill_lists_available_names() {
let tmp = TempDir::new().unwrap();
let skill_dir = tmp.path().join("workspace/skills/weather");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(skill_dir.join("SKILL.md"), "# Weather\n").unwrap();
let result = make_tool(&tmp)
.execute(json!({ "name": "calendar" }))
.await
.unwrap();
assert!(!result.success);
assert_eq!(
result.error.as_deref(),
Some("Unknown skill 'calendar'. Available skills: weather")
);
}
}
+82 -1
View File
@@ -1,5 +1,6 @@
use super::traits::{Tool, ToolResult};
use crate::runtime::RuntimeAdapter;
use crate::security::traits::Sandbox;
use crate::security::SecurityPolicy;
use async_trait::async_trait;
use serde_json::json;
@@ -44,11 +45,28 @@ const SAFE_ENV_VARS: &[&str] = &[
pub struct ShellTool {
security: Arc<SecurityPolicy>,
runtime: Arc<dyn RuntimeAdapter>,
sandbox: Arc<dyn Sandbox>,
}
impl ShellTool {
pub fn new(security: Arc<SecurityPolicy>, runtime: Arc<dyn RuntimeAdapter>) -> Self {
Self { security, runtime }
Self {
security,
runtime,
sandbox: Arc::new(crate::security::NoopSandbox),
}
}
pub fn new_with_sandbox(
security: Arc<SecurityPolicy>,
runtime: Arc<dyn RuntimeAdapter>,
sandbox: Arc<dyn Sandbox>,
) -> Self {
Self {
security,
runtime,
sandbox,
}
}
}
@@ -169,6 +187,14 @@ impl Tool for ShellTool {
});
}
};
// Apply sandbox wrapping before execution.
// The Sandbox trait operates on std::process::Command, so use as_std_mut()
// to get a mutable reference to the underlying command.
self.sandbox
.wrap_command(cmd.as_std_mut())
.map_err(|e| anyhow::anyhow!("Sandbox error: {}", e))?;
cmd.env_clear();
for var in collect_allowed_shell_env_vars(&self.security) {
@@ -690,4 +716,59 @@ mod tests {
|| r2.error.as_deref().unwrap_or("").contains("budget")
);
}
// ── Sandbox integration tests ────────────────────────
#[test]
fn shell_tool_can_be_constructed_with_sandbox() {
use crate::security::NoopSandbox;
let sandbox: Arc<dyn Sandbox> = Arc::new(NoopSandbox);
let tool = ShellTool::new_with_sandbox(
test_security(AutonomyLevel::Supervised),
test_runtime(),
sandbox,
);
assert_eq!(tool.name(), "shell");
}
#[test]
fn noop_sandbox_does_not_modify_command() {
use crate::security::NoopSandbox;
let sandbox = NoopSandbox;
let mut cmd = std::process::Command::new("echo");
cmd.arg("hello");
let program_before = cmd.get_program().to_os_string();
let args_before: Vec<_> = cmd.get_args().map(|a| a.to_os_string()).collect();
sandbox
.wrap_command(&mut cmd)
.expect("wrap_command should succeed");
assert_eq!(cmd.get_program(), program_before);
assert_eq!(
cmd.get_args().map(|a| a.to_os_string()).collect::<Vec<_>>(),
args_before
);
}
#[tokio::test]
async fn shell_executes_with_sandbox() {
use crate::security::NoopSandbox;
let sandbox: Arc<dyn Sandbox> = Arc::new(NoopSandbox);
let tool = ShellTool::new_with_sandbox(
test_security(AutonomyLevel::Supervised),
test_runtime(),
sandbox,
);
let result = tool
.execute(json!({"command": "echo sandbox_test"}))
.await
.expect("command with sandbox should succeed");
assert!(result.success);
assert!(result.output.contains("sandbox_test"));
}
}
+4 -4
View File
@@ -72,11 +72,11 @@ fn agent_config_default_tool_dispatcher() {
}
#[test]
fn agent_config_default_compact_context_off() {
fn agent_config_default_compact_context_on() {
let agent = AgentConfig::default();
assert!(
!agent.compact_context,
"compact_context should default to false"
agent.compact_context,
"compact_context should default to true"
);
}
@@ -204,7 +204,7 @@ default_temperature = 0.7
// Agent config should use defaults
assert_eq!(parsed.agent.max_tool_iterations, 10);
assert_eq!(parsed.agent.max_history_messages, 50);
assert!(!parsed.agent.compact_context);
assert!(parsed.agent.compact_context);
}
#[test]
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<link rel="icon" type="image/png" href="/_app/logo.png" />
<link rel="icon" type="image/png" href="/_app/zeroclaw-trans.png" />
<title>ZeroClaw</title>
</head>
<body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

+1 -1
View File
@@ -135,7 +135,7 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
<div className="text-center mb-8">
<img
src="/_app/logo.png"
src="/_app/zeroclaw-trans.png"
alt="ZeroClaw"
className="h-20 w-20 rounded-2xl object-cover mx-auto mb-4 animate-float"
style={{ boxShadow: '0 0 30px rgba(0,128,255,0.3)' }}
+1 -1
View File
@@ -35,7 +35,7 @@ export default function Sidebar() {
{/* Logo / Title */}
<div className="flex items-center gap-3 px-4 py-4 border-b border-[#1a1a3e]/50">
<img
src="/_app/logo.png"
src="/_app/zeroclaw-trans.png"
alt="ZeroClaw"
className="h-10 w-10 rounded-xl object-cover animate-pulse-glow"
/>
+19
View File
@@ -193,6 +193,25 @@ export function getCronRuns(
).then((data) => unwrapField(data, 'runs'));
}
export interface CronSettings {
enabled: boolean;
catch_up_on_startup: boolean;
max_run_history: number;
}
export function getCronSettings(): Promise<CronSettings> {
return apiFetch<CronSettings>('/api/cron/settings');
}
export function patchCronSettings(
patch: Partial<CronSettings>,
): Promise<CronSettings> {
return apiFetch<CronSettings & { status: string }>('/api/cron/settings', {
method: 'PATCH',
body: JSON.stringify(patch),
});
}
// ---------------------------------------------------------------------------
// Integrations
// ---------------------------------------------------------------------------
+62 -1
View File
@@ -12,7 +12,15 @@ import {
RefreshCw,
} from 'lucide-react';
import type { CronJob, CronRun } from '@/types/api';
import { getCronJobs, addCronJob, deleteCronJob, getCronRuns } from '@/lib/api';
import {
getCronJobs,
addCronJob,
deleteCronJob,
getCronRuns,
getCronSettings,
patchCronSettings,
} from '@/lib/api';
import type { CronSettings } from '@/lib/api';
import { t } from '@/lib/i18n';
function formatDate(iso: string | null): string {
@@ -143,6 +151,8 @@ export default function Cron() {
const [showForm, setShowForm] = useState(false);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [expandedJob, setExpandedJob] = useState<string | null>(null);
const [settings, setSettings] = useState<CronSettings | null>(null);
const [togglingCatchUp, setTogglingCatchUp] = useState(false);
// Form state
const [formName, setFormName] = useState('');
@@ -159,8 +169,28 @@ export default function Cron() {
.finally(() => setLoading(false));
};
const fetchSettings = () => {
getCronSettings().then(setSettings).catch(() => {});
};
const toggleCatchUp = async () => {
if (!settings) return;
setTogglingCatchUp(true);
try {
const updated = await patchCronSettings({
catch_up_on_startup: !settings.catch_up_on_startup,
});
setSettings(updated);
} catch {
// silently fail — user can retry
} finally {
setTogglingCatchUp(false);
}
};
useEffect(() => {
fetchJobs();
fetchSettings();
}, []);
const handleAdd = async () => {
@@ -250,6 +280,37 @@ export default function Cron() {
</button>
</div>
{/* Catch-up toggle */}
{settings && (
<div className="glass-card px-4 py-3 flex items-center justify-between">
<div>
<span className="text-sm font-medium text-white">
Catch up missed jobs on startup
</span>
<p className="text-xs text-[#556080] mt-0.5">
Run all overdue jobs when ZeroClaw starts after downtime
</p>
</div>
<button
onClick={toggleCatchUp}
disabled={togglingCatchUp}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-300 focus:outline-none ${
settings.catch_up_on_startup
? 'bg-[#0080ff]'
: 'bg-[#1a1a3e]'
}`}
>
<span
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform duration-300 ${
settings.catch_up_on_startup
? 'translate-x-6'
: 'translate-x-1'
}`}
/>
</button>
</div>
)}
{/* Add Job Form Modal */}
{showForm && (
<div className="fixed inset-0 modal-backdrop flex items-center justify-center z-50">