Compare commits

...

12 Commits

Author SHA1 Message Date
8c59673eac feat(cli): add skillforge command for auto-discovery of skills
Some checks failed
CodeQL Analysis / CodeQL Analysis (push) Has been cancelled
PR Hygiene / nudge-stale-prs (push) Has been cancelled
CI / Detect Change Scope (push) Has been cancelled
Docker / PR Docker Smoke (push) Has been cancelled
Docker / Build and Push Docker Image (push) Has been cancelled
Rust Package Security Audit / Security Audit (push) Has been cancelled
Rust Package Security Audit / License & Supply Chain (push) Has been cancelled
CI / Format & Lint (push) Has been cancelled
CI / Lint Strict Delta (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (Smoke) (push) Has been cancelled
CI / Docs-Only Fast Path (push) Has been cancelled
CI / Non-Rust Fast Path (push) Has been cancelled
CI / Docs Quality (push) Has been cancelled
CI / CI Required Gate (push) Has been cancelled
Stale / stale (push) Has been cancelled
Update Contributors NOTICE / Update NOTICE with new contributors (push) Has been cancelled
- Add zeroclaw skillforge run --min-score 0.7 --dry-run
- Add zeroclaw skillforge scout --query "ai agent" --limit 20
- Add zeroclaw skillforge status to show configuration
- Wire SkillForgeConfig into main Config struct
- Add new_with_query method to GitHubScout for custom searches
2026-02-18 03:37:45 +00:00
1a9102e871 feat(telegram): add voice message auto-transcription
Some checks failed
CI / Detect Change Scope (push) Has been cancelled
CI / Format & Lint (push) Has been cancelled
CI / Lint Strict Delta (push) Has been cancelled
Docker / PR Docker Smoke (push) Has been cancelled
Docker / Build and Push Docker Image (push) Has been cancelled
Rust Package Security Audit / Security Audit (push) Has been cancelled
Rust Package Security Audit / License & Supply Chain (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (Smoke) (push) Has been cancelled
CI / Docs-Only Fast Path (push) Has been cancelled
CI / Non-Rust Fast Path (push) Has been cancelled
CI / Docs Quality (push) Has been cancelled
CI / CI Required Gate (push) Has been cancelled
Stale / stale (push) Has been cancelled
- Add download_voice_file method to download voice/audio from Telegram
- Add transcribe_voice_file method using faster-whisper
- Add parse_voice_message to detect voice/audio messages
- Modify listen() to process voice messages and include transcription
- Add file_url helper for Telegram file downloads
- Add tests for voice message parsing
2026-02-18 02:19:37 +00:00
5d9c716a72 feat: add homelab configuration and transcription support
Some checks failed
CI / Detect Change Scope (push) Has been cancelled
CI / Format & Lint (push) Has been cancelled
CI / Lint Strict Delta (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (Smoke) (push) Has been cancelled
CI / Docs-Only Fast Path (push) Has been cancelled
CI / Non-Rust Fast Path (push) Has been cancelled
CI / Docs Quality (push) Has been cancelled
CI / CI Required Gate (push) Has been cancelled
Docker / PR Docker Smoke (push) Has been cancelled
Docker / Build and Push Docker Image (push) Has been cancelled
Rust Package Security Audit / Security Audit (push) Has been cancelled
Rust Package Security Audit / License & Supply Chain (push) Has been cancelled
- Add host-based delegate agents (ubuntu, grizzley, truenas, panda, pve)
- Add functional delegates (coder, reasoner, research, quick)
- Add NanoGPT provider with minimax-m2.5 model
- Add transcribe tool using faster-whisper
- Update TelegramChannel with workspace_dir
- Configure Z.AI as default provider with glm-5
2026-02-18 01:55:33 +00:00
Will Sarg
a2f29838b4 fix(build): restore ChannelMessage reply_target usage (#541) 2026-02-17 08:41:02 -05:00
Will Sarg
7ebc98d8d0 fix(ci): sync devsecops with main and repair auto-response workflow (#538)
* fix(workflows): standardize runner configuration for security jobs

* ci(actionlint): add Blacksmith runner label to config

Add blacksmith-2vcpu-ubuntu-2404 to actionlint self-hosted-runner labels config
to suppress "unknown label" warnings during workflow linting.

This label is used across all workflows after the Blacksmith migration.

* fix(actionlint): adjust indentation for self-hosted runner labels

* feat(security): enhance security workflow with CodeQL analysis steps

* fix(security): update CodeQL action to version 4 for improved analysis

* fix(security): remove duplicate permissions in security workflow

* fix(security): revert CodeQL action to v3 for stability

The v4 version was causing workflow file validation failures.
Reverting to proven v3 version that is working on main branch.

* fix(security): remove duplicate permissions causing workflow validation failure

The permissions block had duplicate security-events and actions keys,
which caused YAML validation errors and prevented workflow execution.

Fixes: workflow file validation failures on main branch

* fix(security): remove pull_request trigger to reduce costs

* fix(security): restore PR trigger but skip codeql on PRs

* fix(security): resolve YAML syntax error in security workflow

* refactor(security): split CodeQL into dedicated scheduled workflow

* fix(security): update workflow name to Rust Package Security Audit

* fix(codeql): remove push trigger, keep schedule and on-demand only

* feat(codeql): add CodeQL configuration file to ignore specific paths

* Potential fix for code scanning alert no. 39: Hard-coded cryptographic value

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* fix(ci): resolve auto-response workflow merge markers

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-02-17 08:34:09 -05:00
Chummy
a35d1e37c8 chore(labeler): normalize module labels and backfill contributor tiers (#462)
Co-authored-by: Will Sarg <12886992+willsarg@users.noreply.github.com>
2026-02-17 08:25:50 -05:00
Vernon Stinebaker
df31359ec4 feat(agent): scrub credentials from tool output (#532)
* feat(channels): add channel capabilities to system prompt

Add channel capabilities section to system prompt so the agent knows
it can send Discord messages directly without asking permission.
Also reminds agent not to repeat or echo credentials.

Co-authored-by: Vernon Stinebaker <vernon.stinebaker@gmail.com>

* feat(agent): scrub credentials from tool output

* chore: fix clippy and formatting for scrubbing
2026-02-17 08:23:11 -05:00
beee003
8ad5b6146b feat: add Astrai as a named provider (#486)
Add Astrai (https://as-trai.com) as a first-class OpenAI-compatible
provider. Astrai is an AI inference router with built-in cost
optimization, PII stripping, and compliance logging.

- Register ASTRAI_API_KEY env var in resolve_api_key
- Add "astrai" entry in provider factory → as-trai.com/v1
- Add factory_astrai unit test
- Add Astrai to compatible provider test list
- Update README provider count (22+ → 23+) and list

Co-authored-by: Maya Walcher <maya.walcher@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 08:22:38 -05:00
ehu shubham shaw
d7c1fd7bf8 security(deps): remove vulnerable xmas-elf dependency via embuild (#414)
* security(deps): remove vulnerable xmas-elf dependency via embuild

* chore(deps): update dependencies and improve ESP-IDF compatibility

- Updated `bindgen`, `embassy-sync`, `embedded-svc`, and `embuild` versions in `Cargo.lock`.
- Added patch section in `Cargo.toml` to use latest esp-rs crates for better compatibility with ESP-IDF 5.x.
- Enhanced README with updated prerequisites and build instructions for Python and Rust tools.
- Introduced `rust-toolchain.toml` to pin nightly Rust and added necessary components.
- Modified GPIO handling in `main.rs` to improve pin management and added support for 64-bit time_t in ESP-IDF.
- Updated `.cargo/config.toml` for new linker and runner configurations.

* docs: add detailed setup guide for ESP32 firmware and link in README

- Introduced a new `SETUP.md` file with comprehensive step-by-step instructions for building and flashing the ZeroClaw ESP32 firmware.
- Updated `README.md` to include a link to the new setup guide for easier access to installation and troubleshooting information.

* chore: update .gitignore and refactor main.rs for improved readability

- Added .embuild/ to .gitignore to exclude ESP32 build cache.
- Refactored code in main.rs for better readability by adjusting the formatting of the handle_request function call.

* docs: add newline for better readability in README.md

- Added a newline in the protocol section of README.md to enhance clarity and formatting.

* chore: configure workspace settings in Cargo.toml

- Added workspace configuration to `Cargo.toml` with members and resolver settings for improved project management.

---------

Co-authored-by: ehushubhamshaw <eshaw1@wpi.edu>
Co-authored-by: Will Sarg <12886992+willsarg@users.noreply.github.com>
2026-02-17 08:18:41 -05:00
fettpl
55b3c2c00c test(security): add HTTP hostname canonicalization edge-case tests (#522)
* test(security): add HTTP hostname canonicalization edge-case tests

Document that Rust's IpAddr::parse() rejects non-standard IP notations
(octal, hex, decimal integer, zero-padded) which provides defense-in-depth
against SSRF bypass attempts. Tests only — no production code changes.

Closes #515

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

* style: apply rustfmt to providers/mod.rs

Fix pre-existing formatting issue from main.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 08:16:00 -05:00
fettpl
e3f00e82b9 fix(ci): add pull-requests write permission to contributor-tier-issues job (#501)
The contributor-tier-issues job triggers on pull_request_target events
but only had issues:write permission. GitHub API requires
pull-requests:write to set labels on pull requests, causing a 403
"Resource not accessible by integration" error.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 08:14:41 -05:00
Rin
9ec1106f53 security: fix argument injection in shell command validation (#465) 2026-02-17 08:11:20 -05:00
38 changed files with 1722 additions and 217 deletions

View File

@@ -12,7 +12,11 @@ Describe this PR in 2-5 bullets:
- Risk label (`risk: low|medium|high`):
- Size label (`size: XS|S|M|L|XL`, auto-managed/read-only):
- Scope labels (`core|agent|channel|config|cron|daemon|doctor|gateway|health|heartbeat|integration|memory|observability|onboard|provider|runtime|security|service|skillforge|skills|tool|tunnel|docs|dependencies|ci|tests|scripts|dev`, comma-separated):
<<<<<<< chore/labeler-spacing-trusted-tier
- Module labels (`<module>: <component>`, for example `channel: telegram`, `provider: kimi`, `tool: shell`):
=======
- Module labels (`<module>:<component>`, for example `channel:telegram`, `provider:kimi`, `tool:shell`):
>>>>>>> main
- Contributor tier label (`trusted contributor|experienced contributor|principal contributor|distinguished contributor`, auto-managed/read-only; author merged PRs >=5/10/20/50):
- If any auto-label is incorrect, note requested correction:

View File

@@ -18,6 +18,7 @@ jobs:
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
issues: write
pull-requests: write
steps:
- name: Apply contributor tier label for issue author
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8

View File

@@ -325,13 +325,18 @@ jobs:
return pattern.test(text);
}
function formatModuleLabel(prefix, segment) {
return `${prefix}: ${segment}`;
}
function parseModuleLabel(label) {
const separatorIndex = label.indexOf(":");
if (separatorIndex <= 0 || separatorIndex >= label.length - 1) return null;
return {
prefix: label.slice(0, separatorIndex),
segment: label.slice(separatorIndex + 1),
};
if (typeof label !== "string") return null;
const match = label.match(/^([^:]+):\s*(.+)$/);
if (!match) return null;
const prefix = match[1].trim().toLowerCase();
const segment = (match[2] || "").trim().toLowerCase();
if (!prefix || !segment) return null;
return { prefix, segment };
}
function sortByPriority(labels, priorityIndex) {
@@ -389,7 +394,7 @@ jobs:
for (const [prefix, segments] of segmentsByPrefix) {
const hasSpecificSegment = [...segments].some((segment) => segment !== "core");
if (hasSpecificSegment) {
refined.delete(`${prefix}:core`);
refined.delete(formatModuleLabel(prefix, "core"));
}
}
@@ -418,7 +423,7 @@ jobs:
if (uniqueSegments.length === 0) continue;
if (uniqueSegments.length === 1) {
compactedModuleLabels.add(`${prefix}:${uniqueSegments[0]}`);
compactedModuleLabels.add(formatModuleLabel(prefix, uniqueSegments[0]));
} else {
forcePathPrefixes.add(prefix);
}
@@ -609,7 +614,7 @@ jobs:
segment = normalizeLabelSegment(segment);
if (!segment) continue;
detectedModuleLabels.add(`${rule.prefix}:${segment}`);
detectedModuleLabels.add(formatModuleLabel(rule.prefix, segment));
}
}
@@ -635,7 +640,7 @@ jobs:
for (const keyword of providerKeywordHints) {
if (containsKeyword(searchableText, keyword)) {
detectedModuleLabels.add(`provider:${keyword}`);
detectedModuleLabels.add(formatModuleLabel("provider", keyword));
}
}
}
@@ -661,7 +666,7 @@ jobs:
for (const keyword of channelKeywordHints) {
if (containsKeyword(searchableText, keyword)) {
detectedModuleLabels.add(`channel:${keyword}`);
detectedModuleLabels.add(formatModuleLabel("channel", keyword));
}
}
}

9
.gitignore vendored
View File

@@ -10,6 +10,15 @@ docker-compose.override.yml
# Environment files (may contain secrets)
.env
# Python virtual environments
.venv/
venv/
# ESP32 build cache (esp-idf-sys managed)
.embuild/
.env.local
.env.*.local

1
Cargo.lock generated
View File

@@ -4927,6 +4927,7 @@ dependencies = [
"prometheus",
"prost",
"rand 0.8.5",
"regex",
"reqwest",
"rppal",
"rusqlite",

View File

@@ -1,3 +1,7 @@
[workspace]
members = ["."]
resolver = "2"
[package]
name = "zeroclaw"
version = "0.1.0"
@@ -86,6 +90,7 @@ glob = "0.3"
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
futures-util = { version = "0.3", default-features = false, features = ["sink"] }
futures = "0.3"
regex = "1.10"
hostname = "0.4.2"
lettre = { version = "0.11.19", default-features = false, features = ["builder", "smtp-transport", "rustls-tls"] }
mail-parser = "0.11.2"

View File

@@ -18,7 +18,7 @@
Fast, small, and fully autonomous AI assistant infrastructure — deploy anywhere, swap anything.
```
~3.4MB binary · <10ms startup · 1,017 tests · 22+ providers · 8 traits · Pluggable everything
~3.4MB binary · <10ms startup · 1,017 tests · 23+ providers · 8 traits · Pluggable everything
```
### ✨ Features
@@ -191,7 +191,7 @@ Every subsystem is a **trait** — swap implementations with a config change, ze
| Subsystem | Trait | Ships with | Extend |
|-----------|-------|------------|--------|
| **AI Models** | `Provider` | 22+ providers (OpenRouter, Anthropic, OpenAI, Ollama, Venice, Groq, Mistral, xAI, DeepSeek, Together, Fireworks, Perplexity, Cohere, Bedrock, etc.) | `custom:https://your-api.com` — any OpenAI-compatible API |
| **AI Models** | `Provider` | 23+ providers (OpenRouter, Anthropic, OpenAI, Ollama, Venice, Groq, Mistral, xAI, DeepSeek, Together, Fireworks, Perplexity, Cohere, Bedrock, Astrai, etc.) | `custom:https://your-api.com` — any OpenAI-compatible API |
| **Channels** | `Channel` | CLI, Telegram, Discord, Slack, iMessage, Matrix, WhatsApp, Webhook | Any messaging API |
| **Memory** | `Memory` | SQLite with hybrid search (FTS5 + vector cosine similarity), Lucid bridge (CLI sync + SQLite fallback), Markdown | Any persistence backend |
| **Tools** | `Tool` | shell, file_read, file_write, memory_store, memory_recall, memory_forget, browser_open (Brave + allowlist), browser (agent-browser / rust-native), composio (optional) | Any capability |

View File

@@ -27,7 +27,7 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
### Optional Repository Automation
- `.github/workflows/labeler.yml` (`PR Labeler`)
- Purpose: scope/path labels + size/risk labels + fine-grained module labels (`<module>:<component>`)
- Purpose: scope/path labels + size/risk labels + fine-grained module labels (`<module>: <component>`)
- Additional behavior: label descriptions are auto-managed as hover tooltips to explain each auto-judgment rule
- Additional behavior: provider-related keywords in provider/config/onboard/integration changes are promoted to `provider:*` labels (for example `provider:kimi`, `provider:deepseek`)
- Additional behavior: hierarchical de-duplication keeps only the most specific scope labels (for example `tool:composio` suppresses `tool:core` and `tool`)

View File

@@ -244,7 +244,7 @@ Label discipline:
- Path labels identify subsystem ownership quickly.
- Size labels drive batching strategy.
- Risk labels drive review depth (`risk: low/medium/high`).
- Module labels (`<module>:<component>`) improve reviewer routing for integration-specific changes and future newly-added modules.
- Module labels (`<module>: <component>`) improve reviewer routing for integration-specific changes and future newly-added modules.
- `risk: manual` allows maintainers to preserve a human risk judgment when automation lacks context.
- `no-stale` is reserved for accepted-but-blocked work.

View File

@@ -14,7 +14,7 @@ Use it to reduce review latency without reducing quality.
For every new PR, do a fast intake pass:
1. Confirm template completeness (`summary`, `validation`, `security`, `rollback`).
2. Confirm labels (`size:*`, `risk:*`, scope labels such as `provider`/`channel`/`security`, module-scoped labels such as `channel:*`/`provider:*`/`tool:*`, and contributor tier labels when applicable) are present and plausible.
2. Confirm labels (`size:*`, `risk:*`, scope labels such as `provider`/`channel`/`security`, module-scoped labels such as `channel: *`/`provider: *`/`tool: *`, and contributor tier labels when applicable) are present and plausible.
3. Confirm CI signal status (`CI Required Gate`).
4. Confirm scope is one concern (reject mixed mega-PRs unless justified).
5. Confirm privacy/data-hygiene and neutral test wording requirements are satisfied.

View File

@@ -2,4 +2,10 @@
target = "riscv32imc-esp-espidf"
[target.riscv32imc-esp-espidf]
linker = "ldproxy"
runner = "espflash flash --monitor"
# ESP-IDF 5.x uses 64-bit time_t
rustflags = ["-C", "default-linker-libraries", "--cfg", "espidf_time64"]
[unstable]
build-std = ["std", "panic_abort"]

View File

@@ -58,24 +58,22 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "bindgen"
version = "0.63.0"
version = "0.71.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36d860121800b2a9a94f9b5604b332d5cffb234ce17609ea479d723dbc9d3885"
checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
dependencies = [
"bitflags 1.3.2",
"bitflags 2.11.0",
"cexpr",
"clang-sys",
"lazy_static",
"lazycell",
"itertools",
"log",
"peeking_take_while",
"prettyplease",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex",
"syn 1.0.109",
"which",
"syn 2.0.116",
]
[[package]]
@@ -374,14 +372,15 @@ checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01"
[[package]]
name = "embassy-sync"
version = "0.5.0"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd938f25c0798db4280fcd8026bf4c2f48789aebf8f77b6e5cf8a7693ba114ec"
checksum = "73974a3edbd0bd286759b3d483540f0ebef705919a5f56f4fc7709066f71689b"
dependencies = [
"cfg-if",
"critical-section",
"embedded-io-async",
"futures-util",
"futures-core",
"futures-sink",
"heapless",
]
@@ -446,16 +445,15 @@ dependencies = [
[[package]]
name = "embedded-svc"
version = "0.27.1"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac6f87e7654f28018340aa55f933803017aefabaa5417820a3b2f808033c7bbc"
checksum = "a7770e30ab55cfbf954c00019522490d6ce26a3334bede05a732ba61010e98e0"
dependencies = [
"defmt 0.3.100",
"embedded-io",
"embedded-io-async",
"enumset",
"heapless",
"no-std-net",
"num_enum",
"serde",
"strum 0.25.0",
@@ -463,9 +461,9 @@ dependencies = [
[[package]]
name = "embuild"
version = "0.31.4"
version = "0.33.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4caa4f198bb9152a55c0103efb83fa4edfcbb8625f4c9e94ae8ec8e23827c563"
checksum = "e188ad2bbe82afa841ea4a29880651e53ab86815db036b2cb9f8de3ac32dad75"
dependencies = [
"anyhow",
"bindgen",
@@ -475,6 +473,7 @@ dependencies = [
"globwalk",
"home",
"log",
"regex",
"remove_dir_all",
"serde",
"serde_json",
@@ -533,9 +532,8 @@ dependencies = [
[[package]]
name = "esp-idf-hal"
version = "0.43.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7adf3fb19a9ca016cbea1ab8a7b852ac69df8fcde4923c23d3b155efbc42a74"
version = "0.45.2"
source = "git+https://github.com/esp-rs/esp-idf-hal#bc48639bd626c72afc1e25e5d497b5c639161d30"
dependencies = [
"atomic-waker",
"embassy-sync",
@@ -552,14 +550,12 @@ dependencies = [
"heapless",
"log",
"nb 1.1.0",
"num_enum",
]
[[package]]
name = "esp-idf-svc"
version = "0.48.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2180642ca122a7fec1ec417a9b1a77aa66aaa067fdf1daae683dd8caba84f26b"
version = "0.51.0"
source = "git+https://github.com/esp-rs/esp-idf-svc#dee202f146c7681e54eabbf118a216fc0195d203"
dependencies = [
"embassy-futures",
"embedded-hal-async",
@@ -567,6 +563,7 @@ dependencies = [
"embuild",
"enumset",
"esp-idf-hal",
"futures-io",
"heapless",
"log",
"num_enum",
@@ -575,14 +572,13 @@ dependencies = [
[[package]]
name = "esp-idf-sys"
version = "0.34.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e148f97c04ed3e9181a08bcdc9560a515aad939b0ba7f50a0022e294665e0af"
version = "0.36.1"
source = "git+https://github.com/esp-rs/esp-idf-sys#64667a38fb8004e1fc3b032488af6857ca3cd849"
dependencies = [
"anyhow",
"bindgen",
"build-time",
"cargo_metadata",
"cmake",
"const_format",
"embuild",
"envy",
@@ -649,21 +645,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-task"
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-util"
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
]
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "getrandom"
@@ -827,6 +818,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.17"
@@ -843,18 +843,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "lazycell"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
[[package]]
name = "leb128fmt"
version = "0.1.0"
@@ -945,12 +933,6 @@ dependencies = [
"libc",
]
[[package]]
name = "no-std-net"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bcece43b12349917e096cddfa66107277f123e6c96a5aea78711dc601a47152"
[[package]]
name = "nom"
version = "7.1.3"
@@ -1007,18 +989,6 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "peeking_take_while"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099"
[[package]]
name = "pin-project-lite"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -1138,9 +1108,9 @@ dependencies = [
[[package]]
name = "rustc-hash"
version = "1.1.0"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustix"

View File

@@ -14,15 +14,21 @@ edition = "2021"
license = "MIT"
description = "ZeroClaw ESP32 peripheral firmware — GPIO over JSON serial"
[patch.crates-io]
# Use latest esp-rs crates to fix u8/i8 char pointer compatibility with ESP-IDF 5.x
esp-idf-sys = { git = "https://github.com/esp-rs/esp-idf-sys" }
esp-idf-hal = { git = "https://github.com/esp-rs/esp-idf-hal" }
esp-idf-svc = { git = "https://github.com/esp-rs/esp-idf-svc" }
[dependencies]
esp-idf-svc = "0.48"
esp-idf-svc = { git = "https://github.com/esp-rs/esp-idf-svc" }
log = "0.4"
anyhow = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[build-dependencies]
embuild = "0.31"
embuild = { version = "0.33", features = ["espidf"] }
[profile.release]
opt-level = "s"

View File

@@ -2,8 +2,11 @@
Peripheral firmware for ESP32 — speaks the same JSON-over-serial protocol as the STM32 firmware. Flash this to your ESP32, then configure ZeroClaw on the host to connect via serial.
**New to this?** See [SETUP.md](SETUP.md) for step-by-step commands and troubleshooting.
## Protocol
- **Request** (host → ESP32): `{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}\n`
- **Response** (ESP32 → host): `{"id":"1","ok":true,"result":"done"}\n`
@@ -11,19 +14,44 @@ Commands: `gpio_read`, `gpio_write`.
## Prerequisites
1. **ESP toolchain** (espup):
1. **RISC-V ESP-IDF** (ESP32-C2/C3): Uses nightly Rust with `build-std`.
**Python**: ESP-IDF requires Python 3.103.13 (not 3.14). If you have Python 3.14:
```sh
brew install python@3.12
```
**virtualenv** (needed by ESP-IDF tools; PEP 668 workaround on macOS):
```sh
/opt/homebrew/opt/python@3.12/bin/python3.12 -m pip install virtualenv --break-system-packages
```
**Rust tools**:
```sh
cargo install espflash ldproxy
```
The project's `rust-toolchain.toml` pins nightly + rust-src. `esp-idf-sys` downloads ESP-IDF automatically on first build. Use Python 3.12 for the build:
```sh
export PATH="/opt/homebrew/opt/python@3.12/libexec/bin:$PATH"
```
2. **Xtensa targets** (ESP32, ESP32-S2, ESP32-S3): Use espup instead:
```sh
cargo install espup espflash
espup install
source ~/export-esp.sh # or ~/export-esp.fish for Fish
source ~/export-esp.sh
```
2. **Target**: ESP32-C3 (RISC-V) by default. Edit `.cargo/config.toml` for other targets (e.g. `xtensa-esp32-espidf` for original ESP32).
Then edit `.cargo/config.toml` to change the target (e.g. `xtensa-esp32-espidf`).
## Build & Flash
```sh
cd firmware/zeroclaw-esp32
# Use Python 3.12 (required if you have 3.14)
export PATH="/opt/homebrew/opt/python@3.12/libexec/bin:$PATH"
# Optional: pin MCU (esp32c3 or esp32c2)
export MCU=esp32c3
cargo build --release
espflash flash target/riscv32imc-esp-espidf/release/zeroclaw-esp32 --monitor
```

View File

@@ -0,0 +1,156 @@
# ESP32 Firmware Setup Guide
Step-by-step setup for building the ZeroClaw ESP32 firmware. Follow this if you run into issues.
## Quick Start (copy-paste)
```sh
# 1. Install Python 3.12 (ESP-IDF needs 3.103.13, not 3.14)
brew install python@3.12
# 2. Install virtualenv (PEP 668 workaround on macOS)
/opt/homebrew/opt/python@3.12/bin/python3.12 -m pip install virtualenv --break-system-packages
# 3. Install Rust tools
cargo install espflash ldproxy
# 4. Build
cd firmware/zeroclaw-esp32
export PATH="/opt/homebrew/opt/python@3.12/libexec/bin:$PATH"
cargo build --release
# 5. Flash (connect ESP32 via USB)
espflash flash target/riscv32imc-esp-espidf/release/zeroclaw-esp32 --monitor
```
---
## Detailed Steps
### 1. Python
ESP-IDF requires Python 3.103.13. **Python 3.14 is not supported.**
```sh
brew install python@3.12
```
### 2. virtualenv
ESP-IDF tools need `virtualenv`. On macOS with Homebrew Python, PEP 668 blocks `pip install`; use:
```sh
/opt/homebrew/opt/python@3.12/bin/python3.12 -m pip install virtualenv --break-system-packages
```
### 3. Rust Tools
```sh
cargo install espflash ldproxy
```
- **espflash**: flash and monitor
- **ldproxy**: linker for ESP-IDF builds
### 4. Use Python 3.12 for Builds
Before every build (or add to `~/.zshrc`):
```sh
export PATH="/opt/homebrew/opt/python@3.12/libexec/bin:$PATH"
```
### 5. Build
```sh
cd firmware/zeroclaw-esp32
cargo build --release
```
First build downloads and compiles ESP-IDF (~515 min).
### 6. Flash
```sh
espflash flash target/riscv32imc-esp-espidf/release/zeroclaw-esp32 --monitor
```
---
## Troubleshooting
### "No space left on device"
Free disk space. Common targets:
```sh
# Cargo cache (often 520 GB)
rm -rf ~/.cargo/registry/cache ~/.cargo/registry/src
# Unused Rust toolchains
rustup toolchain list
rustup toolchain uninstall <name>
# iOS Simulator runtimes (~35 GB)
xcrun simctl delete unavailable
# Temp files
rm -rf /var/folders/*/T/cargo-install*
```
### "can't find crate for `core`" / "riscv32imc-esp-espidf target may not be installed"
This project uses **nightly Rust with build-std**, not espup. Ensure:
- `rust-toolchain.toml` exists (pins nightly + rust-src)
- You are **not** sourcing `~/export-esp.sh` (that's for Xtensa targets)
- Run `cargo build` from `firmware/zeroclaw-esp32`
### "externally-managed-environment" / "No module named 'virtualenv'"
Install virtualenv with the PEP 668 workaround:
```sh
/opt/homebrew/opt/python@3.12/bin/python3.12 -m pip install virtualenv --break-system-packages
```
### "expected `i64`, found `i32`" (time_t mismatch)
Already fixed in `.cargo/config.toml` with `espidf_time64` for ESP-IDF 5.x. If you use ESP-IDF 4.4, switch to `espidf_time32`.
### "expected `*const u8`, found `*const i8`" (esp-idf-svc)
Already fixed via `[patch.crates-io]` in `Cargo.toml` using esp-rs crates from git. Do not remove the patch.
### 10,000+ files in `git status`
The `.embuild/` directory (ESP-IDF cache) has ~100k+ files. It is in `.gitignore`. If you see them, ensure `.gitignore` contains:
```
.embuild/
```
---
## Optional: Auto-load Python 3.12
Add to `~/.zshrc`:
```sh
# ESP32 firmware build
export PATH="/opt/homebrew/opt/python@3.12/libexec/bin:$PATH"
```
---
## Xtensa Targets (ESP32, ESP32-S2, ESP32-S3)
For nonRISC-V chips, use espup instead:
```sh
cargo install espup espflash
espup install
source ~/export-esp.sh
```
Then edit `.cargo/config.toml` to use `xtensa-esp32-espidf` (or the correct target).

View File

@@ -0,0 +1,3 @@
[toolchain]
channel = "nightly"
components = ["rust-src"]

View File

@@ -6,8 +6,9 @@
//! Protocol: same as STM32 — see docs/hardware-peripherals-design.md
use esp_idf_svc::hal::gpio::PinDriver;
use esp_idf_svc::hal::prelude::*;
use esp_idf_svc::hal::uart::*;
use esp_idf_svc::hal::peripherals::Peripherals;
use esp_idf_svc::hal::uart::{UartConfig, UartDriver};
use esp_idf_svc::hal::units::Hertz;
use log::info;
use serde::{Deserialize, Serialize};
@@ -36,9 +37,13 @@ fn main() -> anyhow::Result<()> {
let peripherals = Peripherals::take()?;
let pins = peripherals.pins;
// Create GPIO output drivers first (they take ownership of pins)
let mut gpio2 = PinDriver::output(pins.gpio2)?;
let mut gpio13 = PinDriver::output(pins.gpio13)?;
// UART0: TX=21, RX=20 (ESP32) — ESP32-C3 may use different pins; adjust for your board
let config = UartConfig::new().baudrate(Hertz(115_200));
let mut uart = UartDriver::new(
let uart = UartDriver::new(
peripherals.uart0,
pins.gpio21,
pins.gpio20,
@@ -60,7 +65,8 @@ fn main() -> anyhow::Result<()> {
if b == b'\n' {
if !line.is_empty() {
if let Ok(line_str) = std::str::from_utf8(&line) {
if let Ok(resp) = handle_request(line_str, &peripherals) {
if let Ok(resp) = handle_request(line_str, &mut gpio2, &mut gpio13)
{
let out = serde_json::to_string(&resp).unwrap_or_default();
let _ = uart.write(format!("{}\n", out).as_bytes());
}
@@ -80,10 +86,15 @@ fn main() -> anyhow::Result<()> {
}
}
fn handle_request(
fn handle_request<G2, G13>(
line: &str,
peripherals: &esp_idf_svc::hal::peripherals::Peripherals,
) -> anyhow::Result<Response> {
gpio2: &mut PinDriver<'_, G2>,
gpio13: &mut PinDriver<'_, G13>,
) -> anyhow::Result<Response>
where
G2: esp_idf_svc::hal::gpio::OutputMode,
G13: esp_idf_svc::hal::gpio::OutputMode,
{
let req: Request = serde_json::from_str(line.trim())?;
let id = req.id.clone();
@@ -98,13 +109,13 @@ fn handle_request(
}
"gpio_read" => {
let pin_num = req.args.get("pin").and_then(|v| v.as_u64()).unwrap_or(0) as i32;
let value = gpio_read(peripherals, pin_num)?;
let value = gpio_read(pin_num)?;
Ok(value.to_string())
}
"gpio_write" => {
let pin_num = req.args.get("pin").and_then(|v| v.as_u64()).unwrap_or(0) as i32;
let value = req.args.get("value").and_then(|v| v.as_u64()).unwrap_or(0);
gpio_write(peripherals, pin_num, value)?;
gpio_write(gpio2, gpio13, pin_num, value)?;
Ok("done".into())
}
_ => Err(anyhow::anyhow!("Unknown command: {}", req.cmd)),
@@ -126,28 +137,26 @@ fn handle_request(
}
}
fn gpio_read(_peripherals: &esp_idf_svc::hal::peripherals::Peripherals, _pin: i32) -> anyhow::Result<u8> {
fn gpio_read(_pin: i32) -> anyhow::Result<u8> {
// TODO: implement input pin read — requires storing InputPin drivers per pin
Ok(0)
}
fn gpio_write(
peripherals: &esp_idf_svc::hal::peripherals::Peripherals,
fn gpio_write<G2, G13>(
gpio2: &mut PinDriver<'_, G2>,
gpio13: &mut PinDriver<'_, G13>,
pin: i32,
value: u64,
) -> anyhow::Result<()> {
let pins = peripherals.pins;
let level = value != 0;
) -> anyhow::Result<()>
where
G2: esp_idf_svc::hal::gpio::OutputMode,
G13: esp_idf_svc::hal::gpio::OutputMode,
{
let level = esp_idf_svc::hal::gpio::Level::from(value != 0);
match pin {
2 => {
let mut out = PinDriver::output(pins.gpio2)?;
out.set_level(esp_idf_svc::hal::gpio::Level::from(level))?;
}
13 => {
let mut out = PinDriver::output(pins.gpio13)?;
out.set_level(esp_idf_svc::hal::gpio::Level::from(level))?;
}
2 => gpio2.set_level(level)?,
13 => gpio13.set_level(level)?,
_ => anyhow::bail!("Pin {} not configured (add to gpio_write)", pin),
}
Ok(())

View File

@@ -0,0 +1,324 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_NAME="$(basename "$0")"
usage() {
cat <<USAGE
Recompute contributor tier labels for historical PRs/issues.
Usage:
./$SCRIPT_NAME [options]
Options:
--repo <owner/repo> Target repository (default: current gh repo)
--kind <both|prs|issues>
Target objects (default: both)
--state <all|open|closed>
State filter for listing objects (default: all)
--limit <N> Limit processed objects after fetch (default: 0 = no limit)
--apply Apply label updates (default is dry-run)
--dry-run Preview only (default)
-h, --help Show this help
Examples:
./$SCRIPT_NAME --repo zeroclaw-labs/zeroclaw --limit 50
./$SCRIPT_NAME --repo zeroclaw-labs/zeroclaw --kind prs --state open --apply
USAGE
}
die() {
echo "[$SCRIPT_NAME] ERROR: $*" >&2
exit 1
}
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
die "Required command not found: $1"
fi
}
urlencode() {
jq -nr --arg value "$1" '$value|@uri'
}
select_contributor_tier() {
local merged_count="$1"
if (( merged_count >= 50 )); then
echo "distinguished contributor"
elif (( merged_count >= 20 )); then
echo "principal contributor"
elif (( merged_count >= 10 )); then
echo "experienced contributor"
elif (( merged_count >= 5 )); then
echo "trusted contributor"
else
echo ""
fi
}
DRY_RUN=1
KIND="both"
STATE="all"
LIMIT=0
REPO=""
while (($# > 0)); do
case "$1" in
--repo)
[[ $# -ge 2 ]] || die "Missing value for --repo"
REPO="$2"
shift 2
;;
--kind)
[[ $# -ge 2 ]] || die "Missing value for --kind"
KIND="$2"
shift 2
;;
--state)
[[ $# -ge 2 ]] || die "Missing value for --state"
STATE="$2"
shift 2
;;
--limit)
[[ $# -ge 2 ]] || die "Missing value for --limit"
LIMIT="$2"
shift 2
;;
--apply)
DRY_RUN=0
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
die "Unknown option: $1"
;;
esac
done
case "$KIND" in
both|prs|issues) ;;
*) die "--kind must be one of: both, prs, issues" ;;
esac
case "$STATE" in
all|open|closed) ;;
*) die "--state must be one of: all, open, closed" ;;
esac
if ! [[ "$LIMIT" =~ ^[0-9]+$ ]]; then
die "--limit must be a non-negative integer"
fi
require_cmd gh
require_cmd jq
if ! gh auth status >/dev/null 2>&1; then
die "gh CLI is not authenticated. Run: gh auth login"
fi
if [[ -z "$REPO" ]]; then
REPO="$(gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null || true)"
[[ -n "$REPO" ]] || die "Unable to infer repo. Pass --repo <owner/repo>."
fi
echo "[$SCRIPT_NAME] Repo: $REPO"
echo "[$SCRIPT_NAME] Mode: $([[ "$DRY_RUN" -eq 1 ]] && echo "dry-run" || echo "apply")"
echo "[$SCRIPT_NAME] Kind: $KIND | State: $STATE | Limit: $LIMIT"
TIERS_JSON='["trusted contributor","experienced contributor","principal contributor","distinguished contributor"]'
TMP_FILES=()
cleanup() {
if ((${#TMP_FILES[@]} > 0)); then
rm -f "${TMP_FILES[@]}"
fi
}
trap cleanup EXIT
new_tmp_file() {
local tmp
tmp="$(mktemp)"
TMP_FILES+=("$tmp")
echo "$tmp"
}
targets_file="$(new_tmp_file)"
if [[ "$KIND" == "both" || "$KIND" == "prs" ]]; then
gh api --paginate "repos/$REPO/pulls?state=$STATE&per_page=100" \
--jq '.[] | {
kind: "pr",
number: .number,
author: (.user.login // ""),
author_type: (.user.type // ""),
labels: [(.labels[]?.name // empty)]
}' >> "$targets_file"
fi
if [[ "$KIND" == "both" || "$KIND" == "issues" ]]; then
gh api --paginate "repos/$REPO/issues?state=$STATE&per_page=100" \
--jq '.[] | select(.pull_request | not) | {
kind: "issue",
number: .number,
author: (.user.login // ""),
author_type: (.user.type // ""),
labels: [(.labels[]?.name // empty)]
}' >> "$targets_file"
fi
if [[ "$LIMIT" -gt 0 ]]; then
limited_file="$(new_tmp_file)"
head -n "$LIMIT" "$targets_file" > "$limited_file"
mv "$limited_file" "$targets_file"
fi
target_count="$(wc -l < "$targets_file" | tr -d ' ')"
if [[ "$target_count" -eq 0 ]]; then
echo "[$SCRIPT_NAME] No targets found."
exit 0
fi
echo "[$SCRIPT_NAME] Targets fetched: $target_count"
# Ensure tier labels exist (trusted contributor might be new).
label_color=""
for probe_label in "experienced contributor" "principal contributor" "distinguished contributor" "trusted contributor"; do
encoded_label="$(urlencode "$probe_label")"
if color_candidate="$(gh api "repos/$REPO/labels/$encoded_label" --jq '.color' 2>/dev/null || true)"; then
if [[ -n "$color_candidate" ]]; then
label_color="$(echo "$color_candidate" | tr '[:lower:]' '[:upper:]')"
break
fi
fi
done
[[ -n "$label_color" ]] || label_color="C5D7A2"
while IFS= read -r tier_label; do
[[ -n "$tier_label" ]] || continue
encoded_label="$(urlencode "$tier_label")"
if gh api "repos/$REPO/labels/$encoded_label" >/dev/null 2>&1; then
continue
fi
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "[dry-run] Would create missing label: $tier_label (color=$label_color)"
else
gh api -X POST "repos/$REPO/labels" \
-f name="$tier_label" \
-f color="$label_color" >/dev/null
echo "[apply] Created missing label: $tier_label"
fi
done < <(jq -r '.[]' <<<"$TIERS_JSON")
# Build merged PR count cache by unique human authors.
authors_file="$(new_tmp_file)"
jq -r 'select(.author != "" and .author_type != "Bot") | .author' "$targets_file" | sort -u > "$authors_file"
author_count="$(wc -l < "$authors_file" | tr -d ' ')"
echo "[$SCRIPT_NAME] Unique human authors: $author_count"
author_counts_file="$(new_tmp_file)"
while IFS= read -r author; do
[[ -n "$author" ]] || continue
query="repo:$REPO is:pr is:merged author:$author"
merged_count="$(gh api search/issues -f q="$query" -F per_page=1 --jq '.total_count' 2>/dev/null || true)"
if ! [[ "$merged_count" =~ ^[0-9]+$ ]]; then
merged_count=0
fi
printf '%s\t%s\n' "$author" "$merged_count" >> "$author_counts_file"
done < "$authors_file"
updated=0
unchanged=0
skipped=0
failed=0
while IFS= read -r target_json; do
[[ -n "$target_json" ]] || continue
number="$(jq -r '.number' <<<"$target_json")"
kind="$(jq -r '.kind' <<<"$target_json")"
author="$(jq -r '.author' <<<"$target_json")"
author_type="$(jq -r '.author_type' <<<"$target_json")"
current_labels_json="$(jq -c '.labels // []' <<<"$target_json")"
if [[ -z "$author" || "$author_type" == "Bot" ]]; then
skipped=$((skipped + 1))
continue
fi
merged_count="$(awk -F '\t' -v key="$author" '$1 == key { print $2; exit }' "$author_counts_file")"
if ! [[ "$merged_count" =~ ^[0-9]+$ ]]; then
merged_count=0
fi
desired_tier="$(select_contributor_tier "$merged_count")"
if ! current_tier="$(jq -r --argjson tiers "$TIERS_JSON" '[.[] | select(. as $label | ($tiers | index($label)) != null)][0] // ""' <<<"$current_labels_json" 2>/dev/null)"; then
echo "[warn] Skipping ${kind} #${number}: cannot parse current labels JSON" >&2
failed=$((failed + 1))
continue
fi
if ! next_labels_json="$(jq -c --arg desired "$desired_tier" --argjson tiers "$TIERS_JSON" '
(. // [])
| map(select(. as $label | ($tiers | index($label)) == null))
| if $desired != "" then . + [$desired] else . end
| unique
' <<<"$current_labels_json" 2>/dev/null)"; then
echo "[warn] Skipping ${kind} #${number}: cannot compute next labels" >&2
failed=$((failed + 1))
continue
fi
if ! normalized_current="$(jq -c 'unique | sort' <<<"$current_labels_json" 2>/dev/null)"; then
echo "[warn] Skipping ${kind} #${number}: cannot normalize current labels" >&2
failed=$((failed + 1))
continue
fi
if ! normalized_next="$(jq -c 'unique | sort' <<<"$next_labels_json" 2>/dev/null)"; then
echo "[warn] Skipping ${kind} #${number}: cannot normalize next labels" >&2
failed=$((failed + 1))
continue
fi
if [[ "$normalized_current" == "$normalized_next" ]]; then
unchanged=$((unchanged + 1))
continue
fi
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "[dry-run] ${kind} #${number} @${author} merged=${merged_count} tier: '${current_tier:-none}' -> '${desired_tier:-none}'"
updated=$((updated + 1))
continue
fi
payload="$(jq -cn --argjson labels "$next_labels_json" '{labels: $labels}')"
if gh api -X PUT "repos/$REPO/issues/$number/labels" --input - <<<"$payload" >/dev/null; then
echo "[apply] Updated ${kind} #${number} @${author} tier: '${current_tier:-none}' -> '${desired_tier:-none}'"
updated=$((updated + 1))
else
echo "[apply] FAILED ${kind} #${number}" >&2
failed=$((failed + 1))
fi
done < "$targets_file"
echo ""
echo "[$SCRIPT_NAME] Summary"
echo " Targets: $target_count"
echo " Updated: $updated"
echo " Unchanged: $unchanged"
echo " Skipped: $skipped"
echo " Failed: $failed"
if [[ "$failed" -gt 0 ]]; then
exit 1
fi

View File

@@ -7,14 +7,70 @@ use crate::security::SecurityPolicy;
use crate::tools::{self, Tool};
use crate::util::truncate_with_ellipsis;
use anyhow::Result;
use regex::{Regex, RegexSet};
use std::fmt::Write;
use std::io::Write as _;
use std::sync::Arc;
use std::sync::{Arc, LazyLock};
use std::time::Instant;
use uuid::Uuid;
/// Maximum agentic tool-use iterations per user message to prevent runaway loops.
const MAX_TOOL_ITERATIONS: usize = 10;
static SENSITIVE_KEY_PATTERNS: LazyLock<RegexSet> = LazyLock::new(|| {
RegexSet::new([
r"(?i)token",
r"(?i)api[_-]?key",
r"(?i)password",
r"(?i)secret",
r"(?i)user[_-]?key",
r"(?i)bearer",
r"(?i)credential",
])
.unwrap()
});
static SENSITIVE_KV_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?i)(token|api[_-]?key|password|secret|user[_-]?key|bearer|credential)["']?\s*[:=]\s*(?:"([^"]{8,})"|'([^']{8,})'|([a-zA-Z0-9_\-\.]{8,}))"#).unwrap()
});
/// Scrub credentials from tool output to prevent accidental exfiltration.
/// Replaces known credential patterns with a redacted placeholder while preserving
/// a small prefix for context.
fn scrub_credentials(input: &str) -> String {
SENSITIVE_KV_REGEX
.replace_all(input, |caps: &regex::Captures| {
let full_match = &caps[0];
let key = &caps[1];
let val = caps
.get(2)
.or(caps.get(3))
.or(caps.get(4))
.map(|m| m.as_str())
.unwrap_or("");
// Preserve first 4 chars for context, then redact
let prefix = if val.len() > 4 { &val[..4] } else { "" };
if full_match.contains(':') {
if full_match.contains('"') {
format!("\"{}\": \"{}*[REDACTED]\"", key, prefix)
} else {
format!("{}: {}*[REDACTED]", key, prefix)
}
} else if full_match.contains('=') {
if full_match.contains('"') {
format!("{}=\"{}*[REDACTED]\"", key, prefix)
} else {
format!("{}={}*[REDACTED]", key, prefix)
}
} else {
format!("{}: {}*[REDACTED]", key, prefix)
}
})
.to_string()
}
/// Trigger auto-compaction when non-system message count exceeds this threshold.
const MAX_HISTORY_MESSAGES: usize = 50;
@@ -608,7 +664,7 @@ pub(crate) async fn run_tool_call_loop(
success: r.success,
});
if r.success {
r.output
scrub_credentials(&r.output)
} else {
format!("Error: {}", r.error.unwrap_or_else(|| r.output))
}
@@ -1222,6 +1278,25 @@ pub async fn process_message(config: Config, message: &str) -> Result<String> {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scrub_credentials() {
let input = "API_KEY=sk-1234567890abcdef; token: 1234567890; password=\"secret123456\"";
let scrubbed = scrub_credentials(input);
assert!(scrubbed.contains("API_KEY=sk-1*[REDACTED]"));
assert!(scrubbed.contains("token: 1234*[REDACTED]"));
assert!(scrubbed.contains("password=\"secr*[REDACTED]\""));
assert!(!scrubbed.contains("abcdef"));
assert!(!scrubbed.contains("secret123456"));
}
#[test]
fn test_scrub_credentials_json() {
let input = r#"{"api_key": "sk-1234567890", "other": "public"}"#;
let scrubbed = scrub_credentials(input);
assert!(scrubbed.contains("\"api_key\": \"sk-1*[REDACTED]\""));
assert!(scrubbed.contains("public"));
}
use crate::memory::{Memory, MemoryCategory, SqliteMemory};
use tempfile::TempDir;

View File

@@ -40,7 +40,7 @@ impl Channel for CliChannel {
let msg = ChannelMessage {
id: Uuid::new_v4().to_string(),
sender: "user".to_string(),
reply_to: "user".to_string(),
reply_target: "user".to_string(),
content: line,
channel: "cli".to_string(),
timestamp: std::time::SystemTime::now()

View File

@@ -238,7 +238,7 @@ impl Channel for DingTalkChannel {
let channel_msg = ChannelMessage {
id: Uuid::new_v4().to_string(),
sender: sender_id.to_string(),
reply_to: chat_id,
reply_target: chat_id,
content: content.to_string(),
channel: "dingtalk".to_string(),
timestamp: std::time::SystemTime::now()

View File

@@ -450,7 +450,7 @@ impl LarkChannel {
let channel_msg = ChannelMessage {
id: Uuid::new_v4().to_string(),
sender: lark_msg.chat_id.clone(),
reply_to: lark_msg.chat_id.clone(),
reply_target: lark_msg.chat_id.clone(),
content: text,
channel: "lark".to_string(),
timestamp: std::time::SystemTime::now()

View File

@@ -633,6 +633,7 @@ pub async fn doctor_channels(config: Config) -> Result<()> {
Arc::new(TelegramChannel::new(
tg.bot_token.clone(),
tg.allowed_users.clone(),
Some(config.workspace_dir.clone()),
)),
));
}
@@ -923,6 +924,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
channels.push(Arc::new(TelegramChannel::new(
tg.bot_token.clone(),
tg.allowed_users.clone(),
Some(config.workspace_dir.clone()),
)));
}

View File

@@ -3,6 +3,7 @@ use async_trait::async_trait;
use reqwest::multipart::{Form, Part};
use std::path::Path;
use std::time::Duration;
use tokio::process::Command;
/// Telegram's maximum message length for text messages
const TELEGRAM_MAX_MESSAGE_LENGTH: usize = 4096;
@@ -180,22 +181,35 @@ fn parse_attachment_markers(message: &str) -> (String, Vec<TelegramAttachment>)
/// Telegram channel — long-polls the Bot API for updates
pub struct TelegramChannel {
workspace_dir: std::path::PathBuf,
bot_token: String,
allowed_users: Vec<String>,
client: reqwest::Client,
}
impl TelegramChannel {
pub fn new(bot_token: String, allowed_users: Vec<String>) -> Self {
pub fn new(
bot_token: String,
allowed_users: Vec<String>,
workspace_dir: Option<std::path::PathBuf>,
) -> Self {
Self {
bot_token,
allowed_users,
client: reqwest::Client::new(),
workspace_dir: workspace_dir.unwrap_or_else(|| std::path::PathBuf::from("/tmp")),
}
}
fn api_url(&self, method: &str) -> String {
format!("https://api.telegram.org/bot{}/{method}", self.bot_token)
format!("https://api.telegram.org/bot{}/{}", self.bot_token, method)
}
fn file_url(&self, file_path: &str) -> String {
format!(
"https://api.telegram.org/file/bot{}/{}",
self.bot_token, file_path
)
}
fn is_user_allowed(&self, username: &str) -> bool {
@@ -209,6 +223,131 @@ impl TelegramChannel {
identities.into_iter().any(|id| self.is_user_allowed(id))
}
/// Download a voice/audio file from Telegram and return the local path
async fn download_voice_file(&self, file_id: &str) -> anyhow::Result<std::path::PathBuf> {
// Get file path from Telegram
let get_file_url = self.api_url("getFile");
let body = serde_json::json!({
"file_id": file_id
});
let resp = self
.client
.post(&get_file_url)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram getFile failed: {}", err);
}
let data: serde_json::Value = resp.json().await?;
let file_path = data
.get("result")
.and_then(|r| r.get("file_path"))
.and_then(|p| p.as_str())
.ok_or_else(|| anyhow::anyhow!("No file_path in Telegram response"))?;
// Download the file
let download_url = self.file_url(file_path);
let file_resp = self.client.get(&download_url).send().await?;
if !file_resp.status().is_success() {
anyhow::bail!("Failed to download voice file: {}", file_resp.status());
}
// Save to workspace with unique name
let file_name = format!("voice_{}.ogg", chrono::Utc::now().format("%Y%m%d_%H%M%S_%f"));
let local_path = self.workspace_dir.join(&file_name);
let bytes = file_resp.bytes().await?;
tokio::fs::write(&local_path, &bytes).await?;
tracing::info!("Downloaded voice file to: {:?}", local_path);
Ok(local_path)
}
/// Transcribe a voice file using faster-whisper
async fn transcribe_voice_file(&self, audio_path: &std::path::Path) -> anyhow::Result<String> {
let script = r#"
import sys
import json
def transcribe(audio_path):
from faster_whisper import WhisperModel
model = WhisperModel("base", device="cpu", compute_type="int8")
segments, info = model.transcribe(audio_path, beam_size=5)
text_parts = []
for segment in segments:
text_parts.append(segment.text.strip())
result = {
"language": info.language,
"duration": round(info.duration, 2),
"text": " ".join(text_parts)
}
print(json.dumps(result))
if __name__ == "__main__":
transcribe(sys.argv[1])
"#;
let output = Command::new("python3")
.arg("-c")
.arg(script)
.arg(audio_path)
.output()
.await;
match output {
Ok(result) => {
if result.status.success() {
let stdout = String::from_utf8_lossy(&result.stdout);
match serde_json::from_str::<serde_json::Value>(&stdout) {
Ok(json) => {
let text = json
.get("text")
.and_then(|t| t.as_str())
.unwrap_or("")
.to_string();
let language = json
.get("language")
.and_then(|l| l.as_str())
.unwrap_or("unknown");
let duration = json
.get("duration")
.and_then(|d| d.as_f64())
.unwrap_or(0.0);
tracing::info!(
"Transcribed voice message: {:.1}s, language: {}",
duration,
language
);
Ok(text)
}
Err(_) => {
// Fallback: return raw stdout
Ok(stdout.trim().to_string())
}
}
} else {
let stderr = String::from_utf8_lossy(&result.stderr);
tracing::warn!("Voice transcription failed: {}", stderr);
Err(anyhow::anyhow!("Transcription failed: {}", stderr.trim()))
}
}
Err(e) => {
tracing::warn!("Failed to run transcription: {}", e);
Err(anyhow::anyhow!("Failed to run transcription: {}", e))
}
}
}
fn parse_update_message(&self, update: &serde_json::Value) -> Option<ChannelMessage> {
let message = update.get("message")?;
@@ -240,8 +379,9 @@ impl TelegramChannel {
if !self.is_any_user_allowed(identities.iter().copied()) {
tracing::warn!(
"Telegram: ignoring message from unauthorized user: username={username}, user_id={}. \
"Telegram: ignoring message from unauthorized user: username={}, user_id={}. \
Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --channels-only`.",
username,
user_id.as_deref().unwrap_or("unknown")
);
return None;
@@ -259,7 +399,7 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
.unwrap_or(0);
Some(ChannelMessage {
id: format!("telegram_{chat_id}_{message_id}"),
id: format!("telegram_{}_{}", chat_id, message_id),
sender: sender_identity,
reply_target: chat_id,
content: text.to_string(),
@@ -271,17 +411,80 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
})
}
/// Parse voice/audio messages from Telegram update (returns file_id and metadata)
fn parse_voice_message(
&self,
update: &serde_json::Value,
) -> Option<(String, String, String, i64)> {
let message = update.get("message")?;
// Check for voice message
let file_id = if let Some(voice) = message.get("voice") {
voice.get("file_id").and_then(|v| v.as_str())?
} else if let Some(audio) = message.get("audio") {
// Also support audio messages
audio.get("file_id").and_then(|v| v.as_str())?
} else {
return None;
};
let username = message
.get("from")
.and_then(|from| from.get("username"))
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown")
.to_string();
let user_id = message
.get("from")
.and_then(|from| from.get("id"))
.and_then(serde_json::Value::as_i64)
.map(|id| id.to_string());
let sender_identity = if username == "unknown" {
user_id.clone().unwrap_or_else(|| "unknown".to_string())
} else {
username.clone()
};
let mut identities = vec![username.as_str()];
if let Some(id) = user_id.as_deref() {
identities.push(id);
}
if !self.is_any_user_allowed(identities.iter().copied()) {
tracing::warn!(
"Telegram: ignoring voice message from unauthorized user: {}",
sender_identity
);
return None;
}
let chat_id = message
.get("chat")
.and_then(|chat| chat.get("id"))
.and_then(serde_json::Value::as_i64)
.map(|id| id.to_string())?;
let message_id = message
.get("message_id")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
Some((file_id.to_string(), sender_identity, chat_id, message_id))
}
async fn send_text_chunks(&self, message: &str, chat_id: &str) -> anyhow::Result<()> {
let chunks = split_message_for_telegram(message);
for (index, chunk) in chunks.iter().enumerate() {
let text = if chunks.len() > 1 {
if index == 0 {
format!("{chunk}\n\n(continues...)")
format!("{}\n\n(continues...)", chunk)
} else if index == chunks.len() - 1 {
format!("(continued)\n\n{chunk}")
format!("(continued)\n\n{}", chunk)
} else {
format!("(continued)\n\n{chunk}\n\n(continues...)")
format!("(continued)\n\n{}\n\n(continues...)", chunk)
}
} else {
chunk.to_string()
@@ -371,10 +574,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram {method} by URL failed: {err}");
anyhow::bail!("Telegram {} by URL failed: {}", method, err);
}
tracing::info!("Telegram {method} sent to {chat_id}: {url}");
tracing::info!("Telegram {} sent to {}: {}", method, chat_id, url);
Ok(())
}
@@ -407,7 +610,7 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
let path = Path::new(target);
if !path.exists() {
anyhow::bail!("Telegram attachment path not found: {target}");
anyhow::bail!("Telegram attachment path not found: {}", target);
}
match attachment.kind {
@@ -451,10 +654,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendDocument failed: {err}");
anyhow::bail!("Telegram sendDocument failed: {}", err);
}
tracing::info!("Telegram document sent to {chat_id}: {file_name}");
tracing::info!("Telegram document sent to {}: {}", chat_id, file_name);
Ok(())
}
@@ -485,10 +688,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendDocument failed: {err}");
anyhow::bail!("Telegram sendDocument failed: {}", err);
}
tracing::info!("Telegram document sent to {chat_id}: {file_name}");
tracing::info!("Telegram document sent to {}: {}", chat_id, file_name);
Ok(())
}
@@ -524,10 +727,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendPhoto failed: {err}");
anyhow::bail!("Telegram sendPhoto failed: {}", err);
}
tracing::info!("Telegram photo sent to {chat_id}: {file_name}");
tracing::info!("Telegram photo sent to {}: {}", chat_id, file_name);
Ok(())
}
@@ -558,10 +761,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendPhoto failed: {err}");
anyhow::bail!("Telegram sendPhoto failed: {}", err);
}
tracing::info!("Telegram photo sent to {chat_id}: {file_name}");
tracing::info!("Telegram photo sent to {}: {}", chat_id, file_name);
Ok(())
}
@@ -597,10 +800,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendVideo failed: {err}");
anyhow::bail!("Telegram sendVideo failed: {}", err);
}
tracing::info!("Telegram video sent to {chat_id}: {file_name}");
tracing::info!("Telegram video sent to {}: {}", chat_id, file_name);
Ok(())
}
@@ -636,10 +839,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendAudio failed: {err}");
anyhow::bail!("Telegram sendAudio failed: {}", err);
}
tracing::info!("Telegram audio sent to {chat_id}: {file_name}");
tracing::info!("Telegram audio sent to {}: {}", chat_id, file_name);
Ok(())
}
@@ -675,10 +878,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendVoice failed: {err}");
anyhow::bail!("Telegram sendVoice failed: {}", err);
}
tracing::info!("Telegram voice sent to {chat_id}: {file_name}");
tracing::info!("Telegram voice sent to {}: {}", chat_id, file_name);
Ok(())
}
@@ -707,10 +910,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendDocument by URL failed: {err}");
anyhow::bail!("Telegram sendDocument by URL failed: {}", err);
}
tracing::info!("Telegram document (URL) sent to {chat_id}: {url}");
tracing::info!("Telegram document (URL) sent to {}: {}", chat_id, url);
Ok(())
}
@@ -739,10 +942,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
if !resp.status().is_success() {
let err = resp.text().await?;
anyhow::bail!("Telegram sendPhoto by URL failed: {err}");
anyhow::bail!("Telegram sendPhoto by URL failed: {}", err);
}
tracing::info!("Telegram photo (URL) sent to {chat_id}: {url}");
tracing::info!("Telegram photo (URL) sent to {}: {}", chat_id, url);
Ok(())
}
@@ -826,7 +1029,7 @@ impl Channel for TelegramChannel {
let resp = match self.client.post(&url).json(&body).send().await {
Ok(r) => r,
Err(e) => {
tracing::warn!("Telegram poll error: {e}");
tracing::warn!("Telegram poll error: {}", e);
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
continue;
}
@@ -835,7 +1038,7 @@ impl Channel for TelegramChannel {
let data: serde_json::Value = match resp.json().await {
Ok(d) => d,
Err(e) => {
tracing::warn!("Telegram parse error: {e}");
tracing::warn!("Telegram parse error: {}", e);
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
continue;
}
@@ -848,24 +1051,81 @@ impl Channel for TelegramChannel {
offset = uid + 1;
}
let Some(msg) = self.parse_update_message(update) else {
// First, try to parse as text message
if let Some(msg) = self.parse_update_message(update) {
// Send "typing" indicator immediately when we receive a message
let typing_body = serde_json::json!({
"chat_id": &msg.reply_target,
"action": "typing"
});
let _ = self
.client
.post(self.api_url("sendChatAction"))
.json(&typing_body)
.send()
.await;
if tx.send(msg).await.is_err() {
return Ok(());
}
continue;
};
}
// Send "typing" indicator immediately when we receive a message
let typing_body = serde_json::json!({
"chat_id": &msg.reply_target,
"action": "typing"
});
let _ = self
.client
.post(self.api_url("sendChatAction"))
.json(&typing_body)
.send()
.await; // Ignore errors for typing indicator
// Then, try to parse as voice/audio message
if let Some((file_id, sender, chat_id, message_id)) =
self.parse_voice_message(update)
{
// Send "typing" indicator
let typing_body = serde_json::json!({
"chat_id": &chat_id,
"action": "typing"
});
let _ = self
.client
.post(self.api_url("sendChatAction"))
.json(&typing_body)
.send()
.await;
if tx.send(msg).await.is_err() {
return Ok(());
// Download and transcribe the voice file
let transcription = match self.download_voice_file(&file_id).await {
Ok(audio_path) => {
let result = self.transcribe_voice_file(&audio_path).await;
// Clean up the downloaded file
let _ = tokio::fs::remove_file(&audio_path).await;
result
}
Err(e) => {
tracing::warn!("Failed to download voice file: {}", e);
Err(e)
}
};
let content = match transcription {
Ok(text) if !text.is_empty() => {
format!("[Voice message transcription]\n{}", text)
}
Ok(_) => "[Voice message - empty transcription]".to_string(),
Err(e) => {
format!("[Voice message - transcription failed: {}]", e)
}
};
let msg = ChannelMessage {
id: format!("telegram_{}_{}", chat_id, message_id),
sender,
reply_target: chat_id,
content,
channel: "telegram".to_string(),
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
};
if tx.send(msg).await.is_err() {
return Ok(());
}
}
}
}
@@ -883,7 +1143,7 @@ impl Channel for TelegramChannel {
{
Ok(Ok(resp)) => resp.status().is_success(),
Ok(Err(e)) => {
tracing::debug!("Telegram health check failed: {e}");
tracing::debug!("Telegram health check failed: {}", e);
false
}
Err(_) => {
@@ -900,41 +1160,50 @@ mod tests {
#[test]
fn telegram_channel_name() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
assert_eq!(ch.name(), "telegram");
}
#[test]
fn telegram_api_url() {
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
let ch = TelegramChannel::new("123:ABC".into(), vec![], None);
assert_eq!(
ch.api_url("getMe"),
"https://api.telegram.org/bot123:ABC/getMe"
);
}
#[test]
fn telegram_file_url() {
let ch = TelegramChannel::new("123:ABC".into(), vec![], None);
assert_eq!(
ch.file_url("voice/file.ogg"),
"https://api.telegram.org/file/bot123:ABC/voice/file.ogg"
);
}
#[test]
fn telegram_user_allowed_wildcard() {
let ch = TelegramChannel::new("t".into(), vec!["*".into()]);
let ch = TelegramChannel::new("t".into(), vec!["*".into()], None);
assert!(ch.is_user_allowed("anyone"));
}
#[test]
fn telegram_user_allowed_specific() {
let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "bob".into()]);
let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "bob".into()], None);
assert!(ch.is_user_allowed("alice"));
assert!(!ch.is_user_allowed("eve"));
}
#[test]
fn telegram_user_denied_empty() {
let ch = TelegramChannel::new("t".into(), vec![]);
let ch = TelegramChannel::new("t".into(), vec![], None);
assert!(!ch.is_user_allowed("anyone"));
}
#[test]
fn telegram_user_exact_match_not_substring() {
let ch = TelegramChannel::new("t".into(), vec!["alice".into()]);
let ch = TelegramChannel::new("t".into(), vec!["alice".into()], None);
assert!(!ch.is_user_allowed("alice_bot"));
assert!(!ch.is_user_allowed("alic"));
assert!(!ch.is_user_allowed("malice"));
@@ -942,13 +1211,13 @@ mod tests {
#[test]
fn telegram_user_empty_string_denied() {
let ch = TelegramChannel::new("t".into(), vec!["alice".into()]);
let ch = TelegramChannel::new("t".into(), vec!["alice".into()], None);
assert!(!ch.is_user_allowed(""));
}
#[test]
fn telegram_user_case_sensitive() {
let ch = TelegramChannel::new("t".into(), vec!["Alice".into()]);
let ch = TelegramChannel::new("t".into(), vec!["Alice".into()], None);
assert!(ch.is_user_allowed("Alice"));
assert!(!ch.is_user_allowed("alice"));
assert!(!ch.is_user_allowed("ALICE"));
@@ -956,7 +1225,7 @@ mod tests {
#[test]
fn telegram_wildcard_with_specific_users() {
let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "*".into()]);
let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "*".into()], None);
assert!(ch.is_user_allowed("alice"));
assert!(ch.is_user_allowed("bob"));
assert!(ch.is_user_allowed("anyone"));
@@ -964,13 +1233,13 @@ mod tests {
#[test]
fn telegram_user_allowed_by_numeric_id_identity() {
let ch = TelegramChannel::new("t".into(), vec!["123456789".into()]);
let ch = TelegramChannel::new("t".into(), vec!["123456789".into()], None);
assert!(ch.is_any_user_allowed(["unknown", "123456789"]));
}
#[test]
fn telegram_user_denied_when_none_of_identities_match() {
let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "987654321".into()]);
let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "987654321".into()], None);
assert!(!ch.is_any_user_allowed(["unknown", "123456789"]));
}
@@ -1024,7 +1293,7 @@ mod tests {
#[test]
fn parse_update_message_uses_chat_id_as_reply_target() {
let ch = TelegramChannel::new("token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("token".into(), vec!["*".into()], None);
let update = serde_json::json!({
"update_id": 1,
"message": {
@@ -1052,7 +1321,7 @@ mod tests {
#[test]
fn parse_update_message_allows_numeric_id_without_username() {
let ch = TelegramChannel::new("token".into(), vec!["555".into()]);
let ch = TelegramChannel::new("token".into(), vec!["555".into()], None);
let update = serde_json::json!({
"update_id": 2,
"message": {
@@ -1075,11 +1344,63 @@ mod tests {
assert_eq!(msg.reply_target, "12345");
}
#[test]
fn parse_voice_message_extracts_file_id() {
let ch = TelegramChannel::new("token".into(), vec!["*".into()], None);
let update = serde_json::json!({
"update_id": 3,
"message": {
"message_id": 42,
"voice": {
"file_id": "AwADBAADbXXXXXXXX",
"duration": 5
},
"from": {
"id": 555,
"username": "alice"
},
"chat": {
"id": 12345
}
}
});
let result = ch.parse_voice_message(&update);
assert!(result.is_some());
let (file_id, sender, chat_id, message_id) = result.unwrap();
assert_eq!(file_id, "AwADBAADbXXXXXXXX");
assert_eq!(sender, "alice");
assert_eq!(chat_id, "12345");
assert_eq!(message_id, 42);
}
#[test]
fn parse_voice_message_returns_none_for_text_message() {
let ch = TelegramChannel::new("token".into(), vec!["*".into()], None);
let update = serde_json::json!({
"update_id": 4,
"message": {
"message_id": 10,
"text": "hello",
"from": {
"id": 555,
"username": "alice"
},
"chat": {
"id": 12345
}
}
});
let result = ch.parse_voice_message(&update);
assert!(result.is_none());
}
// ── File sending API URL tests ──────────────────────────────────
#[test]
fn telegram_api_url_send_document() {
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
let ch = TelegramChannel::new("123:ABC".into(), vec![], None);
assert_eq!(
ch.api_url("sendDocument"),
"https://api.telegram.org/bot123:ABC/sendDocument"
@@ -1088,7 +1409,7 @@ mod tests {
#[test]
fn telegram_api_url_send_photo() {
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
let ch = TelegramChannel::new("123:ABC".into(), vec![], None);
assert_eq!(
ch.api_url("sendPhoto"),
"https://api.telegram.org/bot123:ABC/sendPhoto"
@@ -1097,7 +1418,7 @@ mod tests {
#[test]
fn telegram_api_url_send_video() {
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
let ch = TelegramChannel::new("123:ABC".into(), vec![], None);
assert_eq!(
ch.api_url("sendVideo"),
"https://api.telegram.org/bot123:ABC/sendVideo"
@@ -1106,7 +1427,7 @@ mod tests {
#[test]
fn telegram_api_url_send_audio() {
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
let ch = TelegramChannel::new("123:ABC".into(), vec![], None);
assert_eq!(
ch.api_url("sendAudio"),
"https://api.telegram.org/bot123:ABC/sendAudio"
@@ -1115,7 +1436,7 @@ mod tests {
#[test]
fn telegram_api_url_send_voice() {
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
let ch = TelegramChannel::new("123:ABC".into(), vec![], None);
assert_eq!(
ch.api_url("sendVoice"),
"https://api.telegram.org/bot123:ABC/sendVoice"
@@ -1127,7 +1448,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_document_bytes_builds_correct_form() {
// This test verifies the method doesn't panic and handles bytes correctly
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
let file_bytes = b"Hello, this is a test file content".to_vec();
// The actual API call will fail (no real server), but we verify the method exists
@@ -1142,13 +1463,14 @@ mod tests {
// Error should be network-related, not a code bug
assert!(
err.contains("error") || err.contains("failed") || err.contains("connect"),
"Expected network error, got: {err}"
"Expected network error, got: {}",
err
);
}
#[tokio::test]
async fn telegram_send_photo_bytes_builds_correct_form() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
// Minimal valid PNG header bytes
let file_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
@@ -1161,7 +1483,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_document_by_url_builds_correct_json() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
let result = ch
.send_document_by_url("123456", "https://example.com/file.pdf", Some("PDF doc"))
@@ -1172,7 +1494,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_photo_by_url_builds_correct_json() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
let result = ch
.send_photo_by_url("123456", "https://example.com/image.jpg", None)
@@ -1185,7 +1507,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_document_nonexistent_file() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
let path = Path::new("/nonexistent/path/to/file.txt");
let result = ch.send_document("123456", path, None).await;
@@ -1195,13 +1517,14 @@ mod tests {
// Should fail with file not found error
assert!(
err.contains("No such file") || err.contains("not found") || err.contains("os error"),
"Expected file not found error, got: {err}"
"Expected file not found error, got: {}",
err
);
}
#[tokio::test]
async fn telegram_send_photo_nonexistent_file() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
let path = Path::new("/nonexistent/path/to/photo.jpg");
let result = ch.send_photo("123456", path, None).await;
@@ -1211,7 +1534,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_video_nonexistent_file() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
let path = Path::new("/nonexistent/path/to/video.mp4");
let result = ch.send_video("123456", path, None).await;
@@ -1221,7 +1544,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_audio_nonexistent_file() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
let path = Path::new("/nonexistent/path/to/audio.mp3");
let result = ch.send_audio("123456", path, None).await;
@@ -1231,7 +1554,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_voice_nonexistent_file() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
let path = Path::new("/nonexistent/path/to/voice.ogg");
let result = ch.send_voice("123456", path, None).await;
@@ -1319,7 +1642,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_document_bytes_with_caption() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
let file_bytes = b"test content".to_vec();
// With caption
@@ -1337,7 +1660,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_photo_bytes_with_caption() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
let file_bytes = vec![0x89, 0x50, 0x4E, 0x47];
// With caption
@@ -1362,7 +1685,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_document_bytes_empty_file() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
let file_bytes: Vec<u8> = vec![];
let result = ch
@@ -1375,7 +1698,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_document_bytes_empty_filename() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
let file_bytes = b"content".to_vec();
let result = ch.send_document_bytes("123456", file_bytes, "", None).await;
@@ -1386,7 +1709,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_document_bytes_empty_chat_id() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
let file_bytes = b"content".to_vec();
let result = ch
@@ -1404,7 +1727,7 @@ mod tests {
// Verify that message IDs follow the format: telegram_{chat_id}_{message_id}
let chat_id = "123456";
let message_id = 789;
let expected_id = format!("telegram_{chat_id}_{message_id}");
let expected_id = format!("telegram_{}_{}", chat_id, message_id);
assert_eq!(expected_id, "telegram_123456_789");
}
@@ -1413,8 +1736,8 @@ mod tests {
// Same chat_id + same message_id = same ID (prevents duplicates after restart)
let chat_id = "123456";
let message_id = 789;
let id1 = format!("telegram_{chat_id}_{message_id}");
let id2 = format!("telegram_{chat_id}_{message_id}");
let id1 = format!("telegram_{}_{}", chat_id, message_id);
let id2 = format!("telegram_{}_{}", chat_id, message_id);
assert_eq!(id1, id2);
}
@@ -1422,8 +1745,8 @@ mod tests {
fn telegram_message_id_different_message_different_id() {
// Different message IDs produce different IDs
let chat_id = "123456";
let id1 = format!("telegram_{chat_id}_789");
let id2 = format!("telegram_{chat_id}_790");
let id1 = format!("telegram_{}_789", chat_id);
let id2 = format!("telegram_{}_790", chat_id);
assert_ne!(id1, id2);
}
@@ -1431,8 +1754,8 @@ mod tests {
fn telegram_message_id_different_chat_different_id() {
// Different chats produce different IDs even with same message_id
let message_id = 789;
let id1 = format!("telegram_123456_{message_id}");
let id2 = format!("telegram_789012_{message_id}");
let id1 = format!("telegram_123456_{}", message_id);
let id2 = format!("telegram_789012_{}", message_id);
assert_ne!(id1, id2);
}
@@ -1441,7 +1764,7 @@ mod tests {
// Verify format doesn't contain random UUID components
let chat_id = "123456";
let message_id = 789;
let id = format!("telegram_{chat_id}_{message_id}");
let id = format!("telegram_{}_{}", chat_id, message_id);
assert!(!id.contains('-')); // No UUID dashes
assert!(id.starts_with("telegram_"));
}
@@ -1451,7 +1774,7 @@ mod tests {
// Edge case: message_id can be 0 (fallback/missing case)
let chat_id = "123456";
let message_id = 0;
let id = format!("telegram_{chat_id}_{message_id}");
let id = format!("telegram_{}_{}", chat_id, message_id);
assert_eq!(id, "telegram_123456_0");
}
}

View File

@@ -92,6 +92,10 @@ pub struct Config {
/// Hardware configuration (wizard-driven physical world setup).
#[serde(default)]
pub hardware: HardwareConfig,
/// SkillForge auto-discovery configuration.
#[serde(default)]
pub skillforge: crate::skillforge::SkillForgeConfig,
}
// ── Delegate Agents ──────────────────────────────────────────────
@@ -1645,6 +1649,7 @@ impl Default for Config {
peripherals: PeripheralsConfig::default(),
agents: HashMap::new(),
hardware: HardwareConfig::default(),
skillforge: crate::skillforge::SkillForgeConfig::default(),
}
}
}

View File

@@ -231,7 +231,7 @@ async fn deliver_if_configured(config: &Config, job: &CronJob, output: &str) ->
.telegram
.as_ref()
.ok_or_else(|| anyhow::anyhow!("telegram channel not configured"))?;
let channel = TelegramChannel::new(tg.bot_token.clone(), tg.allowed_users.clone());
let channel = TelegramChannel::new(tg.bot_token.clone(), tg.allowed_users.clone(), Some(config.workspace_dir.clone()));
channel.send(output, target).await?;
}
"discord" => {

View File

@@ -709,7 +709,7 @@ async fn handle_whatsapp_message(
{
Ok(response) => {
// Send reply via WhatsApp
if let Err(e) = wa.send(&response, &msg.reply_to).await {
if let Err(e) = wa.send(&response, &msg.reply_target).await {
tracing::error!("Failed to send WhatsApp reply: {e}");
}
}
@@ -718,7 +718,7 @@ async fn handle_whatsapp_message(
let _ = wa
.send(
"Sorry, I couldn't process your message right now.",
&msg.reply_to,
&msg.reply_target,
)
.await;
}

View File

@@ -61,6 +61,7 @@ pub mod rag;
pub mod runtime;
pub mod security;
pub mod service;
pub mod skillforge;
pub mod skills;
pub mod tools;
pub mod tunnel;

View File

@@ -209,6 +209,12 @@ enum Commands {
skill_command: SkillCommands,
},
/// Auto-discover and integrate skills from GitHub/ClawHub
Skillforge {
#[command(subcommand)]
skillforge_command: skillforge::SkillforgeCommands,
},
/// Migrate data from other agent runtimes
Migrate {
#[command(subcommand)]
@@ -563,6 +569,10 @@ async fn main() -> Result<()> {
skills::handle_command(skill_command, &config.workspace_dir)
}
Commands::Skillforge { skillforge_command } => {
skillforge::handle_command(skillforge_command, &config).await
}
Commands::Migrate { migrate_command } => {
migration::handle_command(migrate_command, &config).await
}

View File

@@ -132,6 +132,7 @@ pub fn run_wizard() -> Result<Config> {
peripherals: crate::config::PeripheralsConfig::default(),
agents: std::collections::HashMap::new(),
hardware: hardware_config,
skillforge: crate::skillforge::SkillForgeConfig::default(),
};
println!(
@@ -346,6 +347,7 @@ pub fn run_quick_setup(
peripherals: crate::config::PeripheralsConfig::default(),
agents: std::collections::HashMap::new(),
hardware: crate::config::HardwareConfig::default(),
skillforge: crate::skillforge::SkillForgeConfig::default(),
};
config.save()?;

View File

@@ -894,6 +894,7 @@ mod tests {
make_provider("Groq", "https://api.groq.com/openai", None),
make_provider("Mistral", "https://api.mistral.ai", None),
make_provider("xAI", "https://api.x.ai", None),
make_provider("Astrai", "https://as-trai.com/v1", None),
];
for p in providers {

View File

@@ -135,9 +135,11 @@ fn resolve_provider_credential(name: &str, credential_override: Option<&str>) ->
"zai" | "z.ai" => vec!["ZAI_API_KEY"],
"nvidia" | "nvidia-nim" | "build.nvidia.com" => vec!["NVIDIA_API_KEY"],
"synthetic" => vec!["SYNTHETIC_API_KEY"],
"nanogpt" | "nano-gpt" => vec!["NANO_GPT_API_KEY"],
"opencode" | "opencode-zen" => vec!["OPENCODE_API_KEY"],
"vercel" | "vercel-ai" => vec!["VERCEL_API_KEY"],
"cloudflare" | "cloudflare-ai" => vec!["CLOUDFLARE_API_KEY"],
"astrai" => vec!["ASTRAI_API_KEY"],
_ => vec![],
};
@@ -245,6 +247,12 @@ pub fn create_provider_with_url(
key,
AuthStyle::Bearer,
))),
"nanogpt" | "nano-gpt" => Ok(Box::new(OpenAiCompatibleProvider::new(
"NanoGPT",
"https://nano-gpt.com/api/v1",
key,
AuthStyle::Bearer,
))),
"bedrock" | "aws-bedrock" => Ok(Box::new(OpenAiCompatibleProvider::new(
"Amazon Bedrock",
"https://bedrock-runtime.us-east-1.amazonaws.com",
@@ -313,6 +321,11 @@ pub fn create_provider_with_url(
),
)),
// ── AI inference routers ─────────────────────────────
"astrai" => Ok(Box::new(OpenAiCompatibleProvider::new(
"Astrai", "https://as-trai.com/v1", key, AuthStyle::Bearer,
))),
// ── Bring Your Own Provider (custom URL) ───────────
// Format: "custom:https://your-api.com" or "custom:http://localhost:1234"
name if name.starts_with("custom:") => {
@@ -651,6 +664,13 @@ mod tests {
assert!(create_provider("build.nvidia.com", Some("nvapi-test")).is_ok());
}
// ── AI inference routers ─────────────────────────────────
#[test]
fn factory_astrai() {
assert!(create_provider("astrai", Some("sk-astrai-test")).is_ok());
}
// ── Custom / BYOP provider ─────────────────────────────
#[test]

View File

@@ -343,6 +343,7 @@ impl SecurityPolicy {
/// validates each sub-command against the allowlist
/// - Blocks single `&` background chaining (`&&` remains supported)
/// - Blocks output redirections (`>`, `>>`) that could write outside workspace
/// - Blocks dangerous arguments (e.g. `find -exec`, `git config`)
pub fn is_command_allowed(&self, command: &str) -> bool {
if self.autonomy == AutonomyLevel::ReadOnly {
return false;
@@ -398,13 +399,9 @@ impl SecurityPolicy {
// Strip leading env var assignments (e.g. FOO=bar cmd)
let cmd_part = skip_env_assignments(segment);
let base_cmd = cmd_part
.split_whitespace()
.next()
.unwrap_or("")
.rsplit('/')
.next()
.unwrap_or("");
let mut words = cmd_part.split_whitespace();
let base_raw = words.next().unwrap_or("");
let base_cmd = base_raw.rsplit('/').next().unwrap_or("");
if base_cmd.is_empty() {
continue;
@@ -417,6 +414,12 @@ impl SecurityPolicy {
{
return false;
}
// Validate arguments for the command
let args: Vec<String> = words.map(|w| w.to_ascii_lowercase()).collect();
if !self.is_args_safe(base_cmd, &args) {
return false;
}
}
// At least one command must be present
@@ -428,6 +431,29 @@ impl SecurityPolicy {
has_cmd
}
/// Check for dangerous arguments that allow sub-command execution.
fn is_args_safe(&self, base: &str, args: &[String]) -> bool {
let base = base.to_ascii_lowercase();
match base.as_str() {
"find" => {
// find -exec and find -ok allow arbitrary command execution
!args.iter().any(|arg| arg == "-exec" || arg == "-ok")
}
"git" => {
// git config, alias, and -c can be used to set dangerous options
// (e.g. git config core.editor "rm -rf /")
!args.iter().any(|arg| {
arg == "config"
|| arg.starts_with("config.")
|| arg == "alias"
|| arg.starts_with("alias.")
|| arg == "-c"
})
}
_ => true,
}
}
/// Check if a file path is allowed (no path traversal, within workspace)
pub fn is_path_allowed(&self, path: &str) -> bool {
// Block null bytes (can truncate paths in C-backed syscalls)
@@ -996,6 +1022,22 @@ mod tests {
assert!(!p.is_command_allowed("ls >> /tmp/exfil.txt"));
}
#[test]
fn command_argument_injection_blocked() {
let p = default_policy();
// find -exec is a common bypass
assert!(!p.is_command_allowed("find . -exec rm -rf {} +"));
assert!(!p.is_command_allowed("find / -ok cat {} \\;"));
// git config/alias can execute commands
assert!(!p.is_command_allowed("git config core.editor \"rm -rf /\""));
assert!(!p.is_command_allowed("git alias.st status"));
assert!(!p.is_command_allowed("git -c core.editor=calc.exe commit"));
// Legitimate commands should still work
assert!(p.is_command_allowed("find . -name '*.txt'"));
assert!(p.is_command_allowed("git status"));
assert!(p.is_command_allowed("git add ."));
}
#[test]
fn command_injection_dollar_brace_blocked() {
let p = default_policy();

View File

@@ -16,6 +16,136 @@ use self::evaluate::{EvalResult, Evaluator, Recommendation};
use self::integrate::Integrator;
use self::scout::{GitHubScout, Scout, ScoutResult, ScoutSource};
use crate::config::Config;
#[derive(Debug, Clone, clap::Subcommand)]
pub enum SkillforgeCommands {
Run {
#[arg(long, default_value = "0.7")]
min_score: f64,
#[arg(long)]
dry_run: bool,
#[arg(long)]
source: Option<String>,
},
Scout {
#[arg(long, default_value = "zeroclaw skill")]
query: String,
#[arg(long, default_value = "20")]
limit: usize,
},
Status,
}
pub async fn handle_command(command: SkillforgeCommands, config: &Config) -> Result<()> {
let _forge_config = config.skillforge.clone();
match command {
SkillforgeCommands::Run { min_score, dry_run, source } => {
run_forge(config, min_score, dry_run, source).await
}
SkillforgeCommands::Scout { query, limit } => {
run_scout(config, query, limit).await
}
SkillforgeCommands::Status => {
show_status(config)
}
}
}
async fn run_forge(config: &Config, min_score: f64, dry_run: bool, source: Option<String>) -> Result<()> {
println!("🔮 SkillForge Pipeline");
println!(" Min Score: {}", min_score);
println!(" Dry Run: {}", dry_run);
println!(" Source: {}", source.as_deref().unwrap_or("all"));
println!();
let mut forge_config = config.skillforge.clone();
forge_config.enabled = true;
forge_config.min_score = min_score;
forge_config.auto_integrate = !dry_run;
if let Some(src) = source {
forge_config.sources = vec![src];
}
let forge = SkillForge::new(forge_config);
let report = forge.forge().await?;
println!("📊 Results:");
println!(" Discovered: {}", report.discovered);
println!(" Evaluated: {}", report.evaluated);
println!(" Auto-integrated: {}", report.auto_integrated);
println!(" Manual review: {}", report.manual_review);
println!(" Skipped: {}", report.skipped);
println!();
if !report.results.is_empty() {
println!("📋 Top Candidates:");
for (i, res) in report.results.iter().take(10).enumerate() {
let status = match res.recommendation {
Recommendation::Auto => "✅ AUTO",
Recommendation::Manual => "⚠️ REVIEW",
Recommendation::Skip => "❌ SKIP",
};
println!(" {}. {} [{:.2}] {}", i + 1, res.candidate.name, res.total_score, status);
println!(" {}", res.candidate.description.chars().take(60).collect::<String>());
}
}
Ok(())
}
async fn run_scout(config: &Config, query: String, limit: usize) -> Result<()> {
println!("🔍 SkillForge Scout");
println!(" Query: {}", query);
println!(" Limit: {}", limit);
println!();
let github_token = config.skillforge.github_token.clone();
let scout = GitHubScout::new_with_query(github_token, query.clone());
let results = scout.discover().await?;
let limited: Vec<_> = results.into_iter().take(limit).collect();
println!("📋 Found {} candidates:", limited.len());
for (i, candidate) in limited.iter().enumerate() {
println!(" {}. {}", i + 1, candidate.name);
println!(" URL: {}", candidate.url);
println!("{} stars", candidate.stars);
let desc: String = candidate.description.chars().take(60).collect();
if !desc.is_empty() {
println!(" {}", desc);
}
}
Ok(())
}
fn show_status(config: &Config) -> Result<()> {
println!("🔮 SkillForge Status");
println!();
println!("Configuration:");
println!(" Enabled: {}", config.skillforge.enabled);
println!(" Auto-integrate: {}", config.skillforge.auto_integrate);
println!(" Sources: {:?}", config.skillforge.sources);
println!(" Scan interval: {}h", config.skillforge.scan_interval_hours);
println!(" Min score: {:.2}", config.skillforge.min_score);
println!(" Output directory: {}", config.skillforge.output_dir);
println!(" GitHub token: {}", if config.skillforge.github_token.is_some() { "configured" } else { "not set" });
let skills_dir = std::path::Path::new(&config.skillforge.output_dir);
if skills_dir.exists() {
let count = std::fs::read_dir(skills_dir)
.map(|entries| entries.filter_map(|e| e.ok()).count())
.unwrap_or(0);
println!();
println!("Integrated skills: {}", count);
}
Ok(())
}
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------

View File

@@ -103,6 +103,36 @@ impl GitHubScout {
}
}
pub fn new_with_query(token: Option<String>, query: String) -> Self {
use std::time::Duration;
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::ACCEPT,
"application/vnd.github+json".parse().expect("valid header"),
);
headers.insert(
reqwest::header::USER_AGENT,
"ZeroClaw-SkillForge/0.1".parse().expect("valid header"),
);
if let Some(ref t) = token {
if let Ok(val) = format!("Bearer {t}").parse() {
headers.insert(reqwest::header::AUTHORIZATION, val);
}
}
let client = reqwest::Client::builder()
.default_headers(headers)
.timeout(Duration::from_secs(30))
.build()
.expect("failed to build reqwest client");
Self {
client,
queries: vec![query],
}
}
/// Parse the GitHub search/repositories JSON response.
fn parse_items(body: &serde_json::Value) -> Vec<ScoutResult> {
let items = match body.get("items").and_then(|v| v.as_array()) {

View File

@@ -749,4 +749,54 @@ mod tests {
let _ = HttpRequestTool::redact_headers_for_display(&headers);
assert_eq!(headers[0].1, "Bearer real-token");
}
// ── SSRF: alternate IP notation bypass defense-in-depth ─────────
//
// Rust's IpAddr::parse() rejects non-standard notations (octal, hex,
// decimal integer, zero-padded). These tests document that property
// so regressions are caught if the parsing strategy ever changes.
#[test]
fn ssrf_octal_loopback_not_parsed_as_ip() {
// 0177.0.0.1 is octal for 127.0.0.1 in some languages, but
// Rust's IpAddr rejects it — it falls through as a hostname.
assert!(!is_private_or_local_host("0177.0.0.1"));
}
#[test]
fn ssrf_hex_loopback_not_parsed_as_ip() {
// 0x7f000001 is hex for 127.0.0.1 in some languages.
assert!(!is_private_or_local_host("0x7f000001"));
}
#[test]
fn ssrf_decimal_loopback_not_parsed_as_ip() {
// 2130706433 is decimal for 127.0.0.1 in some languages.
assert!(!is_private_or_local_host("2130706433"));
}
#[test]
fn ssrf_zero_padded_loopback_not_parsed_as_ip() {
// 127.000.000.001 uses zero-padded octets.
assert!(!is_private_or_local_host("127.000.000.001"));
}
#[test]
fn ssrf_alternate_notations_rejected_by_validate_url() {
// Even if is_private_or_local_host doesn't flag these, they
// fail the allowlist because they're treated as hostnames.
let tool = test_tool(vec!["example.com"]);
for notation in [
"http://0177.0.0.1",
"http://0x7f000001",
"http://2130706433",
"http://127.000.000.001",
] {
let err = tool.validate_url(notation).unwrap_err().to_string();
assert!(
err.contains("allowed_domains"),
"Expected allowlist rejection for {notation}, got: {err}"
);
}
}
}

View File

@@ -25,6 +25,7 @@ pub mod schema;
pub mod screenshot;
pub mod shell;
pub mod traits;
pub mod transcribe;
pub use browser::{BrowserTool, ComputerUseConfig};
pub use browser_open::BrowserOpenTool;
@@ -53,6 +54,7 @@ pub use schema::{CleaningStrategy, SchemaCleanr};
pub use screenshot::ScreenshotTool;
pub use shell::ShellTool;
pub use traits::Tool;
pub use transcribe::TranscribeTool;
#[allow(unused_imports)]
pub use traits::{ToolResult, ToolSpec};
@@ -191,6 +193,8 @@ pub fn all_tools_with_runtime(
tools.push(Box::new(ScreenshotTool::new(security.clone())));
tools.push(Box::new(ImageInfoTool::new(security.clone())));
tools.push(Box::new(TranscribeTool::new(security.clone(), None, None)));
if let Some(key) = composio_key {
if !key.is_empty() {
tools.push(Box::new(ComposioTool::new(key, composio_entity_id)));

283
src/tools/transcribe.rs Normal file
View File

@@ -0,0 +1,283 @@
use super::traits::{Tool, ToolResult};
use crate::security::SecurityPolicy;
use async_trait::async_trait;
use serde_json::json;
use std::path::Path;
use std::sync::Arc;
use tokio::process::Command;
const MAX_AUDIO_BYTES: u64 = 104_857_600;
const SUPPORTED_FORMATS: &[&str] = &["mp3", "wav", "m4a", "flac", "ogg", "webm", "mp4", "mpeg", "mpga"];
pub struct TranscribeTool {
security: Arc<SecurityPolicy>,
model: String,
device: String,
}
impl TranscribeTool {
pub fn new(security: Arc<SecurityPolicy>, model: Option<String>, device: Option<String>) -> Self {
Self {
security,
model: model.unwrap_or_else(|| "base".to_string()),
device: device.unwrap_or_else(|| "cpu".to_string()),
}
}
fn is_supported_format(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| SUPPORTED_FORMATS.contains(&ext.to_lowercase().as_str()))
.unwrap_or(false)
}
fn transcription_script() -> &'static str {
r#"
import sys
import json
def transcribe(audio_path, model_size, device):
from faster_whisper import WhisperModel
model = WhisperModel(model_size, device=device, compute_type="int8")
segments, info = model.transcribe(audio_path, beam_size=5)
transcription = []
for segment in segments:
transcription.append({
"start": round(segment.start, 2),
"end": round(segment.end, 2),
"text": segment.text.strip()
})
result = {
"language": info.language,
"language_probability": round(info.language_probability, 2),
"duration": round(info.duration, 2),
"segments": transcription,
"text": " ".join(s["text"] for s in transcription)
}
print(json.dumps(result))
if __name__ == "__main__":
if len(sys.argv) != 4:
print(json.dumps({"error": "Usage: script.py <audio_path> <model> <device>"}))
sys.exit(1)
transcribe(sys.argv[1], sys.argv[2], sys.argv[3])
"#
}
}
#[async_trait]
impl Tool for TranscribeTool {
fn name(&self) -> &str {
"transcribe"
}
fn description(&self) -> &str {
"Transcribe audio files to text using faster-whisper. \
Supports mp3, wav, m4a, flac, ogg, webm, and other common audio formats. \
Returns the transcription with timestamps and detected language."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"path": {
"type": "string",
"description": "Path to the audio file to transcribe"
},
"model": {
"type": "string",
"enum": ["tiny", "base", "small", "medium", "large-v2", "large-v3"],
"description": "Whisper model size (default: base). Larger models are more accurate but slower."
},
"language": {
"type": "string",
"description": "Hint for the spoken language (e.g., 'en', 'es', 'zh'). Optional."
}
},
"required": ["path"]
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let path_str = args
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'path' parameter"))?;
if self.security.is_rate_limited() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("Rate limit exceeded: too many actions in the last hour".into()),
});
}
if !self.security.is_path_allowed(path_str) {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Path not allowed by security policy: {}", path_str)),
});
}
if !self.security.record_action() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("Rate limit exceeded: action budget exhausted".into()),
});
}
let path = Path::new(path_str);
let full_path = self.security.workspace_dir.join(path);
if !full_path.exists() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("File not found: {}", path_str)),
});
}
let metadata = match std::fs::metadata(&full_path) {
Ok(m) => m,
Err(e) => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Cannot read file metadata: {}", e)),
});
}
};
if metadata.len() > MAX_AUDIO_BYTES {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"File too large: {} bytes (max: {} bytes)",
metadata.len(),
MAX_AUDIO_BYTES
)),
});
}
if !Self::is_supported_format(&full_path) {
let ext = full_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("unknown");
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Unsupported audio format: {}. Supported: {}",
ext,
SUPPORTED_FORMATS.join(", ")
)),
});
}
let model = args
.get("model")
.and_then(|v| v.as_str())
.unwrap_or(&self.model);
let script = Self::transcription_script();
let output = Command::new("python3")
.arg("-c")
.arg(script)
.arg(&full_path)
.arg(model)
.arg(&self.device)
.output()
.await;
match output {
Ok(result) => {
if result.status.success() {
let stdout = String::from_utf8_lossy(&result.stdout);
match serde_json::from_str::<serde_json::Value>(&stdout) {
Ok(json) => {
let text = json
.get("text")
.and_then(|t| t.as_str())
.unwrap_or(&stdout);
let language = json
.get("language")
.and_then(|l| l.as_str())
.unwrap_or("unknown");
let duration = json
.get("duration")
.and_then(|d| d.as_f64())
.unwrap_or(0.0);
let duration_f = duration;
let mut out = format!(
"**Transcription** ({:.1}, language: {})\n\n{}\n",
duration_f, language, text
);
if let Some(segments) = json.get("segments").and_then(|s| s.as_array())
{
if segments.len() > 1 {
out.push_str("\n**Segments:**\n");
for seg in segments.iter().take(20) {
if let (Some(start), Some(end), Some(seg_text)) = (
seg.get("start").and_then(|v| v.as_f64()),
seg.get("end").and_then(|v| v.as_f64()),
seg.get("text").and_then(|v| v.as_str()),
) {
let start_f = start;
let end_f = end;
out.push_str(&format!(
"[{:05.1} - {:05.1}] {}\n",
start_f, end_f, seg_text
));
}
}
if segments.len() > 20 {
out.push_str(&format!(
"... and {} more segments\n",
segments.len() - 20
));
}
}
}
Ok(ToolResult {
success: true,
output: out,
error: None,
})
}
Err(_) => Ok(ToolResult {
success: true,
output: stdout.to_string(),
error: None,
}),
}
} else {
let stderr = String::from_utf8_lossy(&result.stderr);
Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Transcription failed: {}", stderr.trim())),
})
}
}
Err(e) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Failed to run transcription: {}", e)),
}),
}
}
}