mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8901baafd | ||
|
|
4003300a8c | ||
|
|
c68dc2ff2a | ||
|
|
d4785ce4d2 | ||
|
|
2544df1c4a | ||
|
|
82f24bf08f | ||
|
|
510fba4c92 | ||
|
|
04d3b005b1 | ||
|
|
ea57447649 | ||
|
|
a320f265b3 | ||
|
|
c3ce26278a | ||
|
|
91b602790a | ||
|
|
e5ce076773 | ||
|
|
c1f3b83c98 | ||
|
|
37c0158765 | ||
|
|
0a30c95ee1 | ||
|
|
b3bf50f10e | ||
|
|
48b5323ec9 | ||
|
|
3124ab2b7f | ||
|
|
dbd3e0807f | ||
|
|
436066415b | ||
|
|
3d4c647216 | ||
|
|
b68d67bd35 | ||
|
|
493e4578d0 | ||
|
|
250551799b | ||
|
|
c038c7705b | ||
|
|
98ee648fcb | ||
|
|
2cdd1acb1e | ||
|
|
542268fde5 | ||
|
|
3b6105d5ea | ||
|
|
df8616b604 | ||
|
|
e8dcb52fda | ||
|
|
1f18422b88 | ||
|
|
448383cfb0 | ||
|
|
7df356c109 | ||
|
|
3829d81269 | ||
|
|
8a4f3b6f88 | ||
|
|
140f29decf | ||
|
|
5725a62c83 | ||
|
|
bfe393eb38 | ||
|
|
9906190de7 | ||
|
|
9349a3baca | ||
|
|
3f135bdde9 | ||
|
|
97a7637f30 | ||
|
|
dae26d640e | ||
|
|
fa64df05ff | ||
|
|
356f56f77c | ||
|
|
17434d6499 | ||
|
|
3f58ed6232 | ||
|
|
097a26ace6 | ||
|
|
e87d7bd066 | ||
|
|
e42b1e5ec1 | ||
|
|
ccf60055f4 | ||
|
|
89fdd81420 | ||
|
|
fd46cbd30d | ||
|
|
8dbb0996da | ||
|
|
ae714b5003 | ||
|
|
5416866bcf | ||
|
|
c18f6730f8 | ||
|
|
479ca888a2 | ||
|
|
5c9546602b | ||
|
|
ffb1cc9be8 | ||
|
|
6330f1b27a | ||
|
|
9e6e1471ab | ||
|
|
2d3eb4de9a | ||
|
|
913073d83d | ||
|
|
d46ab3a1d7 | ||
|
|
05cb01816b | ||
|
|
750a94030b | ||
|
|
c3340c60ef | ||
|
|
3669a7b1cd | ||
|
|
96d5fc0d39 | ||
|
|
c1926c83d9 | ||
|
|
a1b0e34b3b | ||
|
|
cfb579a4bb | ||
|
|
bac2d75713 | ||
|
|
8e6e84a08d | ||
|
|
a158eee1b0 | ||
|
|
436dda0f2f | ||
|
|
c1ca3bb91c | ||
|
|
e499795b8c | ||
|
|
5e1da4827a | ||
|
|
d04af5cd75 | ||
|
|
956037c4d3 | ||
|
|
68a1851c19 | ||
|
|
dfa105539b | ||
|
|
8929baf76a | ||
|
|
e07dfab449 | ||
|
|
6783cba4e4 | ||
|
|
63302ab406 | ||
|
|
7c553b0973 | ||
|
|
5e44185e48 | ||
|
|
72623c9e5b | ||
|
|
6895adbcc9 | ||
|
|
f1480f471b | ||
|
|
9db949746f | ||
|
|
61a123a746 | ||
|
|
0e981429ee | ||
|
|
1b38a64e15 | ||
|
|
2e5f8b60d5 | ||
|
|
f0a0642e7d | ||
|
|
ca8d5c6b5e | ||
|
|
9fed8453c7 | ||
|
|
eaef335db6 | ||
|
|
225af29db2 | ||
|
|
a53b2c10b5 | ||
|
|
408ae8a29a | ||
|
|
d9ff86d7e0 | ||
|
|
e843c18141 | ||
|
|
54e9206f0b | ||
|
|
5df0d13b59 | ||
|
|
bbb68f7490 | ||
|
|
b3dee13954 | ||
|
|
33ef0a6ea5 | ||
|
|
e0a43c81f9 | ||
|
|
bada79ba4a | ||
|
|
a70c89d9e3 | ||
|
|
247445f819 | ||
|
|
14254a699f | ||
|
|
2039442885 | ||
|
|
e796b838fa | ||
|
|
54ce7434fb | ||
|
|
e9e0374c85 | ||
|
|
5582a0dfbf | ||
|
|
1cf08a4b42 | ||
|
|
d55b302b39 | ||
|
|
517be42ccc | ||
|
|
09198c68ab | ||
|
|
115b7f38fe | ||
|
|
bb228f6315 | ||
|
|
45f547c711 | ||
|
|
23de75d75b | ||
|
|
ced83d5b4d | ||
|
|
810ba58fd2 | ||
|
|
202665a55c | ||
|
|
a35db4d32d | ||
|
|
e6725eb6d9 | ||
|
|
91e27b7395 | ||
|
|
642c320b13 | ||
|
|
6831a54793 | ||
|
|
6bcc168ec5 | ||
|
|
bf3b8b339f | ||
|
|
2cdd04a359 | ||
|
|
9b729795fb | ||
|
|
a351711312 | ||
|
|
f34a80191e | ||
|
|
9d156411fc | ||
|
|
8439293df3 | ||
|
|
8be390afab | ||
|
|
4d0fe7d37e | ||
|
|
cb987321a9 | ||
|
|
a93c7ed893 | ||
|
|
3c54e692a5 | ||
|
|
4f8fd4ad5f | ||
|
|
7fcc2279cc | ||
|
|
0ca05e3de3 |
@@ -0,0 +1,95 @@
|
||||
---
|
||||
description: Scaffold a new SSE event end-to-end (Rust backend to web frontend)
|
||||
allowed-tools: Read, Edit, Write, Glob, Grep, Bash(cargo fmt:*), Bash(cargo clippy:*), Bash(cargo test:*)
|
||||
argument-hint: <event_name> [description]
|
||||
model: opus
|
||||
---
|
||||
|
||||
Add a new SSE event called `$ARGUMENTS` to the IronClaw web gateway. This involves changes across 5 files in a specific order. Follow each step exactly.
|
||||
|
||||
## Step 1: Add `StatusUpdate` variant
|
||||
|
||||
**File**: `src/channels/channel.rs`
|
||||
|
||||
Find the `StatusUpdate` enum and add a new variant. Use the event name in PascalCase. Include any fields the event needs as named fields (not a generic String).
|
||||
|
||||
Example for reference (existing variants):
|
||||
```rust
|
||||
pub enum StatusUpdate {
|
||||
Thinking(String),
|
||||
ToolStarted { name: String },
|
||||
ToolCompleted { name: String, success: bool },
|
||||
Status(String),
|
||||
ApprovalNeeded {
|
||||
request_id: String,
|
||||
tool_name: String,
|
||||
description: String,
|
||||
parameters: serde_json::Value,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Step 2: Map to `SseEvent` in web channel
|
||||
|
||||
**File**: `src/channels/web/mod.rs`
|
||||
|
||||
Find the `send_status` method in the `Channel` impl for `WebChannel`. Add a match arm for the new `StatusUpdate` variant that maps it to an `SseEvent`. The SSE event name should be snake_case.
|
||||
|
||||
Look at existing match arms for the pattern. The event data is serialized as JSON.
|
||||
|
||||
## Step 3: Add types if needed
|
||||
|
||||
**File**: `src/channels/web/types.rs`
|
||||
|
||||
If the event carries structured data beyond a simple string, add a serializable DTO struct here. Use `#[derive(Debug, Clone, Serialize, Deserialize)]`. Follow the existing patterns in the file.
|
||||
|
||||
## Step 4: Add frontend handler
|
||||
|
||||
**File**: `src/channels/web/static/app.js`
|
||||
|
||||
In the `connectSSE()` function, add a new `eventSource.addEventListener()` for the snake_case event name. Parse the JSON data and call a handler function.
|
||||
|
||||
Create the handler function that updates the DOM. Follow existing patterns:
|
||||
- `showApproval(data)` for complex card-style UI
|
||||
- `addMessage(role, content)` for simple text
|
||||
- `setStatus(text, spinning)` for status bar updates
|
||||
|
||||
## Step 5: Add CSS if needed
|
||||
|
||||
**File**: `src/channels/web/static/style.css`
|
||||
|
||||
If the event needs custom UI (cards, badges, etc.), add styles. Follow the existing naming conventions (`.approval-card`, `.log-entry`, etc.).
|
||||
|
||||
## Step 6: Send the event from Rust
|
||||
|
||||
Identify where in the backend this event should be triggered. Common locations:
|
||||
- `src/agent/agent_loop.rs` - During message processing or tool execution
|
||||
- `src/agent/worker.rs` - During job execution
|
||||
- `src/agent/heartbeat.rs` - During periodic execution
|
||||
|
||||
Use the existing pattern:
|
||||
```rust
|
||||
let _ = self.channels.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::YourNewVariant { ... },
|
||||
&message.metadata,
|
||||
).await;
|
||||
```
|
||||
|
||||
## Step 7: Quality gate
|
||||
|
||||
Run `cargo fmt` and `cargo clippy --all --benches --tests --examples --all-features` to verify the changes compile cleanly.
|
||||
|
||||
## Checklist
|
||||
|
||||
Before finishing, verify:
|
||||
- [ ] `StatusUpdate` variant added in `channel.rs`
|
||||
- [ ] Match arm added in `web/mod.rs` `send_status`
|
||||
- [ ] DTO added in `types.rs` (if needed)
|
||||
- [ ] `addEventListener` added in `app.js`
|
||||
- [ ] Handler function created in `app.js`
|
||||
- [ ] CSS styles added (if needed)
|
||||
- [ ] Event sent from appropriate backend location
|
||||
- [ ] `cargo fmt` clean
|
||||
- [ ] `cargo clippy` clean
|
||||
- [ ] Non-web channels unaffected (they ignore unknown StatusUpdate variants)
|
||||
@@ -0,0 +1,382 @@
|
||||
---
|
||||
description: Scaffold a new tool (WASM or built-in Rust) with all boilerplate wired up
|
||||
allowed-tools: Read, Edit, Write, Glob, Grep, Bash(cargo fmt:*), Bash(cargo clippy:*), Bash(cargo test:*), Bash(cargo component:*), Bash(ls:*), Bash(mkdir:*)
|
||||
argument-hint: <tool_name> [description]
|
||||
model: opus
|
||||
---
|
||||
|
||||
Scaffold a new tool called `$ARGUMENTS` for the IronClaw agent. First, determine the tool type and then follow the appropriate path.
|
||||
|
||||
## Step 0: Determine tool type
|
||||
|
||||
Ask the user which type of tool to create:
|
||||
|
||||
- **WASM tool** (recommended) - Sandboxed, dynamically loadable, external API integrations. Lives in `tools-src/<name>/`. This is the right choice for anything that talks to an external service (Notion, GitHub, Discord, etc.).
|
||||
- **Built-in tool** - Compiled into the main binary. Only for core agent infrastructure (e.g., memory, file ops, shell). Lives in `src/tools/builtin/<name>.rs`.
|
||||
|
||||
If the description clearly implies an external service integration, default to WASM. If it's a core agent capability, default to built-in.
|
||||
|
||||
---
|
||||
|
||||
## Path A: WASM Tool
|
||||
|
||||
### A1: Create directory structure
|
||||
|
||||
Create `tools-src/<name>/` with:
|
||||
|
||||
```
|
||||
tools-src/<name>/
|
||||
├── Cargo.toml
|
||||
├── <name>-tool.capabilities.json
|
||||
└── src/
|
||||
├── lib.rs
|
||||
├── types.rs
|
||||
└── api.rs
|
||||
```
|
||||
|
||||
### A2: Write `Cargo.toml`
|
||||
|
||||
Follow this exact pattern (adjust name and description):
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "<name>-tool"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "<Description> tool for IronClaw (WASM component)"
|
||||
license = "MIT OR Apache-2.0"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "=0.36"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
```
|
||||
|
||||
### A3: Write `<name>-tool.capabilities.json`
|
||||
|
||||
Declare the tool's security requirements. Determine what APIs it needs and create the allowlist. Reference `tools-src/slack/slack-tool.capabilities.json` for the format.
|
||||
|
||||
Key sections to include:
|
||||
- `http.allowlist` - API endpoints (host, path_prefix, methods)
|
||||
- `http.credentials` - Secret injection config (secret_name, location type: bearer/header/query)
|
||||
- `http.rate_limit` - requests_per_minute, requests_per_hour
|
||||
- `http.timeout_secs`
|
||||
- `secrets.allowed_names` - Which secrets the tool can check existence of
|
||||
- `auth` - Authentication setup (OAuth or manual token entry)
|
||||
|
||||
If the tool needs OAuth, include:
|
||||
```json
|
||||
{
|
||||
"auth": {
|
||||
"secret_name": "<service>_token",
|
||||
"display_name": "<Service>",
|
||||
"oauth": {
|
||||
"authorization_url": "https://...",
|
||||
"token_url": "https://...",
|
||||
"client_id_env": "<SERVICE>_OAUTH_CLIENT_ID",
|
||||
"client_secret_env": "<SERVICE>_OAUTH_CLIENT_SECRET",
|
||||
"scopes": [],
|
||||
"use_pkce": false
|
||||
},
|
||||
"env_var": "<SERVICE>_TOKEN"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If no OAuth, include manual setup instructions:
|
||||
```json
|
||||
{
|
||||
"auth": {
|
||||
"secret_name": "<service>_api_key",
|
||||
"display_name": "<Service>",
|
||||
"instructions": "Get your API key from <url>",
|
||||
"setup_url": "https://...",
|
||||
"token_hint": "Starts with '<prefix>'",
|
||||
"env_var": "<SERVICE>_API_KEY"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### A4: Write `src/types.rs`
|
||||
|
||||
Define the action enum using serde's tagged enum pattern:
|
||||
|
||||
```rust
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
pub enum <Name>Action {
|
||||
// Add variants based on the tool's capabilities.
|
||||
// Each variant maps to one API operation.
|
||||
}
|
||||
```
|
||||
|
||||
Add result structs with `#[derive(Debug, Serialize)]`. Use `#[serde(skip_serializing_if = "Option::is_none")]` for optional fields.
|
||||
|
||||
### A5: Write `src/api.rs`
|
||||
|
||||
Implement the API calls using the host HTTP capability:
|
||||
|
||||
```rust
|
||||
use crate::near::agent::host;
|
||||
use crate::types::*;
|
||||
|
||||
const API_BASE: &str = "https://api.example.com";
|
||||
|
||||
fn api_call(method: &str, endpoint: &str, body: Option<&str>) -> Result<String, String> {
|
||||
let url = format!("{}/{}", API_BASE, endpoint);
|
||||
let headers = if body.is_some() {
|
||||
r#"{"Content-Type": "application/json"}"#
|
||||
} else {
|
||||
"{}"
|
||||
};
|
||||
let body_bytes = body.map(|b| b.as_bytes().to_vec());
|
||||
|
||||
host::log(host::LogLevel::Debug, &format!("API: {} {}", method, endpoint));
|
||||
|
||||
let response = host::http_request(method, &url, headers, body_bytes.as_deref())?;
|
||||
|
||||
if response.status < 200 || response.status >= 300 {
|
||||
return Err(format!(
|
||||
"API returned status {}: {}",
|
||||
response.status,
|
||||
String::from_utf8_lossy(&response.body)
|
||||
));
|
||||
}
|
||||
|
||||
String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8: {}", e))
|
||||
}
|
||||
```
|
||||
|
||||
Add one function per action variant that calls `api_call` and parses the response into the result structs.
|
||||
|
||||
### A6: Write `src/lib.rs`
|
||||
|
||||
Wire everything together:
|
||||
|
||||
```rust
|
||||
mod api;
|
||||
mod types;
|
||||
|
||||
use types::<Name>Action;
|
||||
|
||||
wit_bindgen::generate!({
|
||||
world: "sandboxed-tool",
|
||||
path: "../../wit/tool.wit",
|
||||
});
|
||||
|
||||
struct <Name>Tool;
|
||||
|
||||
impl exports::near::agent::tool::Guest for <Name>Tool {
|
||||
fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response {
|
||||
match execute_inner(&req.params) {
|
||||
Ok(result) => exports::near::agent::tool::Response {
|
||||
output: Some(result),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => exports::near::agent::tool::Response {
|
||||
output: None,
|
||||
error: Some(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn schema() -> String {
|
||||
// Return JSON Schema matching the action enum
|
||||
todo!("Fill in JSON Schema")
|
||||
}
|
||||
|
||||
fn description() -> String {
|
||||
"<Description>".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_inner(params: &str) -> Result<String, String> {
|
||||
// Check required secrets
|
||||
if !crate::near::agent::host::secret_exists("<secret_name>") {
|
||||
return Err("<Secret> not configured. Please add the '<secret_name>' secret.".to_string());
|
||||
}
|
||||
|
||||
let action: <Name>Action =
|
||||
serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {}", e))?;
|
||||
|
||||
crate::near::agent::host::log(
|
||||
crate::near::agent::host::LogLevel::Info,
|
||||
&format!("Executing action: {:?}", action),
|
||||
);
|
||||
|
||||
let result = match action {
|
||||
// Dispatch to api:: functions for each variant
|
||||
};
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
export!(<Name>Tool);
|
||||
```
|
||||
|
||||
Fill in the `schema()` with a proper JSON Schema using `oneOf` for each action variant. Reference `tools-src/slack/src/lib.rs` for the exact pattern.
|
||||
|
||||
### A7: Verify
|
||||
|
||||
Run `cargo fmt` in the tool directory. If `cargo-component` is available, run `cargo component build --release` to verify the WASM compiles.
|
||||
|
||||
---
|
||||
|
||||
## Path B: Built-in Tool
|
||||
|
||||
### B1: Create the tool file
|
||||
|
||||
Create `src/tools/builtin/<name>.rs` implementing the `Tool` trait:
|
||||
|
||||
```rust
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::context::JobContext;
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||
|
||||
pub struct <Name>Tool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for <Name>Tool {
|
||||
fn name(&self) -> &str {
|
||||
"<snake_case_name>"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"<Description>"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
// Define parameters here
|
||||
},
|
||||
"required": []
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// Extract and validate parameters
|
||||
// Do the work
|
||||
// Return result
|
||||
|
||||
Ok(ToolOutput::text("result", start.elapsed()))
|
||||
}
|
||||
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
false // Set true if tool processes external data
|
||||
}
|
||||
|
||||
fn requires_approval(&self, _params: &serde_json::Value) -> crate::tools::tool::ApprovalRequirement {
|
||||
crate::tools::tool::ApprovalRequirement::Never // Set to UnlessAutoApproved or Always as needed
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If the tool needs shared state (HTTP client, config), add a struct field and `new()` constructor:
|
||||
|
||||
```rust
|
||||
pub struct <Name>Tool {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl <Name>Tool {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("Failed to create HTTP client"),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### B2: Update `src/tools/builtin/mod.rs`
|
||||
|
||||
Add the module declaration and pub use, keeping alphabetical order:
|
||||
|
||||
```rust
|
||||
mod <name>;
|
||||
pub use <name>::<Name>Tool;
|
||||
```
|
||||
|
||||
### B3: Update `src/tools/registry.rs`
|
||||
|
||||
Add the import to the `use crate::tools::builtin::{...}` block and register the tool in the appropriate registration method:
|
||||
|
||||
- If it's a core tool: add to `register_builtin_tools()`
|
||||
- If it needs shared state (workspace, context_manager, etc.): create a new `register_<category>_tools()` method or add to an existing one
|
||||
- Wire the new registration call in `src/main.rs` if a new method was created
|
||||
|
||||
### B4: Add tests
|
||||
|
||||
Add a `mod tests {}` block at the bottom of the tool file:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::context::JobContext;
|
||||
|
||||
fn test_context() -> JobContext {
|
||||
JobContext::test_default()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_<name>_basic() {
|
||||
let tool = <Name>Tool::new();
|
||||
let params = serde_json::json!({ /* test params */ });
|
||||
let result = tool.execute(params, &test_context()).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_<name>_missing_params() {
|
||||
let tool = <Name>Tool::new();
|
||||
let params = serde_json::json!({});
|
||||
let result = tool.execute(params, &test_context()).await;
|
||||
assert!(matches!(result, Err(ToolError::InvalidParameters(_))));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### B5: Quality gate
|
||||
|
||||
Run `cargo fmt` and `cargo clippy --all --benches --tests --examples --all-features`. Fix any issues.
|
||||
|
||||
Run the new tests: `cargo test --lib -- builtin::<name>::tests`
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
Before finishing, verify:
|
||||
- [ ] Tool type chosen (WASM or built-in) and confirmed with user
|
||||
- [ ] All files created with correct structure
|
||||
- [ ] For WASM: capabilities.json declares all needed permissions (HTTP, secrets, auth)
|
||||
- [ ] For WASM: JSON Schema in `schema()` matches the action enum variants
|
||||
- [ ] For built-in: mod.rs updated with module + pub use
|
||||
- [ ] For built-in: registry.rs imports and registers the tool
|
||||
- [ ] For built-in: tests added and passing
|
||||
- [ ] `cargo fmt` clean
|
||||
- [ ] `cargo clippy` clean (for built-in) or `cargo component build` clean (for WASM)
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
description: Fetch a GitHub issue, create a branch, research the codebase, plan the fix, implement with tests, and commit
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Bash(gh issue view:*), Bash(gh repo view:*), Bash(git fetch:*), Bash(git checkout:*), Bash(git status:*), Bash(git branch:*), Bash(git add:*), Bash(git commit:*), Bash(cargo fmt:*), Bash(cargo clippy:*), Bash(cargo test:*), Read, Edit, Write, Grep, Glob
|
||||
argument-hint: "<issue-number or github-issue-url>"
|
||||
---
|
||||
|
||||
# Fix GitHub Issue
|
||||
|
||||
## Step 1: Resolve the issue
|
||||
|
||||
Parse `$ARGUMENTS` to extract the issue number:
|
||||
- If it's a URL like `https://github.com/owner/repo/issues/42`, extract `42`.
|
||||
- If it's a bare number, use it directly.
|
||||
- If empty, stop and ask the user for an issue number.
|
||||
|
||||
Fetch the issue:
|
||||
|
||||
```
|
||||
gh issue view {number} --json title,body,labels,assignees,comments,state
|
||||
```
|
||||
|
||||
If the issue is closed, warn the user and ask if they still want to proceed.
|
||||
|
||||
## Step 2: Create a branch
|
||||
|
||||
Create a fresh branch off the latest main:
|
||||
|
||||
1. Fetch latest: `git fetch origin`
|
||||
2. Detect default branch: `gh repo view --json defaultBranchRef --jq .defaultBranchRef.name`
|
||||
3. Create and switch to a new branch: `git checkout -b fix/{number}-{short-slug} origin/{default-branch}`
|
||||
- `{short-slug}` is 3-5 words from the issue title, lowercase, hyphenated (e.g. `fix/42-idor-workspace-check`)
|
||||
|
||||
If the working tree has uncommitted changes, warn the user and stop. Do not stash or discard their work.
|
||||
|
||||
## Step 3: Understand the issue
|
||||
|
||||
Summarize the issue in 2-3 sentences. Identify:
|
||||
- **What's broken or missing** (the symptom or feature request)
|
||||
- **Acceptance criteria** (what "done" looks like, from the issue body or comments)
|
||||
- **Constraints** (mentioned technologies, backward compatibility, performance requirements)
|
||||
|
||||
If the issue is unclear or ambiguous, list the open questions. These will be addressed during planning.
|
||||
|
||||
## Step 4: Research the codebase
|
||||
|
||||
Before planning, gather context:
|
||||
|
||||
1. **Find relevant code** - Search for files, functions, types, and patterns mentioned in the issue. Read them in full.
|
||||
2. **Trace the flow** - If the issue is about a specific behavior, trace the code path from the entry point (route handler, CLI command, etc.) through to the relevant logic.
|
||||
3. **Check existing tests** - Find tests related to the affected code. Understand what's already covered.
|
||||
4. **Check for prior art** - Look for similar patterns in the codebase that solve analogous problems. Prefer consistency with existing patterns.
|
||||
|
||||
## Step 5: Enter planning mode
|
||||
|
||||
Enter planning mode to design the implementation. The plan MUST cover:
|
||||
|
||||
1. **Root cause** (for bugs) or **design approach** (for features)
|
||||
2. **Files to modify** with specific descriptions of what changes in each
|
||||
3. **New files** (if any) with justification for why they're needed
|
||||
4. **Tests to add** - every code path introduced or changed needs a test:
|
||||
- Happy path (expected input produces expected output)
|
||||
- Error paths (invalid input, missing data, permission denied)
|
||||
- Edge cases (empty collections, boundary values, concurrent access)
|
||||
5. **IronClaw-specific concerns**:
|
||||
- If the change touches persistence, both database backends must be updated (`postgres.rs` and `libsql_backend.rs`)
|
||||
- New `Database` trait methods need implementations in both backends
|
||||
- No `.unwrap()` or `.expect()` in production code
|
||||
- Use `crate::` imports, not `super::`
|
||||
- Error types via `thiserror` in `error.rs`
|
||||
6. **Migration or compatibility concerns** (if any)
|
||||
|
||||
Follow the project's CLAUDE.md guidance for architecture decisions.
|
||||
|
||||
Wait for user approval before implementing.
|
||||
|
||||
## Step 6: Implement
|
||||
|
||||
After the plan is approved:
|
||||
|
||||
1. Implement each change from the plan.
|
||||
2. Write all planned tests.
|
||||
3. Run IronClaw's full quality gate:
|
||||
- `cargo fmt`
|
||||
- `cargo clippy --all --benches --tests --examples --all-features` (zero warnings)
|
||||
- `cargo test --lib` (all tests pass)
|
||||
4. If any check fails, fix it before proceeding.
|
||||
|
||||
Note: Integration tests (`--test workspace_integration`) require PostgreSQL and are expected to fail locally. Only `--lib` test failures are blocking.
|
||||
|
||||
## Step 7: Commit and summarize
|
||||
|
||||
1. Commit with a descriptive message referencing the issue (e.g. `fix: prevent IDOR in function call outputs (#42)`).
|
||||
2. Summarize what was done:
|
||||
- Files changed with line references
|
||||
- Tests added and what they cover
|
||||
- Any follow-up work or open questions
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
description: Respond to PR review comments — triage, plan fixes, implement after confirmation, push, and reply to reviewers
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Bash(gh pr list:*), Bash(gh pr comment:*), Bash(gh api:*), Bash(gh repo view:*), Bash(git branch:*), Bash(git status:*), Bash(git add:*), Bash(git commit:*), Bash(git push:*), Bash(cargo fmt:*), Bash(cargo clippy:*), Bash(cargo test:*), Read, Edit, Write, Grep, Glob
|
||||
argument-hint: "[pr-number (optional, auto-detects from branch)]"
|
||||
---
|
||||
|
||||
# Review and Address PR Comments
|
||||
|
||||
## Step 1: Find the PR
|
||||
|
||||
If `$ARGUMENTS` is provided, use that as the PR number. Otherwise, detect the PR for the current branch:
|
||||
|
||||
```
|
||||
gh pr list --head $(git branch --show-current) --json number,title,url --jq '.[0]'
|
||||
```
|
||||
|
||||
If no PR is found, tell the user and stop.
|
||||
|
||||
## Step 2: Fetch all review comments
|
||||
|
||||
Resolve the repo owner and name:
|
||||
|
||||
```
|
||||
gh repo view --json owner,name --jq '"\(.owner.login)/\(.name)"'
|
||||
```
|
||||
|
||||
Fetch the full set of review comments (not issue-level comments):
|
||||
|
||||
```
|
||||
gh api --paginate repos/{owner}/{repo}/pulls/{number}/comments
|
||||
```
|
||||
|
||||
Also fetch the review summaries:
|
||||
|
||||
```
|
||||
gh api --paginate repos/{owner}/{repo}/pulls/{number}/reviews
|
||||
```
|
||||
|
||||
Deduplicate comments that appear multiple times (bots sometimes post the same finding under different IDs). Group by the actual issue being raised, not by comment ID.
|
||||
|
||||
## Step 3: Triage and plan
|
||||
|
||||
For each unique issue raised in the comments:
|
||||
|
||||
1. **Check if already addressed** - Read the current code at the referenced location. If a prior commit already fixed it, note it as "already resolved".
|
||||
2. **Assess validity** - Determine if the comment identifies a real problem or is a false positive. Be honest about false positives but explain why.
|
||||
3. **Classify severity** - Critical (security/data loss), High (bugs/broken behavior), Medium (correctness/robustness), Low (style/naming/nits).
|
||||
4. **Plan the fix** - For each valid unresolved issue, describe the specific code change needed.
|
||||
|
||||
Present the plan as a table to the user:
|
||||
|
||||
| # | Issue | File:Line | Severity | Status | Planned Fix |
|
||||
|---|-------|-----------|----------|--------|-------------|
|
||||
|
||||
Wait for user confirmation before proceeding to implementation.
|
||||
|
||||
## Step 4: Implement fixes
|
||||
|
||||
After user confirms:
|
||||
|
||||
1. Implement each fix in the plan.
|
||||
2. Run IronClaw's quality gate to verify nothing breaks:
|
||||
- `cargo fmt`
|
||||
- `cargo clippy --all --benches --tests --examples --all-features`
|
||||
- `cargo test --lib`
|
||||
3. Commit with a descriptive message referencing the PR review.
|
||||
4. Push to the branch.
|
||||
|
||||
## Step 5: Reply to comments
|
||||
|
||||
For each comment addressed, reply on the PR with a short message stating what was fixed and the commit SHA. For false positives or already-resolved items, reply explaining why no change was needed.
|
||||
|
||||
## Rules
|
||||
|
||||
- Never guess at code you haven't read. Always read the referenced file and line before assessing a comment.
|
||||
- Group duplicate comments (same issue reported by multiple bots) and reply to all of them.
|
||||
- Do not make changes beyond what the review comments ask for. Stay focused.
|
||||
- If a comment suggests a change you disagree with, present your reasoning to the user during the planning phase rather than silently ignoring it.
|
||||
- Follow IronClaw conventions: no `.unwrap()` in production code, use `crate::` imports, `thiserror` errors.
|
||||
- If changes touch persistence, verify both database backends are updated.
|
||||
@@ -0,0 +1,245 @@
|
||||
---
|
||||
description: Deep audit of the IronClaw crate for vulnerabilities, bugs, unfinished work, inconsistencies, and oversights
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Bash(cargo fmt:*), Bash(cargo clippy:*), Bash(cargo test:*), Bash(cargo audit:*), Bash(git diff:*), Bash(git log:*), Bash(git show:*), Bash(wc:*), Read, Grep, Glob, Task
|
||||
argument-hint: "[path/to/crate]"
|
||||
---
|
||||
|
||||
# Rust Crate Audit
|
||||
|
||||
You are performing a thorough audit of a Rust crate. Your goal is to find every vulnerability, bug, unfinished piece of work, inconsistency, and oversight before it ships. Leave no stone unturned.
|
||||
|
||||
## Step 1: Locate the crate
|
||||
|
||||
Parse `$ARGUMENTS`:
|
||||
- If a path is provided, use it as the crate root.
|
||||
- If empty, use the current working directory.
|
||||
|
||||
Verify it's a valid Rust crate by checking for `Cargo.toml`. If not found, stop and ask the user.
|
||||
|
||||
## Step 2: Understand the crate
|
||||
|
||||
Read `Cargo.toml` to understand:
|
||||
- Crate name, version, edition
|
||||
- Dependencies (look for outdated, unmaintained, or suspicious crates)
|
||||
- Feature flags and their implications
|
||||
- Build scripts (`build.rs`) if any
|
||||
|
||||
Read `CLAUDE.md`, `README.md`, or top-level documentation if present to understand intent and architecture.
|
||||
|
||||
Read `src/lib.rs` or `src/main.rs` to get the module tree. Then read each module's `mod.rs` or top-level file to build a mental map of the crate's structure before diving into details.
|
||||
Read all Rust files (`src/*.rs`) to make sure everything is in context when you are reasoning.
|
||||
|
||||
## Step 3: Run the compiler's checks
|
||||
|
||||
Run these commands and capture output. Do NOT fix anything, just collect findings:
|
||||
|
||||
```
|
||||
cargo fmt --check 2>&1
|
||||
```
|
||||
|
||||
```
|
||||
cargo clippy --all --benches --tests --examples --all-features -- -W clippy::all -W clippy::pedantic -W clippy::nursery 2>&1
|
||||
```
|
||||
|
||||
```
|
||||
cargo test --lib 2>&1
|
||||
```
|
||||
|
||||
If any of these fail, record the failures as findings. If `cargo test` has ignored tests, note which ones and why.
|
||||
|
||||
Note: Integration tests (`--test workspace_integration`) require a PostgreSQL database and are expected to fail locally. Only report `--lib` test failures as blocking.
|
||||
|
||||
## Step 4: Scan for unfinished work
|
||||
|
||||
Search the entire `src/` tree for:
|
||||
|
||||
```
|
||||
todo!
|
||||
unimplemented!
|
||||
fixme
|
||||
FIXME
|
||||
TODO
|
||||
HACK
|
||||
XXX
|
||||
SAFETY:
|
||||
stub
|
||||
placeholder
|
||||
temporary
|
||||
```
|
||||
|
||||
For each match:
|
||||
- Is it in production code or test code?
|
||||
- Is it a genuine incomplete feature or a deliberate placeholder?
|
||||
- Is there a tracking issue referenced?
|
||||
- Could this panic at runtime?
|
||||
|
||||
Any `todo!()` or `unimplemented!()` in non-test code is **High severity** (runtime panic).
|
||||
|
||||
## Step 5: Audit for vulnerabilities and unsafe code
|
||||
|
||||
### 5a. Unsafe code
|
||||
|
||||
Search for all `unsafe` blocks. For each one:
|
||||
- Is the safety invariant documented with a `// SAFETY:` comment?
|
||||
- Is the invariant actually upheld by the surrounding code?
|
||||
- Could the unsafe block be replaced with a safe alternative?
|
||||
- Are there any pointer dereferences, transmutes, or FFI calls?
|
||||
|
||||
### 5b. Unwrap and panic paths
|
||||
|
||||
Search for `.unwrap()`, `.expect(`, `panic!`, `unreachable!` in non-test code. For each:
|
||||
- Can this actually panic in production?
|
||||
- Is there a code path that reaches this with None/Err?
|
||||
- Should it be replaced with proper error handling (`?`, `.ok()`, `.unwrap_or_default()`)?
|
||||
|
||||
IronClaw convention: `.unwrap()` and `.expect()` are banned in production code. Any occurrence outside `#[cfg(test)]` blocks is a **High severity** finding.
|
||||
|
||||
### 5c. SQL and injection vectors
|
||||
|
||||
Search for string formatting used in SQL queries, shell commands, or HTML:
|
||||
- `format!` used near `.execute(`, `.query(`, `Command::new(`
|
||||
- String interpolation in query construction vs parameterized queries
|
||||
- User input flowing into file paths (`Path::new`, `std::fs::`)
|
||||
|
||||
IronClaw has two database backends (PostgreSQL and libSQL). Check both for injection vectors.
|
||||
|
||||
### 5d. Cryptographic issues
|
||||
|
||||
If the crate uses crypto:
|
||||
- Are comparisons constant-time? (look for `==` on secrets/hashes vs `subtle::ConstantTimeEq`)
|
||||
- Is randomness from `OsRng` / `thread_rng` and not a fixed seed?
|
||||
- Are keys/secrets zeroized after use? (`secrecy`, `zeroize` crates)
|
||||
- Are deprecated algorithms used? (MD5, SHA1 for security, RC4, DES)
|
||||
|
||||
### 5e. Resource exhaustion
|
||||
|
||||
- Are there unbounded allocations? (`Vec` growing from user input without limits)
|
||||
- Are there unbounded loops? (retry loops without max attempts)
|
||||
- Are file reads bounded? (`std::fs::read_to_string` on user-provided paths)
|
||||
- Are timeouts set on all network operations?
|
||||
- Are there connection/resource leaks? (opened but never closed, missing `Drop`)
|
||||
|
||||
### 5f. Error handling
|
||||
|
||||
- Are errors swallowed silently? (`let _ = ...`, `.ok()` discarding errors that matter)
|
||||
- Do error types carry enough context to debug in production?
|
||||
- Are there error type mismatches? (returning generic `anyhow::Error` where a typed error would prevent confusion)
|
||||
- Is `thiserror` used consistently for error types (IronClaw convention)?
|
||||
|
||||
## Step 6: Check for inconsistencies
|
||||
|
||||
### 6a. Naming conventions
|
||||
|
||||
- Are types, functions, modules named consistently? (e.g., mixing `get_` and `fetch_`, `create_` and `new_`)
|
||||
- Do similar operations follow the same patterns?
|
||||
|
||||
### 6b. Duplicate or near-duplicate code
|
||||
|
||||
Look for:
|
||||
- Functions that do nearly the same thing with minor variations (candidates for generics or shared helpers)
|
||||
- Repeated error mapping patterns that should be extracted
|
||||
- Copy-pasted SQL queries or string templates with slight differences
|
||||
- Identical struct definitions or conversion logic in different modules
|
||||
|
||||
### 6c. API consistency
|
||||
|
||||
- Do similar functions take arguments in the same order?
|
||||
- Are return types consistent? (e.g., some functions return `Option<T>`, similar ones return `Result<T, E>`)
|
||||
- Are visibility modifiers consistent? (`pub` where it should be `pub(crate)`, or vice versa)
|
||||
|
||||
### 6d. Dead code and unused items
|
||||
|
||||
- Are there functions, structs, or modules that nothing references?
|
||||
- Are there `#[allow(dead_code)]` annotations that should be investigated?
|
||||
- Are there feature-gated items where the feature is never enabled?
|
||||
|
||||
### 6e. Import style
|
||||
|
||||
IronClaw convention: use `crate::` imports, not `super::`. Flag any `super::` imports in non-test code.
|
||||
|
||||
## Step 7: Inspect for change oversights
|
||||
|
||||
### 7a. Partial refactors
|
||||
|
||||
- Are there old patterns coexisting with new patterns?
|
||||
- Are there renamed types/functions where some call sites still use the old name via a compatibility alias?
|
||||
- Are there comments referencing behavior that no longer exists?
|
||||
|
||||
### 7b. Trait implementation gaps
|
||||
|
||||
- If a trait is defined, do all intended types implement it?
|
||||
- Are there `impl` blocks that look incomplete?
|
||||
- Are `Default` implementations sensible?
|
||||
|
||||
IronClaw key traits: `Database` (~60 methods), `Channel`, `Tool`, `LlmProvider`, `SuccessEvaluator`, `EmbeddingProvider`. If any new methods were added to `Database`, verify both `postgres.rs` and `libsql_backend.rs` implement them.
|
||||
|
||||
### 7c. Test coverage gaps
|
||||
|
||||
- Are there public functions without any test?
|
||||
- Are there error paths without tests?
|
||||
- Are there recently-changed functions where the tests still assert old behavior?
|
||||
|
||||
### 7d. Documentation drift
|
||||
|
||||
- Do doc comments match actual function behavior?
|
||||
- Are examples in doc comments still valid and compilable?
|
||||
|
||||
## Step 8: Dependency audit
|
||||
|
||||
Review `Cargo.toml` and `Cargo.lock`:
|
||||
- Are there duplicate versions of the same crate in the lock file? (potential version conflicts)
|
||||
- Are there dependencies with known security advisories? Run `cargo audit` to check (install with `cargo install cargo-audit` if not present).
|
||||
- Are there heavy dependencies used for trivial functionality?
|
||||
- Are dependency features minimal?
|
||||
|
||||
## Step 9: Present findings
|
||||
|
||||
Compile all findings into a structured report. Group by severity, then by category.
|
||||
|
||||
### Format
|
||||
|
||||
For each finding:
|
||||
|
||||
```
|
||||
### [Severity] Category: One-line summary
|
||||
|
||||
**Location:** `file_path:line_number`
|
||||
**Category:** Vulnerability | Bug | Unfinished | Inconsistency | Duplicate | Oversight | Style
|
||||
|
||||
**Description:**
|
||||
Detailed explanation of the issue, why it matters, and how it could manifest.
|
||||
|
||||
**Suggested fix:**
|
||||
Concrete suggestion with code if applicable.
|
||||
```
|
||||
|
||||
### Severity levels
|
||||
|
||||
- **Critical**: Security vulnerability, data loss, or crash in production
|
||||
- **High**: Bug that causes incorrect behavior, `todo!()`/`unimplemented!()` in prod code, or missing validation on trust boundaries
|
||||
- **Medium**: Inconsistency, duplicate code, incomplete error handling, missing tests for important paths
|
||||
- **Low**: Naming inconsistency, unnecessary complexity, documentation drift, minor dead code
|
||||
- **Nit**: Style preference, optional improvement
|
||||
|
||||
### Summary table
|
||||
|
||||
End with a summary table:
|
||||
|
||||
| # | Severity | Category | File:Line | Finding |
|
||||
|---|----------|----------|-----------|---------|
|
||||
|
||||
And a final tally: X Critical, Y High, Z Medium, W Low, V Nit.
|
||||
|
||||
## Rules
|
||||
|
||||
- Read every file before reporting on it. Never guess about code you haven't seen.
|
||||
- Be specific. "This might have issues" is worthless. "Line 42 calls `.unwrap()` on a `Result` that returns `Err` when the DB connection is dropped" is useful.
|
||||
- Distinguish certainty levels: "this IS a bug" vs "this COULD be a bug if X".
|
||||
- Don't invent problems to look thorough. If the code is solid, say so.
|
||||
- Focus on substance over style. Don't flag formatting unless it causes real confusion.
|
||||
- Respect existing project conventions (check CLAUDE.md). Don't flag patterns the project explicitly endorses.
|
||||
- When in doubt about severity, round up.
|
||||
- For large crates (>50 files), prioritize: core logic > public API > internal utilities > tests > examples.
|
||||
- Use the Task tool to parallelize file reading across modules when the crate is large.
|
||||
- Do NOT fix anything. This is a read-only audit. Report findings for the user to action.
|
||||
@@ -0,0 +1,170 @@
|
||||
---
|
||||
description: Paranoid architect review of a PR — fetches diff, reads changed files, deep review across 6 lenses, posts findings as GitHub comments
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Bash(gh pr view:*), Bash(gh pr diff:*), Bash(gh pr comment:*), Bash(gh api:*), Bash(gh repo view:*), Bash(git diff:*), Bash(git log:*), Read, Grep, Glob
|
||||
argument-hint: "<pr-number or github-pr-url>"
|
||||
---
|
||||
|
||||
# Paranoid Architect Code Review
|
||||
|
||||
You are reviewing this PR as a paranoid architect. Your job is to find every bug, vulnerability, race condition, edge case, and undocumented assumption before it ships. Assume adversarial users, concurrent access, and Murphy's law.
|
||||
|
||||
## Step 1: Resolve the PR
|
||||
|
||||
Parse `$ARGUMENTS` to extract the PR number:
|
||||
- If it's a URL like `https://github.com/owner/repo/pull/123`, extract `123`.
|
||||
- If it's a bare number, use it directly.
|
||||
- If empty, stop and ask the user for a PR number.
|
||||
|
||||
Fetch PR metadata (including head commit SHA for posting line comments later):
|
||||
|
||||
```
|
||||
gh pr view {number} --json title,body,baseRefName,headRefName,headRefOid,files,additions,deletions
|
||||
```
|
||||
|
||||
Save the `headRefOid` value, you'll need it as `commit_id` in Step 6.
|
||||
|
||||
## Step 2: Load the full diff
|
||||
|
||||
```
|
||||
gh pr diff {number}
|
||||
```
|
||||
|
||||
Also get the list of changed files:
|
||||
|
||||
```
|
||||
gh pr diff {number} --name-only
|
||||
```
|
||||
|
||||
## Step 3: Read every changed file in full
|
||||
|
||||
For each changed file, read the ENTIRE current file (not just the diff hunks). You need surrounding context to catch:
|
||||
- Callers of modified functions that now behave differently
|
||||
- Trait/interface contracts that the change may violate
|
||||
- Invariants established elsewhere that the diff breaks
|
||||
|
||||
If the PR touches more than 20 files, still read all of them, but process in this priority order: service logic > routes/handlers > models/types > tests > docs. Batch reads in groups of ~20 if needed.
|
||||
|
||||
## Step 4: Deep review
|
||||
|
||||
Go through the changes with each of these lenses. For every finding, note the file, line range, severity, and a concrete description.
|
||||
|
||||
### IronClaw-specific checks
|
||||
|
||||
In addition to the general lenses below, check IronClaw conventions (see CLAUDE.md):
|
||||
- No `.unwrap()` or `.expect()` in production code (tests are fine)
|
||||
- Use `crate::` imports, not `super::`
|
||||
- Error types use `thiserror` in `error.rs`
|
||||
- If the change touches persistence, verify both database backends are updated (PostgreSQL in `postgres.rs` AND libSQL in `libsql_backend.rs`)
|
||||
- New tools must implement the `Tool` trait correctly and be registered in `registry.rs`
|
||||
- External tool output must pass through the safety layer
|
||||
|
||||
### 4a. Correctness and bugs
|
||||
|
||||
- Off-by-one errors, wrong comparison operators, inverted conditions
|
||||
- Unreachable code, dead branches, impossible match arms
|
||||
- Type confusion (mixing up IDs, using wrong enum variant)
|
||||
- Incorrect error propagation (swallowed errors, wrong error type/status code)
|
||||
- Broken invariants (e.g. uniqueness assumptions violated, ordering assumptions wrong)
|
||||
- Concurrency issues (TOCTOU, missing locks, race conditions between check and use)
|
||||
|
||||
### 4b. Edge cases and failure handling
|
||||
|
||||
- What happens with empty input, None/null, zero-length collections?
|
||||
- What happens when external services fail (DB down, HTTP timeout, malformed response)?
|
||||
- What happens at integer boundaries (overflow, underflow, i64::MAX)?
|
||||
- What happens with malformed or adversarial input (invalid UTF-8, huge payloads, deeply nested JSON)?
|
||||
- Are all error paths tested? Does every `?` propagation make sense?
|
||||
- Are partial failures handled (e.g. wrote to DB but failed to emit event)?
|
||||
|
||||
### 4c. Security (assume a malicious actor)
|
||||
|
||||
- **Authentication/Authorization bypass**: Can an unauthenticated user reach this? Can workspace A's user access workspace B's data? Are there IDOR vulnerabilities?
|
||||
- **Injection**: SQL injection via string interpolation? Command injection? Log injection? Header injection?
|
||||
- **Data leakage**: Are secrets, PII, or conversation content logged? Returned in error messages? Exposed in API responses?
|
||||
- **Resource exhaustion / DoS**: Can an attacker send unbounded input? Trigger expensive operations without rate limits? Cause OOM via large allocations?
|
||||
- **Financial abuse**: Can tokens/credits be consumed without being tracked? Can usage limits be bypassed?
|
||||
- **Replay / race conditions**: Can the same request be replayed for double-spend? Can concurrent requests bypass limits?
|
||||
- **Cryptographic issues**: Timing attacks on comparisons? Weak randomness? Missing HMAC verification?
|
||||
|
||||
### 4d. Test coverage
|
||||
|
||||
- Is every new public function/method tested?
|
||||
- Are error paths tested (not just happy paths)?
|
||||
- Are edge cases covered (empty input, boundary values, concurrent access)?
|
||||
- Do existing tests still make sense with the new changes, or do they assert stale behavior?
|
||||
- Are there integration/e2e tests for the full flow?
|
||||
- If a test is missing, describe exactly what test should be written.
|
||||
|
||||
### 4e. Documentation and assumptions
|
||||
|
||||
- Are new assumptions documented in comments? (e.g. "this field is always non-empty because X")
|
||||
- Are non-obvious algorithms or business rules explained?
|
||||
- Are API contracts (request/response shapes, error codes, status codes) documented?
|
||||
- Are there TODO/FIXME/HACK comments that should be tracked as issues?
|
||||
|
||||
### 4f. Architectural concerns
|
||||
|
||||
- Does this change follow existing patterns in the codebase, or does it introduce a new one without justification?
|
||||
- Are there unnecessary abstractions or premature generalizations?
|
||||
- Is there duplicated logic that should be extracted?
|
||||
- Are dependencies between modules clean, or does this create circular/tight coupling?
|
||||
- Will this change make future work harder?
|
||||
|
||||
## Step 5: Present findings
|
||||
|
||||
Summarize findings to the user as a table:
|
||||
|
||||
| # | Severity | Category | File:Line | Finding | Suggested Fix |
|
||||
|---|----------|----------|-----------|---------|---------------|
|
||||
|
||||
Severity levels:
|
||||
- **Critical**: Security vulnerability, data loss, or financial exploit
|
||||
- **High**: Bug that will cause incorrect behavior in production
|
||||
- **Medium**: Robustness issue, missing validation, or incomplete error handling
|
||||
- **Low**: Style, naming, documentation, or minor improvement
|
||||
- **Nit**: Optional suggestion, take-it-or-leave-it
|
||||
|
||||
Ask the user which findings to post as PR comments. Default: all Critical, High, and Medium.
|
||||
|
||||
## Step 6: Post comments on GitHub
|
||||
|
||||
Resolve the repo owner and name if not already known:
|
||||
|
||||
```
|
||||
gh repo view --json owner,name --jq '"\(.owner.login)/\(.name)"'
|
||||
```
|
||||
|
||||
For each approved finding, post a review comment on the PR at the specific file and line. Use the `headRefOid` from Step 1 as the `commit_id`:
|
||||
|
||||
```
|
||||
gh api repos/{owner}/{repo}/pulls/{number}/comments \
|
||||
-f body="..." \
|
||||
-f path="..." \
|
||||
-f commit_id="{headRefOid}" \
|
||||
-F line=... \
|
||||
-f side="RIGHT"
|
||||
```
|
||||
|
||||
For findings that span multiple locations or are architectural, post as a regular PR comment:
|
||||
|
||||
```
|
||||
gh pr comment {number} --body "..."
|
||||
```
|
||||
|
||||
Format each comment clearly:
|
||||
- Severity tag (e.g. `**High Severity**`)
|
||||
- One-line summary
|
||||
- Detailed explanation of the issue
|
||||
- Concrete suggestion for the fix (with code if possible)
|
||||
|
||||
## Rules
|
||||
|
||||
- Read every changed file in full before writing a single finding. Context matters.
|
||||
- Never post a comment about code you haven't actually read. Verify line numbers against the actual file.
|
||||
- Be specific. "This might have issues" is useless. "Line 42 returns 404 but should return 400 because X" is useful.
|
||||
- Distinguish between "this IS a bug" and "this COULD be a bug if X". Be honest about certainty.
|
||||
- Don't nitpick formatting or style unless it causes actual confusion. Focus on substance.
|
||||
- If the code is good and you find nothing, say so. Don't invent problems to look thorough.
|
||||
- Respect the project's CLAUDE.md privacy rules: never include customer data, secrets, or PII in comments.
|
||||
- When in doubt about severity, round up. It's cheaper to dismiss a false alarm than to miss a real bug.
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
description: Run the full Rust quality gate (fmt, clippy, tests) before shipping changes
|
||||
allowed-tools: Bash(cargo fmt:*), Bash(cargo clippy:*), Bash(cargo test:*)
|
||||
---
|
||||
|
||||
Run the IronClaw shipping checklist. This is the mandatory quality gate before any change is considered done.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Format**: Run `cargo fmt` to normalize formatting.
|
||||
|
||||
2. **Lint**: Run `cargo clippy --all --benches --tests --examples --all-features` and report any warnings or errors. ALL clippy warnings must be resolved before proceeding.
|
||||
|
||||
3. **Test**: Run `cargo test --lib` to execute the full library test suite. Report the total pass/fail count.
|
||||
|
||||
4. **Summary**: Report results for all three steps. If any step failed, list the specific errors and suggest fixes. Do NOT proceed past a failing step.
|
||||
|
||||
If `$ARGUMENTS` is provided, treat it as a specific test filter and run `cargo test --lib -- $ARGUMENTS` instead of the full suite in step 3.
|
||||
|
||||
The expected outcome for a clean ship is:
|
||||
- `cargo fmt` produces no changes
|
||||
- `cargo clippy` has zero warnings
|
||||
- All tests pass
|
||||
|
||||
Note: Integration tests (`--test workspace_integration`) require a PostgreSQL database and are expected to fail locally. Only report `--lib` test failures as blocking.
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
description: Trace a data flow or bug through the IronClaw codebase end-to-end
|
||||
allowed-tools: Read, Glob, Grep, Bash(cargo test:*)
|
||||
argument-hint: <symptom or feature name>
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
Trace the flow of `$ARGUMENTS` through the IronClaw codebase. Your job is to map every file and function involved, identify where data transforms or could break, and report the full chain.
|
||||
|
||||
## Architecture Reference
|
||||
|
||||
IronClaw has three main data flow paths. Identify which one(s) are relevant and trace through them:
|
||||
|
||||
### Message Flow (user input to LLM response)
|
||||
```
|
||||
Channel (cli/web/wasm) → IncomingMessage
|
||||
→ Agent::run() message loop (agent_loop.rs)
|
||||
→ handle_message() dispatches by Submission type
|
||||
→ SubmissionParser::parse() (submission.rs) classifies input
|
||||
→ process_user_input() for new turns
|
||||
→ process_approval() for tool approval responses
|
||||
→ handle_command() for /commands
|
||||
→ run_agentic_loop() iterates LLM calls
|
||||
→ Reasoning::respond_with_tools() (reasoning.rs)
|
||||
→ LlmProvider::complete_with_tools() (nearai_chat.rs or nearai.rs)
|
||||
→ Tool execution with approval gating
|
||||
→ Context message accumulation
|
||||
→ Response flows back through Channel::send_response()
|
||||
```
|
||||
|
||||
### SSE Event Flow (backend status to web UI)
|
||||
```
|
||||
StatusUpdate variant (channel.rs)
|
||||
→ Channel::send_status() trait method
|
||||
→ WebChannel::send_status() (web/mod.rs) maps to SseEvent
|
||||
→ broadcast via tokio::broadcast channel
|
||||
→ SSE endpoint streams events (web/server.rs)
|
||||
→ Browser EventSource listener (app.js)
|
||||
→ DOM update function
|
||||
→ CSS styling (style.css)
|
||||
```
|
||||
|
||||
### Tool Flow (tool definition to execution)
|
||||
```
|
||||
Tool trait impl (tools/builtin/*.rs or tools/mcp/client.rs or tools/wasm/wrapper.rs)
|
||||
→ ToolRegistry::register() (tools/registry.rs)
|
||||
→ tool_definitions() builds Vec<ToolDefinition> for LLM
|
||||
→ ToolDefinition { name, description, parameters } (llm/provider.rs)
|
||||
→ Serialized to ChatCompletionTool (nearai_chat.rs)
|
||||
→ LLM returns ToolCall { id, name, arguments }
|
||||
→ agent_loop.rs executes via execute_chat_tool()
|
||||
→ Safety layer sanitizes output
|
||||
→ Result added as ChatMessage::tool_result()
|
||||
```
|
||||
|
||||
## Tracing Instructions
|
||||
|
||||
1. **Read** each file in the relevant flow path, focusing on the functions that handle the data.
|
||||
2. **Identify transforms**: Where does the data change shape? (e.g., `McpTool.input_schema` → `ToolDefinition.parameters` → `ChatCompletionTool.function.parameters`)
|
||||
3. **Identify failure points**: Where could the data be lost, malformed, or misrouted?
|
||||
4. **Report the chain**: List every file:line involved, what happens at each step, and where the issue (if any) is.
|
||||
|
||||
## Key Files Quick Reference
|
||||
|
||||
| Area | File | Key Functions |
|
||||
|------|------|---------------|
|
||||
| Message dispatch | `src/agent/agent_loop.rs` | `handle_message`, `process_user_input`, `process_approval`, `run_agentic_loop` |
|
||||
| Input parsing | `src/agent/submission.rs` | `SubmissionParser::parse` |
|
||||
| LLM reasoning | `src/llm/reasoning.rs` | `respond_with_tools`, `select_tools`, `plan` |
|
||||
| Chat completions | `src/llm/nearai_chat.rs` | `complete_with_tools`, `From<ChatMessage>` |
|
||||
| Responses API | `src/llm/nearai.rs` | `complete_with_tools`, `split_messages` |
|
||||
| Channel trait | `src/channels/channel.rs` | `Channel`, `StatusUpdate`, `IncomingMessage` |
|
||||
| Web gateway | `src/channels/web/mod.rs` | `send_status`, `send_response` |
|
||||
| Web server | `src/channels/web/server.rs` | Route handlers, SSE endpoints |
|
||||
| Web frontend | `src/channels/web/static/app.js` | SSE listeners, DOM builders |
|
||||
| Tool registry | `src/tools/registry.rs` | `tool_definitions`, `get`, `register` |
|
||||
| MCP tools | `src/tools/mcp/client.rs` | `McpToolWrapper`, `list_tools`, `call_tool` |
|
||||
| MCP protocol | `src/tools/mcp/protocol.rs` | `McpTool`, `inputSchema` |
|
||||
| Safety | `src/safety/sanitizer.rs` | `sanitize_tool_output`, `wrap_for_llm` |
|
||||
| Session state | `src/agent/session.rs` | `ThreadState`, `Turn`, `PendingApproval` |
|
||||
|
||||
## Output Format
|
||||
|
||||
Report your findings as:
|
||||
|
||||
1. **Flow path**: The specific chain of files and functions involved
|
||||
2. **Data transforms**: How the data changes at each step
|
||||
3. **Findings**: Any bugs, missing data, or suspicious patterns
|
||||
4. **Recommendation**: What to fix or investigate further
|
||||
@@ -0,0 +1,257 @@
|
||||
---
|
||||
description: Triage open GitHub issues — split into bugs vs features, rank by severity/opportunity, and flag under-specified issues
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Bash(gh issue list:*), Bash(gh issue view:*), Bash(gh api:*), Bash(git log:*), Read, Grep, Glob, Task
|
||||
argument-hint: "[--label=<filter>] [--milestone=<filter>]"
|
||||
---
|
||||
|
||||
# Issue Triage
|
||||
|
||||
You are triaging all open issues on this repository. Your job is to split them into **bugs** and **feature requests**, rank each group, assess how well-specified each issue is, and produce an actionable triage report.
|
||||
|
||||
## Step 1: Fetch all open issues
|
||||
|
||||
Fetch every open issue with metadata:
|
||||
|
||||
```
|
||||
gh issue list --state open --limit 200 --json number,title,author,labels,assignees,createdAt,updatedAt,body,commentsCount,reactionGroups,milestone
|
||||
```
|
||||
|
||||
If `$ARGUMENTS` contains `--label=<X>`, append `--label '<X>'` to the command. If it contains `--milestone=<X>`, append `--milestone '<X>'` to the command.
|
||||
|
||||
Also fetch recently closed issues (last 14 days) to detect duplicates and already-resolved work:
|
||||
|
||||
```
|
||||
gh issue list --state closed --search "closed:>=$(date -v-14d +%Y-%m-%d)" --limit 100 --json number,title,body,labels,closedAt
|
||||
```
|
||||
|
||||
**Exclude pull requests** — `gh issue list` may include PRs. Fetch open PR numbers to filter them out:
|
||||
|
||||
```
|
||||
gh pr list --state open --json number --jq '.[].number'
|
||||
```
|
||||
|
||||
Remove any issue whose number appears in this list.
|
||||
|
||||
## Step 2: Classify each issue as Bug or Feature
|
||||
|
||||
Read each issue's title, body, and labels to classify it into one of these categories:
|
||||
|
||||
### Bugs
|
||||
Issues that describe **broken existing behavior** — something that worked or should work but doesn't. Signals:
|
||||
- Labels: `bug`, `defect`, `regression`, `crash`, `error`
|
||||
- Title/body keywords: "broken", "fails", "crash", "panic", "error", "regression", "doesn't work", "unexpected behavior"
|
||||
- Includes reproduction steps or error output
|
||||
- References existing functionality not working as documented
|
||||
|
||||
### Feature Requests
|
||||
Issues that describe **new or enhanced behavior** — something that doesn't exist yet. Signals:
|
||||
- Labels: `enhancement`, `feature`, `feature-request`, `improvement`, `proposal`
|
||||
- Title/body keywords: "add", "support", "implement", "would be nice", "proposal", "RFC", "new"
|
||||
- Describes a capability the project doesn't have
|
||||
- Proposes a design or API change
|
||||
|
||||
### Ambiguous
|
||||
If an issue doesn't clearly fit either category (e.g., "improve X performance" could be a bug or a feature), classify it as **Ambiguous** and note why.
|
||||
|
||||
## Step 3: Rate issue detail level
|
||||
|
||||
For each issue, assess how well-specified it is on a 3-tier scale:
|
||||
|
||||
| Detail Level | Criteria |
|
||||
|-------------|----------|
|
||||
| **Well-specified** | Has clear description of what/why, reproduction steps (bugs) or user story (features), acceptance criteria or expected behavior, and enough context to start working immediately |
|
||||
| **Adequate** | Describes the problem or request clearly, but missing some detail — no repro steps, vague acceptance criteria, or unclear scope. Needs 1-2 clarifying questions before work can start |
|
||||
| **Under-specified** | Vague title-only or single-sentence body, no context on why it matters, no clear definition of done. Needs significant discussion before it's actionable |
|
||||
|
||||
Indicators of good specification:
|
||||
- Code snippets, error logs, or screenshots
|
||||
- Steps to reproduce (bugs)
|
||||
- Proposed API/behavior (features)
|
||||
- Links to related issues or discussions
|
||||
- Clear "done when" criteria
|
||||
|
||||
## Step 4: Rank bugs by severity
|
||||
|
||||
Score each bug on these dimensions and compute an overall severity rank:
|
||||
|
||||
### Impact (1-4)
|
||||
| Score | Level | Description |
|
||||
|-------|-------|-------------|
|
||||
| 4 | **Critical** | Data loss, security vulnerability, complete feature broken, crash in common path |
|
||||
| 3 | **High** | Major feature degraded, workaround exists but painful, affects many users |
|
||||
| 2 | **Medium** | Minor feature broken, easy workaround, affects subset of users |
|
||||
| 1 | **Low** | Cosmetic, edge case, documentation error, minor inconvenience |
|
||||
|
||||
### Urgency (1-3)
|
||||
| Score | Level | Description |
|
||||
|-------|-------|-------------|
|
||||
| 3 | **Urgent** | Security issue, regression in recent release, blocking other work |
|
||||
| 2 | **Normal** | Should be fixed in next release cycle |
|
||||
| 1 | **Low** | Fix when convenient, backlog-worthy |
|
||||
|
||||
### Scope (1-3)
|
||||
| Score | Level | Description |
|
||||
|-------|-------|-------------|
|
||||
| 3 | **Broad** | Affects core path, multiple modules, or all users |
|
||||
| 2 | **Moderate** | Affects one module or a specific configuration |
|
||||
| 1 | **Narrow** | Affects edge case or single obscure path |
|
||||
|
||||
**Bug severity score** = Impact × 2 + Urgency + Scope (base max 14)
|
||||
|
||||
Apply a one-time +2 boost if any of the following are true (max 16):
|
||||
- Has a linked PR already (someone is working on it — fast-track review)
|
||||
- Is labeled `security`
|
||||
- Is a regression (worked before, broken now)
|
||||
|
||||
## Step 5: Rank features by opportunity
|
||||
|
||||
Score each feature request on these dimensions:
|
||||
|
||||
### Value (1-4)
|
||||
| Score | Level | Description |
|
||||
|-------|-------|-------------|
|
||||
| 4 | **High** | Unlocks new use cases, frequently requested, strategic alignment |
|
||||
| 3 | **Medium-High** | Significant quality-of-life improvement, good user demand signals |
|
||||
| 2 | **Medium** | Nice to have, modest improvement to existing workflow |
|
||||
| 1 | **Low** | Marginal value, niche use case, unclear demand |
|
||||
|
||||
Look for value signals in the issue:
|
||||
- Number of thumbs-up reactions or "+1" comments
|
||||
- Multiple people asking for the same thing
|
||||
- Alignment with project roadmap (check CLAUDE.md TODOs)
|
||||
- Unblocks other features or simplifies architecture
|
||||
|
||||
### Effort estimate (1-3, inverted — lower effort = higher score)
|
||||
| Score | Level | Description |
|
||||
|-------|-------|-------------|
|
||||
| 3 | **Small** | <1 day, isolated change, clear implementation path |
|
||||
| 2 | **Medium** | 1-3 days, touches a few modules, some design needed |
|
||||
| 1 | **Large** | 3+ days, cross-cutting, needs RFC or architectural discussion |
|
||||
|
||||
### Readiness (1-3)
|
||||
| Score | Level | Description |
|
||||
|-------|-------|-------------|
|
||||
| 3 | **Ready** | Well-specified, implementation path clear, no blockers |
|
||||
| 2 | **Almost ready** | Needs minor clarification, but scope is understood |
|
||||
| 1 | **Not ready** | Needs design discussion, has open questions, blocked by other work |
|
||||
|
||||
**Opportunity score** = Value × 2 + Effort + Readiness (base max 14)
|
||||
|
||||
Apply a one-time +2 boost if any of the following are true (max 16):
|
||||
- A community member offered to implement it
|
||||
- It has a linked draft PR
|
||||
- It closes a gap listed in the project's "Current Limitations / TODOs"
|
||||
|
||||
## Step 6: Detect duplicates and relationships
|
||||
|
||||
Check for:
|
||||
- **Duplicates** — Issues describing the same bug or requesting the same feature (compare titles and bodies)
|
||||
- **Related clusters** — Groups of issues around the same area (e.g., multiple workspace issues, multiple CLI issues)
|
||||
- **Already fixed** — Open issues that may have been resolved by recently closed issues or merged PRs
|
||||
- **Blockers** — Issues that reference other issues as prerequisites ("depends on #N", "blocked by #N")
|
||||
- **Epic candidates** — Multiple small issues that could be grouped under a single tracking issue
|
||||
|
||||
## Step 7: Produce the triage report
|
||||
|
||||
Present the output in this format:
|
||||
|
||||
### Quick Stats
|
||||
|
||||
```
|
||||
Open: N | Bugs: N | Features: N | Ambiguous: N
|
||||
Well-specified: N | Adequate: N | Under-specified: N
|
||||
Unassigned: N | Stale (>30d): N
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Critical Bugs (Severity 12+)
|
||||
|
||||
Bugs that need immediate attention. For each:
|
||||
|
||||
| # | Title | Severity | Impact | Detail | Age | Assignee |
|
||||
|---|-------|----------|--------|--------|-----|----------|
|
||||
|
||||
Include a 1-line summary of the root cause if discernible from the issue.
|
||||
|
||||
### High-Priority Bugs (Severity 8-12)
|
||||
|
||||
Same table format. These should be addressed in the next release cycle.
|
||||
|
||||
### Medium/Low Bugs (Severity <8)
|
||||
|
||||
Compact table, sorted by severity descending.
|
||||
|
||||
---
|
||||
|
||||
### Quick Wins (Opportunity 12+ AND Effort = Small)
|
||||
|
||||
Features that are high-value and low-effort — do these first. For each:
|
||||
|
||||
| # | Title | Opportunity | Value | Effort | Detail | Age |
|
||||
|---|-------|-------------|-------|--------|--------|-----|
|
||||
|
||||
### High-Opportunity Features (Opportunity 10+)
|
||||
|
||||
Same table format. Worth investing in.
|
||||
|
||||
### Backlog Features (Opportunity <10)
|
||||
|
||||
Compact table, sorted by opportunity descending.
|
||||
|
||||
---
|
||||
|
||||
### Under-Specified Issues (Need Clarification)
|
||||
|
||||
Issues rated "Under-specified" that can't be triaged effectively. For each, suggest 1-2 specific questions to ask the author to make it actionable.
|
||||
|
||||
| # | Title | Type | What's missing |
|
||||
|---|-------|------|---------------|
|
||||
|
||||
### Ambiguous Issues (Bug or Feature?)
|
||||
|
||||
Issues that couldn't be clearly classified. For each, explain the ambiguity and suggest which category it likely belongs in.
|
||||
|
||||
---
|
||||
|
||||
### Duplicates & Overlaps
|
||||
|
||||
Groups of issues that appear to be duplicates or closely related. Recommend which to keep and which to close.
|
||||
|
||||
### Already Fixed?
|
||||
|
||||
Open issues that may have been resolved by recently closed issues or merged PRs.
|
||||
|
||||
### Stale Issues (>30 days, no activity)
|
||||
|
||||
Issues with no updates in 30+ days. Recommend: close, ping author, or keep.
|
||||
|
||||
---
|
||||
|
||||
### By Area
|
||||
|
||||
Group all issues by the area of the codebase they affect (infer from title/body/labels):
|
||||
|
||||
| Area | Bugs | Features | Top Priority |
|
||||
|------|------|----------|-------------|
|
||||
|
||||
### Suggested Next Actions
|
||||
|
||||
Based on the triage, provide 3-5 concrete recommendations:
|
||||
1. Which bugs to fix first and why
|
||||
2. Which quick-win features to pick up
|
||||
3. Which under-specified issues to clarify
|
||||
4. Which stale issues to close
|
||||
5. Any clusters that suggest a larger initiative
|
||||
|
||||
## Rules
|
||||
|
||||
- Use `gh` CLI for all GitHub operations. Never guess issue state — always check.
|
||||
- For large issue lists (>20), use the Task tool to parallelize fetching issue details and comments.
|
||||
- Be concise in summaries. One line per issue in tables.
|
||||
- When scoring, be honest about uncertainty. If you can't tell severity from the description, say so and rate it conservatively.
|
||||
- Factor in issue age — older unresolved bugs may indicate they're less critical than they seem, or that they're hard to fix. Note this in your assessment.
|
||||
- Check comment threads for additional context that the original body may lack. An under-specified issue with rich discussion may actually be well-understood.
|
||||
- Do NOT post comments, close issues, or take any action. This skill is read-only analysis.
|
||||
- If the repo has >100 open issues, focus the detailed analysis on the top 30 by recency and engagement (comments + reactions), and provide a summary table for the rest.
|
||||
@@ -0,0 +1,161 @@
|
||||
---
|
||||
description: Classify all open PRs by module, review state, scope, and architectural impact — produces a prioritized triage dashboard
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Bash(gh pr list:*), Bash(gh pr view:*), Bash(gh pr diff:*), Bash(gh api:*), Bash(gh pr checks:*), Bash(git log:*), Read, Grep, Glob, Task
|
||||
argument-hint: "[--label=<filter>] [--author=<filter>]"
|
||||
---
|
||||
|
||||
# PR Triage Dashboard
|
||||
|
||||
You are triaging all open PRs on this repository. Your job is to produce a prioritized, module-grouped dashboard that tells the maintainer exactly which PRs need attention and in what order.
|
||||
|
||||
## Step 1: Fetch all open PRs
|
||||
|
||||
Fetch every open PR with metadata:
|
||||
|
||||
```
|
||||
gh pr list --state open --limit 100 --json number,title,author,labels,additions,deletions,headRefName,createdAt,updatedAt,isDraft,reviewRequests,reviews,files,body
|
||||
```
|
||||
|
||||
If `$ARGUMENTS` contains `--label=<X>`, append `--label '<X>'` to the `gh pr list` command. If it contains `--author=<X>`, append `--author '<X>'` to the command.
|
||||
|
||||
Also fetch recently merged PRs (last 7 days) to detect superseded/conflicting work:
|
||||
|
||||
```
|
||||
gh pr list --state merged --search "merged:>=$(date -v-7d +%Y-%m-%d)" --limit 100 --json number,title,body,mergedAt
|
||||
```
|
||||
|
||||
## Step 2: Classify each PR by module
|
||||
|
||||
For each open PR, determine the primary module it touches by examining the `files` field. Classify into these categories based on the dominant `src/` subdirectory:
|
||||
|
||||
| Category | Directories |
|
||||
|----------|------------|
|
||||
| **LLM & Inference** | `src/llm/` |
|
||||
| **Agent Core** | `src/agent/`, `src/skills/` |
|
||||
| **Tools** | `src/tools/`, `tools-src/` |
|
||||
| **Channels** | `src/channels/`, `channels-src/` |
|
||||
| **Storage & Memory** | `src/db/`, `src/workspace/`, `migrations/` |
|
||||
| **Security** | `src/safety/`, `src/secrets/` |
|
||||
| **Config & Setup** | `src/config.rs`, `src/setup/`, `src/cli/` |
|
||||
| **Sandbox & Orchestration** | `src/sandbox/`, `src/orchestrator/`, `src/worker/` |
|
||||
| **Hooks & Extensions** | `src/hooks/`, `src/extensions/` |
|
||||
| **Context & History** | `src/context/`, `src/history/`, `src/estimation/`, `src/evaluation/` |
|
||||
| **Web Gateway** | `src/channels/web/` |
|
||||
| **CI/CD & Docs** | `.github/`, `README.md`, `CLAUDE.md`, `*.md` (no src) |
|
||||
| **Other** | Anything else |
|
||||
|
||||
If a PR touches multiple modules, assign it to the **primary** module (most files changed) but note the cross-cutting modules.
|
||||
|
||||
## Step 3: Assess review state
|
||||
|
||||
For each PR, determine its review status:
|
||||
|
||||
- **Approved** — At least one human APPROVED review, no outstanding CHANGES_REQUESTED
|
||||
- **Changes requested** — At least one CHANGES_REQUESTED review still unresolved
|
||||
- **Reviewed (comments only)** — Human comments but no formal approve/reject
|
||||
- **Automated only** — Only bot reviews (gemini-code-assist, copilot, etc.)
|
||||
- **No review** — No reviews at all
|
||||
|
||||
Also check:
|
||||
- CI status: `gh pr checks {number}` — PASS / FAIL / NONE
|
||||
- Draft status: is the PR marked as draft?
|
||||
- Staleness: how many days since `updatedAt`?
|
||||
|
||||
## Step 4: Determine scope and risk
|
||||
|
||||
Classify each PR by scope:
|
||||
|
||||
| Scope | Criteria |
|
||||
|-------|----------|
|
||||
| **Tiny** | <50 lines changed (additions + deletions), 1-2 files |
|
||||
| **Small** | 50-200 lines, 1-5 files |
|
||||
| **Medium** | 200-500 lines, 3-10 files |
|
||||
| **Large** | 500-2000 lines, 5-20 files |
|
||||
| **XL** | 2000+ lines or 20+ files |
|
||||
|
||||
## Step 5: Classify as fix vs. architectural
|
||||
|
||||
For each PR, determine its nature:
|
||||
|
||||
### Fixes (merge fast)
|
||||
- Bug fixes with clear root cause
|
||||
- Security patches
|
||||
- Crash/panic prevention
|
||||
- Typo/doc corrections
|
||||
- Code quality (removing .unwrap(), etc.)
|
||||
|
||||
### Features (standard review)
|
||||
- New functionality within existing patterns
|
||||
- New tool implementations
|
||||
- Configuration additions
|
||||
- Test additions
|
||||
|
||||
### Architectural (deep review needed)
|
||||
- New modules or subsystems
|
||||
- Changes to core traits or interfaces
|
||||
- New database backends or storage engines
|
||||
- New provider abstractions
|
||||
- Changes touching 5+ modules
|
||||
- Anything modifying the agent loop, session model, or security layer
|
||||
- New dependencies (check Cargo.toml changes)
|
||||
|
||||
## Step 6: Detect conflicts and superseded PRs
|
||||
|
||||
Check for:
|
||||
- Multiple PRs fixing the same issue (look at "Closes #N" / "Fixes #N" in PR bodies)
|
||||
- PRs touching the same files (potential merge conflicts)
|
||||
- PRs that are follow-ups to other open PRs (dependency chains)
|
||||
- PRs superseded by recently merged work
|
||||
|
||||
## Step 7: Produce the dashboard
|
||||
|
||||
Present the output in this format:
|
||||
|
||||
### Quick Stats
|
||||
```
|
||||
Open: N | Draft: N | Needs review: N | Changes requested: N | Ready to merge: N
|
||||
```
|
||||
|
||||
### Ready to Merge
|
||||
PRs that are approved, CI passing, and non-draft. List with one-line summary.
|
||||
|
||||
### Needs Human Review (Fixes)
|
||||
Fixes that have no human review yet, sorted by severity (security > crash > bug > quality).
|
||||
|
||||
### Needs Human Review (Features)
|
||||
Features with no human review, sorted by scope (smallest first).
|
||||
|
||||
### Needs Deep Architectural Review
|
||||
Large/XL PRs, new modules, or cross-cutting changes. For each, include:
|
||||
- Which modules are affected
|
||||
- What new patterns or abstractions are introduced
|
||||
- Key risk areas to focus review on
|
||||
|
||||
### Changes Requested (Waiting on Author)
|
||||
PRs where a reviewer asked for changes. Include who requested and a 1-line summary of what's needed.
|
||||
|
||||
### Stale / Blocked
|
||||
PRs with no activity >7 days, or blocked by other PRs.
|
||||
|
||||
### Conflicts & Overlaps
|
||||
Any detected conflicts, superseded PRs, or dependency chains.
|
||||
|
||||
### By Module
|
||||
Group all PRs by their primary module in a compact table:
|
||||
|
||||
| Module | PRs | Key PR to review first |
|
||||
|--------|-----|----------------------|
|
||||
|
||||
### Superseded PRs (recommend closing)
|
||||
PRs that are clearly superseded by merged work. Include reasoning.
|
||||
|
||||
## Rules
|
||||
|
||||
- Use `gh` CLI for all GitHub operations. Never guess PR state — always check.
|
||||
- For large PR lists (>15), use the Task tool to parallelize fetching PR details and diffs.
|
||||
- Be concise in summaries. One line per PR in tables.
|
||||
- When assessing "ready to merge", be conservative. If there's any unresolved concern from a repo member, it's not ready.
|
||||
- Flag any PR that has been open >14 days with no review as needing attention.
|
||||
- If a PR description says "Closes #N" but #N was already closed by another merged PR, flag it as potentially superseded.
|
||||
- Do NOT post comments or take any action on PRs. This skill is read-only analysis.
|
||||
@@ -0,0 +1,8 @@
|
||||
target/
|
||||
.git/
|
||||
.env
|
||||
.env.*
|
||||
*.md
|
||||
!CLAUDE.md
|
||||
node_modules/
|
||||
tools-src/
|
||||
+58
-8
@@ -1,15 +1,59 @@
|
||||
# Database Configuration
|
||||
DATABASE_URL=postgres://ironclaw:password@localhost:5432/ironclaw
|
||||
DATABASE_URL=postgres://localhost/ironclaw
|
||||
DATABASE_POOL_SIZE=10
|
||||
|
||||
# LLM Provider (NEAR AI)
|
||||
# NEAR AI provides a unified interface to all models with user authentication
|
||||
# Session token is stored in ~/.near-agent/session.json and managed automatically.
|
||||
# On first run, the agent will open a browser for OAuth authentication.
|
||||
NEARAI_MODEL=claude-3-5-sonnet-20241022
|
||||
NEARAI_BASE_URL=https://cloud-api.near.ai
|
||||
# LLM Provider
|
||||
# LLM_BACKEND=nearai # default
|
||||
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, tinfoil
|
||||
|
||||
# === NEAR AI (Chat Completions API) ===
|
||||
# Two auth modes:
|
||||
# 1. Session token (default): Uses browser OAuth (GitHub/Google) on first run.
|
||||
# Session token stored in ~/.ironclaw/session.json automatically.
|
||||
# Base URL defaults to https://private.near.ai
|
||||
# 2. API key: Set NEARAI_API_KEY to use API key auth from cloud.near.ai.
|
||||
# Base URL defaults to https://cloud-api.near.ai
|
||||
NEARAI_MODEL=zai-org/GLM-5-FP8
|
||||
NEARAI_BASE_URL=https://private.near.ai
|
||||
NEARAI_AUTH_URL=https://private.near.ai
|
||||
# NEARAI_SESSION_PATH=~/.near-agent/session.json # optional, default shown
|
||||
# NEARAI_SESSION_TOKEN=sess_... # hosting providers: set this
|
||||
# NEARAI_SESSION_PATH=~/.ironclaw/session.json # optional, default shown
|
||||
# NEARAI_API_KEY=... # API key from cloud.near.ai
|
||||
|
||||
# Local LLM Providers (Ollama, LM Studio, vLLM, LiteLLM)
|
||||
|
||||
# === Ollama ===
|
||||
# OLLAMA_MODEL=llama3.2
|
||||
# LLM_BACKEND=ollama
|
||||
# OLLAMA_BASE_URL=http://localhost:11434 # default
|
||||
|
||||
# === OpenAI-compatible (LM Studio, vLLM, Anything-LLM) ===
|
||||
# LLM_MODEL=llama-3.2-3b-instruct-q4_K_M
|
||||
# LLM_BACKEND=openai_compatible
|
||||
# LLM_BASE_URL=http://localhost:1234/v1
|
||||
# LLM_API_KEY=sk-... # optional for local servers
|
||||
|
||||
# === OpenRouter (300+ models via OpenAI-compatible) ===
|
||||
# LLM_MODEL=anthropic/claude-sonnet-4 # see openrouter.ai/models for IDs
|
||||
# LLM_BACKEND=openai_compatible
|
||||
# LLM_BASE_URL=https://openrouter.ai/api/v1
|
||||
# LLM_API_KEY=sk-or-...
|
||||
# LLM_EXTRA_HEADERS=HTTP-Referer:https://myapp.com,X-Title:MyApp
|
||||
|
||||
# === Together AI (via OpenAI-compatible) ===
|
||||
# LLM_MODEL=meta-llama/Llama-3.3-70B-Instruct-Turbo
|
||||
# LLM_BACKEND=openai_compatible
|
||||
# LLM_BASE_URL=https://api.together.xyz/v1
|
||||
# LLM_API_KEY=...
|
||||
|
||||
# === Fireworks AI (via OpenAI-compatible) ===
|
||||
# LLM_MODEL=accounts/fireworks/models/llama4-maverick-instruct-basic
|
||||
# LLM_BACKEND=openai_compatible
|
||||
# LLM_BASE_URL=https://api.fireworks.ai/inference/v1
|
||||
# LLM_API_KEY=fw_...
|
||||
|
||||
# For full provider setup guide see docs/LLM_PROVIDERS.md
|
||||
|
||||
|
||||
# Channel Configuration
|
||||
# CLI is always enabled
|
||||
@@ -46,6 +90,12 @@ HEARTBEAT_INTERVAL_SECS=1800
|
||||
HEARTBEAT_NOTIFY_CHANNEL=cli
|
||||
HEARTBEAT_NOTIFY_USER=default
|
||||
|
||||
# Memory hygiene settings (automatic cleanup of stale workspace documents)
|
||||
# Runs on each heartbeat tick; identity files (IDENTITY.md, SOUL.md) are never deleted
|
||||
# MEMORY_HYGIENE_ENABLED=true
|
||||
# MEMORY_HYGIENE_RETENTION_DAYS=30 # delete daily/ docs older than this many days
|
||||
# MEMORY_HYGIENE_CADENCE_HOURS=12 # minimum hours between cleanup passes
|
||||
|
||||
# Safety settings
|
||||
SAFETY_MAX_OUTPUT_LENGTH=100000
|
||||
SAFETY_INJECTION_CHECK_ENABLED=true
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
tests/test-pages/**/*.html linguist-generated=true
|
||||
@@ -0,0 +1,166 @@
|
||||
# Scope labels for actions/labeler@v6
|
||||
# Maps file path globs to scope labels. Multiple labels can apply per PR.
|
||||
|
||||
"scope: agent":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/agent/**
|
||||
|
||||
"scope: channel":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/channels/channel.rs
|
||||
- src/channels/manager.rs
|
||||
- src/channels/mod.rs
|
||||
|
||||
"scope: channel/cli":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/channels/cli/**
|
||||
- src/cli/**
|
||||
|
||||
"scope: channel/web":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/channels/web/**
|
||||
|
||||
"scope: channel/wasm":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/channels/wasm/**
|
||||
|
||||
"scope: tool":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/tools/tool.rs
|
||||
- src/tools/registry.rs
|
||||
- src/tools/mod.rs
|
||||
- src/tools/sandbox.rs
|
||||
|
||||
"scope: tool/builtin":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/tools/builtin/**
|
||||
|
||||
"scope: tool/wasm":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/tools/wasm/**
|
||||
|
||||
"scope: tool/mcp":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/tools/mcp/**
|
||||
|
||||
"scope: tool/builder":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/tools/builder/**
|
||||
|
||||
"scope: db":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/db/mod.rs
|
||||
|
||||
"scope: db/postgres":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/db/postgres.rs
|
||||
- migrations/**
|
||||
|
||||
"scope: db/libsql":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/db/libsql_backend.rs
|
||||
- src/db/libsql_migrations.rs
|
||||
|
||||
"scope: safety":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/safety/**
|
||||
|
||||
"scope: llm":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/llm/**
|
||||
|
||||
"scope: workspace":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/workspace/**
|
||||
|
||||
"scope: orchestrator":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/orchestrator/**
|
||||
|
||||
"scope: worker":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/worker/**
|
||||
|
||||
"scope: secrets":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/secrets/**
|
||||
|
||||
"scope: config":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/config.rs
|
||||
- src/settings.rs
|
||||
|
||||
"scope: extensions":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/extensions/**
|
||||
|
||||
"scope: setup":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/setup/**
|
||||
|
||||
"scope: evaluation":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/evaluation/**
|
||||
|
||||
"scope: estimation":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/estimation/**
|
||||
|
||||
"scope: sandbox":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/sandbox/**
|
||||
- Dockerfile*
|
||||
|
||||
"scope: hooks":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/hooks/**
|
||||
|
||||
"scope: pairing":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/pairing/**
|
||||
|
||||
"scope: ci":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- .github/workflows/**
|
||||
- .github/scripts/**
|
||||
|
||||
"scope: docs":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "**/*.md"
|
||||
- docs/**
|
||||
- LICENSE*
|
||||
|
||||
"scope: dependencies":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
# Idempotent label bootstrap for IronClaw PR automation.
|
||||
# Uses `gh label create --force` so it can be re-run safely.
|
||||
#
|
||||
# Usage: bash .github/scripts/create-labels.sh
|
||||
# Requires: gh CLI authenticated with repo scope
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v gh &>/dev/null; then
|
||||
echo "Error: gh CLI is required. Install from https://cli.github.com" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
create() {
|
||||
local name="$1" color="$2" description="$3"
|
||||
gh label create "$name" --color "$color" --description "$description" --force
|
||||
}
|
||||
|
||||
echo "==> Creating size labels..."
|
||||
create "size: XS" "F9D0C4" "< 10 changed lines (excluding docs)"
|
||||
create "size: S" "F5A3A3" "10-49 changed lines"
|
||||
create "size: M" "E57373" "50-199 changed lines"
|
||||
create "size: L" "D32F2F" "200-499 changed lines"
|
||||
create "size: XL" "B71C1C" "500+ changed lines"
|
||||
|
||||
echo "==> Creating risk labels..."
|
||||
create "risk: low" "4CAF50" "Changes to docs, tests, or low-risk modules"
|
||||
create "risk: medium" "FFC107" "Business logic, config, or moderate-risk modules"
|
||||
create "risk: high" "F44336" "Safety, secrets, auth, or critical infrastructure"
|
||||
create "risk: manual" "9E9E9E" "Risk level set manually (sticky, not overwritten)"
|
||||
|
||||
echo "==> Creating scope labels..."
|
||||
create "scope: agent" "006B75" "Agent core (agent loop, router, scheduler)"
|
||||
create "scope: channel" "00838F" "Channel infrastructure"
|
||||
create "scope: channel/cli" "00897B" "TUI / CLI channel"
|
||||
create "scope: channel/web" "00796B" "Web gateway channel"
|
||||
create "scope: channel/wasm" "00695C" "WASM channel runtime"
|
||||
create "scope: tool" "1565C0" "Tool infrastructure"
|
||||
create "scope: tool/builtin" "1976D2" "Built-in tools"
|
||||
create "scope: tool/wasm" "1E88E5" "WASM tool sandbox"
|
||||
create "scope: tool/mcp" "2196F3" "MCP client"
|
||||
create "scope: tool/builder" "42A5F5" "Dynamic tool builder"
|
||||
create "scope: db" "4A148C" "Database trait / abstraction"
|
||||
create "scope: db/postgres" "6A1B9A" "PostgreSQL backend"
|
||||
create "scope: db/libsql" "7B1FA2" "libSQL / Turso backend"
|
||||
create "scope: safety" "880E4F" "Prompt injection defense"
|
||||
create "scope: llm" "4527A0" "LLM integration"
|
||||
create "scope: workspace" "283593" "Persistent memory / workspace"
|
||||
create "scope: orchestrator" "0D47A1" "Container orchestrator"
|
||||
create "scope: worker" "01579B" "Container worker"
|
||||
create "scope: secrets" "BF360C" "Secrets management"
|
||||
create "scope: config" "E65100" "Configuration"
|
||||
create "scope: extensions" "33691E" "Extension management"
|
||||
create "scope: setup" "827717" "Onboarding / setup"
|
||||
create "scope: evaluation" "558B2F" "Success evaluation"
|
||||
create "scope: estimation" "9E9D24" "Cost/time estimation"
|
||||
create "scope: sandbox" "00BFA5" "Docker sandbox"
|
||||
create "scope: hooks" "6D4C41" "Git/event hooks"
|
||||
create "scope: pairing" "4E342E" "Pairing mode"
|
||||
create "scope: ci" "546E7A" "CI/CD workflows"
|
||||
create "scope: docs" "78909C" "Documentation"
|
||||
create "scope: dependencies" "90A4AE" "Dependency updates"
|
||||
|
||||
echo "==> Creating contributor labels..."
|
||||
create "contributor: new" "FFF9C4" "First-time contributor"
|
||||
create "contributor: regular" "FFE082" "2-5 merged PRs"
|
||||
create "contributor: experienced" "FFB74D" "6-19 merged PRs"
|
||||
create "contributor: core" "FF8A65" "20+ merged PRs"
|
||||
|
||||
echo "Done. All labels created/updated."
|
||||
Executable
+139
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env bash
|
||||
# Classify a PR by size, risk, and contributor tier.
|
||||
# Called by the pr-label-classify workflow.
|
||||
#
|
||||
# Inputs (env vars):
|
||||
# PR_NUMBER — pull request number
|
||||
# REPO — owner/repo (e.g. "user/ironclaw")
|
||||
#
|
||||
# Requires: gh CLI, jq
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PR_NUMBER="${PR_NUMBER:?PR_NUMBER is required}"
|
||||
REPO="${REPO:?REPO is required}"
|
||||
|
||||
# ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
# Remove all labels in a dimension except the desired one.
|
||||
# Usage: set_exclusive_label "size" "size: M"
|
||||
set_exclusive_label() {
|
||||
local prefix="$1" desired="$2"
|
||||
|
||||
# Fetch current labels on the PR
|
||||
local current
|
||||
current=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json labels --jq '.labels[].name')
|
||||
|
||||
# Remove any existing label with the same prefix
|
||||
while IFS= read -r label; do
|
||||
[[ -z "$label" ]] && continue
|
||||
if [[ "$label" == "${prefix}:"* && "$label" != "$desired" ]]; then
|
||||
gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "$label" 2>/dev/null || true
|
||||
fi
|
||||
done <<< "$current"
|
||||
|
||||
# Add the desired label
|
||||
gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label "$desired"
|
||||
}
|
||||
|
||||
# ─── size ───────────────────────────────────────────────────────────────────
|
||||
|
||||
classify_size() {
|
||||
# Sum changed lines across non-doc files
|
||||
local total
|
||||
total=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" \
|
||||
--paginate --jq '
|
||||
[.[] | select(.filename | test("\\.(md|txt|rst|adoc)$") | not) | .changes]
|
||||
| add // 0
|
||||
')
|
||||
|
||||
local label
|
||||
if (( total < 10 )); then label="size: XS"
|
||||
elif (( total < 50 )); then label="size: S"
|
||||
elif (( total < 200 )); then label="size: M"
|
||||
elif (( total < 500 )); then label="size: L"
|
||||
else label="size: XL"
|
||||
fi
|
||||
|
||||
echo "Size: ${total} changed lines -> ${label}"
|
||||
set_exclusive_label "size" "$label"
|
||||
}
|
||||
|
||||
# ─── risk ───────────────────────────────────────────────────────────────────
|
||||
|
||||
classify_risk() {
|
||||
# If "risk: manual" is present, skip — it's a sticky override
|
||||
local current
|
||||
current=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json labels --jq '.labels[].name')
|
||||
if echo "$current" | grep -qx "risk: manual"; then
|
||||
echo "Risk: skipped (manual override)"
|
||||
return
|
||||
fi
|
||||
|
||||
# Fetch changed file paths
|
||||
local files
|
||||
files=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" \
|
||||
--paginate --jq '.[].filename')
|
||||
|
||||
local risk="low"
|
||||
|
||||
while IFS= read -r file; do
|
||||
[[ -z "$file" ]] && continue
|
||||
|
||||
case "$file" in
|
||||
# High risk: safety, secrets, auth, crypto, setup, orchestrator auth
|
||||
src/safety/*|src/secrets/*|src/llm/session.rs|src/orchestrator/auth.rs|\
|
||||
src/channels/web/auth.rs|src/setup/*)
|
||||
risk="high"
|
||||
break # can't go higher
|
||||
;;
|
||||
|
||||
# Medium risk: agent core, config, database, worker, tools, channels
|
||||
src/agent/*|src/config.rs|src/settings.rs|src/db/*|src/worker/*|\
|
||||
src/tools/*|src/channels/*|src/orchestrator/*|src/context/*|\
|
||||
src/hooks/*|src/sandbox/*|src/extensions/*|Cargo.toml|\
|
||||
.github/workflows/*)
|
||||
# Only upgrade, never downgrade
|
||||
[[ "$risk" != "high" ]] && risk="medium"
|
||||
;;
|
||||
|
||||
# Low risk: docs, tests, estimation, evaluation, history, etc.
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
done <<< "$files"
|
||||
|
||||
echo "Risk: ${risk}"
|
||||
set_exclusive_label "risk" "risk: ${risk}"
|
||||
}
|
||||
|
||||
# ─── contributor tier ───────────────────────────────────────────────────────
|
||||
|
||||
classify_contributor() {
|
||||
# Get PR author
|
||||
local author
|
||||
author=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json author --jq '.author.login')
|
||||
|
||||
# Count merged PRs by this author in this repo
|
||||
local count
|
||||
count=$(gh pr list --repo "$REPO" --state merged --author "$author" \
|
||||
--limit 100 --json number --jq 'length')
|
||||
|
||||
local label
|
||||
if (( count == 0 )); then label="contributor: new"
|
||||
elif (( count < 6 )); then label="contributor: regular"
|
||||
elif (( count < 20 )); then label="contributor: experienced"
|
||||
else label="contributor: core"
|
||||
fi
|
||||
|
||||
echo "Contributor: ${author} has ${count} merged PRs -> ${label}"
|
||||
set_exclusive_label "contributor" "$label"
|
||||
}
|
||||
|
||||
# ─── main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "Classifying PR #${PR_NUMBER} in ${REPO}..."
|
||||
classify_size
|
||||
classify_risk
|
||||
classify_contributor
|
||||
echo "Done."
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Code Style
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
codestyle:
|
||||
name: Code Style (fmt + clippy)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
profile: minimal
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Check formatting
|
||||
run: |
|
||||
cargo fmt --all -- --check
|
||||
- name: Check lints (cargo clippy)
|
||||
run: cargo clippy -- -D warnings
|
||||
@@ -0,0 +1,26 @@
|
||||
name: "PR: Classify (Size, Risk, Contributor)"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: read # needed for search/issues API (contributor count)
|
||||
|
||||
jobs:
|
||||
classify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.ref }}
|
||||
|
||||
- name: Classify PR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: bash .github/scripts/pr-labeler.sh
|
||||
@@ -0,0 +1,18 @@
|
||||
name: "PR: Scope Labels"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
scope:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@v5
|
||||
with:
|
||||
configuration-path: .github/labeler.yml
|
||||
sync-labels: false # additive only — never remove scope labels
|
||||
@@ -0,0 +1,67 @@
|
||||
name: Release-plz
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
|
||||
# Release unpublished packages.
|
||||
release-plz-release:
|
||||
if: ${{ github.repository_owner == 'nearai' }}
|
||||
name: Release-plz release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- &checkout
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- &install-rust
|
||||
name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# Generating a GitHub token, so that PRs and tags created by
|
||||
# the release-plz-action can trigger actions workflows.
|
||||
- name: Generate GitHub token
|
||||
uses: actions/create-github-app-token@v2
|
||||
id: generate-token
|
||||
with:
|
||||
# GitHub App ID secret name
|
||||
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
|
||||
# GitHub App private key secret name
|
||||
private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }}
|
||||
- name: Run release-plz
|
||||
uses: release-plz/[email protected]
|
||||
with:
|
||||
command: release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
|
||||
# Create a PR with the new versions and changelog, preparing the next release.
|
||||
release-plz-pr:
|
||||
if: ${{ github.repository_owner == 'nearai' }}
|
||||
name: Release-plz PR
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
concurrency:
|
||||
group: release-plz-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- *checkout
|
||||
- *install-rust
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Run release-plz
|
||||
uses: release-plz/[email protected]
|
||||
with:
|
||||
command: release-pr
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
@@ -0,0 +1,398 @@
|
||||
# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist
|
||||
#
|
||||
# Copyright 2022-2024, axodotdev
|
||||
# SPDX-License-Identifier: MIT or Apache-2.0
|
||||
#
|
||||
# CI that:
|
||||
#
|
||||
# * checks for a Git Tag that looks like a release
|
||||
# * builds artifacts with dist (archives, installers, hashes)
|
||||
# * uploads those artifacts to temporary workflow zip
|
||||
# * on success, uploads the artifacts to a GitHub Release
|
||||
#
|
||||
# Note that the GitHub Release will be created with a generated
|
||||
# title/body based on your changelogs.
|
||||
|
||||
name: Release
|
||||
permissions:
|
||||
"contents": "write"
|
||||
|
||||
# This task will run whenever you push a git tag that looks like a version
|
||||
# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc.
|
||||
# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where
|
||||
# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION
|
||||
# must be a Cargo-style SemVer Version (must have at least major.minor.patch).
|
||||
#
|
||||
# If PACKAGE_NAME is specified, then the announcement will be for that
|
||||
# package (erroring out if it doesn't have the given version or isn't dist-able).
|
||||
#
|
||||
# If PACKAGE_NAME isn't specified, then the announcement will be for all
|
||||
# (dist-able) packages in the workspace with that version (this mode is
|
||||
# intended for workspaces with only one dist-able package, or with all dist-able
|
||||
# packages versioned/released in lockstep).
|
||||
#
|
||||
# If you push multiple tags at once, separate instances of this workflow will
|
||||
# spin up, creating an independent announcement for each one. However, GitHub
|
||||
# will hard limit this to 3 tags per commit, as it will assume more tags is a
|
||||
# mistake.
|
||||
#
|
||||
# If there's a prerelease-style suffix to the version, then the release(s)
|
||||
# will be marked as a prerelease.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '**[0-9]+.[0-9]+.[0-9]+*'
|
||||
|
||||
jobs:
|
||||
# Run 'dist plan' (or host) to determine what tasks we need to do
|
||||
plan:
|
||||
runs-on: "ubuntu-22.04"
|
||||
outputs:
|
||||
val: ${{ steps.plan.outputs.manifest }}
|
||||
tag: ${{ !github.event.pull_request && github.ref_name || '' }}
|
||||
tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }}
|
||||
publishing: ${{ !github.event.pull_request }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install dist
|
||||
# we specify bash to get pipefail; it guards against the `curl` command
|
||||
# failing. otherwise `sh` won't catch that `curl` returned non-0
|
||||
shell: bash
|
||||
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.3/cargo-dist-installer.sh | sh"
|
||||
- name: Cache dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cargo-dist-cache
|
||||
path: ~/.cargo/bin/dist
|
||||
# sure would be cool if github gave us proper conditionals...
|
||||
# so here's a doubly-nested ternary-via-truthiness to try to provide the best possible
|
||||
# functionality based on whether this is a pull_request, and whether it's from a fork.
|
||||
# (PRs run on the *source* but secrets are usually on the *target* -- that's *good*
|
||||
# but also really annoying to build CI around when it needs secrets to work right.)
|
||||
- id: plan
|
||||
run: |
|
||||
dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json
|
||||
echo "dist ran successfully"
|
||||
cat plan-dist-manifest.json
|
||||
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
|
||||
- name: "Upload dist-manifest.json"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: artifacts-plan-dist-manifest
|
||||
path: plan-dist-manifest.json
|
||||
|
||||
# Build and packages all the platform-specific things
|
||||
build-local-artifacts:
|
||||
name: build-local-artifacts (${{ join(matrix.targets, ', ') }})
|
||||
# Let the initial task tell us to not run (currently very blunt)
|
||||
needs:
|
||||
- plan
|
||||
if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# Target platforms/runners are computed by dist in create-release.
|
||||
# Each member of the matrix has the following arguments:
|
||||
#
|
||||
# - runner: the github runner
|
||||
# - dist-args: cli flags to pass to dist
|
||||
# - install-dist: expression to run to install dist on the runner
|
||||
#
|
||||
# Typically there will be:
|
||||
# - 1 "global" task that builds universal installers
|
||||
# - N "local" tasks that build each platform's binaries and platform-specific installers
|
||||
matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
container: ${{ matrix.container && matrix.container.image || null }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json
|
||||
steps:
|
||||
- name: enable windows longpaths
|
||||
run: |
|
||||
git config --global core.longpaths true
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install Rust non-interactively if not already installed
|
||||
if: ${{ matrix.container }}
|
||||
run: |
|
||||
if ! command -v cargo > /dev/null 2>&1; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
fi
|
||||
- uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
key: ${{ join(matrix.targets, '-') }}
|
||||
cache-provider: ${{ matrix.cache_provider }}
|
||||
- name: Install dist
|
||||
run: ${{ matrix.install_dist.run }}
|
||||
# Get the dist-manifest
|
||||
- name: Fetch local artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: target/distrib/
|
||||
merge-multiple: true
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
${{ matrix.packages_install }}
|
||||
- name: Build artifacts
|
||||
run: |
|
||||
# Actually do builds and make zips and whatnot
|
||||
dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json
|
||||
echo "dist ran successfully"
|
||||
- id: cargo-dist
|
||||
name: Post-build
|
||||
# We force bash here just because github makes it really hard to get values up
|
||||
# to "real" actions without writing to env-vars, and writing to env-vars has
|
||||
# inconsistent syntax between shell and powershell.
|
||||
shell: bash
|
||||
run: |
|
||||
# Parse out what we just built and upload it to scratch storage
|
||||
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
|
||||
dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
|
||||
- name: "Upload artifacts"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
|
||||
path: |
|
||||
${{ steps.cargo-dist.outputs.paths }}
|
||||
${{ env.BUILD_MANIFEST_NAME }}
|
||||
|
||||
# Build and package all the platform-agnostic(ish) things
|
||||
build-global-artifacts:
|
||||
needs:
|
||||
- plan
|
||||
- build-local-artifacts
|
||||
runs-on: "ubuntu-22.04"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install cached dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: cargo-dist-cache
|
||||
path: ~/.cargo/bin/
|
||||
- run: chmod +x ~/.cargo/bin/dist
|
||||
# Get all the local artifacts for the global tasks to use (for e.g. checksums)
|
||||
- name: Fetch local artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: target/distrib/
|
||||
merge-multiple: true
|
||||
- id: cargo-dist
|
||||
shell: bash
|
||||
run: |
|
||||
dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json
|
||||
echo "dist ran successfully"
|
||||
|
||||
# Parse out what we just built and upload it to scratch storage
|
||||
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
|
||||
jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
|
||||
- name: "Upload artifacts"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: artifacts-build-global
|
||||
path: |
|
||||
${{ steps.cargo-dist.outputs.paths }}
|
||||
${{ env.BUILD_MANIFEST_NAME }}
|
||||
# Build WASM extension bundles (tar.gz with .wasm + .capabilities.json)
|
||||
build-wasm-extensions:
|
||||
needs:
|
||||
- plan
|
||||
if: ${{ needs.plan.outputs.publishing == 'true' }}
|
||||
runs-on: "ubuntu-22.04"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install Rust toolchain + wasm target
|
||||
run: |
|
||||
rustup target add wasm32-wasip2
|
||||
cargo install cargo-component --locked || true
|
||||
- uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
key: wasm-extensions
|
||||
- name: Build and package WASM extensions
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p target/wasm-bundles
|
||||
|
||||
# Process each manifest in registry/tools/ and registry/channels/
|
||||
for manifest in registry/tools/*.json registry/channels/*.json; do
|
||||
[ -f "$manifest" ] || continue
|
||||
|
||||
name=$(jq -r '.name' "$manifest")
|
||||
source_dir=$(jq -r '.source.dir' "$manifest")
|
||||
caps_file=$(jq -r '.source.capabilities' "$manifest")
|
||||
crate_name=$(jq -r '.source.crate_name' "$manifest")
|
||||
|
||||
if [ ! -d "$source_dir" ]; then
|
||||
echo "::warning::Source dir '$source_dir' not found for '$name', skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "=== Building $name from $source_dir ==="
|
||||
|
||||
# Build WASM component
|
||||
cargo component build --release --manifest-path "$source_dir/Cargo.toml" || {
|
||||
echo "::warning::Build failed for '$name', skipping"
|
||||
continue
|
||||
}
|
||||
|
||||
# Find the built WASM file (Cargo uses underscores in artifact names)
|
||||
wasm_artifact="${crate_name//-/_}"
|
||||
wasm_path=""
|
||||
for target_dir in wasm32-wasip2 wasm32-wasip1 wasm32-wasi; do
|
||||
candidate="$source_dir/target/$target_dir/release/${wasm_artifact}.wasm"
|
||||
if [ -f "$candidate" ]; then
|
||||
wasm_path="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$wasm_path" ]; then
|
||||
echo "::warning::No WASM output found for '$name', skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Copy files with standardized names for the archive
|
||||
cp "$wasm_path" "target/wasm-bundles/${name}.wasm"
|
||||
|
||||
caps_path="$source_dir/$caps_file"
|
||||
if [ -f "$caps_path" ]; then
|
||||
cp "$caps_path" "target/wasm-bundles/${name}.capabilities.json"
|
||||
else
|
||||
echo "::warning::No capabilities file at '$caps_path' for '$name'"
|
||||
fi
|
||||
|
||||
# Create tar.gz bundle
|
||||
bundle="target/wasm-bundles/${name}-wasm32-wasip2.tar.gz"
|
||||
(cd target/wasm-bundles && if [ -f "${name}.capabilities.json" ]; then tar czf "${name}-wasm32-wasip2.tar.gz" "${name}.wasm" "${name}.capabilities.json"; else tar czf "${name}-wasm32-wasip2.tar.gz" "${name}.wasm"; fi)
|
||||
|
||||
# Compute SHA256
|
||||
sha256=$(sha256sum "$bundle" | cut -d' ' -f1)
|
||||
echo "$sha256 ${name}-wasm32-wasip2.tar.gz" >> target/wasm-bundles/checksums.txt
|
||||
|
||||
# Clean up intermediate files
|
||||
rm -f "target/wasm-bundles/${name}.wasm" "target/wasm-bundles/${name}.capabilities.json"
|
||||
|
||||
echo " -> $bundle ($sha256)"
|
||||
done
|
||||
|
||||
echo "=== WASM bundles built ==="
|
||||
ls -la target/wasm-bundles/
|
||||
- name: "Upload WASM bundles"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: artifacts-wasm-extensions
|
||||
path: |
|
||||
target/wasm-bundles/*.tar.gz
|
||||
target/wasm-bundles/checksums.txt
|
||||
|
||||
# Determines if we should publish/announce
|
||||
host:
|
||||
needs:
|
||||
- plan
|
||||
- build-local-artifacts
|
||||
- build-global-artifacts
|
||||
- build-wasm-extensions
|
||||
# Only run if we're "publishing", and only if plan, local, global, and wasm didn't fail (skipped is fine)
|
||||
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') && (needs.build-wasm-extensions.result == 'skipped' || needs.build-wasm-extensions.result == 'success') }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
runs-on: "ubuntu-22.04"
|
||||
outputs:
|
||||
val: ${{ steps.host.outputs.manifest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install cached dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: cargo-dist-cache
|
||||
path: ~/.cargo/bin/
|
||||
- run: chmod +x ~/.cargo/bin/dist
|
||||
# Fetch artifacts from scratch-storage
|
||||
- name: Fetch artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: target/distrib/
|
||||
merge-multiple: true
|
||||
- id: host
|
||||
shell: bash
|
||||
run: |
|
||||
dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json
|
||||
echo "artifacts uploaded and released successfully"
|
||||
cat dist-manifest.json
|
||||
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
|
||||
- name: "Upload dist-manifest.json"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
# Overwrite the previous copy
|
||||
name: artifacts-dist-manifest
|
||||
path: dist-manifest.json
|
||||
# Create a GitHub Release while uploading all files to it
|
||||
- name: "Download GitHub Artifacts"
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Cleanup
|
||||
run: |
|
||||
# Remove the granular manifests
|
||||
rm -f artifacts/*-dist-manifest.json
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}"
|
||||
ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}"
|
||||
ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}"
|
||||
RELEASE_COMMIT: "${{ github.sha }}"
|
||||
run: |
|
||||
# Write and read notes from a file to avoid quoting breaking things
|
||||
echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt
|
||||
|
||||
gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/*
|
||||
|
||||
announce:
|
||||
needs:
|
||||
- plan
|
||||
- host
|
||||
# use "always() && ..." to allow us to wait for all publish jobs while
|
||||
# still allowing individual publish jobs to skip themselves (for prereleases).
|
||||
# "host" however must run to completion, no skipping allowed!
|
||||
if: ${{ always() && needs.host.result == 'success' }}
|
||||
runs-on: "ubuntu-22.04"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Run Tests
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
profile: minimal
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Run Tests
|
||||
run: cargo test --all-features -- --nocapture
|
||||
- name: Run Telegram Channel Tests
|
||||
run: cargo test --manifest-path channels-src/telegram/Cargo.toml -- --nocapture
|
||||
+15
@@ -1,6 +1,21 @@
|
||||
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Claude Code worktrees
|
||||
.claude/worktrees/
|
||||
|
||||
# Sidecar tool data
|
||||
.sidecar/
|
||||
.todos/
|
||||
|
||||
target/
|
||||
|
||||
# Benchmark results (local runs, not committed)
|
||||
bench-results/
|
||||
|
||||
# WASM build artifacts (loaded from disk, not bundled)
|
||||
*.wasm
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Agent Rules
|
||||
|
||||
## Feature Parity Update Policy
|
||||
|
||||
- If you change implementation status for any feature tracked in `FEATURE_PARITY.md`, update that file in the same branch.
|
||||
- Do not open a PR that changes feature behavior without checking `FEATURE_PARITY.md` for needed status updates (`❌`, `🚧`, `✅`, notes, and priorities).
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.10.0](https://github.com/nearai/ironclaw/compare/v0.9.0...v0.10.0) - 2026-02-22
|
||||
|
||||
### Added
|
||||
|
||||
- update dashboard favicon ([#309](https://github.com/nearai/ironclaw/pull/309))
|
||||
- add web UI test skill for Chrome extension ([#302](https://github.com/nearai/ironclaw/pull/302))
|
||||
- implement FullJob routine mode with scheduler dispatch ([#288](https://github.com/nearai/ironclaw/pull/288))
|
||||
- hot-activate WASM channels, channel-first prompts, unified artifact resolution ([#297](https://github.com/nearai/ironclaw/pull/297))
|
||||
- add pairing/permission system to all WASM channels and fix extension registry ([#286](https://github.com/nearai/ironclaw/pull/286))
|
||||
- group chat privacy, channel-aware prompts, and safety hardening ([#285](https://github.com/nearai/ironclaw/pull/285))
|
||||
- embedded registry catalog and WASM bundle install pipeline ([#283](https://github.com/nearai/ironclaw/pull/283))
|
||||
- show token usage and cost tracker in gateway status popover ([#284](https://github.com/nearai/ironclaw/pull/284))
|
||||
- support custom HTTP headers for OpenAI-compatible provider ([#269](https://github.com/nearai/ironclaw/pull/269))
|
||||
- add smart routing provider for cost-optimized model selection ([#281](https://github.com/nearai/ironclaw/pull/281))
|
||||
|
||||
### Fixed
|
||||
|
||||
- persist user message at turn start before agentic loop ([#305](https://github.com/nearai/ironclaw/pull/305))
|
||||
- block send until thread is selected ([#306](https://github.com/nearai/ironclaw/pull/306))
|
||||
- reload chat history on SSE reconnect ([#307](https://github.com/nearai/ironclaw/pull/307))
|
||||
- map Esc to interrupt and Ctrl+C to graceful quit ([#267](https://github.com/nearai/ironclaw/pull/267))
|
||||
|
||||
### Other
|
||||
|
||||
- Fix tool schema OpenAI compatibility ([#301](https://github.com/nearai/ironclaw/pull/301))
|
||||
- simplify config resolution and consolidate main.rs init ([#287](https://github.com/nearai/ironclaw/pull/287))
|
||||
- Update image source in README.md
|
||||
- Add files via upload
|
||||
- remove ExtensionSource::Bundled, use download-only install for WASM channels ([#293](https://github.com/nearai/ironclaw/pull/293))
|
||||
- allow OAuth callback to work on remote servers (fixes #186) ([#212](https://github.com/nearai/ironclaw/pull/212))
|
||||
- add rate limiting for built-in tools (closes #171) ([#276](https://github.com/nearai/ironclaw/pull/276))
|
||||
- add LLM providers guide (OpenRouter, Together AI, Fireworks, Ollama, vLLM) ([#193](https://github.com/nearai/ironclaw/pull/193))
|
||||
- Feat/html to markdown #106 ([#115](https://github.com/nearai/ironclaw/pull/115))
|
||||
- adopt agent-market design language for web UI ([#282](https://github.com/nearai/ironclaw/pull/282))
|
||||
- speed up startup from ~15s to ~2s ([#280](https://github.com/nearai/ironclaw/pull/280))
|
||||
- consolidate tool approval into single param-aware method ([#274](https://github.com/nearai/ironclaw/pull/274))
|
||||
|
||||
## [0.9.0](https://github.com/nearai/ironclaw/compare/v0.8.0...v0.9.0) - 2026-02-21
|
||||
|
||||
### Added
|
||||
|
||||
- add TEE attestation shield to web gateway UI ([#275](https://github.com/nearai/ironclaw/pull/275))
|
||||
- configurable tool iterations, auto-approve, and policy fix ([#251](https://github.com/nearai/ironclaw/pull/251))
|
||||
|
||||
### Fixed
|
||||
|
||||
- add X-Accel-Buffering header to SSE endpoints ([#277](https://github.com/nearai/ironclaw/pull/277))
|
||||
|
||||
## [0.8.0](https://github.com/nearai/ironclaw/compare/ironclaw-v0.7.0...ironclaw-v0.8.0) - 2026-02-20
|
||||
|
||||
### Added
|
||||
|
||||
- extension registry with metadata catalog and onboarding integration ([#238](https://github.com/nearai/ironclaw/pull/238))
|
||||
- *(models)* add GPT-5.3 Codex, full GPT-5.x family, Claude 4.x series, o4-mini ([#197](https://github.com/nearai/ironclaw/pull/197))
|
||||
- wire memory hygiene into the heartbeat loop ([#195](https://github.com/nearai/ironclaw/pull/195))
|
||||
|
||||
### Fixed
|
||||
|
||||
- persist WASM channel workspace writes across callbacks ([#264](https://github.com/nearai/ironclaw/pull/264))
|
||||
- consolidate per-module ENV_MUTEX into crate-wide test lock ([#246](https://github.com/nearai/ironclaw/pull/246))
|
||||
- remove auto-proceed fake user message injection from agent loop ([#255](https://github.com/nearai/ironclaw/pull/255))
|
||||
- onboarding errors reset flow and remote server auth (#185, #186) ([#248](https://github.com/nearai/ironclaw/pull/248))
|
||||
- parallelize tool call execution via JoinSet ([#219](https://github.com/nearai/ironclaw/pull/219)) ([#252](https://github.com/nearai/ironclaw/pull/252))
|
||||
- prevent pipe deadlock in shell command execution ([#140](https://github.com/nearai/ironclaw/pull/140))
|
||||
- persist turns after approval and add agent-level tests ([#250](https://github.com/nearai/ironclaw/pull/250))
|
||||
|
||||
### Other
|
||||
|
||||
- add automated PR labeling system ([#253](https://github.com/nearai/ironclaw/pull/253))
|
||||
- update CLAUDE.md for recently merged features ([#183](https://github.com/nearai/ironclaw/pull/183))
|
||||
|
||||
## [0.7.0](https://github.com/nearai/ironclaw/compare/ironclaw-v0.6.0...ironclaw-v0.7.0) - 2026-02-19
|
||||
|
||||
### Added
|
||||
|
||||
- extend lifecycle hooks with declarative bundles ([#176](https://github.com/nearai/ironclaw/pull/176))
|
||||
- support per-request model override in /v1/chat/completions ([#103](https://github.com/nearai/ironclaw/pull/103))
|
||||
|
||||
### Fixed
|
||||
|
||||
- harden openai-compatible provider, approval replay, and embeddings defaults ([#237](https://github.com/nearai/ironclaw/pull/237))
|
||||
- Network Security Findings ([#201](https://github.com/nearai/ironclaw/pull/201))
|
||||
|
||||
### Added
|
||||
|
||||
- Refactored OpenAI-compatible chat completion routing to use the rig adapter and `RetryProvider` composition for custom base URL usage.
|
||||
- Added Ollama embeddings provider support (`EMBEDDING_PROVIDER=ollama`, `OLLAMA_BASE_URL`) in workspace embeddings.
|
||||
- Added migration `V9__flexible_embedding_dimension.sql` for flexible embedding vector dimensions.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed default sandbox image to `ironclaw-worker:latest` in config/settings/sandbox defaults.
|
||||
- Improved tool-message sanitization and provider compatibility handling across NEAR AI, rig adapter, and shared LLM provider code.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed approval-input aliases (`a`, `/approve`, `/always`, `/deny`, etc.) in submission parsing.
|
||||
- Fixed multi-tool approval resume flow by preserving and replaying deferred tool calls so all prior `tool_use` IDs receive matching `tool_result` messages.
|
||||
- Fixed REPL quit/exit handling to route shutdown through the agent loop for graceful termination.
|
||||
|
||||
## [0.6.0](https://github.com/nearai/ironclaw/compare/ironclaw-v0.5.0...ironclaw-v0.6.0) - 2026-02-19
|
||||
|
||||
### Added
|
||||
|
||||
- add issue triage skill ([#200](https://github.com/nearai/ironclaw/pull/200))
|
||||
- add PR triage dashboard skill ([#196](https://github.com/nearai/ironclaw/pull/196))
|
||||
- add OpenRouter usage examples ([#189](https://github.com/nearai/ironclaw/pull/189))
|
||||
- add Tinfoil private inference provider ([#62](https://github.com/nearai/ironclaw/pull/62))
|
||||
- shell env scrubbing and command injection detection ([#164](https://github.com/nearai/ironclaw/pull/164))
|
||||
- Add PR review tools, job monitor, and channel injection for E2E sandbox workflows ([#57](https://github.com/nearai/ironclaw/pull/57))
|
||||
- Secure prompt-based skills system (Phases 1-4) ([#51](https://github.com/nearai/ironclaw/pull/51))
|
||||
- Add benchmarking harness with spot suite ([#10](https://github.com/nearai/ironclaw/pull/10))
|
||||
- 10 infrastructure improvements from zeroclaw ([#126](https://github.com/nearai/ironclaw/pull/126))
|
||||
|
||||
### Fixed
|
||||
|
||||
- *(rig)* prevent OpenAI Responses API panic on tool call IDs ([#182](https://github.com/nearai/ironclaw/pull/182))
|
||||
- *(docs)* correct settings storage path in README ([#194](https://github.com/nearai/ironclaw/pull/194))
|
||||
- OpenAI tool calling — schema normalization, missing types, and Responses API panic ([#132](https://github.com/nearai/ironclaw/pull/132))
|
||||
- *(security)* prevent path traversal bypass in WASM HTTP allowlist ([#137](https://github.com/nearai/ironclaw/pull/137))
|
||||
- persist OpenAI-compatible provider and respect embeddings disable ([#177](https://github.com/nearai/ironclaw/pull/177))
|
||||
- remove .expect() calls in FailoverProvider::try_providers ([#156](https://github.com/nearai/ironclaw/pull/156))
|
||||
- sentinel value collision in FailoverProvider cooldown ([#125](https://github.com/nearai/ironclaw/pull/125)) ([#154](https://github.com/nearai/ironclaw/pull/154))
|
||||
- skills module audit cleanup ([#173](https://github.com/nearai/ironclaw/pull/173))
|
||||
|
||||
### Other
|
||||
|
||||
- Fix division by zero panic in ValueEstimator::is_profitable ([#139](https://github.com/nearai/ironclaw/pull/139))
|
||||
- audit feature parity matrix against codebase and recent commits ([#202](https://github.com/nearai/ironclaw/pull/202))
|
||||
- architecture improvements for contributor velocity ([#198](https://github.com/nearai/ironclaw/pull/198))
|
||||
- fix rustfmt formatting from PR #137
|
||||
- add .env.example examples for Ollama and OpenAI-compatible ([#110](https://github.com/nearai/ironclaw/pull/110))
|
||||
|
||||
## [0.5.0](https://github.com/nearai/ironclaw/compare/v0.4.0...v0.5.0) - 2026-02-17
|
||||
|
||||
### Added
|
||||
|
||||
- add cooldown management to FailoverProvider ([#114](https://github.com/nearai/ironclaw/pull/114))
|
||||
|
||||
## [0.4.0](https://github.com/nearai/ironclaw/compare/v0.3.0...v0.4.0) - 2026-02-17
|
||||
|
||||
### Added
|
||||
|
||||
- move per-invocation approval check into Tool trait ([#119](https://github.com/nearai/ironclaw/pull/119))
|
||||
- add polished boot screen on CLI startup ([#118](https://github.com/nearai/ironclaw/pull/118))
|
||||
- Add lifecycle hooks system with 6 interception points ([#18](https://github.com/nearai/ironclaw/pull/18))
|
||||
|
||||
### Other
|
||||
|
||||
- remove accidentally committed .sidecar and .todos directories ([#123](https://github.com/nearai/ironclaw/pull/123))
|
||||
|
||||
## [0.3.0](https://github.com/nearai/ironclaw/compare/v0.2.0...v0.3.0) - 2026-02-17
|
||||
|
||||
### Added
|
||||
|
||||
- direct api key and cheap model ([#116](https://github.com/nearai/ironclaw/pull/116))
|
||||
|
||||
## [0.2.0](https://github.com/nearai/ironclaw/compare/v0.1.3...v0.2.0) - 2026-02-16
|
||||
|
||||
### Added
|
||||
|
||||
- mark Ollama + OpenAI-compatible as implemented ([#102](https://github.com/nearai/ironclaw/pull/102))
|
||||
- multi-provider inference + libSQL onboarding selection ([#92](https://github.com/nearai/ironclaw/pull/92))
|
||||
- add multi-provider LLM failover with retry backoff ([#28](https://github.com/nearai/ironclaw/pull/28))
|
||||
- add libSQL/Turso embedded database backend ([#47](https://github.com/nearai/ironclaw/pull/47))
|
||||
- Move debug log truncation from agent loop to REPL channel ([#65](https://github.com/nearai/ironclaw/pull/65))
|
||||
|
||||
### Fixed
|
||||
|
||||
- shell destructive-command check bypassed by Value::Object arguments ([#72](https://github.com/nearai/ironclaw/pull/72))
|
||||
- propagate real tool_call_id instead of hardcoded placeholder ([#73](https://github.com/nearai/ironclaw/pull/73))
|
||||
- Fix wasm tool schemas and runtime ([#42](https://github.com/nearai/ironclaw/pull/42))
|
||||
- flatten tool messages for NEAR AI cloud-api compatibility ([#41](https://github.com/nearai/ironclaw/pull/41))
|
||||
- security hardening across all layers ([#35](https://github.com/nearai/ironclaw/pull/35))
|
||||
|
||||
### Other
|
||||
|
||||
- Explicitly enable cargo-dist caching for binary artifacts building
|
||||
- Skip building binary artifacts on every PR
|
||||
- add module specification rules to CLAUDE.md
|
||||
- add setup/onboarding specification (src/setup/README.md)
|
||||
- deduplicate tool code and remove dead stubs ([#98](https://github.com/nearai/ironclaw/pull/98))
|
||||
- Reformat architecture diagram in README ([#64](https://github.com/nearai/ironclaw/pull/64))
|
||||
- Add review discipline guidelines to CLAUDE.md ([#68](https://github.com/nearai/ironclaw/pull/68))
|
||||
- Bump MSRV to 1.92, add GCP deployment files ([#40](https://github.com/nearai/ironclaw/pull/40))
|
||||
- Add OpenAI-compatible HTTP API (/v1/chat/completions, /v1/models) ([#31](https://github.com/nearai/ironclaw/pull/31))
|
||||
|
||||
|
||||
## [0.1.3](https://github.com/nearai/ironclaw/compare/v0.1.2...v0.1.3) - 2026-02-12
|
||||
|
||||
### Other
|
||||
|
||||
- Enabled builds caching during CI/CD
|
||||
- Disabled npm publishing as the name is already taken
|
||||
|
||||
## [0.1.2](https://github.com/nearai/ironclaw/compare/v0.1.1...v0.1.2) - 2026-02-12
|
||||
|
||||
### Other
|
||||
|
||||
- Added Installation instructions for the pre-built binaries
|
||||
- Disabled Windows ARM64 builds as auto-updater [provided by cargo-dist] does not support this platform yet and it is not a common platform for us to support
|
||||
|
||||
## [0.1.1](https://github.com/nearai/ironclaw/compare/v0.1.0...v0.1.1) - 2026-02-12
|
||||
|
||||
### Other
|
||||
|
||||
- Renamed the secrets in release-plz.yml to match the configuration
|
||||
- Make sure that the binaries release CD it kicking in after release-plz
|
||||
|
||||
## [0.1.0](https://github.com/nearai/ironclaw/releases/tag/v0.1.0) - 2026-02-12
|
||||
|
||||
### Added
|
||||
|
||||
- Add multi-provider LLM support via rig-core adapter ([#36](https://github.com/nearai/ironclaw/pull/36))
|
||||
- Sandbox jobs ([#4](https://github.com/nearai/ironclaw/pull/4))
|
||||
- Add Google Suite & Telegram WASM tools ([#9](https://github.com/nearai/ironclaw/pull/9))
|
||||
- Improve CLI ([#5](https://github.com/nearai/ironclaw/pull/5))
|
||||
|
||||
### Fixed
|
||||
|
||||
- resolve runtime panic in Linux keychain integration ([#32](https://github.com/nearai/ironclaw/pull/32))
|
||||
|
||||
### Other
|
||||
|
||||
- Skip release-plz on forks
|
||||
- Upgraded release-plz CD pipeline
|
||||
- Added CI/CD and release pipelines ([#45](https://github.com/nearai/ironclaw/pull/45))
|
||||
- DM pairing + Telegram channel improvements ([#17](https://github.com/nearai/ironclaw/pull/17))
|
||||
- Fixes build, adds missing sse event and correct command ([#11](https://github.com/nearai/ironclaw/pull/11))
|
||||
- Codex/feature parity pr hook ([#6](https://github.com/nearai/ironclaw/pull/6))
|
||||
- Add WebSocket gateway and control plane ([#8](https://github.com/nearai/ironclaw/pull/8))
|
||||
- select bundled Telegram channel and auto-install ([#3](https://github.com/nearai/ironclaw/pull/3))
|
||||
- Adding skills for reusable work
|
||||
- Fix MCP tool calls, approval loop, shutdown, and improve web UI
|
||||
- Add auth mode, fix MCP token handling, and parallelize startup loading
|
||||
- Merge remote-tracking branch 'origin/main' into ui
|
||||
- Adding web UI
|
||||
- Rename `setup` CLI command to `onboard` for compatibility
|
||||
- Add in-chat extension discovery, auth, and activation system
|
||||
- Add Telegram typing indicator via WIT on-status callback
|
||||
- Add proactivity features: memory CLI, session pruning, self-repair notifications, slash commands, status diagnostics, context warnings
|
||||
- Add hosted MCP server support with OAuth 2.1 and token refresh
|
||||
- Add interactive setup wizard and persistent settings
|
||||
- Rebrand to IronClaw with security-first mission
|
||||
- Fix build_software tool stuck in planning mode loop
|
||||
- Enable sandbox by default
|
||||
- Fix Telegram Markdown formatting and clarify tool/memory distinctions
|
||||
- Simplify Telegram channel config with host-injected tunnel/webhook settings
|
||||
- Apply Telegram channel learnings to WhatsApp implementation
|
||||
- Merge remote-tracking branch 'origin/main'
|
||||
- Docker file for sandbox
|
||||
- Replace hardcoded intent patterns with job tools
|
||||
- Fix router test to match intentional job creation patterns
|
||||
- Add Docker execution sandbox for secure shell command isolation
|
||||
- Move setup wizard credentials to database storage
|
||||
- Add interactive setup wizard for first-run configuration
|
||||
- Add Telegram Bot API channel as WASM module
|
||||
- Add OpenClaw feature parity tracking matrix
|
||||
- Add Chat Completions API support and expand REPL debugging
|
||||
- Implementing channels to be handled in wasm
|
||||
- Support non interactive mode and model selection
|
||||
- Implement tool approval, fix tool definition refresh, and wire embeddings
|
||||
- Tool use
|
||||
- Wiring more
|
||||
- Add heartbeat integration, planning phase, and auto-repair
|
||||
- Login flow
|
||||
- Extend support for session management
|
||||
- Adding builder capability
|
||||
- Load tools at launch
|
||||
- Fix multiline message rendering in TUI
|
||||
- Parse NEAR AI alternative response format with output field
|
||||
- Handle NEAR AI plain text responses
|
||||
- Disable mouse capture to allow text selection in TUI
|
||||
- Add verbose logging to debug empty NEAR AI responses
|
||||
- Improve NEAR AI response parsing for varying response formats
|
||||
- Show status/thinking messages in chat window, debug empty responses
|
||||
- Add timeout and logging to NEAR AI provider
|
||||
- Add status updates to show agent thinking/processing state
|
||||
- Add CLI subcommands for WASM tool management
|
||||
- Fix TUI shutdown: send /shutdown message and handle in agent loop
|
||||
- Remove SimpleCliChannel, add Ctrl+D twice quit, redirect logs to TUI
|
||||
- Fix TuiChannel integration and enable in main.rs
|
||||
- Integrate Codex patterns: task scheduler, TUI, sessions, compaction
|
||||
- Adding LICENSE
|
||||
- Add README with IronClaw branding
|
||||
- Add WASM sandbox secure API extension
|
||||
- Wire database Store into agent loop
|
||||
- Implementing WASM runtime
|
||||
- Add workspace integration tests
|
||||
- Compact memory_tree output format
|
||||
- Replace memory_list with memory_tree tool
|
||||
- Simplify workspace to path-based storage, remove legacy code
|
||||
- Add NEAR AI chat-api as default LLM provider
|
||||
- Add CLAUDE.md project documentation
|
||||
- Add workspace and memory system (OpenClaw-inspired)
|
||||
- Initial implementation of the agent framework
|
||||
@@ -11,11 +11,19 @@
|
||||
- **Always available** - Multi-channel access with proactive background execution
|
||||
|
||||
### Features
|
||||
- **Multi-channel input**: TUI (Ratatui), HTTP webhooks, Telegram, WhatsApp, Slack (WASM channels)
|
||||
- **Multi-channel input**: TUI (Ratatui), HTTP webhooks, WASM channels (Telegram, Slack), web gateway
|
||||
- **Parallel job execution** with state machine and self-repair for stuck jobs
|
||||
- **Sandbox execution**: Docker container isolation with network proxy and credential injection
|
||||
- **Claude Code mode**: Delegate jobs to Claude CLI inside containers
|
||||
- **Skills system**: SKILL.md prompt extensions with trust model, tool attenuation, and ClawHub registry
|
||||
- **Routines**: Scheduled (cron) and reactive (event, webhook) task execution
|
||||
- **Web gateway**: Browser UI with SSE/WebSocket real-time streaming
|
||||
- **Extension management**: Install, auth, activate MCP/WASM extensions
|
||||
- **Extensible tools**: Built-in tools, WASM sandbox, MCP client, dynamic builder
|
||||
- **Persistent memory**: Workspace with hybrid search (FTS + vector via RRF)
|
||||
- **Prompt injection defense**: Sanitizer, validator, policy rules, leak detection
|
||||
- **Prompt injection defense**: Sanitizer, validator, policy rules, leak detection, shell env scrubbing
|
||||
- **Multi-provider LLM**: NEAR AI, OpenAI, Anthropic, Ollama, OpenAI-compatible, Tinfoil private inference
|
||||
- **Setup wizard**: 7-step interactive onboarding for first-run configuration
|
||||
- **Heartbeat system**: Proactive periodic execution with checklist
|
||||
|
||||
## Build & Test
|
||||
@@ -24,7 +32,7 @@
|
||||
# Format code
|
||||
cargo fmt
|
||||
|
||||
# Lint (address warnings before committing)
|
||||
# Lint (fix ALL warnings before committing, including pre-existing ones)
|
||||
cargo clippy --all --benches --tests --examples --all-features
|
||||
|
||||
# Run all tests
|
||||
@@ -59,7 +67,10 @@ src/
|
||||
│ ├── context_monitor.rs # Memory pressure detection
|
||||
│ ├── undo.rs # Turn-based undo/redo with checkpoints
|
||||
│ ├── submission.rs # Submission parsing (undo, redo, compact, clear, etc.)
|
||||
│ └── task.rs # Sub-task execution framework
|
||||
│ ├── dispatcher.rs # Skill-aware job dispatching
|
||||
│ ├── task.rs # Sub-task execution framework
|
||||
│ ├── routine.rs # Routine types (Trigger, Action, Guardrails)
|
||||
│ └── routine_engine.rs # Routine execution (cron ticker, event matcher)
|
||||
│
|
||||
├── channels/ # Multi-channel input
|
||||
│ ├── channel.rs # Channel trait, IncomingMessage, OutgoingResponse
|
||||
@@ -72,8 +83,33 @@ src/
|
||||
│ │ ├── overlay.rs # Approval overlays
|
||||
│ │ └── composer.rs # Message composition
|
||||
│ ├── http.rs # HTTP webhook (axum) with secret validation
|
||||
│ ├── slack.rs # Stub
|
||||
│ └── telegram.rs # Stub
|
||||
│ ├── repl.rs # Simple REPL (for testing)
|
||||
│ ├── web/ # Web gateway (browser UI)
|
||||
│ │ ├── mod.rs # Gateway builder, startup
|
||||
│ │ ├── server.rs # Axum router, 40+ API endpoints
|
||||
│ │ ├── sse.rs # SSE broadcast manager
|
||||
│ │ ├── ws.rs # WebSocket gateway + connection tracking
|
||||
│ │ ├── types.rs # Request/response types, SseEvent enum
|
||||
│ │ ├── auth.rs # Bearer token auth middleware
|
||||
│ │ ├── log_layer.rs # Tracing layer for log streaming
|
||||
│ │ └── static/ # HTML, CSS, JS (single-page app)
|
||||
│ └── wasm/ # WASM channel runtime
|
||||
│ ├── mod.rs
|
||||
│ ├── bundled.rs # Bundled channel discovery
|
||||
│ └── wrapper.rs # Channel trait wrapper for WASM modules
|
||||
│
|
||||
├── orchestrator/ # Internal HTTP API for sandbox containers
|
||||
│ ├── mod.rs
|
||||
│ ├── api.rs # Axum endpoints (LLM proxy, events, prompts)
|
||||
│ ├── auth.rs # Per-job bearer token store
|
||||
│ └── job_manager.rs # Container lifecycle (create, stop, cleanup)
|
||||
│
|
||||
├── worker/ # Runs inside Docker containers
|
||||
│ ├── mod.rs
|
||||
│ ├── runtime.rs # Worker execution loop (tool calls, LLM)
|
||||
│ ├── claude_bridge.rs # Claude Code bridge (spawns claude CLI)
|
||||
│ ├── api.rs # HTTP client to orchestrator
|
||||
│ └── proxy_llm.rs # LlmProvider that proxies through orchestrator
|
||||
│
|
||||
├── safety/ # Prompt injection defense
|
||||
│ ├── sanitizer.rs # Pattern detection, content escaping
|
||||
@@ -81,11 +117,18 @@ src/
|
||||
│ ├── policy.rs # PolicyRule system with severity/actions
|
||||
│ └── leak_detector.rs # Secret detection (API keys, tokens, etc.)
|
||||
│
|
||||
├── llm/ # LLM integration (NEAR AI only)
|
||||
├── llm/ # LLM integration (multi-provider)
|
||||
│ ├── mod.rs # Provider factory, LlmBackend enum
|
||||
│ ├── provider.rs # LlmProvider trait, message types
|
||||
│ ├── nearai.rs # NEAR AI chat-api implementation
|
||||
│ ├── nearai_chat.rs # NEAR AI Chat Completions provider (session token + API key auth)
|
||||
│ ├── reasoning.rs # Planning, tool selection, evaluation
|
||||
│ └── session.rs # Session token management with auto-renewal
|
||||
│ ├── session.rs # Session token management with auto-renewal
|
||||
│ ├── circuit_breaker.rs # Circuit breaker for provider failures
|
||||
│ ├── retry.rs # Retry with exponential backoff
|
||||
│ ├── failover.rs # Multi-provider failover chain
|
||||
│ ├── response_cache.rs # LLM response caching
|
||||
│ ├── costs.rs # Token cost tracking
|
||||
│ └── rig_adapter.rs # Rig framework adapter
|
||||
│
|
||||
├── tools/ # Extensible tool system
|
||||
│ ├── tool.rs # Tool trait, ToolOutput, ToolError
|
||||
@@ -96,6 +139,10 @@ src/
|
||||
│ │ ├── file.rs # ReadFile, WriteFile, ListDir, ApplyPatch
|
||||
│ │ ├── shell.rs # Shell command execution
|
||||
│ │ ├── memory.rs # Memory tools (search, write, read, tree)
|
||||
│ │ ├── job.rs # CreateJob, ListJobs, JobStatus, CancelJob
|
||||
│ │ ├── routine.rs # routine_create/list/update/delete/history
|
||||
│ │ ├── extension_tools.rs # Extension install/auth/activate/remove
|
||||
│ │ ├── skill_tools.rs # skill_list/search/install/remove tools
|
||||
│ │ └── marketplace.rs, ecommerce.rs, taskrabbit.rs, restaurant.rs (stubs)
|
||||
│ ├── builder/ # Dynamic tool building
|
||||
│ │ ├── core.rs # BuildRequirement, SoftwareType, Language
|
||||
@@ -116,6 +163,12 @@ src/
|
||||
│ ├── rate_limiter.rs # Per-tool rate limiting
|
||||
│ └── storage.rs # Linear memory persistence
|
||||
│
|
||||
├── db/ # Database abstraction layer
|
||||
│ ├── mod.rs # Database trait (~60 async methods)
|
||||
│ ├── postgres.rs # PostgreSQL backend (delegates to Store + Repository)
|
||||
│ ├── libsql_backend.rs # libSQL/Turso backend (embedded SQLite)
|
||||
│ └── libsql_migrations.rs # SQLite-dialect schema (idempotent)
|
||||
│
|
||||
├── workspace/ # Persistent memory system (OpenClaw-inspired)
|
||||
│ ├── mod.rs # Workspace struct, memory operations
|
||||
│ ├── document.rs # MemoryDocument, MemoryChunk, WorkspaceEntry
|
||||
@@ -139,11 +192,38 @@ src/
|
||||
│ ├── success.rs # SuccessEvaluator trait, RuleBasedEvaluator, LlmEvaluator
|
||||
│ └── metrics.rs # MetricsCollector, QualityMetrics
|
||||
│
|
||||
├── sandbox/ # Docker execution sandbox
|
||||
│ ├── mod.rs # Public API, default allowlist
|
||||
│ ├── config.rs # SandboxConfig, SandboxPolicy enum
|
||||
│ ├── manager.rs # SandboxManager orchestration
|
||||
│ ├── container.rs # ContainerRunner, Docker lifecycle
|
||||
│ ├── error.rs # SandboxError types
|
||||
│ └── proxy/ # Network proxy for containers
|
||||
│ ├── mod.rs # NetworkProxyBuilder
|
||||
│ ├── http.rs # HttpProxy, CredentialResolver trait
|
||||
│ ├── policy.rs # NetworkPolicyDecider trait
|
||||
│ └── allowlist.rs # DomainAllowlist validation
|
||||
│
|
||||
├── secrets/ # Secrets management
|
||||
│ ├── crypto.rs # AES-256-GCM encryption
|
||||
│ ├── store.rs # Secret storage
|
||||
│ └── types.rs # Credential types
|
||||
│
|
||||
├── setup/ # Onboarding wizard (spec: src/setup/README.md)
|
||||
│ ├── mod.rs # Entry point, check_onboard_needed()
|
||||
│ ├── wizard.rs # 7-step interactive wizard
|
||||
│ ├── channels.rs # Channel setup helpers
|
||||
│ └── prompts.rs # Terminal prompts (select, confirm, secret)
|
||||
│
|
||||
├── skills/ # SKILL.md prompt extension system
|
||||
│ ├── mod.rs # Core types (SkillTrust, LoadedSkill)
|
||||
│ ├── registry.rs # SkillRegistry: discover, install, remove
|
||||
│ ├── selector.rs # Deterministic scoring prefilter
|
||||
│ ├── attenuation.rs # Trust-based tool ceiling
|
||||
│ ├── gating.rs # Requirement checks (bins, env, config)
|
||||
│ ├── parser.rs # SKILL.md frontmatter + markdown parser
|
||||
│ └── catalog.rs # ClawHub registry client
|
||||
│
|
||||
└── history/ # Persistence
|
||||
├── store.rs # PostgreSQL repositories
|
||||
└── analytics.rs # Aggregation queries (JobStats, ToolStats)
|
||||
@@ -151,10 +231,15 @@ src/
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Architecture
|
||||
|
||||
When designing new features or systems, always prefer generic/extensible architectures over hardcoding specific integrations. Ask clarifying questions about the desired abstraction level before implementing.
|
||||
|
||||
### Error Handling
|
||||
- Use `thiserror` for error types in `error.rs`
|
||||
- Never use `.unwrap()` in production code (tests are fine)
|
||||
- Never use `.unwrap()` or `.expect()` in production code (tests are fine)
|
||||
- Map errors with context: `.map_err(|e| SomeError::Variant { reason: e.to_string() })?`
|
||||
- Before committing, grep for `.unwrap()` and `.expect(` in changed files to catch violations mechanically
|
||||
|
||||
### Async
|
||||
- All I/O is async with tokio
|
||||
@@ -162,11 +247,13 @@ src/
|
||||
- Use `RwLock` for concurrent read/write access
|
||||
|
||||
### Traits for Extensibility
|
||||
- `Database` - Add new database backends (must implement all ~60 methods)
|
||||
- `Channel` - Add new input sources
|
||||
- `Tool` - Add new capabilities
|
||||
- `LlmProvider` - Add new LLM backends
|
||||
- `SuccessEvaluator` - Custom evaluation logic
|
||||
- `EmbeddingProvider` - Add embedding backends (workspace search)
|
||||
- `NetworkPolicyDecider` - Custom network access policies for sandbox containers
|
||||
|
||||
### Tool Implementation
|
||||
```rust
|
||||
@@ -205,16 +292,62 @@ Pending -> InProgress -> Completed -> Submitted -> Accepted
|
||||
\-> Failed
|
||||
```
|
||||
|
||||
### Code Style
|
||||
|
||||
- Use `crate::` imports, not `super::`
|
||||
- No `pub use` re-exports unless exposing to downstream consumers
|
||||
- Prefer strong types over strings (enums, newtypes)
|
||||
- Keep functions focused, extract helpers when logic is reused
|
||||
- Comments for non-obvious logic only
|
||||
|
||||
### Review & Fix Discipline
|
||||
|
||||
Hard-won lessons from code review -- follow these when fixing bugs or addressing review feedback.
|
||||
|
||||
**Fix the pattern, not just the instance:** When a reviewer flags a bug (e.g., TOCTOU race in INSERT + SELECT-back), search the entire codebase for all instances of that same pattern. A fix in `SecretsStore::create()` that doesn't also fix `WasmToolStore::store()` is half a fix.
|
||||
|
||||
**Propagate architectural fixes to satellite types:** If a core type changes its concurrency model (e.g., `LibSqlBackend` switches to connection-per-operation), every type that was handed a resource from the old model (e.g., `LibSqlSecretsStore`, `LibSqlWasmToolStore` holding a single `Connection`) must also be updated. Grep for the old type across the codebase.
|
||||
|
||||
**Schema translation is more than DDL:** When translating a database schema between backends (PostgreSQL to libSQL, etc.), check for:
|
||||
- **Indexes** -- diff `CREATE INDEX` statements between the two schemas
|
||||
- **Seed data** -- check for `INSERT INTO` in migrations (e.g., `leak_detection_patterns`)
|
||||
- **Semantic differences** -- document where SQL functions behave differently (e.g., `json_patch` vs `jsonb_set`)
|
||||
|
||||
**Feature flag testing:** When adding feature-gated code, test compilation with each feature in isolation:
|
||||
```bash
|
||||
cargo check # default features
|
||||
cargo check --no-default-features --features libsql # libsql only
|
||||
cargo check --all-features # all features
|
||||
```
|
||||
Dead code behind the wrong `#[cfg]` gate will only show up when building with a single feature.
|
||||
|
||||
**Zero clippy warnings policy:** Fix ALL clippy warnings before committing, including pre-existing ones in files you didn't change. Never leave warnings behind — treat `cargo clippy` output as a zero-tolerance gate.
|
||||
|
||||
**Mechanical verification before committing:** Run these checks on changed files before committing:
|
||||
- `cargo clippy --all --benches --tests --examples --all-features` -- zero warnings
|
||||
- `grep -rnE '\.unwrap\(|\.expect\(' <files>` -- no panics in production
|
||||
- `grep -rn 'super::' <files>` -- use `crate::` imports
|
||||
- If you fixed a pattern bug, `grep` for other instances of that pattern across `src/`
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables (see `.env.example`):
|
||||
```bash
|
||||
# Database backend (default: postgres)
|
||||
DATABASE_BACKEND=postgres # or "libsql" / "turso"
|
||||
DATABASE_URL=postgres://user:pass@localhost/ironclaw
|
||||
LIBSQL_PATH=~/.ironclaw/ironclaw.db # libSQL local path (default)
|
||||
# LIBSQL_URL=libsql://xxx.turso.io # Turso cloud (optional)
|
||||
# LIBSQL_AUTH_TOKEN=xxx # Required with LIBSQL_URL
|
||||
|
||||
# NEAR AI (required)
|
||||
NEARAI_SESSION_TOKEN=sess_...
|
||||
NEARAI_MODEL=claude-3-5-sonnet-20241022
|
||||
# NEAR AI (when LLM_BACKEND=nearai, the default)
|
||||
# Two auth modes: session token (default) or API key
|
||||
# Session token auth (default): uses browser OAuth on first run
|
||||
NEARAI_SESSION_TOKEN=sess_... # hosting providers: set this
|
||||
NEARAI_BASE_URL=https://private.near.ai
|
||||
# API key auth: set NEARAI_API_KEY, base URL defaults to cloud-api.near.ai
|
||||
# NEARAI_API_KEY=... # API key from cloud.near.ai
|
||||
NEARAI_MODEL=claude-3-5-sonnet-20241022
|
||||
|
||||
# Agent settings
|
||||
AGENT_NAME=ironclaw
|
||||
@@ -232,20 +365,105 @@ HEARTBEAT_ENABLED=true
|
||||
HEARTBEAT_INTERVAL_SECS=1800 # 30 minutes
|
||||
HEARTBEAT_NOTIFY_CHANNEL=tui
|
||||
HEARTBEAT_NOTIFY_USER=default
|
||||
|
||||
# Web gateway
|
||||
GATEWAY_ENABLED=true
|
||||
GATEWAY_HOST=127.0.0.1
|
||||
GATEWAY_PORT=3001
|
||||
GATEWAY_AUTH_TOKEN=changeme # Required for API access
|
||||
GATEWAY_USER_ID=default
|
||||
|
||||
# Docker sandbox
|
||||
SANDBOX_ENABLED=true
|
||||
SANDBOX_IMAGE=ironclaw-worker:latest
|
||||
SANDBOX_MEMORY_LIMIT_MB=512
|
||||
SANDBOX_TIMEOUT_SECS=1800
|
||||
SANDBOX_CPU_LIMIT=1.0 # CPU cores per container
|
||||
SANDBOX_NETWORK_PROXY=true # Enable network proxy for containers
|
||||
SANDBOX_PROXY_PORT=8080 # Proxy listener port
|
||||
SANDBOX_DEFAULT_POLICY=workspace_write # ReadOnly, WorkspaceWrite, FullAccess
|
||||
|
||||
# Claude Code mode (runs inside sandbox containers)
|
||||
CLAUDE_CODE_ENABLED=false
|
||||
CLAUDE_CODE_MODEL=claude-sonnet-4-20250514
|
||||
CLAUDE_CODE_MAX_TURNS=50
|
||||
CLAUDE_CODE_CONFIG_DIR=/home/worker/.claude
|
||||
|
||||
# Routines (scheduled/reactive execution)
|
||||
ROUTINES_ENABLED=true
|
||||
ROUTINES_CRON_INTERVAL=60 # Tick interval in seconds
|
||||
ROUTINES_MAX_CONCURRENT=3
|
||||
|
||||
# Skills system
|
||||
SKILLS_ENABLED=true
|
||||
SKILLS_MAX_TOKENS=4000 # Max prompt budget per turn
|
||||
SKILLS_CATALOG_URL=https://clawhub.dev # ClawHub registry URL
|
||||
SKILLS_AUTO_DISCOVER=true # Scan skill directories on startup
|
||||
|
||||
# Tinfoil private inference
|
||||
TINFOIL_API_KEY=... # Required when LLM_BACKEND=tinfoil
|
||||
TINFOIL_MODEL=kimi-k2-5 # Default model
|
||||
```
|
||||
|
||||
### NEAR AI Provider
|
||||
### LLM Providers
|
||||
|
||||
Uses the NEAR AI chat-api (`https://api.near.ai/v1/responses`) which provides:
|
||||
- Unified access to multiple models (OpenAI, Anthropic, etc.)
|
||||
- User authentication via session tokens
|
||||
- Usage tracking and billing through NEAR AI
|
||||
IronClaw supports multiple LLM backends via the `LLM_BACKEND` env var: `nearai` (default), `openai`, `anthropic`, `ollama`, `openai_compatible`, and `tinfoil`.
|
||||
|
||||
Session tokens have the format `sess_xxx` (37 characters). They are authenticated against the NEAR AI auth service.
|
||||
**NEAR AI** -- Uses the Chat Completions API with dual auth support. Session token auth (default): authenticates with session tokens (`sess_xxx`) obtained via browser OAuth (GitHub/Google), base URL defaults to `https://private.near.ai`. API key auth: set `NEARAI_API_KEY` (from `cloud.near.ai`), base URL defaults to `https://cloud-api.near.ai`. Both modes use the same Chat Completions endpoint. Tool messages are flattened to plain text for compatibility. Set `NEARAI_SESSION_TOKEN` env var for hosting providers that inject tokens via environment.
|
||||
|
||||
**NEAR AI Cloud** -- Uses the OpenAI-compatible Chat Completions API (`https://cloud-api.near.ai/v1/chat/completions`). Authenticates with API keys from `cloud.near.ai`. Auto-selected when `NEARAI_API_KEY` is set (or explicitly via `NEARAI_API_MODE=chat_completions`). Tool messages are flattened to plain text for compatibility. Configure with `NEARAI_API_KEY` and `NEARAI_BASE_URL` (default: `https://cloud-api.near.ai`).
|
||||
|
||||
**OpenAI-compatible** -- Any endpoint that speaks the OpenAI API (vLLM, LiteLLM, OpenRouter, etc.). Configure with `LLM_BASE_URL`, `LLM_API_KEY` (optional), `LLM_MODEL`. Set `LLM_EXTRA_HEADERS` to inject custom HTTP headers into every request (format: `Key:Value,Key2:Value2`), useful for OpenRouter attribution headers like `HTTP-Referer` and `X-Title`.
|
||||
|
||||
**Tinfoil** -- Private inference via `https://inference.tinfoil.sh/v1`. Runs models inside hardware-attested TEEs so neither Tinfoil nor the cloud provider can see prompts or responses. Uses the OpenAI-compatible Chat Completions API. Configure with `TINFOIL_API_KEY` and `TINFOIL_MODEL` (default: `kimi-k2-5`).
|
||||
|
||||
## Database
|
||||
|
||||
Single migration in `migrations/V1__initial.sql`. Tables:
|
||||
IronClaw supports two database backends, selected at compile time via Cargo feature flags and at runtime via the `DATABASE_BACKEND` environment variable.
|
||||
|
||||
**IMPORTANT: All new features that touch persistence MUST support both backends.** Implement the operation as a method on the `Database` trait in `src/db/mod.rs`, then add the implementation in both `src/db/postgres.rs` (delegate to Store/Repository) and `src/db/libsql_backend.rs` (native SQL).
|
||||
|
||||
### Backends
|
||||
|
||||
| Backend | Feature Flag | Default | Use Case |
|
||||
|---------|-------------|---------|----------|
|
||||
| PostgreSQL | `postgres` (default) | Yes | Production, existing deployments |
|
||||
| libSQL/Turso | `libsql` | No | Zero-dependency local mode, edge, Turso cloud |
|
||||
|
||||
```bash
|
||||
# Build with PostgreSQL only (default)
|
||||
cargo build
|
||||
|
||||
# Build with libSQL only
|
||||
cargo build --no-default-features --features libsql
|
||||
|
||||
# Build with both backends available
|
||||
cargo build --features "postgres,libsql"
|
||||
```
|
||||
|
||||
### Database Trait
|
||||
|
||||
The `Database` trait (`src/db/mod.rs`) defines ~60 async methods covering all persistence:
|
||||
- Conversations, messages, metadata
|
||||
- Jobs, actions, LLM calls, estimation snapshots
|
||||
- Sandbox jobs, job events
|
||||
- Routines, routine runs
|
||||
- Tool failures, settings
|
||||
- Workspace: documents, chunks, hybrid search
|
||||
|
||||
Both backends implement this trait. PostgreSQL delegates to the existing `Store` + `Repository`. libSQL implements native SQLite-dialect SQL.
|
||||
|
||||
### Schema
|
||||
|
||||
**PostgreSQL:** `migrations/V1__initial.sql` (351 lines). Uses pgvector for embeddings, tsvector for FTS, PL/pgSQL functions. Managed by `refinery`.
|
||||
|
||||
**libSQL:** `src/db/libsql_migrations.rs` (consolidated schema, ~480 lines). Translates PG types:
|
||||
- `UUID` -> `TEXT`, `TIMESTAMPTZ` -> `TEXT` (ISO-8601), `JSONB` -> `TEXT`
|
||||
- `VECTOR(1536)` -> `F32_BLOB(1536)` with `libsql_vector_idx`
|
||||
- `tsvector`/`ts_rank_cd` -> FTS5 virtual table with sync triggers
|
||||
- PL/pgSQL functions -> SQLite triggers
|
||||
|
||||
**Tables (both backends):**
|
||||
|
||||
**Core:**
|
||||
- `conversations` - Multi-channel conversation tracking
|
||||
@@ -257,12 +475,26 @@ Single migration in `migrations/V1__initial.sql`. Tables:
|
||||
|
||||
**Workspace/Memory:**
|
||||
- `memory_documents` - Flexible path-based files (e.g., "context/vision.md", "daily/2024-01-15.md")
|
||||
- `memory_chunks` - Chunked content with FTS (tsvector) and vector (pgvector) indexes
|
||||
- `memory_chunks` - Chunked content with FTS and vector indexes
|
||||
- `heartbeat_state` - Periodic execution tracking
|
||||
|
||||
Requires pgvector extension: `CREATE EXTENSION IF NOT EXISTS vector;`
|
||||
**Other:**
|
||||
- `routines`, `routine_runs` - Scheduled/reactive execution
|
||||
- `settings` - Per-user key-value settings
|
||||
- `tool_failures` - Self-repair tracking
|
||||
- `secrets`, `wasm_tools`, `tool_capabilities` - Extension infrastructure
|
||||
|
||||
Run migrations: `refinery migrate -c refinery.toml`
|
||||
Database configuration: see Configuration section above.
|
||||
|
||||
### Current Limitations (libSQL backend)
|
||||
|
||||
- **Workspace/memory system** not yet wired through Database trait (requires Store migration)
|
||||
- **Secrets store** not yet available (still requires PostgresSecretsStore)
|
||||
- **Hybrid search** uses FTS5 only (vector search via libsql_vector_idx not yet implemented)
|
||||
- **Settings reload from DB** skipped (Config::from_db requires Store)
|
||||
- No incremental migration versioning (schema is CREATE IF NOT EXISTS, no ALTER TABLE support yet)
|
||||
- **No encryption at rest** -- The local SQLite database file stores conversation content, job data, workspace memory, and other application data in plaintext. Only secrets (API tokens, credentials) are encrypted via AES-256-GCM before storage. Users handling sensitive data should use full-disk encryption (FileVault, LUKS, BitLocker) or consider the PostgreSQL backend with TDE/encrypted storage.
|
||||
- **JSON merge patch vs path-targeted update** -- The libSQL backend uses RFC 7396 JSON Merge Patch (`json_patch`) for metadata updates, while PostgreSQL uses path-targeted `jsonb_set`. Merge patch replaces top-level keys entirely, which may drop nested keys not present in the patch. Callers should avoid relying on partial nested object updates in metadata fields.
|
||||
|
||||
## Safety Layer
|
||||
|
||||
@@ -270,6 +502,7 @@ All external tool output passes through `SafetyLayer`:
|
||||
1. **Sanitizer** - Detects injection patterns, escapes dangerous content
|
||||
2. **Validator** - Checks length, encoding, forbidden patterns
|
||||
3. **Policy** - Rules with severity (Critical/High/Medium/Low) and actions (Block/Warn/Review/Sanitize)
|
||||
4. **Leak Detector** - Scans for 15+ secret patterns (API keys, tokens, private keys, connection strings) at two points: tool output before it reaches the LLM, and LLM responses before they reach the user. Actions per pattern: Block (reject entirely), Redact (mask the secret), or Warn (flag but allow)
|
||||
|
||||
Tool outputs are wrapped before reaching LLM:
|
||||
```xml
|
||||
@@ -278,6 +511,99 @@ Tool outputs are wrapped before reaching LLM:
|
||||
</tool_output>
|
||||
```
|
||||
|
||||
### Shell Environment Scrubbing
|
||||
|
||||
The shell tool (`src/tools/builtin/shell.rs`) scrubs sensitive environment variables before executing commands, preventing secrets from leaking through `env`, `printenv`, or `$VAR` expansion. The sanitizer (`src/safety/sanitizer.rs`) also detects command injection patterns (chained commands, subshells, path traversal) and blocks or escapes them based on policy rules.
|
||||
|
||||
## Skills System
|
||||
|
||||
Skills are SKILL.md files that extend the agent's prompt with domain-specific instructions. Each skill is a YAML frontmatter block (metadata, activation criteria, required tools) followed by a markdown body that gets injected into the LLM context when the skill activates.
|
||||
|
||||
### Trust Model
|
||||
|
||||
| Trust Level | Source | Tool Access |
|
||||
|-------------|--------|-------------|
|
||||
| **Trusted** | User-placed in `~/.ironclaw/skills/` or workspace `skills/` | All tools available to the agent |
|
||||
| **Installed** | Downloaded from ClawHub registry | Read-only tools only (no shell, file write, HTTP) |
|
||||
|
||||
### SKILL.md Format
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: my-skill
|
||||
version: 0.1.0
|
||||
description: Does something useful
|
||||
activation:
|
||||
patterns:
|
||||
- "deploy to.*production"
|
||||
keywords:
|
||||
- "deployment"
|
||||
max_context_tokens: 2000
|
||||
metadata:
|
||||
openclaw:
|
||||
requires:
|
||||
bins: [docker, kubectl]
|
||||
env: [KUBECONFIG]
|
||||
---
|
||||
|
||||
# Deployment Skill
|
||||
|
||||
Instructions for the agent when this skill activates...
|
||||
```
|
||||
|
||||
### Selection Pipeline
|
||||
|
||||
1. **Gating** -- Check binary/env/config requirements; skip skills whose prerequisites are missing
|
||||
2. **Scoring** -- Deterministic scoring against message content using keywords, tags, and regex patterns
|
||||
3. **Budget** -- Select top-scoring skills that fit within `SKILLS_MAX_TOKENS` prompt budget
|
||||
4. **Attenuation** -- Apply trust-based tool ceiling; installed skills lose access to dangerous tools
|
||||
|
||||
### Skill Tools
|
||||
|
||||
Four built-in tools for managing skills at runtime:
|
||||
- **`skill_list`** -- List all discovered skills with trust level and status
|
||||
- **`skill_search`** -- Search ClawHub registry for available skills
|
||||
- **`skill_install`** -- Download and install a skill from ClawHub
|
||||
- **`skill_remove`** -- Remove an installed skill
|
||||
|
||||
### Skill Directories
|
||||
|
||||
- `~/.ironclaw/skills/` -- User's global skills (trusted)
|
||||
- `<workspace>/skills/` -- Per-workspace skills (trusted)
|
||||
- `~/.ironclaw/installed_skills/` -- Registry-installed skills (installed trust)
|
||||
|
||||
### Testing Skills
|
||||
|
||||
- `skills/web-ui-test/` -- Manual test checklist for the web gateway UI via Claude for Chrome extension. Covers connection, chat, skills search/install/remove, and other tabs.
|
||||
|
||||
Skills configuration: see Configuration section above.
|
||||
|
||||
## Docker Sandbox
|
||||
|
||||
The `src/sandbox/` module provides Docker-based isolation for job execution with a network proxy that controls outbound access and injects credentials.
|
||||
|
||||
### Sandbox Policies
|
||||
|
||||
| Policy | Filesystem | Network | Use Case |
|
||||
|--------|-----------|---------|----------|
|
||||
| **ReadOnly** | Read-only workspace mount | Allowlisted domains only | Analysis, code review |
|
||||
| **WorkspaceWrite** | Read-write workspace mount | Allowlisted domains only | Code generation, file edits |
|
||||
| **FullAccess** | Full filesystem | Unrestricted | Trusted admin tasks |
|
||||
|
||||
### Network Proxy
|
||||
|
||||
Containers route all HTTP/HTTPS traffic through a host-side proxy (`src/sandbox/proxy/`):
|
||||
- **Domain allowlist** -- Only allowlisted domains are reachable (default: package registries, docs sites, GitHub, common APIs)
|
||||
- **Credential injection** -- The `CredentialResolver` trait injects auth headers into proxied requests so secrets never enter the container environment
|
||||
- **CONNECT tunnel** -- HTTPS traffic uses CONNECT method; the proxy validates the target domain against the allowlist before establishing the tunnel
|
||||
- **Policy decisions** -- The `NetworkPolicyDecider` trait allows custom logic for allow/deny/inject decisions per request
|
||||
|
||||
### Zero-Exposure Credential Model
|
||||
|
||||
Secrets (API keys, tokens) are stored encrypted on the host and injected into HTTP requests by the proxy at transit time. Container processes never have access to raw credential values, preventing exfiltration even if container code is compromised.
|
||||
|
||||
Sandbox configuration: see Configuration section above.
|
||||
|
||||
## Testing
|
||||
|
||||
Tests are in `mod tests {}` blocks at the bottom of each file. Run specific module tests:
|
||||
@@ -293,131 +619,22 @@ Key test patterns:
|
||||
|
||||
## Current Limitations / TODOs
|
||||
|
||||
1. **Slack/Telegram channels** - Stubs only, need implementation
|
||||
2. **Domain-specific tools** - `marketplace.rs`, `restaurant.rs`, `taskrabbit.rs`, `ecommerce.rs` return placeholder responses; need real API integrations
|
||||
3. **Integration tests** - Need testcontainers setup for PostgreSQL
|
||||
4. **MCP stdio transport** - Only HTTP transport implemented
|
||||
5. **WIT bindgen integration** - Auto-extract tool description/schema from WASM modules (stubbed)
|
||||
6. **Capability granting after tool build** - Built tools get empty capabilities; need UX for granting HTTP/secrets access
|
||||
7. **Tool versioning workflow** - No version tracking or rollback for dynamically built tools
|
||||
1. **Domain-specific tools** - `marketplace.rs`, `restaurant.rs`, `taskrabbit.rs`, `ecommerce.rs` return placeholder responses; need real API integrations
|
||||
2. **Integration tests** - Need testcontainers setup for PostgreSQL
|
||||
3. **MCP stdio transport** - Only HTTP transport implemented
|
||||
4. **WIT bindgen integration** - Auto-extract tool description/schema from WASM modules (stubbed)
|
||||
5. **Capability granting after tool build** - Built tools get empty capabilities; need UX for granting HTTP/secrets access
|
||||
6. **Tool versioning workflow** - No version tracking or rollback for dynamically built tools
|
||||
7. **Webhook trigger endpoint** - Routines webhook trigger not yet exposed in web gateway
|
||||
8. **Full channel status view** - Gateway status widget exists, but no per-channel connection dashboard
|
||||
|
||||
### Completed
|
||||
## Tool Architecture
|
||||
|
||||
- ✅ **Workspace integration** - Memory tools registered, workspace passed to Agent and heartbeat
|
||||
- ✅ **WASM sandboxing** - Full implementation in `tools/wasm/` with fuel metering, memory limits, capabilities
|
||||
- ✅ **Dynamic tool building** - `tools/builder/` has LlmSoftwareBuilder with iterative build loop
|
||||
- ✅ **HTTP webhook security** - Secret validation implemented, proper error handling (no panics)
|
||||
- ✅ **Embeddings integration** - OpenAI and NEAR AI providers wired to workspace for semantic search
|
||||
- ✅ **Workspace system prompt** - Identity files (AGENTS.md, SOUL.md, USER.md, IDENTITY.md) injected into LLM context
|
||||
- ✅ **Heartbeat notifications** - Route through channel manager (broadcast API) instead of logging-only
|
||||
- ✅ **Auto-context compaction** - Triggers automatically when context exceeds threshold
|
||||
- ✅ **Embedding backfill** - Runs on startup when embeddings provider is enabled
|
||||
- ✅ **Clippy clean** - All warnings addressed via config struct refactoring
|
||||
- ✅ **Tool approval enforcement** - Tools with `requires_approval()` (shell, http, file write/patch, build_software) now gate execution, track auto-approved tools per session
|
||||
- ✅ **Tool definition refresh** - Tool definitions refreshed each iteration so newly built tools become visible in same session
|
||||
- ✅ **Worker tool call handling** - Uses `respond_with_tools()` to properly execute tool calls when `select_tools()` returns empty
|
||||
**Keep tool-specific logic out of the main agent codebase.** The main agent provides generic infrastructure; tools are self-contained units that declare their requirements through `capabilities.json` files (API endpoints, credentials, rate limits, auth setup). Service-specific auth flows, CLI commands, and configuration do not belong in the main agent.
|
||||
|
||||
## Adding a New Tool
|
||||
Tools can be built as **WASM** (sandboxed, credential-injected, single binary) or **MCP servers** (ecosystem of pre-built servers, any language, but no sandbox). Both are first-class via `ironclaw tool install`. Auth is declared in capabilities files with OAuth and manual token entry support.
|
||||
|
||||
### Built-in Tools (Rust)
|
||||
|
||||
1. Create `src/tools/builtin/my_tool.rs`
|
||||
2. Implement the `Tool` trait
|
||||
3. Add `mod my_tool;` and `pub use` in `src/tools/builtin/mod.rs`
|
||||
4. Register in `ToolRegistry::register_builtin_tools()` in `registry.rs`
|
||||
5. Add tests
|
||||
|
||||
### WASM Tools (Recommended)
|
||||
|
||||
WASM tools are the preferred way to add new capabilities. They run in a sandboxed environment with explicit capabilities.
|
||||
|
||||
1. Create a new crate in `examples/wasm-tools/<name>/`
|
||||
2. Implement the WIT interface (`wit/tool.wit`)
|
||||
3. Create `<name>.capabilities.json` declaring required permissions
|
||||
4. Build with `cargo build --target wasm32-wasip2 --release`
|
||||
5. Install with `ironclaw tool install path/to/tool.wasm`
|
||||
|
||||
See `examples/wasm-tools/` for examples.
|
||||
|
||||
## Tool Architecture Principles
|
||||
|
||||
**CRITICAL: Keep tool-specific logic out of the main agent codebase.**
|
||||
|
||||
The main agent provides generic infrastructure; tools are self-contained units that declare their requirements through capabilities files.
|
||||
|
||||
### What Goes in Tools (capabilities.json)
|
||||
|
||||
- API endpoints the tool needs (HTTP allowlist)
|
||||
- Credentials required (secret names, injection locations)
|
||||
- Rate limits and timeouts
|
||||
- Auth setup instructions (see below)
|
||||
- Workspace paths the tool can read
|
||||
|
||||
### What Does NOT Go in Main Agent
|
||||
|
||||
- Service-specific auth flows (OAuth for Notion, Slack, etc.)
|
||||
- Service-specific CLI commands (`auth notion`, `auth slack`)
|
||||
- Service-specific configuration handling
|
||||
- Hardcoded API URLs or token formats
|
||||
|
||||
### Tool Authentication
|
||||
|
||||
Tools declare their auth requirements in `<tool>.capabilities.json` under the `auth` section. Two methods are supported:
|
||||
|
||||
#### OAuth (Browser-based login)
|
||||
|
||||
For services that support OAuth, users just click through browser login:
|
||||
|
||||
```json
|
||||
{
|
||||
"auth": {
|
||||
"secret_name": "notion_api_token",
|
||||
"display_name": "Notion",
|
||||
"oauth": {
|
||||
"authorization_url": "https://api.notion.com/v1/oauth/authorize",
|
||||
"token_url": "https://api.notion.com/v1/oauth/token",
|
||||
"client_id_env": "NOTION_OAUTH_CLIENT_ID",
|
||||
"client_secret_env": "NOTION_OAUTH_CLIENT_SECRET",
|
||||
"scopes": [],
|
||||
"use_pkce": false,
|
||||
"extra_params": { "owner": "user" }
|
||||
},
|
||||
"env_var": "NOTION_TOKEN"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To enable OAuth for a tool:
|
||||
1. Register a public OAuth app with the service (e.g., notion.so/my-integrations)
|
||||
2. Configure redirect URIs: `http://localhost:9876/callback` through `http://localhost:9886/callback`
|
||||
3. Set environment variables for client_id and client_secret
|
||||
|
||||
#### Manual Token Entry (Fallback)
|
||||
|
||||
For services without OAuth or when OAuth isn't configured:
|
||||
|
||||
```json
|
||||
{
|
||||
"auth": {
|
||||
"secret_name": "openai_api_key",
|
||||
"display_name": "OpenAI",
|
||||
"instructions": "Get your API key from platform.openai.com/api-keys",
|
||||
"setup_url": "https://platform.openai.com/api-keys",
|
||||
"token_hint": "Starts with 'sk-'",
|
||||
"env_var": "OPENAI_API_KEY"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Auth Flow Priority
|
||||
|
||||
When running `ironclaw tool auth <tool>`:
|
||||
|
||||
1. Check `env_var` - if set in environment, use it directly
|
||||
2. Check `oauth` - if configured, open browser for OAuth flow
|
||||
3. Fall back to `instructions` + manual token entry
|
||||
|
||||
The agent reads auth config from the tool's capabilities file and provides the appropriate flow. No service-specific code in the main agent.
|
||||
See `src/tools/README.md` for full tool architecture, adding new tools (built-in Rust and WASM), auth JSON examples, and WASM vs MCP decision guide.
|
||||
|
||||
## Adding a New Channel
|
||||
|
||||
@@ -439,118 +656,30 @@ RUST_LOG=ironclaw::agent=debug cargo run
|
||||
RUST_LOG=ironclaw=debug,tower_http=debug cargo run
|
||||
```
|
||||
|
||||
## Code Style
|
||||
## Module Specifications
|
||||
|
||||
- Use `crate::` imports, not `super::`
|
||||
- No `pub use` re-exports unless exposing to downstream consumers
|
||||
- Prefer strong types over strings (enums, newtypes)
|
||||
- Keep functions focused, extract helpers when logic is reused
|
||||
- Comments for non-obvious logic only
|
||||
Some modules have a `README.md` that serves as the authoritative specification
|
||||
for that module's behavior. When modifying code in a module that has a spec:
|
||||
|
||||
1. **Read the spec first** before making changes
|
||||
2. **Code follows spec**: if the spec says X, the code must do X
|
||||
3. **Update both sides**: if you change behavior, update the spec to match;
|
||||
if you're implementing a spec change, update the code to match
|
||||
4. **Spec is the tiebreaker**: when code and spec disagree, the spec is correct
|
||||
(unless the spec is clearly outdated, in which case fix the spec first)
|
||||
|
||||
| Module | Spec File |
|
||||
|--------|-----------|
|
||||
| `src/setup/` | `src/setup/README.md` |
|
||||
| `src/workspace/` | `src/workspace/README.md` |
|
||||
| `src/tools/` | `src/tools/README.md` |
|
||||
|
||||
## Workspace & Memory System
|
||||
|
||||
Inspired by [OpenClaw](https://github.com/openclaw/openclaw), the workspace provides persistent memory for agents with a flexible filesystem-like structure.
|
||||
OpenClaw-inspired persistent memory with a flexible filesystem-like structure. Principle: "Memory is database, not RAM" -- if you want to remember something, write it explicitly. Uses hybrid search combining FTS (keyword) + vector (semantic) via Reciprocal Rank Fusion.
|
||||
|
||||
### Key Principles
|
||||
Four memory tools for LLM use: `memory_search` (hybrid search -- call before answering questions about prior work), `memory_write`, `memory_read`, `memory_tree`. Identity files (AGENTS.md, SOUL.md, USER.md, IDENTITY.md) are injected into the LLM system prompt.
|
||||
|
||||
1. **"Memory is database, not RAM"** - If you want to remember something, write it explicitly
|
||||
2. **Flexible structure** - Create any directory/file hierarchy you need
|
||||
3. **Self-documenting** - Use README.md files to describe directory structure
|
||||
4. **Hybrid search** - Combines FTS (keyword) + vector (semantic) via Reciprocal Rank Fusion
|
||||
The heartbeat system runs proactive periodic execution (default: 30 minutes), reading `HEARTBEAT.md` and notifying via channel if findings are detected.
|
||||
|
||||
### Filesystem Structure
|
||||
|
||||
```
|
||||
workspace/
|
||||
├── README.md <- Root runbook/index
|
||||
├── MEMORY.md <- Long-term curated memory
|
||||
├── HEARTBEAT.md <- Periodic checklist
|
||||
├── IDENTITY.md <- Agent name, nature, vibe
|
||||
├── SOUL.md <- Core values
|
||||
├── AGENTS.md <- Behavior instructions
|
||||
├── USER.md <- User context
|
||||
├── context/ <- Identity-related docs
|
||||
│ ├── vision.md
|
||||
│ └── priorities.md
|
||||
├── daily/ <- Daily logs
|
||||
│ ├── 2024-01-15.md
|
||||
│ └── 2024-01-16.md
|
||||
├── projects/ <- Arbitrary structure
|
||||
│ └── alpha/
|
||||
│ ├── README.md
|
||||
│ └── notes.md
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Using the Workspace
|
||||
|
||||
```rust
|
||||
use crate::workspace::{Workspace, OpenAiEmbeddings, paths};
|
||||
|
||||
// Create workspace for a user
|
||||
let workspace = Workspace::new("user_123", pool)
|
||||
.with_embeddings(Arc::new(OpenAiEmbeddings::new(api_key)));
|
||||
|
||||
// Read/write any path
|
||||
let doc = workspace.read("projects/alpha/notes.md").await?;
|
||||
workspace.write("context/priorities.md", "# Priorities\n\n1. Feature X").await?;
|
||||
workspace.append("daily/2024-01-15.md", "Completed task X").await?;
|
||||
|
||||
// Convenience methods for well-known files
|
||||
workspace.append_memory("User prefers dark mode").await?;
|
||||
workspace.append_daily_log("Session note").await?;
|
||||
|
||||
// List directory contents
|
||||
let entries = workspace.list("projects/").await?;
|
||||
|
||||
// Search (hybrid FTS + vector)
|
||||
let results = workspace.search("dark mode preference", 5).await?;
|
||||
|
||||
// Get system prompt from identity files
|
||||
let prompt = workspace.system_prompt().await?;
|
||||
```
|
||||
|
||||
### Memory Tools
|
||||
|
||||
Four tools for LLM use:
|
||||
|
||||
- **`memory_search`** - Hybrid search, MUST be called before answering questions about prior work
|
||||
- **`memory_write`** - Write to any path (memory, daily_log, or custom paths)
|
||||
- **`memory_read`** - Read any file by path
|
||||
- **`memory_tree`** - View workspace structure as a tree (depth parameter, default 1)
|
||||
|
||||
### Hybrid Search (RRF)
|
||||
|
||||
Combines full-text search (PostgreSQL `ts_rank_cd`) and vector similarity (pgvector cosine) using Reciprocal Rank Fusion:
|
||||
|
||||
```
|
||||
score(d) = Σ 1/(k + rank(d)) for each method where d appears
|
||||
```
|
||||
|
||||
Default k=60. Results from both methods are combined, with documents appearing in both getting boosted scores.
|
||||
|
||||
### Heartbeat System
|
||||
|
||||
Proactive periodic execution (default: 30 minutes):
|
||||
|
||||
1. Reads `HEARTBEAT.md` checklist
|
||||
2. Runs agent turn with checklist prompt
|
||||
3. If findings, notifies via channel
|
||||
4. If nothing, agent replies "HEARTBEAT_OK" (no notification)
|
||||
|
||||
```rust
|
||||
use crate::agent::{HeartbeatConfig, spawn_heartbeat};
|
||||
|
||||
let config = HeartbeatConfig::default()
|
||||
.with_interval(Duration::from_secs(60 * 30))
|
||||
.with_notify("user_123", "telegram");
|
||||
|
||||
spawn_heartbeat(config, workspace, llm, response_tx);
|
||||
```
|
||||
|
||||
### Chunking Strategy
|
||||
|
||||
Documents are chunked for search indexing:
|
||||
- Default: 800 words per chunk (roughly 800 tokens for English)
|
||||
- 15% overlap between chunks for context preservation
|
||||
- Minimum chunk size: 50 words (tiny trailing chunks merge with previous)
|
||||
See `src/workspace/README.md` for full API documentation, filesystem structure, hybrid search details, chunking strategy, and heartbeat system.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Contributing
|
||||
|
||||
## Feature Parity Requirement
|
||||
|
||||
When your change affects a tracked capability, update `FEATURE_PARITY.md` in the same branch.
|
||||
|
||||
### Required before opening a PR
|
||||
|
||||
1. Review the relevant parity rows in `FEATURE_PARITY.md`.
|
||||
2. Update status/notes if behavior changed.
|
||||
3. Include the `FEATURE_PARITY.md` diff in your commit when applicable.
|
||||
Generated
+1880
-228
File diff suppressed because it is too large
Load Diff
+130
-18
@@ -1,29 +1,60 @@
|
||||
[workspace]
|
||||
members = ["."]
|
||||
exclude = [
|
||||
"channels-src/discord",
|
||||
"channels-src/telegram",
|
||||
"channels-src/slack",
|
||||
"channels-src/whatsapp",
|
||||
"tools-src/github",
|
||||
"tools-src/gmail",
|
||||
"tools-src/google-calendar",
|
||||
"tools-src/google-docs",
|
||||
"tools-src/google-drive",
|
||||
"tools-src/google-sheets",
|
||||
"tools-src/google-slides",
|
||||
"tools-src/okta",
|
||||
"tools-src/slack",
|
||||
"tools-src/telegram",
|
||||
]
|
||||
|
||||
[package]
|
||||
name = "ironclaw"
|
||||
version = "0.1.0"
|
||||
version = "0.10.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
rust-version = "1.92"
|
||||
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
||||
authors = ["NEAR AI <[email protected]>"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
homepage = "https://github.com/nearai/ironclaw"
|
||||
repository = "https://github.com/nearai/ironclaw"
|
||||
|
||||
[package.metadata.wix]
|
||||
upgrade-guid = "D0156E61-BA37-451E-8AB9-1A2ECCCFA48F"
|
||||
path-guid = "F90B6EA6-87F7-499B-BB19-CF55DE1EB339"
|
||||
license = false
|
||||
eula = false
|
||||
|
||||
[dependencies]
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-stream = "0.1"
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
futures = "0.3"
|
||||
|
||||
# HTTP client
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots", "stream"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Database
|
||||
deadpool-postgres = "0.14"
|
||||
tokio-postgres = { version = "0.7", features = ["with-uuid-1", "with-chrono-0_4", "with-serde_json-1"] }
|
||||
postgres-types = { version = "0.2", features = ["with-serde_json-1"] }
|
||||
refinery = { version = "0.8", features = ["tokio-postgres"] }
|
||||
# Database - PostgreSQL (default, feature-gated)
|
||||
deadpool-postgres = { version = "0.14", optional = true }
|
||||
tokio-postgres = { version = "0.7", features = ["with-uuid-1", "with-chrono-0_4", "with-serde_json-1"], optional = true }
|
||||
postgres-types = { version = "0.2", features = ["with-serde_json-1"], optional = true }
|
||||
refinery = { version = "0.8", features = ["tokio-postgres"], optional = true }
|
||||
|
||||
# Database - libSQL/Turso (optional embedded database)
|
||||
libsql = { version = "0.6", optional = true, default-features = false, features = ["core", "replication"] }
|
||||
|
||||
# Error handling
|
||||
thiserror = "2"
|
||||
@@ -35,11 +66,12 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
|
||||
# Configuration
|
||||
dotenvy = "0.15"
|
||||
toml = "0.8"
|
||||
|
||||
# Core types
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
rust_decimal = { version = "1", features = ["serde", "serde-with-str", "db-tokio-postgres", "maths"] }
|
||||
rust_decimal = { version = "1", features = ["serde", "serde-with-str", "maths"] }
|
||||
rust_decimal_macros = "1"
|
||||
|
||||
# Async traits
|
||||
@@ -48,26 +80,35 @@ async-trait = "0.1"
|
||||
# CLI
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
|
||||
# TUI
|
||||
ratatui = "0.29"
|
||||
crossterm = { version = "0.28", features = ["event-stream"] }
|
||||
# Terminal
|
||||
crossterm = "0.28"
|
||||
rustyline = { version = "17", features = ["custom-bindings", "derive", "with-file-history"] }
|
||||
termimad = "0.34"
|
||||
|
||||
# Channel integrations
|
||||
axum = "0.8"
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["trace", "cors"] }
|
||||
tower-http = { version = "0.6", features = ["trace", "cors", "set-header"] }
|
||||
|
||||
# Cron scheduling for routines
|
||||
cron = "0.13"
|
||||
|
||||
# Safety/sanitization
|
||||
regex = "1"
|
||||
aho-corasick = "1"
|
||||
|
||||
# YAML parsing for SKILL.md frontmatter
|
||||
serde_yml = "0.0.12"
|
||||
|
||||
# Filesystem paths
|
||||
dirs = "6"
|
||||
fs4 = "0.6"
|
||||
|
||||
# Secrecy for sensitive values
|
||||
secrecy = { version = "0.10", features = ["serde"] }
|
||||
|
||||
# URL encoding for OAuth flow
|
||||
# URL parsing and encoding
|
||||
url = "2"
|
||||
urlencoding = "2"
|
||||
|
||||
# Open URLs in browser
|
||||
@@ -75,7 +116,7 @@ open = "5"
|
||||
|
||||
# Vector embeddings for semantic search
|
||||
# The postgres feature provides ToSql/FromSql for postgres-types (shared by tokio-postgres)
|
||||
pgvector = { version = "0.4", features = ["postgres"] }
|
||||
pgvector = { version = "0.4", features = ["postgres"], optional = true }
|
||||
|
||||
# WASM sandbox for untrusted tool execution
|
||||
wasmtime = { version = "28", features = ["component-model"] }
|
||||
@@ -88,16 +129,29 @@ hkdf = "0.12"
|
||||
sha2 = "0.10"
|
||||
blake3 = "1"
|
||||
rand = "0.8"
|
||||
subtle = "2" # Constant-time comparisons for token validation
|
||||
|
||||
# Multi-provider LLM support
|
||||
rig-core = "0.30"
|
||||
|
||||
# Docker sandbox
|
||||
bollard = "0.18"
|
||||
|
||||
# Archive extraction for WASM extension bundles
|
||||
flate2 = "1"
|
||||
tar = "0.4"
|
||||
|
||||
# HTTP proxy for sandboxed network access
|
||||
hyper = { version = "1.5", features = ["server", "http1", "http2"] }
|
||||
hyper-util = { version = "0.1", features = ["server", "tokio", "http1", "http2"] }
|
||||
http-body-util = "0.1"
|
||||
bytes = "1"
|
||||
base64 = "0.22.1"
|
||||
mime_guess = "2.0.5"
|
||||
|
||||
# HTML to Markdown conversion (feature gated)
|
||||
html-to-markdown-rs = { version = "2.3", optional = true }
|
||||
readabilityrs = { version = "0.1.2", optional = true }
|
||||
|
||||
# macOS keychain
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
@@ -110,10 +164,68 @@ zbus = "4"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
tokio-tungstenite = "0.26"
|
||||
testcontainers-modules = { version = "0.11", features = ["postgres"] }
|
||||
pretty_assertions = "1"
|
||||
tempfile = "3"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
default = ["postgres", "libsql", "html-to-markdown"]
|
||||
postgres = [
|
||||
"dep:deadpool-postgres",
|
||||
"dep:tokio-postgres",
|
||||
"dep:postgres-types",
|
||||
"dep:refinery",
|
||||
"dep:pgvector",
|
||||
"rust_decimal/db-tokio-postgres",
|
||||
]
|
||||
libsql = ["dep:libsql"]
|
||||
integration = []
|
||||
html-to-markdown = ["dep:html-to-markdown-rs", "dep:readabilityrs"]
|
||||
|
||||
[[test]]
|
||||
name = "html_to_markdown"
|
||||
required-features = ["html-to-markdown"]
|
||||
|
||||
# The profile that 'cargo dist' will build with
|
||||
[profile.dist]
|
||||
inherits = "release"
|
||||
lto = "thin"
|
||||
|
||||
# Config for 'dist'
|
||||
[workspace.metadata.dist]
|
||||
# The preferred dist version to use in CI (Cargo.toml SemVer syntax)
|
||||
cargo-dist-version = "0.30.3"
|
||||
# CI backends to support
|
||||
ci = "github"
|
||||
# The installers to generate for each app
|
||||
installers = ["shell", "powershell", "npm", "msi"]
|
||||
# Publish jobs to run in CI
|
||||
publish-jobs = []
|
||||
# Target platforms to build apps for (Rust target-triple syntax)
|
||||
targets = [
|
||||
"aarch64-apple-darwin",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"x86_64-apple-darwin",
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"x86_64-pc-windows-msvc",
|
||||
]
|
||||
# The archive format to use for windows builds (defaults .zip)
|
||||
windows-archive = ".tar.gz"
|
||||
# The archive format to use for non-windows builds (defaults .tar.xz)
|
||||
unix-archive = ".tar.gz"
|
||||
# Which actions to run on pull requests
|
||||
pr-run-mode = "skip"
|
||||
# Path that installers should place binaries in
|
||||
install-path = "CARGO_HOME"
|
||||
# Whether to install an updater program
|
||||
install-updater = true
|
||||
# Cache intermediate build artifacts to speed up the release pipelines
|
||||
cache-builds = true
|
||||
|
||||
[workspace.metadata.dist.github-custom-runners]
|
||||
aarch64-unknown-linux-gnu = "ubuntu-24.04-arm"
|
||||
x86_64-unknown-linux-gnu = "ubuntu-22.04"
|
||||
x86_64-pc-windows-msvc = "windows-2022"
|
||||
x86_64-apple-darwin = "macos-15-intel"
|
||||
aarch64-apple-darwin = "macos-14"
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# Multi-stage Dockerfile for the IronClaw agent (cloud deployment).
|
||||
#
|
||||
# Build:
|
||||
# docker build --platform linux/amd64 -t ironclaw:latest .
|
||||
#
|
||||
# Run:
|
||||
# docker run --env-file .env -p 3000:3000 ironclaw:latest
|
||||
|
||||
# Stage 1: Build
|
||||
FROM rust:1.92-slim-bookworm AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config libssl-dev cmake gcc g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy manifests first for layer caching
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
|
||||
# Copy source and build artifacts
|
||||
COPY src/ src/
|
||||
COPY migrations/ migrations/
|
||||
COPY wit/ wit/
|
||||
|
||||
RUN cargo build --release --bin ironclaw
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates libssl3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /app/target/release/ironclaw /usr/local/bin/ironclaw
|
||||
COPY --from=builder /app/migrations /app/migrations
|
||||
|
||||
# Non-root user
|
||||
RUN useradd -m -u 1000 -s /bin/bash ironclaw
|
||||
USER ironclaw
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV RUST_LOG=ironclaw=info
|
||||
|
||||
ENTRYPOINT ["ironclaw"]
|
||||
@@ -0,0 +1,69 @@
|
||||
# Multi-stage Dockerfile for the IronClaw worker container.
|
||||
#
|
||||
# This image runs the ironclaw binary in worker mode inside Docker containers.
|
||||
# The orchestrator creates instances of this image for sandboxed job execution.
|
||||
#
|
||||
# Build:
|
||||
# docker build -f Dockerfile.worker -t ironclaw-worker .
|
||||
#
|
||||
# The image includes common development tools so workers can build software,
|
||||
# run tests, and execute shell commands.
|
||||
|
||||
FROM rust:1.92-bookworm AS builder
|
||||
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
|
||||
# Build only the ironclaw binary (release mode)
|
||||
RUN cargo build --release --bin ironclaw
|
||||
|
||||
# ---
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
# Install curl first (needed to fetch the GitHub CLI GPG key), then add the
|
||||
# gh CLI apt repository, then install all remaining dev tools in one layer.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
||||
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
||||
> /etc/apt/sources.list.d/github-cli.list \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
nodejs \
|
||||
npm \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
gh \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Rust toolchain for the sandbox user
|
||||
ENV RUSTUP_HOME=/usr/local/rustup \
|
||||
CARGO_HOME=/usr/local/cargo \
|
||||
PATH=/usr/local/cargo/bin:$PATH
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.92.0 \
|
||||
&& chmod -R a+r /usr/local/rustup /usr/local/cargo
|
||||
|
||||
# Install Claude Code CLI (for claude-bridge mode)
|
||||
RUN npm install -g @anthropic-ai/claude-code@latest
|
||||
|
||||
# Copy the binary
|
||||
COPY --from=builder /build/target/release/ironclaw /usr/local/bin/ironclaw
|
||||
|
||||
# Create non-root user (UID 1000 matches the orchestrator's container config)
|
||||
RUN useradd -m -u 1000 -s /bin/bash sandbox \
|
||||
&& mkdir -p /workspace \
|
||||
&& chown sandbox:sandbox /workspace \
|
||||
&& mkdir -p /home/sandbox/.claude \
|
||||
&& chown sandbox:sandbox /home/sandbox/.claude
|
||||
|
||||
USER sandbox
|
||||
WORKDIR /workspace
|
||||
|
||||
# The orchestrator passes the full command via Docker cmd.
|
||||
ENTRYPOINT ["ironclaw"]
|
||||
+207
-84
@@ -16,8 +16,8 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Hub-and-spoke architecture | ✅ | 🚧 | IronClaw has channels but no central gateway |
|
||||
| WebSocket control plane | ✅ | ❌ | Gateway with ws://127.0.0.1:18789 |
|
||||
| Hub-and-spoke architecture | ✅ | ✅ | Web gateway as central hub |
|
||||
| WebSocket control plane | ✅ | ✅ | Gateway with WebSocket + SSE |
|
||||
| Single-user system | ✅ | ✅ | |
|
||||
| Multi-agent routing | ✅ | ❌ | Workspace isolation per-agent |
|
||||
| Session-based messaging | ✅ | ✅ | Per-sender sessions |
|
||||
@@ -31,20 +31,27 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Gateway control plane | ✅ | ❌ | Central WebSocket server |
|
||||
| HTTP endpoints for Control UI | ✅ | ❌ | Web dashboard |
|
||||
| Channel connection lifecycle | ✅ | 🚧 | ChannelManager handles streams |
|
||||
| Gateway control plane | ✅ | ✅ | Web gateway with 40+ API endpoints |
|
||||
| HTTP endpoints for Control UI | ✅ | ✅ | Web dashboard with chat, memory, jobs, logs, extensions |
|
||||
| Channel connection lifecycle | ✅ | ✅ | ChannelManager + WebSocket tracker |
|
||||
| Session management/routing | ✅ | ✅ | SessionManager exists |
|
||||
| Configuration hot-reload | ✅ | ❌ | |
|
||||
| Network modes (loopback/LAN/remote) | ✅ | 🚧 | HTTP only |
|
||||
| OpenAI-compatible HTTP API | ✅ | ❌ | /v1/chat/completions |
|
||||
| OpenAI-compatible HTTP API | ✅ | ✅ | /v1/chat/completions, per-request `model` override |
|
||||
| Canvas hosting | ✅ | ❌ | Agent-driven UI |
|
||||
| Gateway lock (PID-based) | ✅ | ❌ | |
|
||||
| launchd/systemd integration | ✅ | ❌ | |
|
||||
| Bonjour/mDNS discovery | ✅ | ❌ | |
|
||||
| Tailscale integration | ✅ | ❌ | |
|
||||
| Health check endpoints | ✅ | ❌ | |
|
||||
| Health check endpoints | ✅ | ✅ | /api/health + /api/gateway/status |
|
||||
| `doctor` diagnostics | ✅ | ❌ | |
|
||||
| Agent event broadcast | ✅ | 🚧 | SSE broadcast manager exists (SseManager) but tool/job-state events not fully wired |
|
||||
| Channel health monitor | ✅ | ❌ | Auto-restart with configurable interval |
|
||||
| Presence system | ✅ | ❌ | Beacons on connect, system presence for agents |
|
||||
| Trusted-proxy auth mode | ✅ | ❌ | Header-based auth for reverse proxies |
|
||||
| APNs push pipeline | ✅ | ❌ | Wake disconnected iOS nodes via push |
|
||||
| Oversized payload guard | ✅ | 🚧 | HTTP webhook has 64KB body limit + Content-Length check; no chat.history cap |
|
||||
| Pre-prompt context diagnostics | ✅ | ❌ | Context size logging before prompt |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -58,35 +65,65 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| HTTP webhook | ✅ | ✅ | - | axum with secret validation |
|
||||
| REPL (simple) | ✅ | ✅ | - | For testing |
|
||||
| WASM channels | ❌ | ✅ | - | IronClaw innovation |
|
||||
| WhatsApp | ✅ | ❌ | P1 | Baileys (Web) |
|
||||
| Telegram | ✅ | ❌ | P1 | grammY (Bot API) |
|
||||
| Discord | ✅ | ❌ | P2 | discord.js |
|
||||
| WhatsApp | ✅ | ❌ | P1 | Baileys (Web), same-phone mode with echo detection |
|
||||
| Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username |
|
||||
| Discord | ✅ | ❌ | P2 | discord.js, thread parent binding inheritance |
|
||||
| Signal | ✅ | ❌ | P2 | signal-cli |
|
||||
| Slack | ✅ | 🚧 | P1 | Stub exists, needs implementation |
|
||||
| iMessage | ✅ | ❌ | P3 | BlueBubbles recommended |
|
||||
| Feishu/Lark | ✅ | ❌ | P3 | |
|
||||
| Slack | ✅ | ✅ | - | WASM tool |
|
||||
| iMessage | ✅ | ❌ | P3 | BlueBubbles or Linq recommended |
|
||||
| Linq | ✅ | ❌ | P3 | Real iMessage via API, no Mac required |
|
||||
| Feishu/Lark | ✅ | ❌ | P3 | Bitable create app/field tools |
|
||||
| LINE | ✅ | ❌ | P3 | |
|
||||
| WebChat | ✅ | ❌ | P2 | Browser-based chat |
|
||||
| WebChat | ✅ | ✅ | - | Web gateway chat |
|
||||
| Matrix | ✅ | ❌ | P3 | E2EE support |
|
||||
| Mattermost | ✅ | ❌ | P3 | |
|
||||
| Mattermost | ✅ | ❌ | P3 | Emoji reactions |
|
||||
| Google Chat | ✅ | ❌ | P3 | |
|
||||
| MS Teams | ✅ | ❌ | P3 | |
|
||||
| Twitch | ✅ | ❌ | P3 | |
|
||||
| Voice Call | ✅ | ❌ | P3 | Twilio/Telnyx |
|
||||
| Voice Call | ✅ | ❌ | P3 | Twilio/Telnyx, stale call reaper, pre-cached greeting |
|
||||
| Nostr | ✅ | ❌ | P3 | |
|
||||
|
||||
### Telegram-Specific Features (since Feb 2025)
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Forum topic creation | ✅ | ❌ | Create topics in forum groups |
|
||||
| channel_post support | ✅ | ❌ | Bot-to-bot communication |
|
||||
| User message reactions | ✅ | ❌ | Surface inbound reactions |
|
||||
| sendPoll | ✅ | ❌ | Poll creation via agent |
|
||||
| Cron/heartbeat topic targeting | ✅ | ❌ | Messages land in correct topic |
|
||||
|
||||
### Discord-Specific Features (since Feb 2025)
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Forwarded attachment downloads | ✅ | ❌ | Fetch media from forwarded messages |
|
||||
| Faster reaction state machine | ✅ | ❌ | Watchdog + debounce |
|
||||
| Thread parent binding inheritance | ✅ | ❌ | Threads inherit parent routing |
|
||||
|
||||
### Slack-Specific Features (since Feb 2025)
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Streaming draft replies | ✅ | ❌ | Partial replies via draft message updates |
|
||||
| Configurable stream modes | ✅ | ❌ | Per-channel stream behavior |
|
||||
| Thread ownership | ✅ | ❌ | Thread-level ownership tracking |
|
||||
|
||||
### Channel Features
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| DM pairing codes | ✅ | ❌ | Verification for unknown senders |
|
||||
| Allowlist/blocklist | ✅ | ❌ | Per-channel access control |
|
||||
| DM pairing codes | ✅ | ✅ | `ironclaw pairing list/approve`, host APIs |
|
||||
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
|
||||
| Self-message bypass | ✅ | ❌ | Own messages skip pairing |
|
||||
| Mention-based activation | ✅ | ❌ | Configurable patterns |
|
||||
| Mention-based activation | ✅ | ✅ | bot_username + respond_to_all_group_messages |
|
||||
| Per-group tool policies | ✅ | ❌ | Allow/deny specific tools |
|
||||
| Thread isolation | ✅ | ✅ | Separate sessions per thread |
|
||||
| Per-channel media limits | ✅ | ❌ | |
|
||||
| Typing indicators | ✅ | 🚧 | TUI shows status |
|
||||
| Per-channel media limits | ✅ | 🚧 | Caption support for media; no size limits |
|
||||
| Typing indicators | ✅ | 🚧 | TUI + Telegram typing/actionable status prompts; richer parity pending |
|
||||
| Per-channel ackReaction config | ✅ | ❌ | Customizable acknowledgement reactions |
|
||||
| Group session priming | ✅ | ❌ | Member roster injected for context |
|
||||
| Sender_id in trusted metadata | ✅ | ❌ | Exposed in system metadata |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -99,21 +136,21 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| `run` (agent) | ✅ | ✅ | - | Default command |
|
||||
| `tool install/list/remove` | ✅ | ✅ | - | WASM tools |
|
||||
| `gateway start/stop` | ✅ | ❌ | P2 | |
|
||||
| `onboard` (wizard) | ✅ | ❌ | P2 | Interactive setup |
|
||||
| `onboard` (wizard) | ✅ | ✅ | - | Interactive setup |
|
||||
| `tui` | ✅ | ✅ | - | Ratatui TUI |
|
||||
| `config` | ✅ | ❌ | P2 | Read/write config |
|
||||
| `config` | ✅ | ✅ | - | Read/write config |
|
||||
| `channels` | ✅ | ❌ | P2 | Channel management |
|
||||
| `models` | ✅ | 🚧 | - | Model selector in TUI |
|
||||
| `status` | ✅ | ❌ | P2 | System status |
|
||||
| `status` | ✅ | ✅ | - | System status (enriched session details) |
|
||||
| `agents` | ✅ | ❌ | P3 | Multi-agent management |
|
||||
| `sessions` | ✅ | ❌ | P3 | Session listing |
|
||||
| `memory` | ✅ | ❌ | P2 | Memory search CLI |
|
||||
| `skills` | ✅ | ❌ | P3 | Agent skills |
|
||||
| `pairing` | ✅ | ❌ | P3 | Node pairing |
|
||||
| `nodes` | ✅ | ❌ | P3 | Device management |
|
||||
| `sessions` | ✅ | ❌ | P3 | Session listing (shows subagent models) |
|
||||
| `memory` | ✅ | ✅ | - | Memory search CLI |
|
||||
| `skills` | ✅ | ✅ | - | Skills tools + web API endpoints (install, list, activate) |
|
||||
| `pairing` | ✅ | ✅ | - | list/approve, account selector |
|
||||
| `nodes` | ✅ | ❌ | P3 | Device management, remove/clear flows |
|
||||
| `plugins` | ✅ | ❌ | P3 | Plugin management |
|
||||
| `hooks` | ✅ | ❌ | P2 | Lifecycle hooks |
|
||||
| `cron` | ✅ | ❌ | P2 | Scheduled jobs |
|
||||
| `hooks` | ✅ | ✅ | P2 | Lifecycle hooks |
|
||||
| `cron` | ✅ | ❌ | P2 | Scheduled jobs (model/thinking fields in edit) |
|
||||
| `webhooks` | ✅ | ❌ | P3 | Webhook config |
|
||||
| `message send` | ✅ | ❌ | P2 | Send to channels |
|
||||
| `browser` | ✅ | ❌ | P3 | Browser automation |
|
||||
@@ -122,6 +159,8 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| `logs` | ✅ | ❌ | P3 | Query logs |
|
||||
| `update` | ✅ | ❌ | P3 | Self-update |
|
||||
| `completion` | ✅ | ❌ | P3 | Shell completion |
|
||||
| `/subagents spawn` | ✅ | ❌ | P3 | Spawn subagents from chat |
|
||||
| `/export-session` | ✅ | ❌ | P3 | Export current session transcript |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -132,23 +171,38 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Pi agent runtime | ✅ | ➖ | IronClaw uses custom runtime |
|
||||
| RPC-based execution | ✅ | 🚧 | Worker isolation |
|
||||
| Multi-provider failover | ✅ | ❌ | Provider fallback chains |
|
||||
| RPC-based execution | ✅ | ✅ | Orchestrator/worker pattern |
|
||||
| Multi-provider failover | ✅ | ✅ | `FailoverProvider` tries providers sequentially on retryable errors |
|
||||
| Per-sender sessions | ✅ | ✅ | |
|
||||
| Global sessions | ✅ | ❌ | Optional shared context |
|
||||
| Session pruning | ✅ | ❌ | Auto cleanup old sessions |
|
||||
| Context compaction | ✅ | ✅ | Auto summarization |
|
||||
| Custom system prompts | ✅ | ✅ | Template variables |
|
||||
| Skills (modular capabilities) | ✅ | ❌ | Capability bundles |
|
||||
| Post-compaction read audit | ✅ | ❌ | Layer 3: workspace rules appended to summaries |
|
||||
| Post-compaction context injection | ✅ | ❌ | Workspace context as system event |
|
||||
| Custom system prompts | ✅ | ✅ | Template variables, safety guardrails |
|
||||
| Skills (modular capabilities) | ✅ | ✅ | Prompt-based skills with trust gating, attenuation, activation criteria, catalog, selector |
|
||||
| Skill routing blocks | ✅ | 🚧 | ActivationCriteria (keywords, patterns, tags) but no "Use when / Don't use when" blocks |
|
||||
| Skill path compaction | ✅ | ❌ | ~ prefix to reduce prompt tokens |
|
||||
| Thinking modes (low/med/high) | ✅ | ❌ | Configurable reasoning depth |
|
||||
| Per-model thinkingDefault override | ✅ | ❌ | Override thinking level per model |
|
||||
| Block-level streaming | ✅ | ❌ | |
|
||||
| Tool-level streaming | ✅ | ❌ | |
|
||||
| Z.AI tool_stream | ✅ | ❌ | Real-time tool call streaming |
|
||||
| Plugin tools | ✅ | ✅ | WASM tools |
|
||||
| Tool policies (allow/deny) | ✅ | ✅ | |
|
||||
| Exec approvals (`/approve`) | ✅ | ✅ | TUI approval overlay |
|
||||
| Elevated mode | ✅ | ❌ | Privileged execution |
|
||||
| Subagent support | ✅ | ✅ | Task framework |
|
||||
| `/subagents spawn` command | ✅ | ❌ | Spawn from chat |
|
||||
| Auth profiles | ✅ | ❌ | Multiple auth strategies |
|
||||
| Generic API key rotation | ✅ | ❌ | Rotate keys across providers |
|
||||
| Stuck loop detection | ✅ | ❌ | Exponential backoff on stuck agent loops |
|
||||
| llms.txt discovery | ✅ | ❌ | Auto-discover site metadata |
|
||||
| Multiple images per tool call | ✅ | ❌ | Single tool call, multiple images |
|
||||
| URL allowlist (web_search/fetch) | ✅ | ❌ | Restrict web tool targets |
|
||||
| suppressToolErrors config | ✅ | ❌ | Hide tool errors from user |
|
||||
| Intent-first tool display | ✅ | ❌ | Details and exec summaries |
|
||||
| Transcript file size in status | ✅ | ❌ | Show size in session status |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -159,12 +213,18 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Provider | OpenClaw | IronClaw | Priority | Notes |
|
||||
|----------|----------|----------|----------|-------|
|
||||
| NEAR AI | ✅ | ✅ | - | Primary provider |
|
||||
| Anthropic (Claude) | ✅ | 🚧 | - | Via NEAR AI proxy |
|
||||
| Anthropic (Claude) | ✅ | 🚧 | - | Via NEAR AI proxy; Opus 4.5, Sonnet 4, Sonnet 4.6 |
|
||||
| OpenAI | ✅ | 🚧 | - | Via NEAR AI proxy |
|
||||
| AWS Bedrock | ✅ | ❌ | P3 | |
|
||||
| Google Gemini | ✅ | ❌ | P3 | |
|
||||
| OpenRouter | ✅ | ❌ | P3 | |
|
||||
| Ollama (local) | ✅ | ❌ | P2 | Local models |
|
||||
| NVIDIA API | ✅ | ❌ | P3 | New provider |
|
||||
| OpenRouter | ✅ | ✅ | - | Via OpenAI-compatible provider (RigAdapter) |
|
||||
| Tinfoil | ❌ | ✅ | - | Private inference provider (IronClaw-only) |
|
||||
| OpenAI-compatible | ❌ | ✅ | - | Generic OpenAI-compatible endpoint (RigAdapter) |
|
||||
| Ollama (local) | ✅ | ✅ | - | via `rig::providers::ollama` (full support) |
|
||||
| Perplexity | ✅ | ❌ | P3 | Freshness parameter for web_search |
|
||||
| MiniMax | ✅ | ❌ | P3 | Regional endpoint selection |
|
||||
| GLM-5 | ✅ | ❌ | P3 | |
|
||||
| node-llama-cpp | ✅ | ➖ | - | N/A for Rust |
|
||||
| llama.cpp (native) | ❌ | 🔮 | P3 | Rust bindings |
|
||||
|
||||
@@ -173,10 +233,12 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Auto-discovery | ✅ | ❌ | |
|
||||
| Failover chains | ✅ | ❌ | Provider fallback |
|
||||
| Cooldown management | ✅ | ❌ | Skip failed providers |
|
||||
| Failover chains | ✅ | ✅ | `FailoverProvider` with configurable `fallback_model` |
|
||||
| Cooldown management | ✅ | ✅ | Lock-free per-provider cooldown in `FailoverProvider` |
|
||||
| Per-session model override | ✅ | ✅ | Model selector in TUI |
|
||||
| Model selection UI | ✅ | ✅ | TUI keyboard shortcut |
|
||||
| Per-model thinkingDefault | ✅ | ❌ | Override thinking level per model in config |
|
||||
| 1M context beta header | ✅ | ❌ | Anthropic extended context support |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -187,6 +249,8 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
||||
|---------|----------|----------|----------|-------|
|
||||
| Image processing (Sharp) | ✅ | ❌ | P2 | Resize, format convert |
|
||||
| Configurable image resize dims | ✅ | ❌ | P2 | Per-agent dimension config |
|
||||
| Multiple images per tool call | ✅ | ❌ | P2 | Single tool invocation, multiple images |
|
||||
| Audio transcription | ✅ | ❌ | P2 | |
|
||||
| Video support | ✅ | ❌ | P3 | |
|
||||
| PDF parsing | ✅ | ❌ | P2 | pdfjs-dist |
|
||||
@@ -195,6 +259,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Vision model integration | ✅ | ❌ | P2 | Image understanding |
|
||||
| TTS (Edge TTS) | ✅ | ❌ | P3 | Text-to-speech |
|
||||
| TTS (OpenAI) | ✅ | ❌ | P3 | |
|
||||
| Incremental TTS playback | ✅ | ❌ | P3 | iOS progressive playback |
|
||||
| Sticker-to-image | ✅ | ❌ | P3 | Telegram stickers |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
@@ -213,10 +278,13 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Auth plugins | ✅ | ❌ | |
|
||||
| Memory plugins | ✅ | ❌ | Custom backends |
|
||||
| Tool plugins | ✅ | ✅ | WASM tools |
|
||||
| Hook plugins | ✅ | ❌ | |
|
||||
| Hook plugins | ✅ | ✅ | Declarative hooks from extension capabilities |
|
||||
| Provider plugins | ✅ | ❌ | |
|
||||
| Plugin CLI (`install`, `list`) | ✅ | ✅ | `tool` subcommand |
|
||||
| ClawHub registry | ✅ | ❌ | Discovery |
|
||||
| `before_agent_start` hook | ✅ | ❌ | modelOverride/providerOverride support |
|
||||
| `before_message_write` hook | ✅ | ❌ | Pre-write message interception |
|
||||
| `llm_input`/`llm_output` hooks | ✅ | ❌ | LLM payload inspection |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -235,6 +303,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Legacy migration | ✅ | ➖ | |
|
||||
| State directory | ✅ `~/.openclaw-state/` | ✅ `~/.ironclaw/` | |
|
||||
| Credentials directory | ✅ | ✅ | Session files |
|
||||
| Full model compat fields in schema | ✅ | ❌ | pi-ai model compat exposed in config |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -247,16 +316,19 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Vector memory | ✅ | ✅ | pgvector |
|
||||
| Session-based memory | ✅ | ✅ | |
|
||||
| Hybrid search (BM25 + vector) | ✅ | ✅ | RRF algorithm |
|
||||
| Temporal decay (hybrid search) | ✅ | ❌ | Opt-in time-based scoring factor |
|
||||
| MMR re-ranking | ✅ | ❌ | Maximal marginal relevance for result diversity |
|
||||
| LLM-based query expansion | ✅ | ❌ | Expand FTS queries via LLM |
|
||||
| OpenAI embeddings | ✅ | ✅ | |
|
||||
| Gemini embeddings | ✅ | ❌ | |
|
||||
| Local embeddings | ✅ | ❌ | |
|
||||
| SQLite-vec backend | ✅ | ❌ | IronClaw uses PostgreSQL |
|
||||
| LanceDB backend | ✅ | ❌ | |
|
||||
| LanceDB backend | ✅ | ❌ | Configurable auto-capture max length |
|
||||
| QMD backend | ✅ | ❌ | |
|
||||
| Atomic reindexing | ✅ | ✅ | |
|
||||
| Embeddings batching | ✅ | ❌ | |
|
||||
| Embeddings batching | ✅ | ✅ | `embed_batch` on EmbeddingProvider trait |
|
||||
| Citation support | ✅ | ❌ | |
|
||||
| Memory CLI commands | ✅ | ❌ | `memory search/index/status` |
|
||||
| Memory CLI commands | ✅ | ✅ | `memory search/read/write/tree/status` CLI subcommands |
|
||||
| Flexible path structure | ✅ | ✅ | Filesystem-like API |
|
||||
| Identity files (AGENTS.md, etc.) | ✅ | ✅ | |
|
||||
| Daily logs | ✅ | ✅ | |
|
||||
@@ -272,12 +344,16 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
|---------|----------|----------|----------|-------|
|
||||
| iOS app (SwiftUI) | ✅ | 🚫 | - | Out of scope initially |
|
||||
| Android app (Kotlin) | ✅ | 🚫 | - | Out of scope initially |
|
||||
| Apple Watch companion | ✅ | 🚫 | - | Send/receive messages MVP |
|
||||
| Gateway WebSocket client | ✅ | 🚫 | - | |
|
||||
| Camera/photo access | ✅ | 🚫 | - | |
|
||||
| Voice input | ✅ | 🚫 | - | |
|
||||
| Push-to-talk | ✅ | 🚫 | - | |
|
||||
| Location sharing | ✅ | 🚫 | - | |
|
||||
| Node pairing | ✅ | 🚫 | - | |
|
||||
| APNs push notifications | ✅ | 🚫 | - | Wake disconnected nodes before invoke |
|
||||
| Share to OpenClaw (iOS) | ✅ | 🚫 | - | iOS share sheet integration |
|
||||
| Background listening toggle | ✅ | 🚫 | - | iOS background audio |
|
||||
|
||||
### Owner: _Unassigned_ (if ever prioritized)
|
||||
|
||||
@@ -288,12 +364,17 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
||||
|---------|----------|----------|----------|-------|
|
||||
| SwiftUI native app | ✅ | 🚫 | - | Out of scope |
|
||||
| Menu bar presence | ✅ | 🚫 | - | |
|
||||
| Menu bar presence | ✅ | 🚫 | - | Animated menubar icon |
|
||||
| Bundled gateway | ✅ | 🚫 | - | |
|
||||
| Canvas hosting | ✅ | 🚫 | - | |
|
||||
| Voice wake | ✅ | 🚫 | - | |
|
||||
| Canvas hosting | ✅ | 🚫 | - | Agent-controlled panel with placement/resizing |
|
||||
| Voice wake | ✅ | 🚫 | - | Overlay, mic picker, language selection, live meter |
|
||||
| Voice wake overlay | ✅ | 🚫 | - | Partial transcripts, adaptive delays, dismiss animations |
|
||||
| Push-to-talk hotkey | ✅ | 🚫 | - | System-wide hotkey |
|
||||
| Exec approval dialogs | ✅ | ✅ | - | TUI overlay |
|
||||
| iMessage integration | ✅ | 🚫 | - | |
|
||||
| Instances tab | ✅ | 🚫 | - | Presence beacons across instances |
|
||||
| Agent events debug window | ✅ | 🚫 | - | Real-time event inspector |
|
||||
| Sparkle auto-updates | ✅ | 🚫 | - | Appcast distribution |
|
||||
|
||||
### Owner: _Unassigned_ (if ever prioritized)
|
||||
|
||||
@@ -303,14 +384,17 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
||||
|---------|----------|----------|----------|-------|
|
||||
| Control UI Dashboard | ✅ | ❌ | P2 | Web status/config |
|
||||
| Channel status view | ✅ | ❌ | P2 | |
|
||||
| Control UI Dashboard | ✅ | ✅ | - | Web gateway with chat, memory, jobs, logs, extensions |
|
||||
| Channel status view | ✅ | 🚧 | P2 | Gateway status widget, full channel view pending |
|
||||
| Agent management | ✅ | ❌ | P3 | |
|
||||
| Model selection | ✅ | ✅ | - | TUI only |
|
||||
| Config editing | ✅ | ❌ | P3 | |
|
||||
| Debug/logs viewer | ✅ | ❌ | P3 | |
|
||||
| WebChat interface | ✅ | ❌ | P2 | Browser chat |
|
||||
| Canvas system (A2UI) | ✅ | ❌ | P3 | Agent-driven UI |
|
||||
| Debug/logs viewer | ✅ | ✅ | - | Real-time log streaming with level/target filters |
|
||||
| WebChat interface | ✅ | ✅ | - | Web gateway chat with SSE/WebSocket |
|
||||
| Canvas system (A2UI) | ✅ | ❌ | P3 | Agent-driven UI, improved asset resolution |
|
||||
| Control UI i18n | ✅ | ❌ | P3 | English, Chinese, Portuguese |
|
||||
| WebChat theme sync | ✅ | ❌ | P3 | Sync with system dark/light mode |
|
||||
| Partial output on abort | ✅ | ❌ | P2 | Preserve partial output when aborting |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -320,21 +404,27 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
||||
|---------|----------|----------|----------|-------|
|
||||
| Cron jobs | ✅ | ❌ | P2 | Schedule-based tasks |
|
||||
| Timezone support | ✅ | ❌ | P2 | |
|
||||
| One-shot/recurring jobs | ✅ | ❌ | P2 | |
|
||||
| `beforeInbound` hook | ✅ | ❌ | P2 | |
|
||||
| `beforeOutbound` hook | ✅ | ❌ | P2 | |
|
||||
| `beforeToolCall` hook | ✅ | ❌ | P2 | |
|
||||
| `onMessage` hook | ✅ | ❌ | P2 | |
|
||||
| `onSessionStart` hook | ✅ | ❌ | P2 | |
|
||||
| `onSessionEnd` hook | ✅ | ❌ | P2 | |
|
||||
| Cron jobs | ✅ | ✅ | - | Routines with cron trigger |
|
||||
| Cron stagger controls | ✅ | ❌ | P3 | Default stagger for scheduled jobs |
|
||||
| Cron finished-run webhook | ✅ | ❌ | P3 | Webhook on job completion |
|
||||
| Timezone support | ✅ | ✅ | - | Via cron expressions |
|
||||
| One-shot/recurring jobs | ✅ | ✅ | - | Manual + cron triggers |
|
||||
| Channel health monitor | ✅ | ❌ | P2 | Auto-restart with configurable interval |
|
||||
| `beforeInbound` hook | ✅ | ✅ | P2 | |
|
||||
| `beforeOutbound` hook | ✅ | ✅ | P2 | |
|
||||
| `beforeToolCall` hook | ✅ | ✅ | P2 | |
|
||||
| `before_agent_start` hook | ✅ | ❌ | P2 | Model/provider override |
|
||||
| `before_message_write` hook | ✅ | ❌ | P2 | Pre-write interception |
|
||||
| `onMessage` hook | ✅ | ✅ | - | Routines with event trigger |
|
||||
| `onSessionStart` hook | ✅ | ✅ | P2 | |
|
||||
| `onSessionEnd` hook | ✅ | ✅ | P2 | |
|
||||
| `transcribeAudio` hook | ✅ | ❌ | P3 | |
|
||||
| `transformResponse` hook | ✅ | ❌ | P2 | |
|
||||
| Bundled hooks | ✅ | ❌ | P2 | |
|
||||
| Plugin hooks | ✅ | ❌ | P3 | |
|
||||
| Workspace hooks | ✅ | ❌ | P2 | Inline code |
|
||||
| Outbound webhooks | ✅ | ❌ | P2 | |
|
||||
| `transformResponse` hook | ✅ | ✅ | P2 | |
|
||||
| `llm_input`/`llm_output` hooks | ✅ | ❌ | P3 | LLM payload inspection |
|
||||
| Bundled hooks | ✅ | ✅ | P2 | Audit + declarative rule/webhook hooks |
|
||||
| Plugin hooks | ✅ | ✅ | P3 | Registered from WASM `capabilities.json` |
|
||||
| Workspace hooks | ✅ | ✅ | P2 | `hooks/hooks.json` and `hooks/*.hook.json` |
|
||||
| Outbound webhooks | ✅ | ✅ | P2 | Fire-and-forget lifecycle event delivery |
|
||||
| Heartbeat system | ✅ | ✅ | - | Periodic execution |
|
||||
| Gmail pub/sub | ✅ | ❌ | P3 | |
|
||||
|
||||
@@ -346,28 +436,37 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Gateway token auth | ✅ | 🚧 | HTTP webhook secret |
|
||||
| Gateway token auth | ✅ | ✅ | Bearer token auth on web gateway |
|
||||
| Device pairing | ✅ | ❌ | |
|
||||
| Tailscale identity | ✅ | ❌ | |
|
||||
| Trusted-proxy auth | ✅ | ❌ | Header-based reverse proxy auth |
|
||||
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth |
|
||||
| DM pairing verification | ✅ | ❌ | |
|
||||
| Allowlist/blocklist | ✅ | ❌ | |
|
||||
| DM pairing verification | ✅ | ✅ | ironclaw pairing approve, host APIs |
|
||||
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
|
||||
| Per-group tool policies | ✅ | ❌ | |
|
||||
| Exec approvals | ✅ | ✅ | TUI overlay |
|
||||
| TLS 1.3 minimum | ✅ | ✅ | reqwest rustls |
|
||||
| SSRF protection | ✅ | ✅ | WASM allowlist |
|
||||
| SSRF IPv6 transition bypass block | ✅ | ❌ | Block IPv4-mapped IPv6 bypasses |
|
||||
| Cron webhook SSRF guard | ✅ | ❌ | SSRF checks on webhook delivery |
|
||||
| Loopback-first | ✅ | 🚧 | HTTP binds 0.0.0.0 |
|
||||
| Docker sandbox | ✅ | ❌ | Uses WASM sandbox |
|
||||
| Docker sandbox | ✅ | ✅ | Orchestrator/worker containers |
|
||||
| Podman support | ✅ | ❌ | Alternative to Docker |
|
||||
| WASM sandbox | ❌ | ✅ | IronClaw innovation |
|
||||
| Sandbox env sanitization | ✅ | 🚧 | Shell tool scrubs env vars (secret detection); docker container env sanitization partial |
|
||||
| Tool policies | ✅ | ✅ | |
|
||||
| Elevated mode | ✅ | ❌ | |
|
||||
| Safe bins allowlist | ✅ | ❌ | |
|
||||
| Safe bins allowlist | ✅ | ❌ | Hardened path trust |
|
||||
| LD*/DYLD* validation | ✅ | ❌ | |
|
||||
| Path traversal prevention | ✅ | ✅ | |
|
||||
| Path traversal prevention | ✅ | ✅ | Including config includes (OC-06) |
|
||||
| Credential theft via env injection | ✅ | 🚧 | Shell env scrubbing + command injection detection; no full OC-09 defense |
|
||||
| Session file permissions (0o600) | ✅ | ✅ | Session token file set to 0o600 in llm/session.rs |
|
||||
| Skill download path restriction | ✅ | ❌ | Prevent arbitrary write targets |
|
||||
| Webhook signature verification | ✅ | ✅ | |
|
||||
| Media URL validation | ✅ | ❌ | |
|
||||
| Prompt injection defense | ✅ | ✅ | Pattern detection, sanitization |
|
||||
| Leak detection | ✅ | ✅ | Secret exfiltration |
|
||||
| Dangerous tool re-enable warning | ✅ | ❌ | Warn when gateway.tools.allow re-enables HTTP tools |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -387,6 +486,9 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Coverage | V8 | tarpaulin/llvm-cov | |
|
||||
| CI/CD | GitHub Actions | GitHub Actions | |
|
||||
| Pre-commit hooks | prek | - | Consider adding |
|
||||
| Docker: Chromium + Xvfb | ✅ | ❌ | Optional browser in container |
|
||||
| Docker: init scripts | ✅ | ❌ | /openclaw-init.d/ support |
|
||||
| Browser: extraArgs config | ✅ | ❌ | Custom Chrome launch arguments |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -397,30 +499,44 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
### P0 - Core (Already Done)
|
||||
- ✅ TUI channel with approval overlays
|
||||
- ✅ HTTP webhook channel
|
||||
- ✅ DM pairing (ironclaw pairing list/approve, host APIs)
|
||||
- ✅ WASM tool sandbox
|
||||
- ✅ Workspace/memory with hybrid search
|
||||
- ✅ Workspace/memory with hybrid search + embeddings batching
|
||||
- ✅ Prompt injection defense
|
||||
- ✅ Heartbeat system
|
||||
- ✅ Session management
|
||||
- ✅ Context compaction
|
||||
- ✅ Model selection
|
||||
- ✅ Gateway control plane + WebSocket
|
||||
- ✅ Web Control UI (chat, memory, jobs, logs, extensions, routines)
|
||||
- ✅ WebChat channel (web gateway)
|
||||
- ✅ Slack channel (WASM tool)
|
||||
- ✅ Telegram channel (WASM tool, MTProto)
|
||||
- ✅ Docker sandbox (orchestrator/worker)
|
||||
- ✅ Cron job scheduling (routines)
|
||||
- ✅ CLI subcommands (onboard, config, status, memory)
|
||||
- ✅ Gateway token auth
|
||||
- ✅ Skills system (prompt-based with trust gating, attenuation, activation criteria)
|
||||
- ✅ Session file permissions (0o600)
|
||||
- ✅ Memory CLI commands (search, read, write, tree, status)
|
||||
- ✅ Shell env scrubbing + command injection detection
|
||||
- ✅ Tinfoil private inference provider
|
||||
- ✅ OpenAI-compatible / OpenRouter provider support
|
||||
|
||||
### P1 - High Priority
|
||||
- ❌ Slack channel (real implementation)
|
||||
- ❌ Telegram channel
|
||||
- ✅ Telegram channel (WASM, DM pairing, caption, /start)
|
||||
- ❌ WhatsApp channel
|
||||
- ❌ Multi-provider failover
|
||||
- ❌ Gateway control plane + WebSocket
|
||||
- ❌ Hooks system (beforeInbound, beforeToolCall, etc.)
|
||||
- ✅ Multi-provider failover (`FailoverProvider` with retryable error classification)
|
||||
- ✅ Hooks system (core lifecycle hooks + bundled/plugin/workspace hooks + outbound webhooks)
|
||||
|
||||
### P2 - Medium Priority
|
||||
- ❌ Cron job scheduling
|
||||
- ❌ Web Control UI
|
||||
- ❌ WebChat channel
|
||||
- ❌ Media handling (images, PDFs)
|
||||
- ❌ CLI subcommands (config, status, memory, doctor)
|
||||
- ❌ Ollama/local model support
|
||||
- ✅ Ollama/local model support (via rig::providers::ollama)
|
||||
- ❌ Configuration hot-reload
|
||||
- ❌ Webhook trigger endpoint in web gateway
|
||||
- ❌ Channel health monitor with auto-restart
|
||||
- ❌ Partial output preservation on abort
|
||||
|
||||
### P3 - Lower Priority
|
||||
- ❌ Discord channel
|
||||
@@ -429,8 +545,12 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
- ❌ Other messaging platforms
|
||||
- ❌ TTS/audio features
|
||||
- ❌ Video support
|
||||
- ❌ Skills system
|
||||
- 🚧 Skills routing blocks (activation criteria exist, but no "Use when / Don't use when")
|
||||
- ❌ Plugin registry
|
||||
- ❌ Streaming (block/tool/Z.AI tool_stream)
|
||||
- ❌ Memory: temporal decay, MMR re-ranking, query expansion
|
||||
- ❌ Control UI i18n
|
||||
- ❌ Stuck loop detection
|
||||
|
||||
---
|
||||
|
||||
@@ -455,9 +575,12 @@ IronClaw intentionally differs from OpenClaw in these ways:
|
||||
|
||||
1. **Rust vs TypeScript**: Native performance, memory safety, single binary distribution
|
||||
2. **WASM sandbox vs Docker**: Lighter weight, faster startup, capability-based security
|
||||
3. **PostgreSQL vs SQLite**: Better suited for production deployments
|
||||
3. **PostgreSQL + libSQL vs SQLite**: Dual-backend (production PG + embedded libSQL for zero-dep local mode)
|
||||
4. **NEAR AI focus**: Primary provider with session-based auth
|
||||
5. **No mobile/desktop apps**: Focus on server-side and CLI initially
|
||||
6. **WASM channels**: Novel extension mechanism not in OpenClaw
|
||||
7. **Tinfoil private inference**: IronClaw-only provider for private/encrypted inference
|
||||
8. **GitHub WASM tool**: Native GitHub integration as WASM tool
|
||||
9. **Prompt-based skills**: Different approach than OpenClaw capability bundles (trust gating, attenuation)
|
||||
|
||||
These are intentional architectural choices, not gaps to be filled.
|
||||
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding any notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2026 NEAR AI
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="ironclaw.png" alt="IronClaw" width="200"/>
|
||||
<img src="ironclaw.png?v=2" alt="IronClaw" width="200"/>
|
||||
</p>
|
||||
|
||||
<h1 align="center">IronClaw</h1>
|
||||
@@ -43,7 +43,10 @@ IronClaw is the AI assistant you can actually trust with your personal and profe
|
||||
|
||||
### Always Available
|
||||
|
||||
- **Multi-channel** - Reach your assistant via CLI, Telegram, WhatsApp, Slack, or HTTP webhooks
|
||||
- **Multi-channel** - REPL, HTTP webhooks, WASM channels (Telegram, Slack), and web gateway
|
||||
- **Docker Sandbox** - Isolated container execution with per-job tokens and orchestrator/worker pattern
|
||||
- **Web Gateway** - Browser UI with real-time SSE/WebSocket streaming
|
||||
- **Routines** - Cron schedules, event triggers, webhook handlers for background automation
|
||||
- **Heartbeat System** - Proactive background execution for monitoring and maintenance tasks
|
||||
- **Parallel Jobs** - Handle multiple requests concurrently with isolated contexts
|
||||
- **Self-repair** - Automatic detection and recovery of stuck operations
|
||||
@@ -65,10 +68,41 @@ IronClaw is the AI assistant you can actually trust with your personal and profe
|
||||
### Prerequisites
|
||||
|
||||
- Rust 1.85+
|
||||
- PostgreSQL 15+ with pgvector extension
|
||||
- NEAR AI session token (or other LLM provider)
|
||||
- PostgreSQL 15+ with [pgvector](https://github.com/pgvector/pgvector) extension
|
||||
- NEAR AI account (authentication handled via setup wizard)
|
||||
|
||||
### Build
|
||||
## Download or Build
|
||||
|
||||
Visit [Releases page](https://github.com/nearai/ironclaw/releases/) to see the latest updates.
|
||||
|
||||
<details>
|
||||
<summary>Install via Windows Installer (Windows)</summary>
|
||||
|
||||
Download the [Windows Installer](https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-x86_64-pc-windows-msvc.msi) and run it.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Install via powershell script (Windows)</summary>
|
||||
|
||||
```sh
|
||||
irm https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.ps1 | iex
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Install via shell script (macOS, Linux, Windows/WSL)</summary>
|
||||
|
||||
```sh
|
||||
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.sh | sh
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Compile the source code (Cargo on Windows, Linux, macOS)</summary>
|
||||
|
||||
Install it with `cargo`, just make sure you have [Rust](https://rustup.rs) installed on your computer.
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
@@ -82,6 +116,10 @@ cargo build --release
|
||||
cargo test
|
||||
```
|
||||
|
||||
For **full release** (after modifying channel sources), run `./scripts/build-all.sh` to rebuild channels first.
|
||||
|
||||
</details>
|
||||
|
||||
### Database Setup
|
||||
|
||||
```bash
|
||||
@@ -90,36 +128,37 @@ createdb ironclaw
|
||||
|
||||
# Enable pgvector
|
||||
psql ironclaw -c "CREATE EXTENSION IF NOT EXISTS vector;"
|
||||
|
||||
# Run migrations
|
||||
refinery migrate -c refinery.toml
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy `.env.example` to `.env` and configure:
|
||||
Run the setup wizard to configure IronClaw:
|
||||
|
||||
```bash
|
||||
# Required
|
||||
DATABASE_URL=postgres://user:pass@localhost/ironclaw
|
||||
NEARAI_SESSION_TOKEN=sess_...
|
||||
|
||||
# Optional: Enable channels
|
||||
TELEGRAM_BOT_TOKEN=...
|
||||
WHATSAPP_ACCESS_TOKEN=...
|
||||
SLACK_BOT_TOKEN=xoxb-...
|
||||
HTTP_PORT=8080
|
||||
ironclaw onboard
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
The wizard handles database connection, NEAR AI authentication (via browser OAuth),
|
||||
and secrets encryption (using your system keychain). Settings are persisted in the
|
||||
connected database; bootstrap variables (e.g. `DATABASE_URL`, `LLM_BACKEND`) are
|
||||
written to `~/.ironclaw/.env` so they are available before the database connects.
|
||||
|
||||
| Variable | Description | Required |
|
||||
|----------|-------------|----------|
|
||||
| `DATABASE_URL` | PostgreSQL connection string | Yes |
|
||||
| `NEARAI_SESSION_TOKEN` | NEAR AI authentication token | Yes |
|
||||
| `NEARAI_MODEL` | Model to use (default: claude-3-5-sonnet) | No |
|
||||
| `AGENT_MAX_PARALLEL_JOBS` | Max concurrent jobs (default: 5) | No |
|
||||
| `SECRETS_MASTER_KEY` | 32+ byte key for secret encryption | For secrets |
|
||||
### Alternative LLM Providers
|
||||
|
||||
IronClaw defaults to NEAR AI but works with any OpenAI-compatible endpoint.
|
||||
Popular options include **OpenRouter** (300+ models), **Together AI**, **Fireworks AI**,
|
||||
**Ollama** (local), and self-hosted servers like **vLLM** or **LiteLLM**.
|
||||
|
||||
Select *"OpenAI-compatible"* in the wizard, or set environment variables directly:
|
||||
|
||||
```env
|
||||
LLM_BACKEND=openai_compatible
|
||||
LLM_BASE_URL=https://openrouter.ai/api/v1
|
||||
LLM_API_KEY=sk-or-...
|
||||
LLM_MODEL=anthropic/claude-sonnet-4
|
||||
```
|
||||
|
||||
See [docs/LLM_PROVIDERS.md](docs/LLM_PROVIDERS.md) for a full provider guide.
|
||||
|
||||
## Security
|
||||
|
||||
@@ -160,37 +199,42 @@ External content passes through multiple security layers:
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Channels │
|
||||
│ ┌─────┐ ┌──────────┐ ┌──────────┐ ┌───────┐ │
|
||||
│ │ CLI │ │ Telegram │ │ WhatsApp │ │ Slack │ │
|
||||
│ └──┬──┘ └────┬─────┘ └────┬─────┘ └───┬───┘ │
|
||||
│ └──────────┴─────────────┴────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────▼────┐ │
|
||||
│ │ Router │ Intent classification │
|
||||
│ └────┬────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────▼──────────┐ │
|
||||
│ │ Scheduler │ Parallel job management │
|
||||
│ └──────────┬──────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────┼───────────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ Worker │ │ Worker │ │ Worker │ LLM reasoning │
|
||||
│ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||||
│ └───────────────┼───────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────▼──────────┐ │
|
||||
│ │ Tool Registry │ │
|
||||
│ │ ┌───────────────┐ │ │
|
||||
│ │ │ Built-in │ │ │
|
||||
│ │ │ MCP │ │ │
|
||||
│ │ │ WASM Sandbox │ │ │
|
||||
│ │ └───────────────┘ │ │
|
||||
│ └─────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ Channels │
|
||||
│ ┌──────┐ ┌──────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ REPL │ │ HTTP │ │WASM Channels│ │ Web Gateway │ │
|
||||
│ └──┬───┘ └──┬───┘ └──────┬──────┘ │ (SSE + WS) │ │
|
||||
│ │ │ │ └──────┬──────┘ │
|
||||
│ └─────────┴──────────────┴────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────▼─────────┐ │
|
||||
│ │ Agent Loop │ Intent routing │
|
||||
│ └────┬──────────┬───┘ │
|
||||
│ │ │ │
|
||||
│ ┌──────────▼────┐ ┌──▼───────────────┐ │
|
||||
│ │ Scheduler │ │ Routines Engine │ │
|
||||
│ │(parallel jobs)│ │(cron, event, wh) │ │
|
||||
│ └──────┬────────┘ └────────┬─────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌─────────────┼────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌───▼─────┐ ┌────▼────────────────┐ │
|
||||
│ │ Local │ │ Orchestrator │ │
|
||||
│ │Workers │ │ ┌───────────────┐ │ │
|
||||
│ │(in-proc)│ │ │ Docker Sandbox│ │ │
|
||||
│ └───┬─────┘ │ │ Containers │ │ │
|
||||
│ │ │ │ ┌───────────┐ │ │ │
|
||||
│ │ │ │ │Worker / CC│ │ │ │
|
||||
│ │ │ │ └───────────┘ │ │ │
|
||||
│ │ │ └───────────────┘ │ │
|
||||
│ │ └─────────┬───────────┘ │
|
||||
│ └──────────────────┤ │
|
||||
│ │ │
|
||||
│ ┌───────────▼──────────┐ │
|
||||
│ │ Tool Registry │ │
|
||||
│ │ Built-in, MCP, WASM │ │
|
||||
│ └──────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Core Components
|
||||
@@ -201,33 +245,25 @@ External content passes through multiple security layers:
|
||||
| **Router** | Classifies user intent (command, query, task) |
|
||||
| **Scheduler** | Manages parallel job execution with priorities |
|
||||
| **Worker** | Executes jobs with LLM reasoning and tool calls |
|
||||
| **Orchestrator** | Container lifecycle, LLM proxying, per-job auth |
|
||||
| **Web Gateway** | Browser UI with chat, memory, jobs, logs, extensions, routines |
|
||||
| **Routines Engine** | Scheduled (cron) and reactive (event, webhook) background tasks |
|
||||
| **Workspace** | Persistent memory with hybrid search |
|
||||
| **Safety Layer** | Prompt injection defense and content sanitization |
|
||||
|
||||
## Usage
|
||||
|
||||
### CLI Mode
|
||||
|
||||
```bash
|
||||
# Start interactive CLI
|
||||
# First-time setup (configures database, auth, etc.)
|
||||
ironclaw onboard
|
||||
|
||||
# Start interactive REPL
|
||||
cargo run
|
||||
|
||||
# With debug logging
|
||||
RUST_LOG=ironclaw=debug cargo run
|
||||
```
|
||||
|
||||
### HTTP Server
|
||||
|
||||
```bash
|
||||
# Start with HTTP webhook server
|
||||
HTTP_PORT=8080 cargo run
|
||||
|
||||
# Send a request
|
||||
curl -X POST http://localhost:8080/webhook \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message": "Hello, IronClaw!"}'
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
@@ -238,12 +274,16 @@ cargo fmt
|
||||
cargo clippy --all --benches --tests --examples --all-features
|
||||
|
||||
# Run tests
|
||||
createdb ironclaw_test
|
||||
cargo test
|
||||
|
||||
# Run specific test
|
||||
cargo test test_name
|
||||
```
|
||||
|
||||
- **Telegram channel**: See [docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md) for setup and DM pairing.
|
||||
- **Changing channel sources**: Run `./channels-src/telegram/build.sh` before `cargo build` so the updated WASM is bundled.
|
||||
|
||||
## OpenClaw Heritage
|
||||
|
||||
IronClaw is a Rust reimplementation inspired by [OpenClaw](https://github.com/openclaw/openclaw). See [FEATURE_PARITY.md](FEATURE_PARITY.md) for the complete tracking matrix.
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Build script: compile Telegram channel WASM from source.
|
||||
//!
|
||||
//! Do not commit compiled WASM binaries — they are a supply chain risk.
|
||||
//! This script builds telegram.wasm from channels-src/telegram before the main crate compiles.
|
||||
//!
|
||||
//! Reproducible build:
|
||||
//! cargo build --release
|
||||
//! (build.rs invokes the channel build automatically)
|
||||
//!
|
||||
//! Prerequisites: rustup target add wasm32-wasip2, cargo install wasm-tools
|
||||
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
|
||||
let root = PathBuf::from(&manifest_dir);
|
||||
|
||||
// ── Embed registry manifests ────────────────────────────────────────
|
||||
embed_registry_catalog(&root);
|
||||
|
||||
// ── Build Telegram channel WASM ─────────────────────────────────────
|
||||
let channel_dir = root.join("channels-src/telegram");
|
||||
let wasm_out = channel_dir.join("telegram.wasm");
|
||||
|
||||
// Rerun when channel source or build script changes
|
||||
println!("cargo:rerun-if-changed=channels-src/telegram/src");
|
||||
println!("cargo:rerun-if-changed=channels-src/telegram/Cargo.toml");
|
||||
println!("cargo:rerun-if-changed=wit/channel.wit");
|
||||
|
||||
if !channel_dir.is_dir() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build WASM module
|
||||
let status = match Command::new("cargo")
|
||||
.args([
|
||||
"build",
|
||||
"--release",
|
||||
"--target",
|
||||
"wasm32-wasip2",
|
||||
"--manifest-path",
|
||||
channel_dir.join("Cargo.toml").to_str().unwrap(),
|
||||
])
|
||||
.current_dir(&root)
|
||||
.status()
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
eprintln!(
|
||||
"cargo:warning=Telegram channel build failed. Run: ./channels-src/telegram/build.sh"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if !status.success() {
|
||||
eprintln!(
|
||||
"cargo:warning=Telegram channel build failed. Run: ./channels-src/telegram/build.sh"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let raw_wasm = channel_dir.join("target/wasm32-wasip2/release/telegram_channel.wasm");
|
||||
if !raw_wasm.exists() {
|
||||
eprintln!(
|
||||
"cargo:warning=Telegram WASM output not found at {:?}",
|
||||
raw_wasm
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to component and strip (wasm-tools)
|
||||
let component_ok = Command::new("wasm-tools")
|
||||
.args([
|
||||
"component",
|
||||
"new",
|
||||
raw_wasm.to_str().unwrap(),
|
||||
"-o",
|
||||
wasm_out.to_str().unwrap(),
|
||||
])
|
||||
.current_dir(&root)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !component_ok {
|
||||
// Fallback: copy raw module if wasm-tools unavailable
|
||||
if std::fs::copy(&raw_wasm, &wasm_out).is_err() {
|
||||
eprintln!("cargo:warning=wasm-tools not found. Run: cargo install wasm-tools");
|
||||
}
|
||||
} else {
|
||||
// Strip debug info (use temp file to avoid clobbering)
|
||||
let stripped = wasm_out.with_extension("wasm.stripped");
|
||||
let strip_ok = Command::new("wasm-tools")
|
||||
.args([
|
||||
"strip",
|
||||
wasm_out.to_str().unwrap(),
|
||||
"-o",
|
||||
stripped.to_str().unwrap(),
|
||||
])
|
||||
.current_dir(&root)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if strip_ok {
|
||||
let _ = std::fs::rename(&stripped, &wasm_out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect all registry manifests into a single JSON blob at compile time.
|
||||
///
|
||||
/// Output: `$OUT_DIR/embedded_catalog.json` with structure:
|
||||
/// ```json
|
||||
/// { "tools": [...], "channels": [...], "bundles": {...} }
|
||||
/// ```
|
||||
fn embed_registry_catalog(root: &Path) {
|
||||
use std::fs;
|
||||
|
||||
let registry_dir = root.join("registry");
|
||||
|
||||
// Rerun if the bundles file changes (per-file watches for tools/channels
|
||||
// are emitted inside collect_json_files to track content changes reliably).
|
||||
println!("cargo:rerun-if-changed=registry/_bundles.json");
|
||||
|
||||
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
|
||||
let out_path = out_dir.join("embedded_catalog.json");
|
||||
|
||||
if !registry_dir.is_dir() {
|
||||
// No registry dir: write empty catalog
|
||||
fs::write(
|
||||
&out_path,
|
||||
r#"{"tools":[],"channels":[],"bundles":{"bundles":{}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
return;
|
||||
}
|
||||
|
||||
let mut tools = Vec::new();
|
||||
let mut channels = Vec::new();
|
||||
|
||||
// Collect tool manifests
|
||||
let tools_dir = registry_dir.join("tools");
|
||||
if tools_dir.is_dir() {
|
||||
collect_json_files(&tools_dir, &mut tools);
|
||||
}
|
||||
|
||||
// Collect channel manifests
|
||||
let channels_dir = registry_dir.join("channels");
|
||||
if channels_dir.is_dir() {
|
||||
collect_json_files(&channels_dir, &mut channels);
|
||||
}
|
||||
|
||||
// Read bundles
|
||||
let bundles_path = registry_dir.join("_bundles.json");
|
||||
let bundles_raw = if bundles_path.is_file() {
|
||||
fs::read_to_string(&bundles_path).unwrap_or_else(|_| r#"{"bundles":{}}"#.to_string())
|
||||
} else {
|
||||
r#"{"bundles":{}}"#.to_string()
|
||||
};
|
||||
|
||||
// Build the combined JSON
|
||||
let catalog = format!(
|
||||
r#"{{"tools":[{}],"channels":[{}],"bundles":{}}}"#,
|
||||
tools.join(","),
|
||||
channels.join(","),
|
||||
bundles_raw,
|
||||
);
|
||||
|
||||
fs::write(&out_path, catalog).unwrap();
|
||||
}
|
||||
|
||||
/// Read all .json files from a directory and push their raw contents into `out`.
|
||||
fn collect_json_files(dir: &Path, out: &mut Vec<String>) {
|
||||
use std::fs;
|
||||
|
||||
let mut entries: Vec<_> = fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.path().is_file() && e.path().extension().and_then(|x| x.to_str()) == Some("json")
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort for deterministic output
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
|
||||
for entry in entries {
|
||||
// Emit per-file watch so Cargo reruns when file contents change
|
||||
println!("cargo:rerun-if-changed={}", entry.path().display());
|
||||
if let Ok(content) = fs::read_to_string(entry.path()) {
|
||||
out.push(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+401
@@ -0,0 +1,401 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "discord-channel"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.16.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
|
||||
[[package]]
|
||||
name = "leb128"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "spdx"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e913f9242315ca39eff82aee0e19ee7a372155717ff0eb082c741e435ce25ed1"
|
||||
dependencies = [
|
||||
"leb128",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "185dfcd27fa5db2e6a23906b54c28199935f71d9a27a1a27b3a88d6fee2afae7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"spdx",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d07b6a3b550fefa1a914b6d54fc175dd11c3392da11eee604e6ffc759805d25"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bitflags",
|
||||
"hashbrown 0.14.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a2b3e15cd6068f233926e7d8c7c588b2ec4fb7cc7bf3824115e7c7e2a8485a3"
|
||||
dependencies = [
|
||||
"wit-bindgen-rt",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b632a5a0fa2409489bd49c9e6d99fcc61bb3d4ce9d1907d44662e75a28c71172"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rt"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7947d0131c7c9da3f01dfde0ab8bd4c4cf3c5bd49b6dba0ae640f1fa752572ea"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4329de4186ee30e2ef30a0533f9b3c123c019a237a7c82d692807bf1b3ee2697"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "177fb7ee1484d113b4792cc480b1ba57664bbc951b42a4beebe573502135b1fc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b505603761ed400c90ed30261f44a768317348e49f1864e82ecdc3b2744e5627"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae2a7999ed18efe59be8de2db9cb2b7f84d88b27818c79353dfc53131840fe1a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.39"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.39"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "discord-channel"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Discord channel for IronClaw"
|
||||
license = "MIT OR Apache-2.0"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
wit-bindgen = "0.36"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
|
||||
|
||||
|
||||
[workspace]
|
||||
@@ -0,0 +1,121 @@
|
||||
# Discord Channel for IronClaw
|
||||
|
||||
WASM channel for Discord integration - handle slash commands and button interactions via webhooks.
|
||||
|
||||
## Features
|
||||
|
||||
- **Slash Commands** - Process Discord slash commands
|
||||
- **Button Interactions** - Handle button clicks
|
||||
- **Thread Support** - Respond in threads
|
||||
- **DM Support** - Handle direct messages
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create a Discord Application at <https://discord.com/developers/applications>
|
||||
2. Create a Bot and get the token
|
||||
3. Set up Interactions URL to point to your IronClaw instance
|
||||
4. Copy the Application ID and Public Key
|
||||
5. Store in IronClaw secrets:
|
||||
|
||||
```bash
|
||||
ironclaw secret set discord_bot_token YOUR_BOT_TOKEN
|
||||
```
|
||||
|
||||
**Note:** The `discord_bot_token` secret is the only value read directly by this
|
||||
Discord channel WASM component. The `discord_app_id` and `discord_public_key`
|
||||
secrets are used by the IronClaw host (for example, to verify Discord
|
||||
interaction signatures and manage slash command registration) and are not
|
||||
accessed from the WASM module itself.
|
||||
|
||||
## Discord Configuration
|
||||
|
||||
### Register Slash Commands
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Authorization: Bot YOUR_BOT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
https://discord.com/api/v10/applications/YOUR_APP_ID/commands \
|
||||
-d '{
|
||||
"name": "ask",
|
||||
"description": "Ask the AI agent",
|
||||
"options": [{
|
||||
"name": "question",
|
||||
"description": "Your question",
|
||||
"type": 3,
|
||||
"required": true
|
||||
}]
|
||||
}'
|
||||
```
|
||||
|
||||
### Set Interactions Endpoint
|
||||
|
||||
In your Discord app settings, set:
|
||||
|
||||
- Interactions Endpoint URL: `https://your-ironclaw.com/webhook/discord`
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Slash Command
|
||||
|
||||
User types: `/ask question: What is the weather?`
|
||||
|
||||
The agent receives:
|
||||
|
||||
```text
|
||||
User: @username
|
||||
Content: /ask question: What is the weather?
|
||||
```
|
||||
|
||||
### Button Click
|
||||
|
||||
When a user clicks a button in a message, the agent receives:
|
||||
|
||||
```text
|
||||
User: @username
|
||||
Content: [Button clicked] Original message content
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
If an internal error occurs (e.g., metadata serialization failure), the tool attempts to send an ephemeral message to the user:
|
||||
|
||||
```text
|
||||
❌ Internal Error: Failed to process command metadata.
|
||||
```
|
||||
|
||||
Check the host logs for detailed error information.
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Embeds
|
||||
|
||||
To send embeds, include an `embeds` array in the `metadata_json` field of the agent's response. The structure should match the Discord API `embed` object.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Invalid Signature"
|
||||
|
||||
- Check that `discord_public_key` is set correctly in IronClaw secrets.
|
||||
- This validation happens on the host before reaching the WASM.
|
||||
|
||||
### "401 Unauthorized"
|
||||
|
||||
- Check that `discord_bot_token` is set correctly in IronClaw secrets.
|
||||
- Ensure the bot is added to the server.
|
||||
|
||||
### "Interaction Failed"
|
||||
|
||||
- The interaction might have timed out (Discord requires a response within 3 seconds).
|
||||
- The `interactions_endpoint_url` might be unreachable.
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
cd channels-src/discord
|
||||
cargo build --target wasm32-wasi --release
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT/Apache-2.0
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"type": "channel",
|
||||
"name": "discord",
|
||||
"description": "Discord Gateway/Webhook channel for handling slash commands, buttons, and messages",
|
||||
"setup": {
|
||||
"required_secrets": [
|
||||
{
|
||||
"name": "discord_bot_token",
|
||||
"prompt": "Enter your Discord Bot Token (from Developer Portal)",
|
||||
"optional": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": {
|
||||
"http": {
|
||||
"allowlist": [
|
||||
{ "host": "discord.com", "path_prefix": "/api/v10" }
|
||||
],
|
||||
"credentials": {
|
||||
"discord_bot_token": {
|
||||
"secret_name": "discord_bot_token",
|
||||
"location": { "type": "header", "name": "Authorization", "prefix": "Bot " },
|
||||
"host_patterns": ["discord.com"]
|
||||
}
|
||||
},
|
||||
"rate_limit": {
|
||||
"requests_per_minute": 60,
|
||||
"requests_per_hour": 3600
|
||||
}
|
||||
},
|
||||
"secrets": {
|
||||
"allowed_names": ["discord_bot_token", "discord_*"]
|
||||
},
|
||||
"channel": {
|
||||
"allowed_paths": ["/webhook/discord"],
|
||||
"allow_polling": false,
|
||||
"callback_timeout_secs": 45,
|
||||
"workspace_prefix": "channels/discord/",
|
||||
"emit_rate_limit": {
|
||||
"messages_per_minute": 100,
|
||||
"messages_per_hour": 5000
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"require_signature_verification": true,
|
||||
"owner_id": null,
|
||||
"dm_policy": "pairing",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
//! Discord Gateway/Webhook channel for IronClaw.
|
||||
//!
|
||||
//! This WASM component implements the channel interface for handling Discord
|
||||
//! interactions via webhooks and sending messages back to Discord.
|
||||
//!
|
||||
//! # Features
|
||||
//!
|
||||
//! - URL verification for Discord interactions
|
||||
//! - Slash command handling
|
||||
//! - Message event parsing (@mentions, DMs)
|
||||
//! - Thread support for conversations
|
||||
//! - Response posting via Discord Web API
|
||||
//! - Automatic message truncation (> 2000 chars)
|
||||
//!
|
||||
//! # Security
|
||||
//!
|
||||
//! - Signature validation is handled by the host (webhook secrets)
|
||||
//! - Bot token is injected by host during HTTP requests
|
||||
//! - WASM never sees raw credentials
|
||||
|
||||
wit_bindgen::generate!({
|
||||
world: "sandboxed-channel",
|
||||
path: "../../wit/channel.wit",
|
||||
});
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use exports::near::agent::channel::{
|
||||
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
|
||||
OutgoingHttpResponse, StatusUpdate,
|
||||
};
|
||||
use near::agent::channel_host::{self, EmittedMessage};
|
||||
|
||||
/// Discord interaction wrapper.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DiscordInteraction {
|
||||
/// Interaction type (1=Ping, 2=ApplicationCommand, 3=MessageComponent)
|
||||
#[serde(rename = "type")]
|
||||
interaction_type: u8,
|
||||
|
||||
/// Interaction ID
|
||||
id: String,
|
||||
|
||||
/// Application ID
|
||||
application_id: String,
|
||||
|
||||
/// Guild ID (if in server)
|
||||
#[allow(dead_code)] // Part of API payload, currently unused
|
||||
guild_id: Option<String>,
|
||||
|
||||
/// Channel ID
|
||||
channel_id: Option<String>,
|
||||
|
||||
/// Member info (if in server)
|
||||
member: Option<DiscordMember>,
|
||||
|
||||
/// User info (if DM)
|
||||
user: Option<DiscordUser>,
|
||||
|
||||
/// Command data (for slash commands)
|
||||
data: Option<DiscordCommandData>,
|
||||
|
||||
/// Message (for component interactions)
|
||||
message: Option<DiscordMessage>,
|
||||
|
||||
/// Token for responding
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct DiscordMember {
|
||||
user: DiscordUser,
|
||||
#[allow(dead_code)] // Part of API payload, currently unused
|
||||
nick: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct DiscordUser {
|
||||
id: String,
|
||||
username: String,
|
||||
global_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct DiscordCommandData {
|
||||
#[allow(dead_code)] // Part of API payload, currently unused
|
||||
id: String,
|
||||
name: String,
|
||||
options: Option<Vec<DiscordCommandOption>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct DiscordCommandOption {
|
||||
name: String,
|
||||
value: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct DiscordMessage {
|
||||
#[allow(dead_code)] // Part of API payload, currently unused
|
||||
id: String,
|
||||
content: String,
|
||||
channel_id: String,
|
||||
#[allow(dead_code)] // Part of API payload, currently unused
|
||||
author: DiscordUser,
|
||||
}
|
||||
|
||||
/// Metadata stored with emitted messages for response routing.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct DiscordMessageMetadata {
|
||||
/// Discord channel ID
|
||||
channel_id: String,
|
||||
|
||||
/// Interaction ID for followups
|
||||
interaction_id: String,
|
||||
|
||||
/// Interaction token for responding
|
||||
token: String,
|
||||
|
||||
/// Application ID
|
||||
application_id: String,
|
||||
|
||||
/// Thread ID (for forum threads)
|
||||
thread_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Workspace path for persisting owner_id across WASM callbacks.
|
||||
const OWNER_ID_PATH: &str = "state/owner_id";
|
||||
/// Workspace path for persisting dm_policy across WASM callbacks.
|
||||
const DM_POLICY_PATH: &str = "state/dm_policy";
|
||||
/// Workspace path for persisting allow_from (JSON array) across WASM callbacks.
|
||||
const ALLOW_FROM_PATH: &str = "state/allow_from";
|
||||
/// Channel name for pairing store (used by pairing host APIs).
|
||||
const CHANNEL_NAME: &str = "discord";
|
||||
|
||||
/// Channel configuration from capabilities file.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DiscordConfig {
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
require_signature_verification: bool,
|
||||
#[serde(default)]
|
||||
owner_id: Option<String>,
|
||||
#[serde(default)]
|
||||
dm_policy: Option<String>,
|
||||
#[serde(default)]
|
||||
allow_from: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
struct DiscordChannel;
|
||||
|
||||
impl Guest for DiscordChannel {
|
||||
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
|
||||
let config: DiscordConfig = serde_json::from_str(&config_json)
|
||||
.map_err(|e| format!("Failed to parse config: {}", e))?;
|
||||
|
||||
channel_host::log(channel_host::LogLevel::Info, "Discord channel starting");
|
||||
|
||||
// Persist owner_id so subsequent callbacks can read it
|
||||
if let Some(ref owner_id) = config.owner_id {
|
||||
let _ = channel_host::workspace_write(OWNER_ID_PATH, owner_id);
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!("Owner restriction enabled: user {}", owner_id),
|
||||
);
|
||||
} else {
|
||||
let _ = channel_host::workspace_write(OWNER_ID_PATH, "");
|
||||
}
|
||||
|
||||
// Persist dm_policy and allow_from for DM pairing
|
||||
let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing");
|
||||
let _ = channel_host::workspace_write(DM_POLICY_PATH, dm_policy);
|
||||
|
||||
let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default())
|
||||
.unwrap_or_else(|_| "[]".to_string());
|
||||
let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json);
|
||||
|
||||
Ok(ChannelConfig {
|
||||
display_name: "Discord".to_string(),
|
||||
http_endpoints: vec![HttpEndpointConfig {
|
||||
path: "/webhook/discord".to_string(),
|
||||
methods: vec!["POST".to_string()],
|
||||
require_secret: true,
|
||||
}],
|
||||
poll: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn on_http_request(req: IncomingHttpRequest) -> OutgoingHttpResponse {
|
||||
let body_str = match std::str::from_utf8(&req.body) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
return json_response(400, serde_json::json!({"error": "Invalid UTF-8 body"}));
|
||||
}
|
||||
};
|
||||
|
||||
let interaction: DiscordInteraction = match serde_json::from_str(body_str) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Failed to parse Discord interaction: {}", e),
|
||||
);
|
||||
return json_response(400, serde_json::json!({"error": "Invalid interaction"}));
|
||||
}
|
||||
};
|
||||
|
||||
match interaction.interaction_type {
|
||||
// Ping - Discord verification
|
||||
1 => {
|
||||
channel_host::log(channel_host::LogLevel::Info, "Responding to Discord ping");
|
||||
json_response(200, serde_json::json!({"type": 1}))
|
||||
}
|
||||
|
||||
// Application Command (slash command)
|
||||
2 => {
|
||||
if handle_slash_command(&interaction) {
|
||||
json_response(200, serde_json::json!({"type": 5}))
|
||||
} else {
|
||||
// Permission denied — ephemeral response
|
||||
json_response(
|
||||
200,
|
||||
serde_json::json!({
|
||||
"type": 4,
|
||||
"data": {
|
||||
"content": "You are not authorized to use this bot.",
|
||||
"flags": 64
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Message Component (buttons, selects)
|
||||
3 => {
|
||||
if let Some(ref message) = interaction.message {
|
||||
handle_message_component(&interaction, message);
|
||||
}
|
||||
json_response(200, serde_json::json!({"type": 6}))
|
||||
}
|
||||
|
||||
_ => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Warn,
|
||||
&format!(
|
||||
"Unknown Discord interaction type: {}",
|
||||
interaction.interaction_type
|
||||
),
|
||||
);
|
||||
json_response(200, serde_json::json!({"type": 6}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_poll() {}
|
||||
|
||||
fn on_respond(response: AgentResponse) -> Result<(), String> {
|
||||
let metadata: DiscordMessageMetadata = serde_json::from_str(&response.metadata_json)
|
||||
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
|
||||
|
||||
// Use webhook endpoint for followup
|
||||
let url = format!(
|
||||
"https://discord.com/api/v10/webhooks/{}/{}",
|
||||
metadata.application_id, metadata.token
|
||||
);
|
||||
|
||||
// Truncate content to 2000 characters to comply with Discord limits
|
||||
let content = truncate_message(&response.content);
|
||||
|
||||
let mut payload = serde_json::json!({
|
||||
"content": content,
|
||||
});
|
||||
|
||||
// Check for embeds in metadata
|
||||
if let Ok(meta_json) = serde_json::from_str::<serde_json::Value>(&response.metadata_json) {
|
||||
if let Some(embeds) = meta_json.get("embeds") {
|
||||
payload["embeds"] = embeds.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let payload_bytes =
|
||||
serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?;
|
||||
|
||||
let headers = serde_json::json!({
|
||||
"Content-Type": "application/json"
|
||||
});
|
||||
|
||||
let result = channel_host::http_request(
|
||||
"POST",
|
||||
&url,
|
||||
&headers.to_string(),
|
||||
Some(&payload_bytes),
|
||||
None,
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(http_response) => {
|
||||
if http_response.status >= 200 && http_response.status < 300 {
|
||||
channel_host::log(channel_host::LogLevel::Debug, "Posted followup to Discord");
|
||||
Ok(())
|
||||
} else {
|
||||
let body_str = String::from_utf8_lossy(&http_response.body);
|
||||
Err(format!(
|
||||
"Discord API error: {} - {}",
|
||||
http_response.status, body_str
|
||||
))
|
||||
}
|
||||
}
|
||||
Err(e) => Err(format!("HTTP request failed: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_status(_update: StatusUpdate) {}
|
||||
|
||||
fn on_shutdown() {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
"Discord channel shutting down",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the message was emitted, false if permission denied.
|
||||
fn handle_slash_command(interaction: &DiscordInteraction) -> bool {
|
||||
let user = interaction
|
||||
.member
|
||||
.as_ref()
|
||||
.map(|m| &m.user)
|
||||
.or(interaction.user.as_ref());
|
||||
let user_id = user.map(|u| u.id.clone()).unwrap_or_default();
|
||||
let user_name = user
|
||||
.map(|u| {
|
||||
u.global_name
|
||||
.as_ref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(&u.username)
|
||||
.clone()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// DM if no guild member context (only direct user field set)
|
||||
let is_dm = interaction.member.is_none();
|
||||
|
||||
// Permission check
|
||||
if !check_sender_permission(
|
||||
&user_id,
|
||||
Some(&user_name),
|
||||
is_dm,
|
||||
Some(&PairingReplyCtx {
|
||||
application_id: interaction.application_id.clone(),
|
||||
token: interaction.token.clone(),
|
||||
}),
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let channel_id = interaction.channel_id.clone().unwrap_or_default();
|
||||
|
||||
let command_name = interaction
|
||||
.data
|
||||
.as_ref()
|
||||
.map(|d| d.name.clone())
|
||||
.unwrap_or_default();
|
||||
let options = interaction.data.as_ref().and_then(|d| d.options.clone());
|
||||
|
||||
let content = if let Some(opts) = options {
|
||||
let opt_str = opts
|
||||
.iter()
|
||||
.map(|o| format!("{}: {}", o.name, o.value))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!("/{} {}", command_name, opt_str)
|
||||
} else {
|
||||
format!("/{}", command_name)
|
||||
};
|
||||
|
||||
let metadata = DiscordMessageMetadata {
|
||||
channel_id: channel_id.clone(),
|
||||
interaction_id: interaction.id.clone(),
|
||||
token: interaction.token.clone(),
|
||||
application_id: interaction.application_id.clone(),
|
||||
thread_id: None,
|
||||
};
|
||||
|
||||
let metadata_json = match serde_json::to_string(&metadata) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Failed to serialize metadata: {}", e),
|
||||
);
|
||||
let url = format!(
|
||||
"https://discord.com/api/v10/webhooks/{}/{}",
|
||||
interaction.application_id, interaction.token
|
||||
);
|
||||
let payload = serde_json::json!({
|
||||
"content": "❌ Internal Error: Failed to process command metadata.",
|
||||
"flags": 64
|
||||
});
|
||||
let _ = channel_host::http_request(
|
||||
"POST",
|
||||
&url,
|
||||
&serde_json::json!({"Content-Type": "application/json"}).to_string(),
|
||||
Some(&serde_json::to_vec(&payload).unwrap_or_default()),
|
||||
None,
|
||||
);
|
||||
return true; // Error, but not a permission denial
|
||||
}
|
||||
};
|
||||
|
||||
channel_host::emit_message(&EmittedMessage {
|
||||
user_id,
|
||||
user_name: Some(user_name),
|
||||
content,
|
||||
thread_id: None,
|
||||
metadata_json,
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordMessage) {
|
||||
let user = interaction
|
||||
.member
|
||||
.as_ref()
|
||||
.map(|m| &m.user)
|
||||
.or(interaction.user.as_ref());
|
||||
let user_id = user.map(|u| u.id.clone()).unwrap_or_default();
|
||||
let user_name = user
|
||||
.map(|u| {
|
||||
u.global_name
|
||||
.as_ref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(&u.username)
|
||||
.clone()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let is_dm = interaction.member.is_none();
|
||||
if !check_sender_permission(&user_id, Some(&user_name), is_dm, None) {
|
||||
return;
|
||||
}
|
||||
|
||||
let channel_id = message.channel_id.clone();
|
||||
|
||||
let metadata = DiscordMessageMetadata {
|
||||
channel_id: channel_id.clone(),
|
||||
interaction_id: interaction.id.clone(),
|
||||
token: interaction.token.clone(),
|
||||
application_id: interaction.application_id.clone(),
|
||||
thread_id: None,
|
||||
};
|
||||
|
||||
let metadata_json = match serde_json::to_string(&metadata) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Failed to serialize metadata: {}", e),
|
||||
);
|
||||
return; // Don't emit message if metadata can't be serialized
|
||||
}
|
||||
};
|
||||
|
||||
channel_host::emit_message(&EmittedMessage {
|
||||
user_id,
|
||||
user_name: Some(user_name),
|
||||
content: format!("[Button clicked] {}", message.content),
|
||||
thread_id: None,
|
||||
metadata_json,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Permission & Pairing
|
||||
// ============================================================================
|
||||
|
||||
/// Context needed to send a pairing reply via Discord webhook followup.
|
||||
struct PairingReplyCtx {
|
||||
application_id: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
/// Check if a sender is permitted to interact with the bot.
|
||||
/// Returns true if allowed, false if denied (pairing reply sent if applicable).
|
||||
fn check_sender_permission(
|
||||
user_id: &str,
|
||||
username: Option<&str>,
|
||||
is_dm: bool,
|
||||
reply_ctx: Option<&PairingReplyCtx>,
|
||||
) -> bool {
|
||||
// 1. Owner check (highest priority, applies to all contexts)
|
||||
let owner_id = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty());
|
||||
if let Some(ref owner) = owner_id {
|
||||
if user_id != owner {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Debug,
|
||||
&format!(
|
||||
"Dropping interaction from non-owner user {} (owner: {})",
|
||||
user_id, owner
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. DM policy (only for DMs when no owner_id)
|
||||
if !is_dm {
|
||||
return true; // Guild interactions bypass DM policy
|
||||
}
|
||||
|
||||
let dm_policy =
|
||||
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string());
|
||||
|
||||
if dm_policy == "open" {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. Build merged allow list: config allow_from + pairing store
|
||||
let mut allowed: Vec<String> = channel_host::workspace_read(ALLOW_FROM_PATH)
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Ok(store_allowed) = channel_host::pairing_read_allow_from(CHANNEL_NAME) {
|
||||
allowed.extend(store_allowed);
|
||||
}
|
||||
|
||||
// 4. Check sender against allow list
|
||||
let is_allowed = allowed.contains(&"*".to_string())
|
||||
|| allowed.contains(&user_id.to_string())
|
||||
|| username.is_some_and(|u| allowed.contains(&u.to_string()));
|
||||
|
||||
if is_allowed {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 5. Not allowed — handle by policy
|
||||
if dm_policy == "pairing" {
|
||||
let meta = serde_json::json!({
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
match channel_host::pairing_upsert_request(CHANNEL_NAME, user_id, &meta) {
|
||||
Ok(result) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!(
|
||||
"Pairing request for user {}: code {}",
|
||||
user_id, result.code
|
||||
),
|
||||
);
|
||||
if result.created {
|
||||
if let Some(ctx) = reply_ctx {
|
||||
let _ = send_pairing_reply(ctx, &result.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Pairing upsert failed: {}", e),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Send a pairing code as an ephemeral Discord followup message.
|
||||
fn send_pairing_reply(ctx: &PairingReplyCtx, code: &str) -> Result<(), String> {
|
||||
let url = format!(
|
||||
"https://discord.com/api/v10/webhooks/{}/{}",
|
||||
ctx.application_id, ctx.token
|
||||
);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"content": format!(
|
||||
"To pair with this bot, run: `ironclaw pairing approve discord {}`",
|
||||
code
|
||||
),
|
||||
"flags": 64 // Ephemeral — only visible to the sender
|
||||
});
|
||||
|
||||
let payload_bytes =
|
||||
serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?;
|
||||
|
||||
let headers = serde_json::json!({"Content-Type": "application/json"});
|
||||
|
||||
let result = channel_host::http_request(
|
||||
"POST",
|
||||
&url,
|
||||
&headers.to_string(),
|
||||
Some(&payload_bytes),
|
||||
None,
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(response) if response.status >= 200 && response.status < 300 => Ok(()),
|
||||
Ok(response) => {
|
||||
let body_str = String::from_utf8_lossy(&response.body);
|
||||
Err(format!(
|
||||
"Discord API error: {} - {}",
|
||||
response.status, body_str
|
||||
))
|
||||
}
|
||||
Err(e) => Err(format!("HTTP request failed: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
|
||||
let body = serde_json::to_vec(&value).unwrap_or_default();
|
||||
let headers = serde_json::json!({"Content-Type": "application/json"});
|
||||
|
||||
OutgoingHttpResponse {
|
||||
status,
|
||||
headers_json: headers.to_string(),
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
export!(DiscordChannel);
|
||||
|
||||
fn truncate_message(content: &str) -> String {
|
||||
if content.len() <= 2000 {
|
||||
content.to_string()
|
||||
} else {
|
||||
let max_bytes = 1990;
|
||||
let cutoff = content
|
||||
.char_indices()
|
||||
.map(|(i, c)| i + c.len_utf8())
|
||||
.take_while(|&end| end <= max_bytes)
|
||||
.last()
|
||||
.unwrap_or(0);
|
||||
let mut truncated = content[..cutoff].to_string();
|
||||
truncated.push_str("\n... (truncated)");
|
||||
truncated
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_truncate_message() {
|
||||
let short = "Hello world";
|
||||
assert_eq!(truncate_message(short), short);
|
||||
|
||||
let long = "a".repeat(2005);
|
||||
let truncated = truncate_message(&long);
|
||||
assert_eq!(truncated.len(), 2006); // 1990 + 16 chars suffix
|
||||
assert!(truncated.ends_with("\n... (truncated)"));
|
||||
|
||||
// Test with multibyte characters (Euro sign is 3 bytes)
|
||||
// 1000 chars * 3 bytes = 3000 bytes
|
||||
let multi = "€".repeat(1000);
|
||||
let truncated_multi = truncate_message(&multi);
|
||||
|
||||
// 1990 bytes limit. 1990 / 3 = 663 with remainder 1.
|
||||
// Should truncate at 663 chars (1989 bytes).
|
||||
// Suffix is 16 bytes. Total: 1989 + 16 = 2005 bytes.
|
||||
assert!(truncated_multi.len() <= 2006);
|
||||
assert!(truncated_multi.len() >= 2006 - 4); // Allow for max utf8 char width variance
|
||||
assert!(truncated_multi.ends_with("\n... (truncated)"));
|
||||
|
||||
let content_part = &truncated_multi[..truncated_multi.len() - 16];
|
||||
assert!(content_part.chars().all(|c| c == '€'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metadata_serialization() {
|
||||
let metadata = DiscordMessageMetadata {
|
||||
channel_id: "123".into(),
|
||||
interaction_id: "456".into(),
|
||||
token: "abc".into(),
|
||||
application_id: "789".into(),
|
||||
thread_id: None,
|
||||
};
|
||||
let json = serde_json::to_string(&metadata).unwrap();
|
||||
let parsed: DiscordMessageMetadata = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.channel_id, "123");
|
||||
assert_eq!(parsed.interaction_id, "456");
|
||||
}
|
||||
}
|
||||
Generated
+497
@@ -0,0 +1,497 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.101"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.16.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
|
||||
[[package]]
|
||||
name = "leb128"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.180"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slack-channel"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"hmac",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "spdx"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e913f9242315ca39eff82aee0e19ee7a372155717ff0eb082c741e435ce25ed1"
|
||||
dependencies = [
|
||||
"leb128",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "185dfcd27fa5db2e6a23906b54c28199935f71d9a27a1a27b3a88d6fee2afae7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"spdx",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d07b6a3b550fefa1a914b6d54fc175dd11c3392da11eee604e6ffc759805d25"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bitflags",
|
||||
"hashbrown 0.14.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a2b3e15cd6068f233926e7d8c7c588b2ec4fb7cc7bf3824115e7c7e2a8485a3"
|
||||
dependencies = [
|
||||
"wit-bindgen-rt",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b632a5a0fa2409489bd49c9e6d99fcc61bb3d4ce9d1907d44662e75a28c71172"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rt"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7947d0131c7c9da3f01dfde0ab8bd4c4cf3c5bd49b6dba0ae640f1fa752572ea"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4329de4186ee30e2ef30a0533f9b3c123c019a237a7c82d692807bf1b3ee2697"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "177fb7ee1484d113b4792cc480b1ba57664bbc951b42a4beebe573502135b1fc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b505603761ed400c90ed30261f44a768317348e49f1864e82ecdc3b2744e5627"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae2a7999ed18efe59be8de2db9cb2b7f84d88b27818c79353dfc53131840fe1a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.39"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.39"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445"
|
||||
@@ -27,3 +27,5 @@ opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
|
||||
[workspace]
|
||||
|
||||
@@ -30,7 +30,7 @@ if [ -f "$WASM_PATH" ]; then
|
||||
wasm-tools strip slack.wasm -o slack.wasm
|
||||
|
||||
echo "Built: slack.wasm ($(du -h slack.wasm | cut -f1))"
|
||||
echo "Copy slack.wasm and slack.capabilities.json to ~/.near-agent/channels/"
|
||||
echo "Copy slack.wasm and slack.capabilities.json to ~/.ironclaw/channels/"
|
||||
else
|
||||
echo "Error: WASM output not found at $WASM_PATH"
|
||||
exit 1
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
"type": "channel",
|
||||
"name": "slack",
|
||||
"description": "Slack Events API channel for receiving and responding to Slack messages",
|
||||
"setup": {
|
||||
"required_secrets": [
|
||||
{
|
||||
"name": "slack_bot_token",
|
||||
"prompt": "Enter your Slack Bot OAuth Token (xoxb-...)",
|
||||
"optional": false
|
||||
},
|
||||
{
|
||||
"name": "slack_signing_secret",
|
||||
"prompt": "Enter your Slack Signing Secret (from App Credentials)",
|
||||
"optional": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": {
|
||||
"http": {
|
||||
"allowlist": [
|
||||
@@ -33,6 +47,9 @@
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"signing_secret_name": "slack_signing_secret"
|
||||
"signing_secret_name": "slack_signing_secret",
|
||||
"owner_id": null,
|
||||
"dm_policy": "pairing",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
|
||||
+206
-29
@@ -27,7 +27,7 @@ use serde::{Deserialize, Serialize};
|
||||
// Re-export generated types
|
||||
use exports::near::agent::channel::{
|
||||
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
|
||||
OutgoingHttpResponse, PollConfig,
|
||||
OutgoingHttpResponse, StatusUpdate,
|
||||
};
|
||||
use near::agent::channel_host::{self, EmittedMessage};
|
||||
|
||||
@@ -104,12 +104,31 @@ struct SlackPostMessageResponse {
|
||||
ts: Option<String>,
|
||||
}
|
||||
|
||||
/// Workspace path for persisting owner_id across WASM callbacks.
|
||||
const OWNER_ID_PATH: &str = "state/owner_id";
|
||||
/// Workspace path for persisting dm_policy across WASM callbacks.
|
||||
const DM_POLICY_PATH: &str = "state/dm_policy";
|
||||
/// Workspace path for persisting allow_from (JSON array) across WASM callbacks.
|
||||
const ALLOW_FROM_PATH: &str = "state/allow_from";
|
||||
/// Channel name for pairing store (used by pairing host APIs).
|
||||
const CHANNEL_NAME: &str = "slack";
|
||||
|
||||
/// Channel configuration from capabilities file.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SlackConfig {
|
||||
/// Name of secret containing signing secret (for verification by host).
|
||||
#[serde(default = "default_signing_secret_name")]
|
||||
#[allow(dead_code)]
|
||||
signing_secret_name: String,
|
||||
|
||||
#[serde(default)]
|
||||
owner_id: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
dm_policy: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
allow_from: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
fn default_signing_secret_name() -> String {
|
||||
@@ -120,12 +139,30 @@ struct SlackChannel;
|
||||
|
||||
impl Guest for SlackChannel {
|
||||
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
|
||||
// Parse configuration
|
||||
let _config: SlackConfig = serde_json::from_str(&config_json)
|
||||
let config: SlackConfig = serde_json::from_str(&config_json)
|
||||
.map_err(|e| format!("Failed to parse config: {}", e))?;
|
||||
|
||||
channel_host::log(channel_host::LogLevel::Info, "Slack channel starting");
|
||||
|
||||
// Persist owner_id so subsequent callbacks can read it
|
||||
if let Some(ref owner_id) = config.owner_id {
|
||||
let _ = channel_host::workspace_write(OWNER_ID_PATH, owner_id);
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!("Owner restriction enabled: user {}", owner_id),
|
||||
);
|
||||
} else {
|
||||
let _ = channel_host::workspace_write(OWNER_ID_PATH, "");
|
||||
}
|
||||
|
||||
// Persist dm_policy and allow_from for DM pairing
|
||||
let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing");
|
||||
let _ = channel_host::workspace_write(DM_POLICY_PATH, dm_policy);
|
||||
|
||||
let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default())
|
||||
.unwrap_or_else(|_| "[]".to_string());
|
||||
let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json);
|
||||
|
||||
Ok(ChannelConfig {
|
||||
display_name: "Slack".to_string(),
|
||||
http_endpoints: vec![HttpEndpointConfig {
|
||||
@@ -133,7 +170,7 @@ impl Guest for SlackChannel {
|
||||
methods: vec!["POST".to_string()],
|
||||
require_secret: true,
|
||||
}],
|
||||
poll: None, // Slack uses push via webhooks, no polling needed
|
||||
poll: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -175,11 +212,7 @@ impl Guest for SlackChannel {
|
||||
// Actual event callback
|
||||
"event_callback" => {
|
||||
if let Some(event) = event_wrapper.event {
|
||||
handle_slack_event(
|
||||
event,
|
||||
event_wrapper.team_id,
|
||||
event_wrapper.event_id,
|
||||
);
|
||||
handle_slack_event(event, event_wrapper.team_id, event_wrapper.event_id);
|
||||
}
|
||||
// Always respond 200 quickly to Slack (they have a 3s timeout)
|
||||
json_response(200, serde_json::json!({"ok": true}))
|
||||
@@ -230,6 +263,7 @@ impl Guest for SlackChannel {
|
||||
"https://slack.com/api/chat.postMessage",
|
||||
&headers.to_string(),
|
||||
Some(&payload_bytes),
|
||||
None,
|
||||
);
|
||||
|
||||
match result {
|
||||
@@ -243,14 +277,15 @@ impl Guest for SlackChannel {
|
||||
|
||||
// Parse Slack response
|
||||
let slack_response: SlackPostMessageResponse =
|
||||
serde_json::from_slice(&http_response.body).map_err(|e| {
|
||||
format!("Failed to parse Slack response: {}", e)
|
||||
})?;
|
||||
serde_json::from_slice(&http_response.body)
|
||||
.map_err(|e| format!("Failed to parse Slack response: {}", e))?;
|
||||
|
||||
if !slack_response.ok {
|
||||
return Err(format!(
|
||||
"Slack API error: {}",
|
||||
slack_response.error.unwrap_or_else(|| "unknown".to_string())
|
||||
slack_response
|
||||
.error
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
));
|
||||
}
|
||||
|
||||
@@ -269,23 +304,28 @@ impl Guest for SlackChannel {
|
||||
}
|
||||
}
|
||||
|
||||
fn on_status(_update: StatusUpdate) {}
|
||||
|
||||
fn on_shutdown() {
|
||||
channel_host::log(channel_host::LogLevel::Info, "Slack channel shutting down");
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a Slack event and emit message if applicable.
|
||||
fn handle_slack_event(
|
||||
event: SlackEvent,
|
||||
team_id: Option<String>,
|
||||
_event_id: Option<String>,
|
||||
) {
|
||||
fn handle_slack_event(event: SlackEvent, team_id: Option<String>, _event_id: Option<String>) {
|
||||
match event.event_type.as_str() {
|
||||
// Direct mention of the bot
|
||||
// Direct mention of the bot (always in a channel, not a DM)
|
||||
"app_mention" => {
|
||||
if let (Some(user), Some(channel), Some(text), Some(ts)) =
|
||||
(event.user, event.channel.clone(), event.text, event.ts.clone())
|
||||
{
|
||||
if let (Some(user), Some(channel), Some(text), Some(ts)) = (
|
||||
event.user,
|
||||
event.channel.clone(),
|
||||
event.text,
|
||||
event.ts.clone(),
|
||||
) {
|
||||
// app_mention is always in a channel (not DM)
|
||||
if !check_sender_permission(&user, &channel, false) {
|
||||
return;
|
||||
}
|
||||
emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id);
|
||||
}
|
||||
}
|
||||
@@ -297,11 +337,17 @@ fn handle_slack_event(
|
||||
return;
|
||||
}
|
||||
|
||||
if let (Some(user), Some(channel), Some(text), Some(ts)) =
|
||||
(event.user, event.channel.clone(), event.text, event.ts.clone())
|
||||
{
|
||||
if let (Some(user), Some(channel), Some(text), Some(ts)) = (
|
||||
event.user,
|
||||
event.channel.clone(),
|
||||
event.text,
|
||||
event.ts.clone(),
|
||||
) {
|
||||
// Only process DMs (channel IDs starting with D)
|
||||
if channel.starts_with('D') {
|
||||
if !check_sender_permission(&user, &channel, true) {
|
||||
return;
|
||||
}
|
||||
emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id);
|
||||
}
|
||||
}
|
||||
@@ -333,13 +379,18 @@ fn emit_message(
|
||||
team_id,
|
||||
};
|
||||
|
||||
let metadata_json =
|
||||
serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string());
|
||||
let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|e| {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Failed to serialize Slack metadata: {}", e),
|
||||
);
|
||||
"{}".to_string()
|
||||
});
|
||||
|
||||
// Strip @ mentions of the bot from the text for cleaner messages
|
||||
let cleaned_text = strip_bot_mention(&text);
|
||||
|
||||
channel_host::emit_message(EmittedMessage {
|
||||
channel_host::emit_message(&EmittedMessage {
|
||||
user_id,
|
||||
user_name: None, // Could fetch from Slack API if needed
|
||||
content: cleaned_text,
|
||||
@@ -348,6 +399,126 @@ fn emit_message(
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Permission & Pairing
|
||||
// ============================================================================
|
||||
|
||||
/// Check if a sender is permitted. Returns true if allowed.
|
||||
/// For pairing mode, sends a pairing code DM if denied.
|
||||
fn check_sender_permission(user_id: &str, channel_id: &str, is_dm: bool) -> bool {
|
||||
// 1. Owner check (highest priority, applies to all contexts)
|
||||
let owner_id = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty());
|
||||
if let Some(ref owner) = owner_id {
|
||||
if user_id != owner {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Debug,
|
||||
&format!(
|
||||
"Dropping message from non-owner user {} (owner: {})",
|
||||
user_id, owner
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. DM policy (only for DMs when no owner_id)
|
||||
if !is_dm {
|
||||
return true; // Channel messages bypass DM policy
|
||||
}
|
||||
|
||||
let dm_policy =
|
||||
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string());
|
||||
|
||||
if dm_policy == "open" {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. Build merged allow list: config allow_from + pairing store
|
||||
let mut allowed: Vec<String> = channel_host::workspace_read(ALLOW_FROM_PATH)
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Ok(store_allowed) = channel_host::pairing_read_allow_from(CHANNEL_NAME) {
|
||||
allowed.extend(store_allowed);
|
||||
}
|
||||
|
||||
// 4. Check sender (Slack events only have user ID, not username)
|
||||
let is_allowed =
|
||||
allowed.contains(&"*".to_string()) || allowed.contains(&user_id.to_string());
|
||||
|
||||
if is_allowed {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 5. Not allowed — handle by policy
|
||||
if dm_policy == "pairing" {
|
||||
let meta = serde_json::json!({
|
||||
"user_id": user_id,
|
||||
"channel_id": channel_id,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
match channel_host::pairing_upsert_request(CHANNEL_NAME, user_id, &meta) {
|
||||
Ok(result) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!(
|
||||
"Pairing request for user {}: code {}",
|
||||
user_id, result.code
|
||||
),
|
||||
);
|
||||
if result.created {
|
||||
let _ = send_pairing_reply(channel_id, &result.code);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Pairing upsert failed: {}", e),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Send a pairing code message via Slack chat.postMessage.
|
||||
fn send_pairing_reply(channel_id: &str, code: &str) -> Result<(), String> {
|
||||
let payload = serde_json::json!({
|
||||
"channel": channel_id,
|
||||
"text": format!(
|
||||
"To pair with this bot, run: `ironclaw pairing approve slack {}`",
|
||||
code
|
||||
),
|
||||
});
|
||||
|
||||
let payload_bytes =
|
||||
serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?;
|
||||
|
||||
let headers = serde_json::json!({"Content-Type": "application/json"});
|
||||
|
||||
let result = channel_host::http_request(
|
||||
"POST",
|
||||
"https://slack.com/api/chat.postMessage",
|
||||
&headers.to_string(),
|
||||
Some(&payload_bytes),
|
||||
None,
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(response) if response.status == 200 => Ok(()),
|
||||
Ok(response) => {
|
||||
let body_str = String::from_utf8_lossy(&response.body);
|
||||
Err(format!(
|
||||
"Slack API error: {} - {}",
|
||||
response.status, body_str
|
||||
))
|
||||
}
|
||||
Err(e) => Err(format!("HTTP request failed: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip leading bot mention from text.
|
||||
fn strip_bot_mention(text: &str) -> String {
|
||||
// Slack mentions look like <@U12345678>
|
||||
@@ -362,7 +533,13 @@ fn strip_bot_mention(text: &str) -> String {
|
||||
|
||||
/// Create a JSON HTTP response.
|
||||
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
|
||||
let body = serde_json::to_vec(&value).unwrap_or_default();
|
||||
let body = serde_json::to_vec(&value).unwrap_or_else(|e| {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Failed to serialize JSON response: {}", e),
|
||||
);
|
||||
Vec::new()
|
||||
});
|
||||
let headers = serde_json::json!({"Content-Type": "application/json"});
|
||||
|
||||
OutgoingHttpResponse {
|
||||
|
||||
@@ -16,9 +16,13 @@ wit-bindgen = "0.36"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Exclude from parent workspace (this is a standalone WASM component)
|
||||
|
||||
[profile.release]
|
||||
# Optimize for size
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
|
||||
[workspace]
|
||||
|
||||
@@ -32,8 +32,8 @@ if [ -f "$WASM_PATH" ]; then
|
||||
echo "Built: telegram.wasm ($(du -h telegram.wasm | cut -f1))"
|
||||
echo ""
|
||||
echo "To install:"
|
||||
echo " mkdir -p ~/.near-agent/channels"
|
||||
echo " cp telegram.wasm telegram.capabilities.json ~/.near-agent/channels/"
|
||||
echo " mkdir -p ~/.ironclaw/channels"
|
||||
echo " cp telegram.wasm telegram.capabilities.json ~/.ironclaw/channels/"
|
||||
echo ""
|
||||
echo "Then add your bot token to secrets:"
|
||||
echo " # Set TELEGRAM_BOT_TOKEN in your environment or secrets store"
|
||||
|
||||
+903
-141
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,15 @@
|
||||
"type": "channel",
|
||||
"name": "telegram",
|
||||
"description": "Telegram Bot API channel for receiving and responding to Telegram messages",
|
||||
"setup": {
|
||||
"required_secrets": [
|
||||
{
|
||||
"name": "telegram_bot_token",
|
||||
"prompt": "Enter your Telegram Bot API token (from @BotFather)",
|
||||
"optional": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": {
|
||||
"http": {
|
||||
"allowlist": [
|
||||
@@ -38,6 +47,8 @@
|
||||
"owner_id": null,
|
||||
"respond_to_all_group_messages": false,
|
||||
"polling_enabled": false,
|
||||
"poll_interval_ms": 30000
|
||||
"poll_interval_ms": 30000,
|
||||
"dm_policy": "pairing",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -16,3 +16,5 @@ serde_json = "1"
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
|
||||
[workspace]
|
||||
@@ -30,7 +30,7 @@ use serde::{Deserialize, Serialize};
|
||||
// Re-export generated types
|
||||
use exports::near::agent::channel::{
|
||||
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
|
||||
OutgoingHttpResponse,
|
||||
OutgoingHttpResponse, StatusUpdate,
|
||||
};
|
||||
use near::agent::channel_host::{self, EmittedMessage};
|
||||
|
||||
@@ -226,6 +226,15 @@ struct WhatsAppMessageMetadata {
|
||||
timestamp: String,
|
||||
}
|
||||
|
||||
/// Workspace path for persisting owner_id across WASM callbacks.
|
||||
const OWNER_ID_PATH: &str = "state/owner_id";
|
||||
/// Workspace path for persisting dm_policy across WASM callbacks.
|
||||
const DM_POLICY_PATH: &str = "state/dm_policy";
|
||||
/// Workspace path for persisting allow_from (JSON array) across WASM callbacks.
|
||||
const ALLOW_FROM_PATH: &str = "state/allow_from";
|
||||
/// Channel name for pairing store (used by pairing host APIs).
|
||||
const CHANNEL_NAME: &str = "whatsapp";
|
||||
|
||||
/// Channel configuration from capabilities file.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WhatsAppConfig {
|
||||
@@ -236,6 +245,15 @@ struct WhatsAppConfig {
|
||||
/// Whether to reply to the original message (thread context)
|
||||
#[serde(default = "default_reply_to_message")]
|
||||
reply_to_message: bool,
|
||||
|
||||
#[serde(default)]
|
||||
owner_id: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
dm_policy: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
allow_from: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
fn default_api_version() -> String {
|
||||
@@ -254,10 +272,22 @@ struct WhatsAppChannel;
|
||||
|
||||
impl Guest for WhatsAppChannel {
|
||||
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
|
||||
let config: WhatsAppConfig = serde_json::from_str(&config_json).unwrap_or(WhatsAppConfig {
|
||||
api_version: default_api_version(),
|
||||
reply_to_message: default_reply_to_message(),
|
||||
});
|
||||
let config: WhatsAppConfig = match serde_json::from_str(&config_json) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Warn,
|
||||
&format!("Failed to parse WhatsApp config, using defaults: {}", e),
|
||||
);
|
||||
WhatsAppConfig {
|
||||
api_version: default_api_version(),
|
||||
reply_to_message: default_reply_to_message(),
|
||||
owner_id: None,
|
||||
dm_policy: None,
|
||||
allow_from: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
@@ -267,6 +297,27 @@ impl Guest for WhatsAppChannel {
|
||||
),
|
||||
);
|
||||
|
||||
// Persist api_version in workspace so on_respond() can read it
|
||||
let _ = channel_host::workspace_write("channels/whatsapp/api_version", &config.api_version);
|
||||
|
||||
// Persist permission config for handle_message
|
||||
if let Some(ref owner_id) = config.owner_id {
|
||||
let _ = channel_host::workspace_write(OWNER_ID_PATH, owner_id);
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!("Owner restriction enabled: user {}", owner_id),
|
||||
);
|
||||
} else {
|
||||
let _ = channel_host::workspace_write(OWNER_ID_PATH, "");
|
||||
}
|
||||
|
||||
let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing");
|
||||
let _ = channel_host::workspace_write(DM_POLICY_PATH, dm_policy);
|
||||
|
||||
let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default())
|
||||
.unwrap_or_else(|_| "[]".to_string());
|
||||
let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json);
|
||||
|
||||
// WhatsApp Cloud API is webhook-only, no polling available
|
||||
Ok(ChannelConfig {
|
||||
display_name: "WhatsApp".to_string(),
|
||||
@@ -327,11 +378,16 @@ impl Guest for WhatsAppChannel {
|
||||
let metadata: WhatsAppMessageMetadata = serde_json::from_str(&response.metadata_json)
|
||||
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
|
||||
|
||||
// Read api_version from workspace (set during on_start), fallback to default
|
||||
let api_version = channel_host::workspace_read("channels/whatsapp/api_version")
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "v18.0".to_string());
|
||||
|
||||
// Build WhatsApp API URL with token placeholder
|
||||
// Host will replace {WHATSAPP_ACCESS_TOKEN} with actual token in Authorization header
|
||||
let api_url = format!(
|
||||
"https://graph.facebook.com/v18.0/{}/messages",
|
||||
metadata.phone_number_id
|
||||
"https://graph.facebook.com/{}/{}/messages",
|
||||
api_version, metadata.phone_number_id
|
||||
);
|
||||
|
||||
// Build sendMessage payload
|
||||
@@ -361,6 +417,7 @@ impl Guest for WhatsAppChannel {
|
||||
&api_url,
|
||||
&headers.to_string(),
|
||||
Some(&payload_bytes),
|
||||
None,
|
||||
);
|
||||
|
||||
match result {
|
||||
@@ -417,6 +474,8 @@ impl Guest for WhatsAppChannel {
|
||||
}
|
||||
}
|
||||
|
||||
fn on_status(_update: StatusUpdate) {}
|
||||
|
||||
fn on_shutdown() {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
@@ -584,6 +643,15 @@ fn handle_message(
|
||||
// Look up sender's name from contacts
|
||||
let user_name = contact_names.get(&message.from).cloned();
|
||||
|
||||
// Permission check (WhatsApp is always DM)
|
||||
if !check_sender_permission(
|
||||
&message.from,
|
||||
user_name.as_deref(),
|
||||
phone_number_id,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build metadata for response routing
|
||||
// This is critical - the response handler uses this to know where to send
|
||||
let metadata = WhatsAppMessageMetadata {
|
||||
@@ -617,6 +685,149 @@ fn handle_message(
|
||||
// Utilities
|
||||
// ============================================================================
|
||||
|
||||
// ============================================================================
|
||||
// Permission & Pairing
|
||||
// ============================================================================
|
||||
|
||||
/// Check if a sender is permitted. Returns true if allowed.
|
||||
/// WhatsApp is always 1-to-1 (DM), so dm_policy always applies.
|
||||
fn check_sender_permission(
|
||||
sender_phone: &str,
|
||||
user_name: Option<&str>,
|
||||
phone_number_id: &str,
|
||||
) -> bool {
|
||||
// 1. Owner check (highest priority)
|
||||
let owner_id = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty());
|
||||
if let Some(ref owner) = owner_id {
|
||||
if sender_phone != owner {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Debug,
|
||||
&format!(
|
||||
"Dropping message from non-owner {} (owner: {})",
|
||||
sender_phone, owner
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. DM policy (WhatsApp is always DM)
|
||||
let dm_policy =
|
||||
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string());
|
||||
|
||||
if dm_policy == "open" {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. Build merged allow list
|
||||
let mut allowed: Vec<String> = channel_host::workspace_read(ALLOW_FROM_PATH)
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Ok(store_allowed) = channel_host::pairing_read_allow_from(CHANNEL_NAME) {
|
||||
allowed.extend(store_allowed);
|
||||
}
|
||||
|
||||
// 4. Check sender (phone number or name)
|
||||
let is_allowed = allowed.contains(&"*".to_string())
|
||||
|| allowed.contains(&sender_phone.to_string())
|
||||
|| user_name.is_some_and(|u| allowed.contains(&u.to_string()));
|
||||
|
||||
if is_allowed {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 5. Not allowed — handle by policy
|
||||
if dm_policy == "pairing" {
|
||||
let meta = serde_json::json!({
|
||||
"phone": sender_phone,
|
||||
"name": user_name,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
match channel_host::pairing_upsert_request(CHANNEL_NAME, sender_phone, &meta) {
|
||||
Ok(result) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!(
|
||||
"Pairing request for {}: code {}",
|
||||
sender_phone, result.code
|
||||
),
|
||||
);
|
||||
if result.created {
|
||||
let _ = send_pairing_reply(sender_phone, phone_number_id, &result.code);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Pairing upsert failed: {}", e),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Send a pairing code message via WhatsApp Cloud API.
|
||||
fn send_pairing_reply(
|
||||
recipient_phone: &str,
|
||||
phone_number_id: &str,
|
||||
code: &str,
|
||||
) -> Result<(), String> {
|
||||
let api_version = channel_host::workspace_read("channels/whatsapp/api_version")
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "v18.0".to_string());
|
||||
|
||||
let url = format!(
|
||||
"https://graph.facebook.com/{}/{}/messages",
|
||||
api_version, phone_number_id
|
||||
);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"messaging_product": "whatsapp",
|
||||
"recipient_type": "individual",
|
||||
"to": recipient_phone,
|
||||
"type": "text",
|
||||
"text": {
|
||||
"preview_url": false,
|
||||
"body": format!(
|
||||
"To pair with this bot, run: ironclaw pairing approve whatsapp {}",
|
||||
code
|
||||
)
|
||||
}
|
||||
});
|
||||
|
||||
let payload_bytes =
|
||||
serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?;
|
||||
|
||||
let headers = serde_json::json!({
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer {WHATSAPP_ACCESS_TOKEN}"
|
||||
});
|
||||
|
||||
let result = channel_host::http_request(
|
||||
"POST",
|
||||
&url,
|
||||
&headers.to_string(),
|
||||
Some(&payload_bytes),
|
||||
None,
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(response) if response.status >= 200 && response.status < 300 => Ok(()),
|
||||
Ok(response) => {
|
||||
let body_str = String::from_utf8_lossy(&response.body);
|
||||
Err(format!(
|
||||
"WhatsApp API error: {} - {}",
|
||||
response.status, body_str
|
||||
))
|
||||
}
|
||||
Err(e) => Err(format!("HTTP request failed: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a JSON HTTP response.
|
||||
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
|
||||
let body = serde_json::to_vec(&value).unwrap_or_default();
|
||||
+4
-1
@@ -48,6 +48,9 @@
|
||||
},
|
||||
"config": {
|
||||
"api_version": "v18.0",
|
||||
"reply_to_message": true
|
||||
"reply_to_message": true,
|
||||
"owner_id": null,
|
||||
"dm_policy": "pairing",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=Cloud SQL Auth Proxy
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
DynamicUser=yes
|
||||
ExecStart=/usr/local/bin/cloud-sql-proxy ironclaw-prod:us-central1:ironclaw-db --port=5432
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,30 @@
|
||||
# WARNING: Replace all CHANGE_ME values before deploying.
|
||||
# Do not use placeholder passwords in production.
|
||||
DATABASE_URL=postgres://ironclaw:CHANGE_ME@localhost:5432/ironclaw
|
||||
|
||||
# NEAR AI Cloud (API key auth, Chat Completions API)
|
||||
# Get an API key from https://cloud.near.ai
|
||||
NEARAI_API_KEY=CHANGE_ME
|
||||
NEARAI_MODEL=claude-3-5-sonnet-20241022
|
||||
NEARAI_BASE_URL=https://cloud-api.near.ai
|
||||
|
||||
# Or use NEAR AI Chat (session token auth, Responses API):
|
||||
# NEARAI_SESSION_TOKEN=sess_...
|
||||
# NEARAI_BASE_URL=https://private.near.ai
|
||||
|
||||
# Agent
|
||||
AGENT_NAME=ironclaw
|
||||
CLI_ENABLED=false
|
||||
|
||||
# Web Gateway
|
||||
GATEWAY_ENABLED=true
|
||||
# 0.0.0.0 binds to all interfaces (required for Docker --network=host).
|
||||
# Use 127.0.0.1 if running outside Docker or for local-only access.
|
||||
GATEWAY_HOST=0.0.0.0
|
||||
GATEWAY_PORT=3000
|
||||
GATEWAY_AUTH_TOKEN=CHANGE_ME
|
||||
|
||||
# Disabled for initial deploy
|
||||
SANDBOX_ENABLED=false
|
||||
HEARTBEAT_ENABLED=false
|
||||
EMBEDDING_ENABLED=false
|
||||
@@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=IronClaw AI Assistant
|
||||
After=cloud-sql-proxy.service docker.service
|
||||
Requires=cloud-sql-proxy.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStartPre=/usr/bin/docker pull us-central1-docker.pkg.dev/ironclaw-prod/ironclaw/agent:latest
|
||||
ExecStart=/usr/bin/docker run --rm \
|
||||
--name ironclaw \
|
||||
--env-file /opt/ironclaw/.env \
|
||||
--network=host \
|
||||
us-central1-docker.pkg.dev/ironclaw-prod/ironclaw/agent:latest \
|
||||
--no-onboard
|
||||
ExecStop=/usr/bin/docker stop ironclaw
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# VM bootstrap script for IronClaw on GCP Compute Engine.
|
||||
#
|
||||
# Run on a fresh Debian 12 VM after SSH:
|
||||
# sudo bash setup.sh
|
||||
#
|
||||
# Prerequisites:
|
||||
# - VM has the ironclaw-vm service account attached
|
||||
# - Cloud SQL Auth Proxy accessible via IAM
|
||||
# - Artifact Registry image pushed
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Must run as root
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "ERROR: This script must be run as root (sudo bash setup.sh)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Installing Docker"
|
||||
apt-get update
|
||||
apt-get install -y docker.io
|
||||
systemctl enable docker
|
||||
systemctl start docker
|
||||
|
||||
echo "==> Installing Cloud SQL Auth Proxy"
|
||||
curl -fsSL -o /usr/local/bin/cloud-sql-proxy \
|
||||
https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v2.14.3/cloud-sql-proxy.linux.amd64
|
||||
chmod +x /usr/local/bin/cloud-sql-proxy
|
||||
|
||||
echo "==> Installing systemd services"
|
||||
cp /tmp/deploy/cloud-sql-proxy.service /etc/systemd/system/
|
||||
cp /tmp/deploy/ironclaw.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
|
||||
echo "==> Starting Cloud SQL Auth Proxy"
|
||||
systemctl enable cloud-sql-proxy
|
||||
systemctl start cloud-sql-proxy
|
||||
|
||||
echo "==> Configuring Docker registry auth"
|
||||
# The VM service account provides Artifact Registry access
|
||||
gcloud auth configure-docker us-central1-docker.pkg.dev --quiet
|
||||
|
||||
echo "==> Creating config directory"
|
||||
# Owned by root, readable only by root. Docker reads --env-file as root
|
||||
# before dropping to uid 1000 (ironclaw) inside the container.
|
||||
mkdir -p /opt/ironclaw
|
||||
chmod 700 /opt/ironclaw
|
||||
|
||||
if [ ! -f /opt/ironclaw/.env ]; then
|
||||
echo "WARNING: /opt/ironclaw/.env does not exist."
|
||||
echo "Create it with your configuration before starting IronClaw."
|
||||
echo "See deploy/env.example for the required variables."
|
||||
echo ""
|
||||
echo "Then run: systemctl enable ironclaw && systemctl start ironclaw"
|
||||
else
|
||||
chmod 600 /opt/ironclaw/.env
|
||||
echo "==> Starting IronClaw"
|
||||
systemctl enable ironclaw
|
||||
systemctl start ironclaw
|
||||
fi
|
||||
|
||||
echo "==> Setup complete"
|
||||
echo ""
|
||||
echo "Verify with:"
|
||||
echo " systemctl status cloud-sql-proxy"
|
||||
echo " systemctl status ironclaw"
|
||||
echo " docker logs ironclaw"
|
||||
@@ -0,0 +1,20 @@
|
||||
# Local development only — do NOT use these credentials in production.
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_DB: ironclaw
|
||||
POSTGRES_USER: ironclaw
|
||||
POSTGRES_PASSWORD: ironclaw # dev-only, change for any non-local deployment
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ironclaw"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -246,13 +246,46 @@ Create `my-channel.capabilities.json`:
|
||||
|
||||
## Building and Deploying
|
||||
|
||||
### Supply Chain Security: No Committed Binaries
|
||||
|
||||
**Do not commit compiled WASM binaries.** They are a supply chain risk — the binary in a PR may not match the source. IronClaw builds channels from source:
|
||||
|
||||
- `cargo build` automatically builds `telegram.wasm` via `build.rs`
|
||||
- The built binary is in `.gitignore` and is not committed
|
||||
- CI should run `cargo build` (or `./scripts/build-all.sh`) to produce releases
|
||||
|
||||
**Reproducible build:**
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Prerequisites: `rustup target add wasm32-wasip2`, `cargo install wasm-tools` (optional; fallback copies raw WASM if unavailable).
|
||||
|
||||
### Telegram Channel (Manual Build)
|
||||
|
||||
```bash
|
||||
# Add WASM target if needed
|
||||
rustup target add wasm32-wasip2
|
||||
|
||||
# Build Telegram channel
|
||||
./channels-src/telegram/build.sh
|
||||
|
||||
# Install (or use ironclaw onboard to install bundled channel)
|
||||
mkdir -p ~/.ironclaw/channels
|
||||
cp channels-src/telegram/telegram.wasm channels-src/telegram/telegram.capabilities.json ~/.ironclaw/channels/
|
||||
```
|
||||
|
||||
**Note**: The main IronClaw binary bundles `telegram.wasm` via `include_bytes!`. When modifying the Telegram channel source, run `./channels-src/telegram/build.sh` **before** building the main crate, so the updated WASM is included.
|
||||
|
||||
### Other Channels
|
||||
|
||||
```bash
|
||||
# Build the WASM component
|
||||
cd channels/my-channel
|
||||
cargo component build --release
|
||||
cd channels-src/my-channel
|
||||
cargo build --release --target wasm32-wasip2
|
||||
|
||||
# Deploy to ~/.ironclaw/channels/
|
||||
cp target/wasm32-wasip1/release/my_channel.wasm ~/.ironclaw/channels/my-channel.wasm
|
||||
cp target/wasm32-wasip2/release/my_channel.wasm ~/.ironclaw/channels/my-channel.wasm
|
||||
cp my-channel.capabilities.json ~/.ironclaw/channels/
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# LLM Provider Configuration
|
||||
|
||||
IronClaw defaults to NEAR AI for model access, but supports any OpenAI-compatible
|
||||
endpoint as well as Anthropic and Ollama directly. This guide covers the most common
|
||||
configurations.
|
||||
|
||||
## Provider Overview
|
||||
|
||||
| Provider | Backend value | Requires API key | Notes |
|
||||
|---|---|---|---|
|
||||
| NEAR AI | `nearai` | OAuth (browser) | Default; multi-model |
|
||||
| Anthropic | `anthropic` | `ANTHROPIC_API_KEY` | Claude models |
|
||||
| OpenAI | `openai` | `OPENAI_API_KEY` | GPT models |
|
||||
| Ollama | `ollama` | No | Local inference |
|
||||
| OpenRouter | `openai_compatible` | `LLM_API_KEY` | 300+ models |
|
||||
| Together AI | `openai_compatible` | `LLM_API_KEY` | Fast inference |
|
||||
| Fireworks AI | `openai_compatible` | `LLM_API_KEY` | Fast inference |
|
||||
| vLLM / LiteLLM | `openai_compatible` | Optional | Self-hosted |
|
||||
| LM Studio | `openai_compatible` | No | Local GUI |
|
||||
|
||||
---
|
||||
|
||||
## NEAR AI (default)
|
||||
|
||||
No additional configuration required. On first run, `ironclaw onboard` opens a browser
|
||||
for OAuth authentication. Credentials are saved to `~/.ironclaw/session.json`.
|
||||
|
||||
```env
|
||||
NEARAI_MODEL=claude-3-5-sonnet-20241022
|
||||
NEARAI_BASE_URL=https://private.near.ai
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Anthropic (Claude)
|
||||
|
||||
```env
|
||||
LLM_BACKEND=anthropic
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
Popular models: `claude-sonnet-4-20250514`, `claude-3-5-sonnet-20241022`, `claude-3-5-haiku-20241022`
|
||||
|
||||
---
|
||||
|
||||
## OpenAI (GPT)
|
||||
|
||||
```env
|
||||
LLM_BACKEND=openai
|
||||
OPENAI_API_KEY=sk-...
|
||||
```
|
||||
|
||||
Popular models: `gpt-4o`, `gpt-4o-mini`, `o3-mini`
|
||||
|
||||
---
|
||||
|
||||
## Ollama (local)
|
||||
|
||||
Install Ollama from [ollama.com](https://ollama.com), pull a model, then:
|
||||
|
||||
```env
|
||||
LLM_BACKEND=ollama
|
||||
OLLAMA_MODEL=llama3.2
|
||||
# OLLAMA_BASE_URL=http://localhost:11434 # default
|
||||
```
|
||||
|
||||
Pull a model first: `ollama pull llama3.2`
|
||||
|
||||
---
|
||||
|
||||
## OpenAI-Compatible Endpoints
|
||||
|
||||
All providers below use `LLM_BACKEND=openai_compatible`. Set `LLM_BASE_URL` to the
|
||||
provider's OpenAI-compatible endpoint and `LLM_API_KEY` to your API key.
|
||||
|
||||
### OpenRouter
|
||||
|
||||
[OpenRouter](https://openrouter.ai) routes to 300+ models from a single API key.
|
||||
|
||||
```env
|
||||
LLM_BACKEND=openai_compatible
|
||||
LLM_BASE_URL=https://openrouter.ai/api/v1
|
||||
LLM_API_KEY=sk-or-...
|
||||
LLM_MODEL=anthropic/claude-sonnet-4
|
||||
```
|
||||
|
||||
Popular OpenRouter model IDs:
|
||||
|
||||
| Model | ID |
|
||||
|---|---|
|
||||
| Claude Sonnet 4 | `anthropic/claude-sonnet-4` |
|
||||
| GPT-4o | `openai/gpt-4o` |
|
||||
| Llama 4 Maverick | `meta-llama/llama-4-maverick` |
|
||||
| Gemini 2.0 Flash | `google/gemini-2.0-flash-001` |
|
||||
| Mistral Small | `mistralai/mistral-small-3.1-24b-instruct` |
|
||||
|
||||
Browse all models at [openrouter.ai/models](https://openrouter.ai/models).
|
||||
|
||||
### Together AI
|
||||
|
||||
[Together AI](https://www.together.ai) provides fast inference for open-source models.
|
||||
|
||||
```env
|
||||
LLM_BACKEND=openai_compatible
|
||||
LLM_BASE_URL=https://api.together.xyz/v1
|
||||
LLM_API_KEY=...
|
||||
LLM_MODEL=meta-llama/Llama-3.3-70B-Instruct-Turbo
|
||||
```
|
||||
|
||||
Popular Together AI model IDs:
|
||||
|
||||
| Model | ID |
|
||||
|---|---|
|
||||
| Llama 3.3 70B | `meta-llama/Llama-3.3-70B-Instruct-Turbo` |
|
||||
| DeepSeek R1 | `deepseek-ai/DeepSeek-R1` |
|
||||
| Qwen 2.5 72B | `Qwen/Qwen2.5-72B-Instruct-Turbo` |
|
||||
|
||||
### Fireworks AI
|
||||
|
||||
[Fireworks AI](https://fireworks.ai) offers fast inference with compound AI system support.
|
||||
|
||||
```env
|
||||
LLM_BACKEND=openai_compatible
|
||||
LLM_BASE_URL=https://api.fireworks.ai/inference/v1
|
||||
LLM_API_KEY=fw_...
|
||||
LLM_MODEL=accounts/fireworks/models/llama4-maverick-instruct-basic
|
||||
```
|
||||
|
||||
### vLLM / LiteLLM (self-hosted)
|
||||
|
||||
For self-hosted inference servers:
|
||||
|
||||
```env
|
||||
LLM_BACKEND=openai_compatible
|
||||
LLM_BASE_URL=http://localhost:8000/v1
|
||||
LLM_API_KEY=token-abc123 # set to any string if auth is not configured
|
||||
LLM_MODEL=meta-llama/Llama-3.1-8B-Instruct
|
||||
```
|
||||
|
||||
LiteLLM proxy (forwards to any backend, including Bedrock, Vertex, Azure):
|
||||
|
||||
```env
|
||||
LLM_BACKEND=openai_compatible
|
||||
LLM_BASE_URL=http://localhost:4000/v1
|
||||
LLM_API_KEY=sk-...
|
||||
LLM_MODEL=gpt-4o # as configured in litellm config.yaml
|
||||
```
|
||||
|
||||
### LM Studio (local GUI)
|
||||
|
||||
Start LM Studio's local server, then:
|
||||
|
||||
```env
|
||||
LLM_BACKEND=openai_compatible
|
||||
LLM_BASE_URL=http://localhost:1234/v1
|
||||
LLM_MODEL=llama-3.2-3b-instruct-q4_K_M
|
||||
# LLM_API_KEY is not required for LM Studio
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Using the Setup Wizard
|
||||
|
||||
Instead of editing `.env` manually, run the onboarding wizard:
|
||||
|
||||
```bash
|
||||
ironclaw onboard
|
||||
```
|
||||
|
||||
Select **"OpenAI-compatible"** for OpenRouter, Together AI, Fireworks, vLLM, LiteLLM,
|
||||
or LM Studio. You will be prompted for the base URL and (optionally) an API key.
|
||||
The model name is configured in the following step.
|
||||
@@ -0,0 +1,135 @@
|
||||
# Telegram Channel Setup
|
||||
|
||||
This guide covers configuring the Telegram channel for IronClaw, including DM pairing for access control.
|
||||
|
||||
## Overview
|
||||
|
||||
The Telegram channel lets you interact with IronClaw via Telegram DMs and groups. It supports:
|
||||
|
||||
- **Webhook mode** (recommended): Instant delivery via tunnel
|
||||
- **Polling mode**: No tunnel required; ~30s delay
|
||||
- **DM pairing**: Approve unknown users before they can message the agent
|
||||
- **Group mentions**: `@YourBot` or `/command` to trigger in groups
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- IronClaw installed and configured (`ironclaw onboard`)
|
||||
- A Telegram bot token from [@BotFather](https://t.me/BotFather)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Create a Bot
|
||||
|
||||
1. Message [@BotFather](https://t.me/BotFather) on Telegram
|
||||
2. Send `/newbot` and follow the prompts
|
||||
3. Copy the bot token (e.g., `123456789:ABCdefGHIjklMNOpqrsTUVwxyz`)
|
||||
|
||||
### 2. Configure via Setup Wizard
|
||||
|
||||
```bash
|
||||
ironclaw onboard
|
||||
```
|
||||
|
||||
When prompted, enable the Telegram channel and paste your bot token. The wizard will:
|
||||
|
||||
- Validate the token
|
||||
- Optionally configure a webhook secret
|
||||
- Set up tunnel (if you want webhook mode)
|
||||
|
||||
### 3. (Optional) Configure Tunnel for Webhooks
|
||||
|
||||
For instant message delivery, expose your agent via a tunnel:
|
||||
|
||||
```bash
|
||||
# ngrok
|
||||
ngrok http 8080
|
||||
|
||||
# Cloudflare
|
||||
cloudflared tunnel --url http://localhost:8080
|
||||
```
|
||||
|
||||
Set the tunnel URL in settings or via `TUNNEL_URL` env var. Without a tunnel, the channel uses polling (~30s delay).
|
||||
|
||||
## DM Pairing
|
||||
|
||||
When an unknown user DMs your bot, they receive a pairing code. You must approve them before they can message the agent.
|
||||
|
||||
### Flow
|
||||
|
||||
1. Unknown user sends a message to your bot
|
||||
2. Bot replies: `To pair with this bot, run: ironclaw pairing approve telegram ABC12345`
|
||||
3. You run: `ironclaw pairing approve telegram ABC12345`
|
||||
4. User is added to the allow list; future messages are delivered
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
# List pending pairing requests
|
||||
ironclaw pairing list telegram
|
||||
|
||||
# List as JSON
|
||||
ironclaw pairing list telegram --json
|
||||
|
||||
# Approve a user by code
|
||||
ironclaw pairing approve telegram ABC12345
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Edit `~/.ironclaw/channels/telegram.capabilities.json` (or the config injected by the host):
|
||||
|
||||
| Option | Values | Default | Description |
|
||||
|--------|--------|---------|-------------|
|
||||
| `dm_policy` | `open`, `allowlist`, `pairing` | `pairing` | `open` = allow all; `allowlist` = config + approved only; `pairing` = allowlist + send pairing reply to unknown |
|
||||
| `allow_from` | `["user_id", "username", "*"]` | `[]` | Pre-approved IDs/usernames. `*` allows everyone. |
|
||||
| `owner_id` | Telegram user ID | `null` | When set, only this user can message (overrides dm_policy) |
|
||||
| `bot_username` | Bot username (no @) | `null` | Used for mention detection in groups; when set, only strips this mention from messages |
|
||||
| `respond_to_all_group_messages` | `true`/`false` | `false` | When true, respond to all group messages; when false, only @mentions and /commands |
|
||||
|
||||
## Manual Installation
|
||||
|
||||
If the channel isn't installed via the wizard:
|
||||
|
||||
```bash
|
||||
# Build the Telegram channel (requires wasm32-wasip2 target)
|
||||
rustup target add wasm32-wasip2
|
||||
./channels-src/telegram/build.sh
|
||||
|
||||
# Install
|
||||
mkdir -p ~/.ironclaw/channels
|
||||
cp channels-src/telegram/telegram.wasm channels-src/telegram/telegram.capabilities.json ~/.ironclaw/channels/
|
||||
```
|
||||
|
||||
## Secrets
|
||||
|
||||
The channel expects a secret named `telegram_bot_token`. Configure via:
|
||||
|
||||
- **Setup wizard**: Saves to encrypted secrets store
|
||||
- **Environment**: `TELEGRAM_BOT_TOKEN=your_token`
|
||||
- **Secrets store**: `ironclaw` CLI (if available)
|
||||
|
||||
## Webhook Secret (Optional)
|
||||
|
||||
For webhook validation, set `telegram_webhook_secret` in secrets. Telegram will send `X-Telegram-Bot-Api-Secret-Token` with each request; the host validates it before forwarding.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Messages not delivered
|
||||
|
||||
- **Polling mode**: Check logs for `getUpdates` errors. Ensure the bot token is valid.
|
||||
- **Webhook mode**: Verify tunnel is running and `TUNNEL_URL` is correct. Telegram requires HTTPS.
|
||||
|
||||
### Pairing code not received
|
||||
|
||||
- Verify the channel can send messages (HTTP allowlist includes `api.telegram.org`)
|
||||
- Check `dm_policy` is `pairing` (not `allowlist` which blocks without reply)
|
||||
|
||||
### Group mentions not working
|
||||
|
||||
- Set `bot_username` in config to your bot's username (e.g., `MyIronClawBot`)
|
||||
- Ensure the message contains `@YourBot` or starts with `/`
|
||||
|
||||
### "Connection refused" when starting
|
||||
|
||||
- For webhook mode: Start your tunnel before `ironclaw run`
|
||||
- For polling only: No tunnel needed; ignore tunnel-related warnings
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"http": {
|
||||
"allowlist": [
|
||||
{
|
||||
"host": "slack.com",
|
||||
"path_prefix": "/api/",
|
||||
"methods": ["GET", "POST"]
|
||||
}
|
||||
],
|
||||
"credentials": {
|
||||
"slack_bot_token": {
|
||||
"secret_name": "slack_bot_token",
|
||||
"location": { "type": "bearer" },
|
||||
"host_patterns": ["slack.com"]
|
||||
}
|
||||
},
|
||||
"rate_limit": {
|
||||
"requests_per_minute": 50,
|
||||
"requests_per_hour": 1000
|
||||
},
|
||||
"timeout_secs": 30
|
||||
},
|
||||
"secrets": {
|
||||
"allowed_names": ["slack_bot_token"]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 267 KiB |
@@ -0,0 +1,10 @@
|
||||
-- Add project_dir and user_id columns for sandbox job tracking.
|
||||
-- user_id was previously hardcoded to "default" in the Rust layer;
|
||||
-- now it's persisted so we can filter per-user.
|
||||
|
||||
ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS project_dir TEXT;
|
||||
ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS user_id TEXT NOT NULL DEFAULT 'default';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_jobs_source ON agent_jobs(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_jobs_user ON agent_jobs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_jobs_created ON agent_jobs(created_at DESC);
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Track which mode a sandbox job uses (worker vs claude_code).
|
||||
ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS job_mode TEXT NOT NULL DEFAULT 'worker';
|
||||
|
||||
-- Persist Claude Code streaming events so they survive restarts and can be
|
||||
-- loaded when the frontend opens a job detail view after the fact.
|
||||
CREATE TABLE IF NOT EXISTS claude_code_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
job_id UUID NOT NULL REFERENCES agent_jobs(id),
|
||||
event_type TEXT NOT NULL,
|
||||
data JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cc_events_job ON claude_code_events(job_id, id);
|
||||
@@ -0,0 +1,73 @@
|
||||
-- Routines: scheduled and reactive job system.
|
||||
--
|
||||
-- A routine is a named, persistent, user-owned task with a trigger and an action.
|
||||
-- Triggers fire independently (cron, event, webhook, manual) so only the
|
||||
-- relevant routine's prompt hits the LLM, not the whole checklist.
|
||||
|
||||
CREATE TABLE routines (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
user_id TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
-- Trigger definition
|
||||
trigger_type TEXT NOT NULL, -- 'cron', 'event', 'webhook', 'manual'
|
||||
trigger_config JSONB NOT NULL, -- type-specific config (schedule, pattern, etc.)
|
||||
|
||||
-- Action definition
|
||||
action_type TEXT NOT NULL, -- 'lightweight', 'full_job'
|
||||
action_config JSONB NOT NULL, -- prompt, context_paths, max_tokens / title, max_iterations
|
||||
|
||||
-- Guardrails
|
||||
cooldown_secs INTEGER NOT NULL DEFAULT 300,
|
||||
max_concurrent INTEGER NOT NULL DEFAULT 1,
|
||||
dedup_window_secs INTEGER, -- NULL = no dedup
|
||||
|
||||
-- Notification preferences
|
||||
notify_channel TEXT, -- NULL = use default
|
||||
notify_user TEXT NOT NULL DEFAULT 'default',
|
||||
notify_on_success BOOLEAN NOT NULL DEFAULT false,
|
||||
notify_on_failure BOOLEAN NOT NULL DEFAULT true,
|
||||
notify_on_attention BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
-- Runtime state (updated by engine)
|
||||
state JSONB NOT NULL DEFAULT '{}',
|
||||
last_run_at TIMESTAMPTZ,
|
||||
next_fire_at TIMESTAMPTZ, -- pre-computed for cron triggers
|
||||
run_count BIGINT NOT NULL DEFAULT 0,
|
||||
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE (user_id, name)
|
||||
);
|
||||
|
||||
-- Fast lookup: "which cron routines need to fire right now?"
|
||||
CREATE INDEX idx_routines_next_fire
|
||||
ON routines (next_fire_at)
|
||||
WHERE enabled AND next_fire_at IS NOT NULL;
|
||||
|
||||
-- Fast lookup: event triggers for a user
|
||||
CREATE INDEX idx_routines_event_triggers
|
||||
ON routines (user_id)
|
||||
WHERE enabled AND trigger_type = 'event';
|
||||
|
||||
-- Audit log of individual routine executions.
|
||||
CREATE TABLE routine_runs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
routine_id UUID NOT NULL REFERENCES routines(id) ON DELETE CASCADE,
|
||||
trigger_type TEXT NOT NULL,
|
||||
trigger_detail TEXT, -- e.g. matched message preview, cron expression
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
status TEXT NOT NULL DEFAULT 'running', -- running, ok, attention, failed
|
||||
result_summary TEXT,
|
||||
tokens_used INTEGER,
|
||||
job_id UUID REFERENCES agent_jobs(id), -- non-NULL for full_job runs
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_routine_runs_routine ON routine_runs (routine_id);
|
||||
CREATE INDEX idx_routine_runs_status ON routine_runs (status) WHERE status = 'running';
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Rename claude_code_events to job_events (generic for all sandbox job types).
|
||||
ALTER TABLE claude_code_events RENAME TO job_events;
|
||||
ALTER INDEX idx_cc_events_job RENAME TO idx_job_events_job;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Settings table: key-value store for all user configuration.
|
||||
--
|
||||
-- Replaces ~/.ironclaw/settings.json, session.json, and mcp-servers.json.
|
||||
-- Keys use dotted paths matching the existing Settings.get()/set() convention
|
||||
-- (e.g., "agent.name", "sandbox.enabled", "mcp_servers").
|
||||
-- One row per setting so individual values can be updated atomically.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
user_id TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value JSONB NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_settings_user ON settings (user_id);
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Allow embedding vectors of any dimension (not just 1536).
|
||||
-- This supports Ollama models (768-dim nomic-embed-text, 1024-dim mxbai-embed-large)
|
||||
-- alongside OpenAI models (1536-dim text-embedding-3-small, 3072-dim text-embedding-3-large).
|
||||
--
|
||||
-- NOTE: HNSW indexes require a fixed dimension, so we drop the index.
|
||||
-- Exact (sequential) cosine distance search still works without the index.
|
||||
-- For a personal assistant workspace the dataset is small enough that this
|
||||
-- has negligible impact on query latency.
|
||||
|
||||
-- Drop dependent views first
|
||||
DROP VIEW IF EXISTS chunks_pending_embedding;
|
||||
DROP VIEW IF EXISTS memory_documents_summary;
|
||||
|
||||
DROP INDEX IF EXISTS idx_memory_chunks_embedding;
|
||||
|
||||
ALTER TABLE memory_chunks
|
||||
ALTER COLUMN embedding TYPE vector
|
||||
USING embedding::vector;
|
||||
|
||||
-- Recreate the views
|
||||
CREATE VIEW memory_documents_summary AS
|
||||
SELECT
|
||||
d.id,
|
||||
d.user_id,
|
||||
d.path,
|
||||
d.created_at,
|
||||
d.updated_at,
|
||||
COUNT(c.id) as chunk_count,
|
||||
COUNT(c.embedding) as embedded_chunk_count
|
||||
FROM memory_documents d
|
||||
LEFT JOIN memory_chunks c ON c.document_id = d.id
|
||||
GROUP BY d.id;
|
||||
|
||||
CREATE VIEW chunks_pending_embedding AS
|
||||
SELECT
|
||||
c.id as chunk_id,
|
||||
c.document_id,
|
||||
d.user_id,
|
||||
d.path,
|
||||
LENGTH(c.content) as content_length
|
||||
FROM memory_chunks c
|
||||
JOIN memory_documents d ON d.id = c.document_id
|
||||
WHERE c.embedding IS NULL;
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"bundles": {
|
||||
"google": {
|
||||
"display_name": "Google Suite",
|
||||
"description": "Gmail, Calendar, Drive, Docs, Sheets, Slides",
|
||||
"extensions": [
|
||||
"tools/gmail",
|
||||
"tools/google-calendar",
|
||||
"tools/google-docs",
|
||||
"tools/google-drive",
|
||||
"tools/google-sheets",
|
||||
"tools/google-slides"
|
||||
],
|
||||
"shared_auth": "google_oauth_token"
|
||||
},
|
||||
"messaging": {
|
||||
"display_name": "Messaging Channels",
|
||||
"description": "Discord, Telegram, Slack, and WhatsApp channels",
|
||||
"extensions": [
|
||||
"channels/discord",
|
||||
"channels/telegram",
|
||||
"channels/slack",
|
||||
"channels/whatsapp"
|
||||
],
|
||||
"shared_auth": null
|
||||
},
|
||||
"default": {
|
||||
"display_name": "Recommended Set",
|
||||
"description": "Core tools and channels for a productive setup",
|
||||
"extensions": [
|
||||
"tools/github",
|
||||
"tools/gmail",
|
||||
"tools/google-calendar",
|
||||
"tools/google-drive",
|
||||
"tools/slack",
|
||||
"channels/telegram",
|
||||
"channels/slack"
|
||||
],
|
||||
"shared_auth": null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "discord",
|
||||
"display_name": "Discord",
|
||||
"kind": "channel",
|
||||
"version": "0.1.0",
|
||||
"description": "Discord Gateway/Webhook channel for slash commands, buttons, and messages",
|
||||
"keywords": ["messaging", "chat", "discord", "bot"],
|
||||
|
||||
"source": {
|
||||
"dir": "channels-src/discord",
|
||||
"capabilities": "discord.capabilities.json",
|
||||
"crate_name": "discord-channel"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/discord-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "manual",
|
||||
"provider": "Discord",
|
||||
"secrets": ["discord_bot_token"],
|
||||
"shared_auth": null,
|
||||
"setup_url": "https://discord.com/developers/applications"
|
||||
},
|
||||
|
||||
"tags": ["messaging"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "slack",
|
||||
"display_name": "Slack",
|
||||
"kind": "channel",
|
||||
"version": "0.1.0",
|
||||
"description": "Slack Events API channel for receiving and responding to Slack messages",
|
||||
"keywords": ["messaging", "chat", "workspace", "slack"],
|
||||
|
||||
"source": {
|
||||
"dir": "channels-src/slack",
|
||||
"capabilities": "slack.capabilities.json",
|
||||
"crate_name": "slack-channel"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "manual",
|
||||
"provider": "Slack",
|
||||
"secrets": ["slack_bot_token", "slack_signing_secret"],
|
||||
"shared_auth": null,
|
||||
"setup_url": "https://api.slack.com/apps"
|
||||
},
|
||||
|
||||
"tags": ["default", "messaging"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "telegram",
|
||||
"display_name": "Telegram",
|
||||
"kind": "channel",
|
||||
"version": "0.1.0",
|
||||
"description": "Telegram Bot API channel for receiving and responding to messages",
|
||||
"keywords": ["messaging", "bot", "chat", "telegram"],
|
||||
|
||||
"source": {
|
||||
"dir": "channels-src/telegram",
|
||||
"capabilities": "telegram.capabilities.json",
|
||||
"crate_name": "telegram-channel"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "manual",
|
||||
"provider": "Telegram",
|
||||
"secrets": ["telegram_bot_token"],
|
||||
"shared_auth": null,
|
||||
"setup_url": "https://t.me/BotFather"
|
||||
},
|
||||
|
||||
"tags": ["default", "messaging"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "whatsapp",
|
||||
"display_name": "WhatsApp",
|
||||
"kind": "channel",
|
||||
"version": "0.1.0",
|
||||
"description": "WhatsApp Cloud API channel for receiving and responding to messages",
|
||||
"keywords": ["messaging", "chat", "whatsapp", "meta"],
|
||||
|
||||
"source": {
|
||||
"dir": "channels-src/whatsapp",
|
||||
"capabilities": "whatsapp.capabilities.json",
|
||||
"crate_name": "whatsapp-channel"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/whatsapp-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "manual",
|
||||
"provider": "Meta",
|
||||
"secrets": ["whatsapp_access_token", "whatsapp_verify_token"],
|
||||
"shared_auth": null,
|
||||
"setup_url": "https://developers.facebook.com/apps/"
|
||||
},
|
||||
|
||||
"tags": ["messaging"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "github",
|
||||
"display_name": "GitHub",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"description": "GitHub integration for issues, PRs, repos, and code search",
|
||||
"keywords": ["git", "code", "issues", "pull-requests", "repositories"],
|
||||
|
||||
"source": {
|
||||
"dir": "tools-src/github",
|
||||
"capabilities": "github-tool.capabilities.json",
|
||||
"crate_name": "github-tool"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/github-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "manual",
|
||||
"provider": "GitHub",
|
||||
"secrets": ["github_token"],
|
||||
"shared_auth": null,
|
||||
"setup_url": "https://github.com/settings/tokens"
|
||||
},
|
||||
|
||||
"tags": ["default", "development"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "gmail",
|
||||
"display_name": "Gmail",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"description": "Read, send, and manage Gmail messages and threads",
|
||||
"keywords": ["email", "google", "mail", "messaging"],
|
||||
|
||||
"source": {
|
||||
"dir": "tools-src/gmail",
|
||||
"capabilities": "gmail-tool.capabilities.json",
|
||||
"crate_name": "gmail-tool"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/gmail-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "oauth",
|
||||
"provider": "Google",
|
||||
"secrets": ["google_oauth_token"],
|
||||
"shared_auth": "google_oauth_token",
|
||||
"setup_url": "https://console.cloud.google.com/apis/credentials"
|
||||
},
|
||||
|
||||
"tags": ["default", "google", "messaging"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "google-calendar",
|
||||
"display_name": "Google Calendar",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"description": "Create, read, update, and delete Google Calendar events",
|
||||
"keywords": ["calendar", "google", "scheduling", "events"],
|
||||
|
||||
"source": {
|
||||
"dir": "tools-src/google-calendar",
|
||||
"capabilities": "google-calendar-tool.capabilities.json",
|
||||
"crate_name": "google-calendar-tool"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-calendar-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "oauth",
|
||||
"provider": "Google",
|
||||
"secrets": ["google_oauth_token"],
|
||||
"shared_auth": "google_oauth_token",
|
||||
"setup_url": "https://console.cloud.google.com/apis/credentials"
|
||||
},
|
||||
|
||||
"tags": ["default", "google", "productivity"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "google-docs",
|
||||
"display_name": "Google Docs",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"description": "Create and edit Google Docs documents",
|
||||
"keywords": ["documents", "google", "writing", "docs"],
|
||||
|
||||
"source": {
|
||||
"dir": "tools-src/google-docs",
|
||||
"capabilities": "google-docs-tool.capabilities.json",
|
||||
"crate_name": "google-docs-tool"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-docs-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "oauth",
|
||||
"provider": "Google",
|
||||
"secrets": ["google_oauth_token"],
|
||||
"shared_auth": "google_oauth_token",
|
||||
"setup_url": "https://console.cloud.google.com/apis/credentials"
|
||||
},
|
||||
|
||||
"tags": ["google", "productivity"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "google-drive",
|
||||
"display_name": "Google Drive",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"description": "Upload, download, search, and manage Google Drive files and folders",
|
||||
"keywords": ["storage", "google", "files", "drive"],
|
||||
|
||||
"source": {
|
||||
"dir": "tools-src/google-drive",
|
||||
"capabilities": "google-drive-tool.capabilities.json",
|
||||
"crate_name": "google-drive-tool"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-drive-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "oauth",
|
||||
"provider": "Google",
|
||||
"secrets": ["google_oauth_token"],
|
||||
"shared_auth": "google_oauth_token",
|
||||
"setup_url": "https://console.cloud.google.com/apis/credentials"
|
||||
},
|
||||
|
||||
"tags": ["default", "google", "storage"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "google-sheets",
|
||||
"display_name": "Google Sheets",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"description": "Read and write Google Sheets spreadsheet data",
|
||||
"keywords": ["spreadsheets", "google", "data", "sheets"],
|
||||
|
||||
"source": {
|
||||
"dir": "tools-src/google-sheets",
|
||||
"capabilities": "google-sheets-tool.capabilities.json",
|
||||
"crate_name": "google-sheets-tool"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-sheets-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "oauth",
|
||||
"provider": "Google",
|
||||
"secrets": ["google_oauth_token"],
|
||||
"shared_auth": "google_oauth_token",
|
||||
"setup_url": "https://console.cloud.google.com/apis/credentials"
|
||||
},
|
||||
|
||||
"tags": ["google", "productivity"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "google-slides",
|
||||
"display_name": "Google Slides",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"description": "Create and edit Google Slides presentations",
|
||||
"keywords": ["presentations", "google", "slides"],
|
||||
|
||||
"source": {
|
||||
"dir": "tools-src/google-slides",
|
||||
"capabilities": "google-slides-tool.capabilities.json",
|
||||
"crate_name": "google-slides-tool"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-slides-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "oauth",
|
||||
"provider": "Google",
|
||||
"secrets": ["google_oauth_token"],
|
||||
"shared_auth": "google_oauth_token",
|
||||
"setup_url": "https://console.cloud.google.com/apis/credentials"
|
||||
},
|
||||
|
||||
"tags": ["google", "productivity"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "okta",
|
||||
"display_name": "Okta",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"description": "Okta SSO for user profile, app catalog, and SSO launch links",
|
||||
"keywords": ["sso", "identity", "authentication", "okta"],
|
||||
|
||||
"source": {
|
||||
"dir": "tools-src/okta",
|
||||
"capabilities": "okta-tool.capabilities.json",
|
||||
"crate_name": "okta-tool"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/okta-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "oauth",
|
||||
"provider": "Okta",
|
||||
"secrets": ["okta_oauth_token"],
|
||||
"shared_auth": null,
|
||||
"setup_url": "https://developer.okta.com/docs/guides/implement-oauth-for-okta/main/"
|
||||
},
|
||||
|
||||
"tags": ["identity"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "slack",
|
||||
"display_name": "Slack",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"description": "Post messages, read channels, and manage conversations via Slack API",
|
||||
"keywords": ["messaging", "chat", "workspace"],
|
||||
|
||||
"source": {
|
||||
"dir": "tools-src/slack",
|
||||
"capabilities": "slack-tool.capabilities.json",
|
||||
"crate_name": "slack-tool"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "oauth",
|
||||
"provider": "Slack",
|
||||
"secrets": ["slack_bot_token"],
|
||||
"shared_auth": null,
|
||||
"setup_url": "https://api.slack.com/apps"
|
||||
},
|
||||
|
||||
"tags": ["default", "messaging"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "telegram",
|
||||
"display_name": "Telegram",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"description": "Telegram user-mode integration via MTProto for messages and contacts",
|
||||
"keywords": ["messaging", "chat", "telegram", "mtproto"],
|
||||
|
||||
"source": {
|
||||
"dir": "tools-src/telegram",
|
||||
"capabilities": "telegram-tool.capabilities.json",
|
||||
"crate_name": "telegram-tool"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "manual",
|
||||
"provider": "Telegram",
|
||||
"secrets": ["telegram_api_id", "telegram_api_hash"],
|
||||
"shared_auth": null,
|
||||
"setup_url": "https://my.telegram.org/apps"
|
||||
},
|
||||
|
||||
"tags": ["messaging"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
[workspace]
|
||||
git_release_enable = false
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build IronClaw and all bundled channels.
|
||||
#
|
||||
# Run this before release or when channel sources have changed.
|
||||
# The main binary bundles telegram.wasm via include_bytes!; it must exist.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "Building bundled channels..."
|
||||
if [ -d "channels-src/telegram" ]; then
|
||||
./channels-src/telegram/build.sh
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Building IronClaw..."
|
||||
cargo build --release
|
||||
|
||||
echo ""
|
||||
echo "Done. Binary: target/release/ironclaw"
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# Developer setup script for IronClaw.
|
||||
#
|
||||
# Gets a fresh checkout ready for development without requiring
|
||||
# Docker, PostgreSQL, or any external services.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/dev-setup.sh
|
||||
#
|
||||
# After running, you can:
|
||||
# cargo check # default features (postgres + libsql)
|
||||
# cargo test # default test suite (uses libsql temp DB)
|
||||
# cargo test --all-features # full test suite
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "=== IronClaw Developer Setup ==="
|
||||
echo ""
|
||||
|
||||
# 1. Check rustup
|
||||
if ! command -v rustup &>/dev/null; then
|
||||
echo "ERROR: rustup not found. Install from https://rustup.rs"
|
||||
exit 1
|
||||
fi
|
||||
echo "[1/5] rustup found: $(rustup --version 2>/dev/null | head -1)"
|
||||
|
||||
# 2. Add WASM target (required by build.rs for channel compilation)
|
||||
echo "[2/5] Adding wasm32-wasip2 target..."
|
||||
rustup target add wasm32-wasip2
|
||||
|
||||
# 3. Install wasm-tools (required by build.rs for WASM component model)
|
||||
echo "[3/5] Installing wasm-tools..."
|
||||
if command -v wasm-tools &>/dev/null; then
|
||||
echo " wasm-tools already installed: $(wasm-tools --version)"
|
||||
else
|
||||
cargo install wasm-tools --locked
|
||||
fi
|
||||
|
||||
# 4. Verify the project compiles
|
||||
echo "[4/5] Running cargo check..."
|
||||
cargo check
|
||||
|
||||
# 5. Run tests using libsql temp DB (no Docker/external DB needed)
|
||||
echo "[5/5] Running tests (no external DB required)..."
|
||||
cargo test
|
||||
|
||||
echo ""
|
||||
echo "=== Setup complete ==="
|
||||
echo ""
|
||||
echo "Quick start:"
|
||||
echo " cargo run # Run with default features"
|
||||
echo " cargo test # Test suite (libsql temp DB)"
|
||||
echo " cargo test --all-features # Full test suite"
|
||||
echo " cargo clippy --all-features # Lint all code"
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
name: web-ui-test
|
||||
version: 0.1.0
|
||||
description: Test the IronClaw web UI using the Claude for Chrome browser extension.
|
||||
activation:
|
||||
keywords:
|
||||
- test web ui
|
||||
- test the ui
|
||||
- browser test
|
||||
- chrome test
|
||||
- test skills tab
|
||||
- test chat
|
||||
- web gateway test
|
||||
patterns:
|
||||
- "test.*web.*ui"
|
||||
- "test.*browser"
|
||||
- "chrome.*extension.*test"
|
||||
---
|
||||
|
||||
# Web UI Testing with Claude for Chrome
|
||||
|
||||
Use this skill when manually testing the IronClaw web gateway UI via the Claude for Chrome browser extension.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- IronClaw must be running with `GATEWAY_ENABLED=true`
|
||||
- Note the gateway URL (default: `http://127.0.0.1:3000/`) and auth token
|
||||
- The Claude for Chrome extension must be installed and connected
|
||||
|
||||
## Starting the Server
|
||||
|
||||
```bash
|
||||
CLI_ENABLED=false GATEWAY_AUTH_TOKEN=<your-token> cargo run
|
||||
```
|
||||
|
||||
Wait for "Agent ironclaw ready and listening" in the logs before proceeding.
|
||||
|
||||
## Test Checklist
|
||||
|
||||
### 1. Connection
|
||||
|
||||
- Navigate to `http://127.0.0.1:3000/?token=<token>`
|
||||
- Verify "Connected" indicator in the top-right corner
|
||||
- Verify all tabs are visible: Chat, Memory, Jobs, Routines, Extensions, Skills
|
||||
|
||||
### 2. Chat Tab
|
||||
|
||||
- Send a simple message (e.g., "Hello, what tools do you have?")
|
||||
- Verify the LLM responds without errors
|
||||
- If you see "Invalid schema for function" errors, the tool schema fix (PR #301) may not be merged yet
|
||||
|
||||
### 3. Skills Tab
|
||||
|
||||
- Click the Skills tab
|
||||
- Verify "No skills installed" or a list of installed skills (no "Skills system not enabled" error)
|
||||
- Search for "markdown" in the ClawHub search box
|
||||
- Verify results appear with: name, version, description, relevance score, "updated X ago"
|
||||
- Verify skill names are clickable links to clawhub.ai
|
||||
- If search returns empty with a yellow warning banner, the registry may be unreachable
|
||||
|
||||
### 4. Skill Install (from search)
|
||||
|
||||
- Search for a skill (e.g., "markdown")
|
||||
- Click "Install" on a result
|
||||
- Confirm the install dialog
|
||||
- Verify success toast appears
|
||||
- Verify the skill appears in "Installed Skills" section
|
||||
|
||||
### 5. Skill Install (by URL)
|
||||
|
||||
- Scroll to "Install Skill by URL"
|
||||
- Enter a skill name and a ClawHub download URL:
|
||||
- Name: `markdown-viewer`
|
||||
- URL: `https://wry-manatee-359.convex.site/api/v1/download?slug=markdown-viewer`
|
||||
- Click Install
|
||||
- Verify success toast and skill appears in installed list
|
||||
|
||||
### 6. Skill Remove
|
||||
|
||||
- Find an installed skill
|
||||
- Click "Remove"
|
||||
- Confirm removal
|
||||
- Verify the skill disappears from the installed list
|
||||
|
||||
### 7. Other Tabs (smoke test)
|
||||
|
||||
- **Memory**: Should show the memory filesystem (may be empty)
|
||||
- **Jobs**: Should show job list (may be empty)
|
||||
- **Routines**: Should show routine list
|
||||
- **Extensions**: Should show extension list with install options
|
||||
|
||||
## Cleanup
|
||||
|
||||
After testing, remove any test-installed skills:
|
||||
|
||||
```bash
|
||||
rm -rf ~/.ironclaw/installed_skills/<skill-name>
|
||||
```
|
||||
|
||||
Stop the server with Ctrl+C or by killing the process.
|
||||
|
||||
## Known Issues
|
||||
|
||||
- ClawHub registry at `clawhub.ai` is behind Vercel which blocks non-browser TLS fingerprints; the backend uses `wry-manatee-359.convex.site` directly
|
||||
- Skill downloads are ZIP archives containing SKILL.md, not raw text
|
||||
- The `confirm()` dialog for install may block browser automation; override with `window.confirm = () => true` in the console first
|
||||
@@ -0,0 +1,566 @@
|
||||
# IronClaw Network Security Reference
|
||||
|
||||
This document catalogs every network-facing surface in IronClaw, its authentication mechanism, bind address, security controls, and known findings. Use this as the authoritative reference during code reviews that touch network-facing code.
|
||||
|
||||
**Last updated:** 2026-02-18
|
||||
|
||||
---
|
||||
|
||||
## Threat Model
|
||||
|
||||
IronClaw operates across four trust boundaries:
|
||||
|
||||
| Boundary | Trust Level | Examples |
|
||||
|----------|------------|---------|
|
||||
| **Local user** | Fully trusted | TUI, web gateway (loopback), CLI commands |
|
||||
| **Browser client** | Authenticated | Web UI connected via bearer token; subject to CORS, Origin validation, CSRF protections |
|
||||
| **Docker containers** | Untrusted (sandboxed) | Worker containers executing user jobs; isolated via per-job tokens, allowlisted egress, dropped capabilities |
|
||||
| **External services** | Untrusted | Webhook senders (Telegram, Slack); authenticated via shared secret |
|
||||
|
||||
**Key assumptions:**
|
||||
|
||||
- The local machine is single-user. The web gateway and OAuth listener bind to loopback and do not defend against other local users.
|
||||
- Docker containers are adversarial. A compromised container should not be able to access other jobs, exfiltrate secrets, or reach the host network beyond the orchestrator API.
|
||||
- Webhook senders must prove knowledge of the shared secret. The secret is never transmitted in the clear by IronClaw itself.
|
||||
- MCP server URLs are operator-configured and treated as trusted destinations (see [MCP Client](#mcp-client)).
|
||||
|
||||
---
|
||||
|
||||
## Network Surface Inventory
|
||||
|
||||
| Listener | Default Port | Default Bind | Auth Mechanism | Config Env Var | Source |
|
||||
|----------|-------------|-------------|----------------|----------------|--------|
|
||||
| Web Gateway | 3000 | `127.0.0.1` | Bearer token (constant-time) | `GATEWAY_HOST`, `GATEWAY_PORT`, `GATEWAY_AUTH_TOKEN` | `server.rs` — `start_server()` |
|
||||
| HTTP Webhook Server | 8080 | `0.0.0.0` | Shared secret (body field) | `HTTP_HOST`, `HTTP_PORT`, `HTTP_WEBHOOK_SECRET` | `webhook_server.rs` — `start()` |
|
||||
| Orchestrator Internal API | 50051 | `127.0.0.1` (macOS/Win) / `0.0.0.0` (Linux) | Per-job bearer token (constant-time) | `ORCHESTRATOR_PORT` | `api.rs` — `OrchestratorApi::start()` |
|
||||
| OAuth Callback Listener | 9876 | `127.0.0.1` | None (ephemeral, 5-min timeout) | N/A (hardcoded) | `oauth_defaults.rs` — `bind_callback_listener()` |
|
||||
| Sandbox HTTP Proxy | OS-assigned (ephemeral) | `127.0.0.1` | None (loopback only) | N/A (auto-assigned) | `proxy/http.rs` — `SandboxProxy::start()` |
|
||||
|
||||
---
|
||||
|
||||
## 1. Web Gateway
|
||||
|
||||
**Source:** `src/channels/web/server.rs`, `src/channels/web/auth.rs`
|
||||
|
||||
### Bind Address
|
||||
|
||||
Configurable via `GATEWAY_HOST` (default `127.0.0.1`) and `GATEWAY_PORT` (default `3000`). The gateway is designed as a local-first, single-user service.
|
||||
|
||||
**Reference:** `src/config.rs` — `gateway_host` default (`"127.0.0.1"`), `gateway_port` default (`3000`)
|
||||
|
||||
### Authentication
|
||||
|
||||
Bearer token middleware applied to all `/api/*` routes via `route_layer`. Token checked in two locations:
|
||||
|
||||
1. `Authorization: Bearer <token>` header (primary)
|
||||
2. `?token=<token>` query parameter (fallback for SSE `EventSource` which cannot set headers)
|
||||
|
||||
Both paths use **constant-time comparison** via `subtle::ConstantTimeEq` (`ct_eq`).
|
||||
|
||||
**Reference:** `src/channels/web/auth.rs` — `auth_middleware()`, header check and query-param fallback both use `ct_eq`
|
||||
|
||||
If `GATEWAY_AUTH_TOKEN` is not set, a random hex token is generated at startup.
|
||||
|
||||
### Unauthenticated Routes
|
||||
|
||||
| Route | Purpose | Response |
|
||||
|-------|---------|----------|
|
||||
| `/api/health` | Health check endpoint | `{"status":"healthy","channel":"gateway"}` — no version, uptime, or fingerprinting data |
|
||||
| `/` | Static HTML (embedded) | Single-page app shell |
|
||||
| `/style.css` | Static CSS (embedded) | Stylesheet |
|
||||
| `/app.js` | Static JS (embedded) | Client-side app |
|
||||
|
||||
### CORS Policy
|
||||
|
||||
Restricted to a two-origin allowlist (not browser same-origin policy, but a CORS allowlist that achieves equivalent protection):
|
||||
|
||||
- `http://<bind_ip>:<bind_port>`
|
||||
- `http://localhost:<bind_port>`
|
||||
|
||||
Allowed methods: `GET`, `POST`, `PUT`, `DELETE`. Allowed headers: `Content-Type`, `Authorization`. Credentials allowed.
|
||||
|
||||
**Reference:** `src/channels/web/server.rs` — `CorsLayer::new()` block
|
||||
|
||||
### WebSocket Origin Validation
|
||||
|
||||
The `/api/chat/ws` endpoint has two layers of protection:
|
||||
|
||||
1. **Bearer token auth** — the route is inside the `protected` router with `route_layer`, so `auth_middleware` runs before the handler. The token is passed via the `Authorization: Bearer` header on the HTTP upgrade request (not via query parameter).
|
||||
|
||||
2. **Origin header validation** (inside the handler) as a defense-in-depth guard against cross-site WebSocket hijacking (CSWSH):
|
||||
- Origin header is **required** — missing Origin returns 403 (browsers always send it for WS upgrades; absence implies a non-browser client)
|
||||
- Origin host is extracted by stripping scheme and port, then compared **exactly** against `localhost`, `127.0.0.1`, and `[::1]`
|
||||
- Partial matches like `localhost.evil.com` are rejected because the check extracts the host portion before the first `:` or `/`
|
||||
|
||||
**Reference:** `src/channels/web/server.rs` — `chat_ws_handler()` (origin validation block)
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Chat endpoint (`/api/chat/send`) enforces a sliding-window rate limit: **30 requests per 60 seconds** (global, not per-IP — single-user gateway).
|
||||
|
||||
**Reference:** `src/channels/web/server.rs` — `RateLimiter` struct, `chat_rate_limiter` field
|
||||
|
||||
### Body Limits
|
||||
|
||||
- Global: **1 MB** max request body (`DefaultBodyLimit::max(1024 * 1024)`)
|
||||
- **Reference:** `src/channels/web/server.rs` — `.layer(DefaultBodyLimit::max(...))`
|
||||
|
||||
### Project File Serving
|
||||
|
||||
The `/projects/{project_id}/*` routes serve files from project directories. These are **behind auth middleware** to prevent unauthorized file access.
|
||||
|
||||
**Reference:** `src/channels/web/server.rs` — project file routes in `protected` router
|
||||
|
||||
### Security Headers
|
||||
|
||||
The gateway sets the following security headers on all responses (via `SetResponseHeaderLayer::if_not_present`, so handlers can override):
|
||||
|
||||
- `X-Content-Type-Options: nosniff` — prevents MIME-sniffing
|
||||
- `X-Frame-Options: DENY` — prevents clickjacking via iframes
|
||||
|
||||
**Reference:** `src/channels/web/server.rs` — `SetResponseHeaderLayer` calls
|
||||
|
||||
### Graceful Shutdown
|
||||
|
||||
Shutdown is triggered via a `oneshot::Sender` stored in `GatewayState::shutdown_tx`. The server uses `axum::serve(...).with_graceful_shutdown(...)` to drain in-flight requests before closing the listener.
|
||||
|
||||
**Reference:** `src/channels/web/server.rs` — `shutdown_tx` / `shutdown_rx` setup
|
||||
|
||||
---
|
||||
|
||||
## 2. HTTP Webhook Server
|
||||
|
||||
**Source:** `src/channels/webhook_server.rs`, `src/channels/http.rs`
|
||||
|
||||
### Bind Address
|
||||
|
||||
Configurable via `HTTP_HOST` (default `0.0.0.0`) and `HTTP_PORT` (default `8080`).
|
||||
|
||||
**WARNING:** The default bind address is `0.0.0.0`, meaning the webhook server listens on **all interfaces** by default. This is intentional (webhooks must be reachable from external services like Telegram/Slack), but operators should be aware of the exposure.
|
||||
|
||||
**Reference:** `src/config.rs` — `http_host` default (`"0.0.0.0"`), `http_port` default (`8080`)
|
||||
|
||||
### Authentication
|
||||
|
||||
Webhook secret is passed **in the JSON request body** (`secret` field), not as a header. The secret is compared using **constant-time** `subtle::ConstantTimeEq` (`ct_eq`).
|
||||
|
||||
The secret is required to start the channel — if `HTTP_WEBHOOK_SECRET` is not set, `start()` returns an error.
|
||||
|
||||
**CSRF note:** Because the secret is in the JSON body (not a cookie or header that browsers auto-attach), a cross-origin form POST cannot forge a valid request. Browsers would send `application/x-www-form-urlencoded`, which the `Json<T>` extractor rejects with HTTP 415. Even if `Content-Type` were spoofed via CORS preflight, the attacker would need the secret value, which is never stored in the browser.
|
||||
|
||||
**Reference:** `src/channels/http.rs` — `webhook_handler()` (secret validation with `ct_eq`), `start()` (required-secret check)
|
||||
|
||||
### Content-Type Validation
|
||||
|
||||
The webhook endpoint uses axum's `Json<WebhookRequest>` extractor, which enforces `Content-Type: application/json`. Requests with missing or incorrect Content-Type are rejected with **HTTP 415 Unsupported Media Type** before the handler body executes. Malformed JSON bodies are rejected with **HTTP 422 Unprocessable Entity**.
|
||||
|
||||
**Reference:** `src/channels/http.rs` — `webhook_handler()` function signature (`Json(req): Json<WebhookRequest>`)
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
**60 requests per minute**, enforced via a mutex-protected sliding window.
|
||||
|
||||
**Reference:** `src/channels/http.rs` — `MAX_REQUESTS_PER_MINUTE` constant, rate-limit check in `webhook_handler()`
|
||||
|
||||
### Body Limits
|
||||
|
||||
- JSON body: **64 KB** max (`MAX_BODY_BYTES`)
|
||||
- Message content: **32 KB** max (`MAX_CONTENT_BYTES`)
|
||||
- Pending synchronous responses: **100 max** (`MAX_PENDING_RESPONSES`)
|
||||
- Synchronous response timeout: **60 seconds**
|
||||
|
||||
**Reference:** `src/channels/http.rs` — constants block (`MAX_BODY_BYTES`, `MAX_CONTENT_BYTES`, `MAX_PENDING_RESPONSES`, `MAX_REQUESTS_PER_MINUTE`)
|
||||
|
||||
### Routes
|
||||
|
||||
| Route | Auth | Purpose | Response |
|
||||
|-------|------|---------|----------|
|
||||
| `/health` | None | Health check | `{"status":"healthy","channel":"http"}` — no fingerprinting data |
|
||||
| `/webhook` | Webhook secret | Receive messages | Webhook response |
|
||||
|
||||
### Graceful Shutdown
|
||||
|
||||
Shutdown is triggered via a `oneshot::Sender` stored on the `WebhookServer` struct. The server uses `axum::serve(...).with_graceful_shutdown(...)`. The public `shutdown()` method sends the signal and awaits the task join handle, ensuring a clean drain-and-wait.
|
||||
|
||||
**Reference:** `src/channels/webhook_server.rs` — `shutdown()` method
|
||||
|
||||
---
|
||||
|
||||
## 3. Orchestrator Internal API
|
||||
|
||||
**Source:** `src/orchestrator/api.rs`, `src/orchestrator/auth.rs`
|
||||
|
||||
### Bind Address
|
||||
|
||||
Platform-dependent:
|
||||
|
||||
- **macOS / Windows**: `127.0.0.1:<port>` — Docker Desktop routes `host.docker.internal` through its VM to `127.0.0.1`
|
||||
- **Linux**: `0.0.0.0:<port>` — containers reach the host via the Docker bridge gateway (`172.17.0.1`), which is not loopback
|
||||
|
||||
Default port: `50051`.
|
||||
|
||||
**Reference:** `src/orchestrator/api.rs` — `OrchestratorApi::start()`, platform-conditional bind address block
|
||||
|
||||
### Authentication
|
||||
|
||||
Per-job bearer tokens validated by `worker_auth_middleware`:
|
||||
|
||||
1. Tokens are **cryptographically random** (32 bytes, hex-encoded = 64 chars)
|
||||
2. Tokens are **scoped to a specific job_id** — a token for job A cannot access endpoints for job B
|
||||
3. Comparison uses **constant-time** `subtle::ConstantTimeEq`
|
||||
4. Tokens are **ephemeral** (in-memory only, never persisted to disk or DB)
|
||||
5. Tokens and associated credential grants are **revoked** when the container is cleaned up
|
||||
|
||||
**Reference:** `src/orchestrator/auth.rs` — `TokenStore::create_token()`, `TokenStore::validate()`, `generate_token()`
|
||||
|
||||
### Token Extraction
|
||||
|
||||
The middleware extracts the job UUID from the URL path (`/worker/{job_id}/...`) and validates the `Authorization: Bearer` header against the stored token for that specific job.
|
||||
|
||||
**Reference:** `src/orchestrator/auth.rs` — `worker_auth_middleware()`, `extract_job_id_from_path()`
|
||||
|
||||
### Credential Grants
|
||||
|
||||
The orchestrator can grant per-job access to specific secrets from the encrypted secrets store. Grants are:
|
||||
|
||||
- Stored alongside the token in the `TokenStore`
|
||||
- Scoped to specific `(secret_name, env_var)` pairs
|
||||
- Revoked when the job token is revoked
|
||||
- Decrypted on-demand when the worker requests `/worker/{job_id}/credentials`
|
||||
|
||||
**Reference:** `src/orchestrator/auth.rs` — `CredentialGrant` struct, `src/orchestrator/api.rs` — `get_credentials_handler()`
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
**None.** The orchestrator API has no rate limiting. All `/worker/*` endpoints are authenticated via per-job bearer tokens, but a compromised container could spam authenticated endpoints without throttling.
|
||||
|
||||
**Mitigation:** Tokens are scoped per-job so a compromised container can only abuse its own job's endpoints. Container execution is time-bounded (see [Docker Container Security](#docker-container-security)), which limits the window for abuse.
|
||||
|
||||
### Routes
|
||||
|
||||
| Route | Auth | Purpose | Response |
|
||||
|-------|------|---------|----------|
|
||||
| `/health` | None | Health check | `"ok"` (plain text) — no fingerprinting data |
|
||||
| `/worker/{job_id}/job` | Per-job token | Get job description | Job JSON |
|
||||
| `/worker/{job_id}/llm/complete` | Per-job token | Proxy LLM completion | LLM response |
|
||||
| `/worker/{job_id}/llm/complete_with_tools` | Per-job token | Proxy LLM tool completion | LLM response |
|
||||
| `/worker/{job_id}/status` | Per-job token | Report worker status | Ack |
|
||||
| `/worker/{job_id}/complete` | Per-job token | Report job completion | Ack |
|
||||
| `/worker/{job_id}/event` | Per-job token | Send job events (SSE broadcast) | Ack |
|
||||
| `/worker/{job_id}/prompt` | Per-job token | Poll for follow-up prompts | Prompt or empty |
|
||||
| `/worker/{job_id}/credentials` | Per-job token | Retrieve decrypted credentials | Credentials JSON |
|
||||
|
||||
### Graceful Shutdown
|
||||
|
||||
**None.** The orchestrator calls `axum::serve(listener, router).await?` without `.with_graceful_shutdown()`. The server stops only when the task is dropped (process exit or tokio task cancellation). In-flight requests may be interrupted.
|
||||
|
||||
**Reference:** `src/orchestrator/api.rs` — `OrchestratorApi::start()`
|
||||
|
||||
---
|
||||
|
||||
## 4. OAuth Callback Listener
|
||||
|
||||
**Source:** `src/cli/oauth_defaults.rs`
|
||||
|
||||
### Bind Address
|
||||
|
||||
Always binds to **loopback only**: `127.0.0.1:9876`. Falls back to `[::1]:9876` (IPv6 loopback) if IPv4 binding fails for reasons other than `AddrInUse`. If the port is already in use, the error is returned immediately (fail-fast).
|
||||
|
||||
Both IPv4 and IPv6 loopback addresses are security-equivalent — they are only reachable from the local machine.
|
||||
|
||||
**Reference:** `src/cli/oauth_defaults.rs` — `OAUTH_CALLBACK_PORT` constant, `bind_callback_listener()`
|
||||
|
||||
### Lifecycle
|
||||
|
||||
The listener is **ephemeral** — it is started only when an OAuth flow is initiated (e.g., `ironclaw tool auth <name>`) and shut down after the callback is received or the timeout expires.
|
||||
|
||||
### Timeout
|
||||
|
||||
**5-minute timeout** (`Duration::from_secs(300)`). If the user does not complete the OAuth flow in the browser within 5 minutes, the listener shuts down.
|
||||
|
||||
**Reference:** `src/cli/oauth_defaults.rs` — `tokio::time::timeout(Duration::from_secs(300), ...)`
|
||||
|
||||
### Security Controls
|
||||
|
||||
- **HTML escaping**: Provider names displayed in the landing page are HTML-escaped to prevent XSS (escapes `&`, `<`, `>`, `"`, `'`)
|
||||
- **Error parameter checking**: The handler checks for `error=` in the callback query string before extracting the auth code
|
||||
- **URL decoding**: Callback parameters are URL-decoded safely
|
||||
|
||||
**Reference:** `src/cli/oauth_defaults.rs` — `html_escape()`
|
||||
|
||||
### Built-in OAuth Credentials
|
||||
|
||||
Google OAuth client ID and secret are compiled into the binary (with compile-time override via `IRONCLAW_GOOGLE_CLIENT_ID` / `IRONCLAW_GOOGLE_CLIENT_SECRET`). As noted in the source, Google Desktop App client secrets are [not actually secret](https://developers.google.com/identity/protocols/oauth2/native-app) per Google's documentation.
|
||||
|
||||
**Reference:** `src/cli/oauth_defaults.rs` — `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` constants
|
||||
|
||||
### Graceful Shutdown
|
||||
|
||||
Implicit. The listener is a raw `TcpListener` (not axum) inside a `tokio::time::timeout` future. Once the authorization code or error is received, the future returns and the `TcpListener` is dropped, closing the port. No explicit shutdown signal is needed.
|
||||
|
||||
**Reference:** `src/cli/oauth_defaults.rs` — `wait_for_callback()`
|
||||
|
||||
---
|
||||
|
||||
## 5. Sandbox HTTP Proxy
|
||||
|
||||
**Source:** `src/sandbox/proxy/http.rs`, `src/sandbox/proxy/allowlist.rs`, `src/sandbox/proxy/policy.rs`
|
||||
|
||||
### Bind Address
|
||||
|
||||
Always binds to **`127.0.0.1`** (localhost only). Port is OS-assigned (port `0`, ephemeral). Falls back to `[::1]` (IPv6 loopback) if IPv4 is unavailable.
|
||||
|
||||
Both IPv4 and IPv6 loopback addresses are security-equivalent — they are only reachable from the local machine.
|
||||
|
||||
**Reference:** `src/sandbox/proxy/http.rs` — `SandboxProxy::start()`, `TcpListener::bind("127.0.0.1:0")`
|
||||
|
||||
### Purpose
|
||||
|
||||
Acts as an HTTP/HTTPS proxy for Docker sandbox containers. Containers are configured with `http_proxy` / `https_proxy` environment variables pointing to this proxy, so all outbound HTTP traffic is routed through it.
|
||||
|
||||
### Domain Allowlisting
|
||||
|
||||
All requests are validated against a domain allowlist before being forwarded:
|
||||
|
||||
- **Empty allowlist = deny all** (fail-closed default)
|
||||
- Supports exact matches and wildcard patterns (`*.example.com`)
|
||||
- Validates URL scheme (HTTP/HTTPS only, rejects `ftp://`, `file://`, etc.)
|
||||
|
||||
**Reference:** `src/sandbox/proxy/allowlist.rs` — `DomainAllowlist` struct, `is_allowed()` method
|
||||
|
||||
### HTTPS Tunneling (CONNECT)
|
||||
|
||||
- CONNECT requests for HTTPS tunneling are subject to the same allowlist
|
||||
- **30-minute timeout** on established tunnels to prevent indefinite holds
|
||||
- **No MITM**: the proxy cannot inspect or inject credentials into HTTPS traffic (by design — containers that need credentials must use the orchestrator's `/worker/{job_id}/credentials` endpoint)
|
||||
|
||||
**Reference:** `src/sandbox/proxy/http.rs` — `handle_connect()` function
|
||||
|
||||
### Credential Injection (HTTP only)
|
||||
|
||||
For plain HTTP requests to allowed hosts, the proxy can inject credentials:
|
||||
|
||||
- Bearer tokens in `Authorization` header
|
||||
- Custom headers (e.g., `X-API-Key`)
|
||||
- Query parameters
|
||||
- Credentials are resolved at request time from the encrypted secrets store
|
||||
- Credentials never enter the container's environment or filesystem
|
||||
|
||||
**Reference:** `src/sandbox/proxy/http.rs` — credential injection block in `handle_request()`
|
||||
|
||||
### Hop-by-Hop Header Filtering
|
||||
|
||||
The proxy strips hop-by-hop headers to prevent header-based attacks: `connection`, `keep-alive`, `proxy-authenticate`, `proxy-authorization`, `te`, `trailers`, `transfer-encoding`, `upgrade`.
|
||||
|
||||
**Reference:** `src/sandbox/proxy/http.rs` — `is_hop_by_hop_header()`
|
||||
|
||||
### Docker Container Security
|
||||
|
||||
Containers that use the proxy are configured with defense-in-depth:
|
||||
|
||||
| Control | Setting | Reference |
|
||||
|---------|---------|-----------|
|
||||
| Capabilities | Drop ALL, add only CHOWN | `src/sandbox/container.rs` — `cap_drop` / `cap_add` |
|
||||
| Privilege escalation | `no-new-privileges:true` | `src/sandbox/container.rs` — `security_opt` |
|
||||
| Root filesystem | Read-only (except FullAccess policy) | `src/sandbox/container.rs` — `readonly_rootfs` |
|
||||
| User | Non-root (UID 1000:1000) | `src/sandbox/container.rs` — `user` field |
|
||||
| Network | Bridge mode (isolated) | `src/sandbox/container.rs` — `network_mode` |
|
||||
| Tmpfs | `/tmp` (512 MB), `/home/sandbox/.cargo/registry` (1 GB) | `src/sandbox/container.rs` — `tmpfs` block |
|
||||
| Auto-remove | Enabled | `src/sandbox/container.rs` — `auto_remove` |
|
||||
| Output limits | Configurable max stdout/stderr | `src/sandbox/container.rs` — `collect_logs()` |
|
||||
| Timeout | Enforced with forced container removal | `src/sandbox/container.rs` — `tokio::time::timeout` in `run()` |
|
||||
|
||||
### Graceful Shutdown
|
||||
|
||||
Shutdown is triggered via a `oneshot::Sender` stored on the proxy. The accept loop uses `tokio::select!` to race `listener.accept()` against the shutdown signal. The `stop()` method fires the signal; the loop breaks on the next iteration. Note: `stop()` does not await a join handle, so there is no drain-and-wait for in-flight connections.
|
||||
|
||||
**Reference:** `src/sandbox/proxy/http.rs` — `stop()` method, `tokio::select!` loop
|
||||
|
||||
---
|
||||
|
||||
## Egress Controls
|
||||
|
||||
### WASM Tool HTTP Requests
|
||||
|
||||
WASM tools execute HTTP requests through the host runtime, subject to:
|
||||
|
||||
1. **Endpoint allowlist** — declared in `<tool>.capabilities.json`, validated by `AllowlistValidator`
|
||||
- Host matching (exact or wildcard)
|
||||
- Path prefix matching
|
||||
- HTTP method restriction
|
||||
- HTTPS required by default
|
||||
- Userinfo in URLs (`user:pass@host`) rejected to prevent allowlist bypass
|
||||
- Path traversal (`../`, `%2e%2e/`) normalized and blocked
|
||||
- Invalid percent-encoding rejected
|
||||
- **Reference:** `src/tools/wasm/allowlist.rs`
|
||||
|
||||
2. **Credential injection** — secrets injected at the host boundary by `CredentialInjector`
|
||||
- WASM code never sees actual credential values
|
||||
- Secrets must be in the tool's `allowed_secrets` list
|
||||
- Injection supports: Bearer header, Basic auth, custom header, query parameter
|
||||
- **Reference:** `src/tools/wasm/credential_injector.rs`
|
||||
|
||||
3. **Leak detection** — `LeakDetector` scans both outbound requests and inbound responses for secret patterns
|
||||
- Runs at two points: before sending and after receiving
|
||||
- Uses Aho-Corasick for fast multi-pattern matching
|
||||
- **Reference:** `src/safety/leak_detector.rs`
|
||||
|
||||
### Built-in HTTP Tool
|
||||
|
||||
The `http` tool (`src/tools/builtin/http.rs`) has its own SSRF protections:
|
||||
|
||||
| Protection | Details | Reference |
|
||||
|-----------|---------|-----------|
|
||||
| HTTPS only | Rejects `http://` URLs | `http.rs` — scheme check |
|
||||
| Localhost blocked | Rejects `localhost` and `*.localhost` | `http.rs` — host check |
|
||||
| Private IP blocked | Rejects RFC 1918, loopback, link-local, multicast, unspecified | `http.rs` — `is_disallowed_ip()` |
|
||||
| DNS rebinding | Resolves hostname and checks all resolved IPs against blocklist | `http.rs` — DNS resolution block |
|
||||
| Cloud metadata | Blocks `169.254.169.254` (AWS/GCP metadata endpoint) | `http.rs` — `is_disallowed_ip()` |
|
||||
| Redirect blocking | Returns error on 3xx responses (prevents SSRF via redirect) | `http.rs` — status code check |
|
||||
| Response size limit | **5 MB** max, enforced both via Content-Length header and streaming | `http.rs` — `MAX_RESPONSE_SIZE` constant, streaming cap |
|
||||
| Outbound leak scan | Scans URL, headers, and body for secrets before sending | `http.rs` — `LeakDetector::scan_http_request()` |
|
||||
| Approval required | Requires user approval before execution | `http.rs` — `requires_approval()` returns `true` |
|
||||
| Timeout | 30 seconds default | `http.rs` — `reqwest::Client` builder |
|
||||
| No redirects | `redirect::Policy::none()` — redirects are not followed | `http.rs` — `reqwest::Client` builder |
|
||||
|
||||
### MCP Client
|
||||
|
||||
MCP servers are external processes accessed via HTTP. The MCP client (`src/tools/mcp/client.rs`) uses `reqwest` with a 30-second timeout but has **no SSRF protections** — it connects to whatever URL is configured for the MCP server.
|
||||
|
||||
This is by design: MCP server URLs come from **operator-controlled configuration** (config files, environment variables, or the CLI `tool install` command), not from user input or LLM output. A compromised config file is outside IronClaw's threat model — it would imply the operator's machine is already compromised.
|
||||
|
||||
**Reference:** `src/tools/mcp/client.rs` — `reqwest::Client` builder
|
||||
|
||||
### Sandbox Domain Allowlists
|
||||
|
||||
Sandbox containers route all HTTP traffic through the proxy, which enforces a domain allowlist. The allowlist is built from:
|
||||
|
||||
1. A default set of domains (`src/sandbox/config.rs` — `default_allowlist()`)
|
||||
2. Additional domains from `SANDBOX_EXTRA_DOMAINS` env var (comma-separated)
|
||||
|
||||
**Reference:** `src/config.rs` — sandbox allowlist assembly
|
||||
|
||||
---
|
||||
|
||||
## Authentication Mechanisms Summary
|
||||
|
||||
| Mechanism | Constant-Time | Used By | Reference |
|
||||
|-----------|:------------:|---------|-----------|
|
||||
| Gateway bearer token | Yes | Web gateway (header + query) | `src/channels/web/auth.rs` — `auth_middleware()` |
|
||||
| Webhook shared secret | Yes | HTTP webhook (`ct_eq` comparison) | `src/channels/http.rs` — `webhook_handler()` |
|
||||
| Per-job bearer token | Yes | Orchestrator worker API | `src/orchestrator/auth.rs` — `TokenStore::validate()` |
|
||||
| OAuth callback | N/A | CLI OAuth flow (no auth, loopback-only) | `src/cli/oauth_defaults.rs` — `bind_callback_listener()` |
|
||||
| Sandbox proxy | N/A | No auth (loopback-only, ephemeral) | `src/sandbox/proxy/http.rs` — `SandboxProxy::start()` |
|
||||
|
||||
---
|
||||
|
||||
## Known Security Findings
|
||||
|
||||
### Open
|
||||
|
||||
#### F-2. No TLS at the application layer
|
||||
|
||||
**Severity:** Low (for local deployment)
|
||||
**Details:** None of the listeners terminate TLS. All communication is plain HTTP.
|
||||
**Mitigation:** The web gateway and OAuth callback bind to loopback by default. For production, users are expected to front the gateway with a reverse proxy (nginx, Caddy) or tunnel (Cloudflare, ngrok) that provides TLS.
|
||||
**Recommendation:** Document the requirement for a TLS-terminating reverse proxy in deployment guides.
|
||||
|
||||
#### F-3. Orchestrator binds to `0.0.0.0` on Linux
|
||||
|
||||
**Severity:** Medium
|
||||
**Location:** `src/orchestrator/api.rs` — platform-conditional bind in `OrchestratorApi::start()`
|
||||
**Details:** On Linux, the orchestrator API binds to all interfaces because Docker containers reach the host via the bridge gateway (`172.17.0.1`), not loopback. This means the API is reachable from any network interface on the host.
|
||||
**Mitigation:** All `/worker/*` endpoints require per-job bearer tokens (constant-time, cryptographically random). The `/health` endpoint is the only unauthenticated route and returns only `"ok"`. Firewall rules should block external access to port 50051.
|
||||
**Recommendation:** Document firewall requirements for Linux deployments. Consider binding to the Docker bridge IP (`172.17.0.1`) instead of `0.0.0.0`.
|
||||
|
||||
#### F-6. WebSocket/SSE connection limit
|
||||
|
||||
**Severity:** Info
|
||||
**Details:** The `SseManager` enforces a hard limit of **100 concurrent connections** (`MAX_CONNECTIONS` constant in `src/channels/web/sse.rs`). Both SSE subscribers and WebSocket connections share this counter. When exceeded, new WebSocket upgrades are rejected with a warning log and the connection is immediately closed.
|
||||
**Reference:** `src/channels/web/sse.rs` — `MAX_CONNECTIONS`, `src/channels/web/ws.rs` — `handle_ws_connection()` early return
|
||||
|
||||
#### F-7. Orchestrator API has no rate limiting
|
||||
|
||||
**Severity:** Low
|
||||
**Details:** The orchestrator API has no request-rate throttling. A compromised container could spam authenticated endpoints (e.g., `/worker/{job_id}/llm/complete`) to drive up LLM costs or degrade service for other jobs.
|
||||
**Mitigation:** Tokens are scoped per-job, limiting blast radius. Container execution is time-bounded by the sandbox timeout, which caps the abuse window.
|
||||
**Recommendation:** Consider adding per-token rate limiting on the LLM proxy endpoints.
|
||||
|
||||
#### F-8. Orchestrator API has no graceful shutdown
|
||||
|
||||
**Severity:** Info
|
||||
**Details:** The orchestrator calls `axum::serve(listener, router).await?` without `.with_graceful_shutdown()`. In-flight requests (including LLM proxy calls) may be interrupted during process shutdown.
|
||||
**Reference:** `src/orchestrator/api.rs` — `OrchestratorApi::start()`
|
||||
|
||||
### Resolved / Mitigated
|
||||
|
||||
<details>
|
||||
<summary>Resolved and mitigated findings (click to expand)</summary>
|
||||
|
||||
#### F-1. ~~Webhook secret comparison is not constant-time~~ (Resolved)
|
||||
|
||||
**Severity:** Low
|
||||
**Location:** `src/channels/http.rs` — `webhook_handler()`
|
||||
**Status:** Resolved — webhook secret now uses `subtle::ConstantTimeEq` (`ct_eq`), consistent with web gateway and orchestrator auth.
|
||||
|
||||
#### F-4. ~~HTTP webhook server binds to `0.0.0.0` by default~~ (Mitigated)
|
||||
|
||||
**Severity:** Low
|
||||
**Location:** `src/config.rs`, `src/main.rs`
|
||||
**Status:** Mitigated — a `tracing::warn!` is now emitted at startup when the webhook server binds to an unspecified address (`0.0.0.0` or `::`), advising operators to set `HTTP_HOST=127.0.0.1` to restrict to localhost. The default bind address remains `0.0.0.0`, so webhook exposure is still controlled by operator configuration and external network controls (firewalls, ingress rules).
|
||||
|
||||
#### F-5. ~~Missing security headers on web gateway~~ (Mitigated)
|
||||
|
||||
**Severity:** Low
|
||||
**Status:** Mitigated — `X-Content-Type-Options: nosniff` and `X-Frame-Options: DENY` are now set on all gateway responses via `SetResponseHeaderLayer::if_not_present`. Layer ordering ensures these headers are applied even to error responses generated by inner layers (e.g., `DefaultBodyLimit` 413 rejections).
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Review Checklist for Network Changes
|
||||
|
||||
Use this checklist for any PR that adds or modifies network-facing code.
|
||||
|
||||
### New Listener
|
||||
|
||||
- [ ] **Bind address**: Does it bind to loopback (`127.0.0.1`) or all interfaces (`0.0.0.0`)? Justify if `0.0.0.0`.
|
||||
- [ ] **Port configuration**: Is the port configurable via env var? Is a sensible default set?
|
||||
- [ ] **Authentication**: Is auth required? If yes, is it constant-time? If no, why not?
|
||||
- [ ] **Rate limiting**: Is there a rate limiter? What are the limits?
|
||||
- [ ] **Body size limit**: Is `DefaultBodyLimit` (or equivalent) set?
|
||||
- [ ] **Content-Type validation**: Does the handler validate Content-Type (e.g., via axum `Json<T>` extractor)?
|
||||
- [ ] **Graceful shutdown**: Does the listener support graceful shutdown via oneshot or similar?
|
||||
- [ ] **Inventory update**: Is this document updated with the new listener?
|
||||
|
||||
### New Route on Existing Listener
|
||||
|
||||
- [ ] **Auth layer**: Is the route behind the auth middleware? If public, why?
|
||||
- [ ] **Input validation**: Are path parameters, query parameters, and body fields validated?
|
||||
- [ ] **Error responses**: Do error responses avoid leaking internal details?
|
||||
|
||||
### Egress (Outbound HTTP)
|
||||
|
||||
- [ ] **SSRF protection**: Does the code block private IPs, localhost, and cloud metadata endpoints?
|
||||
- [ ] **DNS rebinding**: Are resolved IPs checked (not just the hostname)?
|
||||
- [ ] **Redirect handling**: Are redirects blocked or validated?
|
||||
- [ ] **Response size**: Is there a max response size?
|
||||
- [ ] **Timeout**: Is a request timeout set?
|
||||
- [ ] **Leak detection**: Is the outbound request scanned for secrets?
|
||||
|
||||
### Credential Handling
|
||||
|
||||
- [ ] **Constant-time comparison**: Are secrets compared with `subtle::ConstantTimeEq`?
|
||||
- [ ] **No logging**: Are credentials excluded from log messages?
|
||||
- [ ] **Ephemeral storage**: Are tokens stored in memory only (not persisted)?
|
||||
- [ ] **Scope**: Are credentials scoped to the minimum necessary (per-job, per-tool)?
|
||||
- [ ] **Revocation**: Are credentials revoked when no longer needed?
|
||||
|
||||
### Container / Sandbox
|
||||
|
||||
- [ ] **Capabilities**: Are all capabilities dropped except what's needed?
|
||||
- [ ] **Filesystem**: Is the root filesystem read-only?
|
||||
- [ ] **User**: Does the container run as non-root?
|
||||
- [ ] **Network**: Is network access routed through the proxy?
|
||||
- [ ] **Timeout**: Is there an execution timeout with forced cleanup?
|
||||
- [ ] **Output limits**: Are stdout/stderr capped?
|
||||
+486
-1402
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,494 @@
|
||||
//! System commands and job handlers for the agent.
|
||||
//!
|
||||
//! Extracted from `agent_loop.rs` to isolate the /help, /model, /status,
|
||||
//! and other command processing from the core agent loop.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::agent::session::Session;
|
||||
use crate::agent::submission::SubmissionResult;
|
||||
use crate::agent::{Agent, MessageIntent};
|
||||
use crate::channels::{IncomingMessage, StatusUpdate};
|
||||
use crate::error::Error;
|
||||
use crate::llm::{ChatMessage, Reasoning};
|
||||
|
||||
impl Agent {
|
||||
/// Handle job-related intents without turn tracking.
|
||||
pub(super) async fn handle_job_or_command(
|
||||
&self,
|
||||
intent: MessageIntent,
|
||||
message: &IncomingMessage,
|
||||
) -> Result<SubmissionResult, Error> {
|
||||
// Send thinking status for non-trivial operations
|
||||
if let MessageIntent::CreateJob { .. } = &intent {
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::Thinking("Processing...".into()),
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let response = match intent {
|
||||
MessageIntent::CreateJob {
|
||||
title,
|
||||
description,
|
||||
category,
|
||||
} => {
|
||||
self.handle_create_job(&message.user_id, title, description, category)
|
||||
.await?
|
||||
}
|
||||
MessageIntent::CheckJobStatus { job_id } => {
|
||||
self.handle_check_status(&message.user_id, job_id).await?
|
||||
}
|
||||
MessageIntent::CancelJob { job_id } => {
|
||||
self.handle_cancel_job(&message.user_id, &job_id).await?
|
||||
}
|
||||
MessageIntent::ListJobs { filter } => {
|
||||
self.handle_list_jobs(&message.user_id, filter).await?
|
||||
}
|
||||
MessageIntent::HelpJob { job_id } => {
|
||||
self.handle_help_job(&message.user_id, &job_id).await?
|
||||
}
|
||||
MessageIntent::Command { command, args } => {
|
||||
match self.handle_command(&command, &args).await? {
|
||||
Some(s) => s,
|
||||
None => return Ok(SubmissionResult::Ok { message: None }), // Shutdown signal
|
||||
}
|
||||
}
|
||||
_ => "Unknown intent".to_string(),
|
||||
};
|
||||
Ok(SubmissionResult::response(response))
|
||||
}
|
||||
|
||||
async fn handle_create_job(
|
||||
&self,
|
||||
user_id: &str,
|
||||
title: String,
|
||||
description: String,
|
||||
category: Option<String>,
|
||||
) -> Result<String, Error> {
|
||||
let job_id = self
|
||||
.scheduler
|
||||
.dispatch_job(user_id, &title, &description, None)
|
||||
.await?;
|
||||
|
||||
// Set the dedicated category field (not stored in metadata)
|
||||
if let Some(cat) = category
|
||||
&& let Err(e) = self
|
||||
.context_manager
|
||||
.update_context(job_id, |ctx| {
|
||||
ctx.category = Some(cat);
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::warn!(job_id = %job_id, "Failed to set job category: {}", e);
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"Created job: {}\nID: {}\n\nThe job has been scheduled and is now running.",
|
||||
title, job_id
|
||||
))
|
||||
}
|
||||
|
||||
async fn handle_check_status(
|
||||
&self,
|
||||
user_id: &str,
|
||||
job_id: Option<String>,
|
||||
) -> Result<String, Error> {
|
||||
match job_id {
|
||||
Some(id) => {
|
||||
let uuid = Uuid::parse_str(&id)
|
||||
.map_err(|_| crate::error::JobError::NotFound { id: Uuid::nil() })?;
|
||||
|
||||
let ctx = self.context_manager.get_context(uuid).await?;
|
||||
if ctx.user_id != user_id {
|
||||
return Err(crate::error::JobError::NotFound { id: uuid }.into());
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"Job: {}\nStatus: {:?}\nCreated: {}\nStarted: {}\nActual cost: {}",
|
||||
ctx.title,
|
||||
ctx.state,
|
||||
ctx.created_at.format("%Y-%m-%d %H:%M:%S"),
|
||||
ctx.started_at
|
||||
.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
.unwrap_or_else(|| "Not started".to_string()),
|
||||
ctx.actual_cost
|
||||
))
|
||||
}
|
||||
None => {
|
||||
// Show summary of all jobs
|
||||
let summary = self.context_manager.summary_for(user_id).await;
|
||||
Ok(format!(
|
||||
"Jobs summary:\n Total: {}\n In Progress: {}\n Completed: {}\n Failed: {}\n Stuck: {}",
|
||||
summary.total,
|
||||
summary.in_progress,
|
||||
summary.completed,
|
||||
summary.failed,
|
||||
summary.stuck
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_cancel_job(&self, user_id: &str, job_id: &str) -> Result<String, Error> {
|
||||
let uuid = Uuid::parse_str(job_id)
|
||||
.map_err(|_| crate::error::JobError::NotFound { id: Uuid::nil() })?;
|
||||
|
||||
let ctx = self.context_manager.get_context(uuid).await?;
|
||||
if ctx.user_id != user_id {
|
||||
return Err(crate::error::JobError::NotFound { id: uuid }.into());
|
||||
}
|
||||
|
||||
self.scheduler.stop(uuid).await?;
|
||||
|
||||
Ok(format!("Job {} has been cancelled.", job_id))
|
||||
}
|
||||
|
||||
async fn handle_list_jobs(
|
||||
&self,
|
||||
user_id: &str,
|
||||
_filter: Option<String>,
|
||||
) -> Result<String, Error> {
|
||||
let jobs = self.context_manager.all_jobs_for(user_id).await;
|
||||
|
||||
if jobs.is_empty() {
|
||||
return Ok("No jobs found.".to_string());
|
||||
}
|
||||
|
||||
let mut output = String::from("Jobs:\n");
|
||||
for job_id in jobs {
|
||||
if let Ok(ctx) = self.context_manager.get_context(job_id).await
|
||||
&& ctx.user_id == user_id
|
||||
{
|
||||
output.push_str(&format!(" {} - {} ({:?})\n", job_id, ctx.title, ctx.state));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
async fn handle_help_job(&self, user_id: &str, job_id: &str) -> Result<String, Error> {
|
||||
let uuid = Uuid::parse_str(job_id)
|
||||
.map_err(|_| crate::error::JobError::NotFound { id: Uuid::nil() })?;
|
||||
|
||||
let ctx = self.context_manager.get_context(uuid).await?;
|
||||
if ctx.user_id != user_id {
|
||||
return Err(crate::error::JobError::NotFound { id: uuid }.into());
|
||||
}
|
||||
|
||||
if ctx.state == crate::context::JobState::Stuck {
|
||||
// Attempt recovery
|
||||
self.context_manager
|
||||
.update_context(uuid, |ctx| ctx.attempt_recovery())
|
||||
.await?
|
||||
.map_err(|s| crate::error::JobError::ContextError {
|
||||
id: uuid,
|
||||
reason: s,
|
||||
})?;
|
||||
|
||||
// Reschedule
|
||||
self.scheduler.schedule(uuid).await?;
|
||||
|
||||
Ok(format!(
|
||||
"Job {} was stuck. Attempting recovery (attempt #{}).",
|
||||
job_id,
|
||||
ctx.repair_attempts + 1
|
||||
))
|
||||
} else {
|
||||
Ok(format!(
|
||||
"Job {} is not stuck (current state: {:?}). No help needed.",
|
||||
job_id, ctx.state
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger a manual heartbeat check.
|
||||
pub(super) async fn process_heartbeat(&self) -> Result<SubmissionResult, Error> {
|
||||
let Some(workspace) = self.workspace() else {
|
||||
return Ok(SubmissionResult::error(
|
||||
"Heartbeat requires a workspace (database must be connected).",
|
||||
));
|
||||
};
|
||||
|
||||
let runner = crate::agent::HeartbeatRunner::new(
|
||||
crate::agent::HeartbeatConfig::default(),
|
||||
crate::workspace::hygiene::HygieneConfig::default(),
|
||||
workspace.clone(),
|
||||
self.llm().clone(),
|
||||
self.safety().clone(),
|
||||
);
|
||||
|
||||
match runner.check_heartbeat().await {
|
||||
crate::agent::HeartbeatResult::Ok => Ok(SubmissionResult::ok_with_message(
|
||||
"Heartbeat: all clear, nothing needs attention.",
|
||||
)),
|
||||
crate::agent::HeartbeatResult::NeedsAttention(msg) => Ok(SubmissionResult::response(
|
||||
format!("Heartbeat findings:\n\n{}", msg),
|
||||
)),
|
||||
crate::agent::HeartbeatResult::Skipped => Ok(SubmissionResult::ok_with_message(
|
||||
"Heartbeat skipped: no HEARTBEAT.md checklist found in workspace.",
|
||||
)),
|
||||
crate::agent::HeartbeatResult::Failed(err) => Ok(SubmissionResult::error(format!(
|
||||
"Heartbeat failed: {}",
|
||||
err
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Summarize the current thread's conversation.
|
||||
pub(super) async fn process_summarize(
|
||||
&self,
|
||||
session: Arc<Mutex<Session>>,
|
||||
thread_id: Uuid,
|
||||
) -> Result<SubmissionResult, Error> {
|
||||
let messages = {
|
||||
let sess = session.lock().await;
|
||||
let thread = sess
|
||||
.threads
|
||||
.get(&thread_id)
|
||||
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
|
||||
thread.messages()
|
||||
};
|
||||
|
||||
if messages.is_empty() {
|
||||
return Ok(SubmissionResult::ok_with_message(
|
||||
"Nothing to summarize (empty thread).",
|
||||
));
|
||||
}
|
||||
|
||||
// Build a summary prompt with the conversation
|
||||
let mut context = Vec::new();
|
||||
context.push(ChatMessage::system(
|
||||
"Summarize the conversation so far in 3-5 concise bullet points. \
|
||||
Focus on decisions made, actions taken, and key outcomes. \
|
||||
Be brief and factual.",
|
||||
));
|
||||
// Include the conversation messages (truncate to last 20 to avoid context overflow)
|
||||
let start = if messages.len() > 20 {
|
||||
messages.len() - 20
|
||||
} else {
|
||||
0
|
||||
};
|
||||
context.extend_from_slice(&messages[start..]);
|
||||
context.push(ChatMessage::user("Summarize this conversation."));
|
||||
|
||||
let request = crate::llm::CompletionRequest::new(context)
|
||||
.with_max_tokens(512)
|
||||
.with_temperature(0.3);
|
||||
|
||||
let reasoning = Reasoning::new(self.llm().clone(), self.safety().clone());
|
||||
match reasoning.complete(request).await {
|
||||
Ok((text, _usage)) => Ok(SubmissionResult::response(format!(
|
||||
"Thread Summary:\n\n{}",
|
||||
text.trim()
|
||||
))),
|
||||
Err(e) => Ok(SubmissionResult::error(format!("Summarize failed: {}", e))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Suggest next steps based on the current thread.
|
||||
pub(super) async fn process_suggest(
|
||||
&self,
|
||||
session: Arc<Mutex<Session>>,
|
||||
thread_id: Uuid,
|
||||
) -> Result<SubmissionResult, Error> {
|
||||
let messages = {
|
||||
let sess = session.lock().await;
|
||||
let thread = sess
|
||||
.threads
|
||||
.get(&thread_id)
|
||||
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
|
||||
thread.messages()
|
||||
};
|
||||
|
||||
if messages.is_empty() {
|
||||
return Ok(SubmissionResult::ok_with_message(
|
||||
"Nothing to suggest from (empty thread).",
|
||||
));
|
||||
}
|
||||
|
||||
let mut context = Vec::new();
|
||||
context.push(ChatMessage::system(
|
||||
"Based on the conversation so far, suggest 2-4 concrete next steps the user could take. \
|
||||
Be actionable and specific. Format as a numbered list.",
|
||||
));
|
||||
let start = if messages.len() > 20 {
|
||||
messages.len() - 20
|
||||
} else {
|
||||
0
|
||||
};
|
||||
context.extend_from_slice(&messages[start..]);
|
||||
context.push(ChatMessage::user("What should I do next?"));
|
||||
|
||||
let request = crate::llm::CompletionRequest::new(context)
|
||||
.with_max_tokens(512)
|
||||
.with_temperature(0.5);
|
||||
|
||||
let reasoning = Reasoning::new(self.llm().clone(), self.safety().clone());
|
||||
match reasoning.complete(request).await {
|
||||
Ok((text, _usage)) => Ok(SubmissionResult::response(format!(
|
||||
"Suggested Next Steps:\n\n{}",
|
||||
text.trim()
|
||||
))),
|
||||
Err(e) => Ok(SubmissionResult::error(format!("Suggest failed: {}", e))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle system commands that bypass thread-state checks entirely.
|
||||
pub(super) async fn handle_system_command(
|
||||
&self,
|
||||
command: &str,
|
||||
args: &[String],
|
||||
) -> Result<SubmissionResult, Error> {
|
||||
match command {
|
||||
"help" => Ok(SubmissionResult::response(concat!(
|
||||
"System:\n",
|
||||
" /help Show this help\n",
|
||||
" /model [name] Show or switch the active model\n",
|
||||
" /version Show version info\n",
|
||||
" /tools List available tools\n",
|
||||
" /debug Toggle debug mode\n",
|
||||
" /ping Connectivity check\n",
|
||||
"\n",
|
||||
"Jobs:\n",
|
||||
" /job <desc> Create a new job\n",
|
||||
" /status [id] Check job status\n",
|
||||
" /cancel <id> Cancel a job\n",
|
||||
" /list List all jobs\n",
|
||||
"\n",
|
||||
"Session:\n",
|
||||
" /undo Undo last turn\n",
|
||||
" /redo Redo undone turn\n",
|
||||
" /compact Compress context window\n",
|
||||
" /clear Clear current thread\n",
|
||||
" /interrupt Stop current operation\n",
|
||||
" /new New conversation thread\n",
|
||||
" /thread <id> Switch to thread\n",
|
||||
" /resume <id> Resume from checkpoint\n",
|
||||
"\n",
|
||||
"Agent:\n",
|
||||
" /heartbeat Run heartbeat check\n",
|
||||
" /summarize Summarize current thread\n",
|
||||
" /suggest Suggest next steps\n",
|
||||
"\n",
|
||||
" /quit Exit",
|
||||
))),
|
||||
|
||||
"ping" => Ok(SubmissionResult::response("pong!")),
|
||||
|
||||
"version" => Ok(SubmissionResult::response(format!(
|
||||
"{} v{}",
|
||||
env!("CARGO_PKG_NAME"),
|
||||
env!("CARGO_PKG_VERSION")
|
||||
))),
|
||||
|
||||
"tools" => {
|
||||
let tools = self.tools().list().await;
|
||||
Ok(SubmissionResult::response(format!(
|
||||
"Available tools: {}",
|
||||
tools.join(", ")
|
||||
)))
|
||||
}
|
||||
|
||||
"debug" => {
|
||||
// Debug toggle is handled client-side in the REPL.
|
||||
// For non-REPL channels, just acknowledge.
|
||||
Ok(SubmissionResult::ok_with_message(
|
||||
"Debug toggle is handled by your client.",
|
||||
))
|
||||
}
|
||||
|
||||
"model" => {
|
||||
let current = self.llm().active_model_name();
|
||||
|
||||
if args.is_empty() {
|
||||
// Show current model and list available models
|
||||
let mut out = format!("Active model: {}\n", current);
|
||||
match self.llm().list_models().await {
|
||||
Ok(models) if !models.is_empty() => {
|
||||
out.push_str("\nAvailable models:\n");
|
||||
for m in &models {
|
||||
let marker = if *m == current { " (active)" } else { "" };
|
||||
out.push_str(&format!(" {}{}\n", m, marker));
|
||||
}
|
||||
out.push_str("\nUse /model <name> to switch.");
|
||||
}
|
||||
Ok(_) => {
|
||||
out.push_str(
|
||||
"\nCould not fetch model list. Use /model <name> to switch.",
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
out.push_str(&format!(
|
||||
"\nCould not fetch models: {}. Use /model <name> to switch.",
|
||||
e
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(SubmissionResult::response(out))
|
||||
} else {
|
||||
let requested = &args[0];
|
||||
|
||||
// Validate the model exists
|
||||
match self.llm().list_models().await {
|
||||
Ok(models) if !models.is_empty() => {
|
||||
if !models.iter().any(|m| m == requested) {
|
||||
return Ok(SubmissionResult::error(format!(
|
||||
"Unknown model: {}. Available models:\n {}",
|
||||
requested,
|
||||
models.join("\n ")
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
// Empty model list, can't validate but try anyway
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Could not fetch model list for validation: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
match self.llm().set_model(requested) {
|
||||
Ok(()) => Ok(SubmissionResult::response(format!(
|
||||
"Switched model to: {}",
|
||||
requested
|
||||
))),
|
||||
Err(e) => Ok(SubmissionResult::error(format!(
|
||||
"Failed to switch model: {}",
|
||||
e
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ => Ok(SubmissionResult::error(format!(
|
||||
"Unknown command: {}. Try /help",
|
||||
command
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle legacy command routing from the Router (job commands that go through
|
||||
/// process_user_input -> router -> handle_job_or_command -> here).
|
||||
pub(super) async fn handle_command(
|
||||
&self,
|
||||
command: &str,
|
||||
args: &[String],
|
||||
) -> Result<Option<String>, Error> {
|
||||
// System commands are now handled directly via Submission::SystemCommand,
|
||||
// but the router may still send us unknown /commands.
|
||||
match self.handle_system_command(command, args).await? {
|
||||
SubmissionResult::Response { content } => Ok(Some(content)),
|
||||
SubmissionResult::Ok { message } => Ok(message),
|
||||
SubmissionResult::Error { message } => Ok(Some(format!("Error: {}", message))),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-7
@@ -12,7 +12,8 @@ use chrono::Utc;
|
||||
use crate::agent::context_monitor::{CompactionStrategy, ContextBreakdown};
|
||||
use crate::agent::session::Thread;
|
||||
use crate::error::Error;
|
||||
use crate::llm::{ChatMessage, CompletionRequest, LlmProvider};
|
||||
use crate::llm::{ChatMessage, CompletionRequest, LlmProvider, Reasoning};
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
/// Result of a compaction operation.
|
||||
@@ -33,12 +34,13 @@ pub struct CompactionResult {
|
||||
/// Compacts conversation context to stay within limits.
|
||||
pub struct ContextCompactor {
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
}
|
||||
|
||||
impl ContextCompactor {
|
||||
/// Create a new context compactor.
|
||||
pub fn new(llm: Arc<dyn LlmProvider>) -> Self {
|
||||
Self { llm }
|
||||
pub fn new(llm: Arc<dyn LlmProvider>, safety: Arc<SafetyLayer>) -> Self {
|
||||
Self { llm, safety }
|
||||
}
|
||||
|
||||
/// Compact a thread's context using the given strategy.
|
||||
@@ -105,7 +107,16 @@ impl ContextCompactor {
|
||||
|
||||
// Write to workspace if available
|
||||
let summary_written = if let Some(ws) = workspace {
|
||||
self.write_summary_to_workspace(ws, &summary).await.is_ok()
|
||||
match self.write_summary_to_workspace(ws, &summary).await {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Compaction summary write failed (turns will still be truncated): {}",
|
||||
e
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
@@ -157,7 +168,16 @@ impl ContextCompactor {
|
||||
let content = format_turns_for_storage(old_turns);
|
||||
|
||||
// Write to workspace
|
||||
let written = self.write_context_to_workspace(ws, &content).await.is_ok();
|
||||
let written = match self.write_context_to_workspace(ws, &content).await {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Compaction context write failed (turns will still be truncated): {}",
|
||||
e
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
// Truncate
|
||||
thread.truncate_turns(keep_recent);
|
||||
@@ -213,8 +233,9 @@ Be brief but capture all important details. Use bullet points."#,
|
||||
.with_max_tokens(1024)
|
||||
.with_temperature(0.3);
|
||||
|
||||
let response = self.llm.complete(request).await?;
|
||||
Ok(response.content)
|
||||
let reasoning = Reasoning::new(self.llm.clone(), self.safety.clone());
|
||||
let (text, _) = reasoning.complete(request).await?;
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// Write a summary to the workspace daily log.
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
//! Cost enforcement guardrails for the agent.
|
||||
//!
|
||||
//! Tracks LLM spending and action rates, enforcing configurable limits
|
||||
//! to prevent runaway agents from burning through API credits. Especially
|
||||
//! important for daemon/heartbeat modes where the agent acts autonomously.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use rust_decimal::Decimal;
|
||||
use rust_decimal_macros::dec;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::llm::costs;
|
||||
|
||||
/// Configuration for cost guardrails.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CostGuardConfig {
|
||||
/// Maximum spend per day in cents (e.g. 10000 = $100). None = unlimited.
|
||||
pub max_cost_per_day_cents: Option<u64>,
|
||||
/// Maximum LLM calls per hour. None = unlimited.
|
||||
pub max_actions_per_hour: Option<u64>,
|
||||
}
|
||||
|
||||
/// Error returned when a cost limit is exceeded.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CostLimitExceeded {
|
||||
/// Daily spending cap reached.
|
||||
DailyBudget { spent_cents: u64, limit_cents: u64 },
|
||||
/// Hourly action rate limit reached.
|
||||
HourlyRate { actions: u64, limit: u64 },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CostLimitExceeded {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::DailyBudget {
|
||||
spent_cents,
|
||||
limit_cents,
|
||||
} => write!(
|
||||
f,
|
||||
"Daily cost limit exceeded: spent ${:.2} of ${:.2} allowed",
|
||||
*spent_cents as f64 / 100.0,
|
||||
*limit_cents as f64 / 100.0
|
||||
),
|
||||
Self::HourlyRate { actions, limit } => write!(
|
||||
f,
|
||||
"Hourly action limit exceeded: {} actions of {} allowed per hour",
|
||||
actions, limit
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-model token usage counters.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ModelTokens {
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cost: Decimal,
|
||||
}
|
||||
|
||||
/// Tracks costs and action rates, enforcing configurable limits.
|
||||
///
|
||||
/// Thread-safe; designed to be shared via `Arc<CostGuard>`.
|
||||
pub struct CostGuard {
|
||||
config: CostGuardConfig,
|
||||
|
||||
/// Running cost total for the current day (in USD, not cents).
|
||||
daily_cost: Mutex<DailyCost>,
|
||||
|
||||
/// Sliding window of action timestamps for rate limiting.
|
||||
action_window: Mutex<VecDeque<Instant>>,
|
||||
|
||||
/// Flag set when daily budget is exceeded to short-circuit checks.
|
||||
budget_exceeded: AtomicBool,
|
||||
|
||||
/// Per-model token usage since startup.
|
||||
model_tokens: Mutex<HashMap<String, ModelTokens>>,
|
||||
}
|
||||
|
||||
struct DailyCost {
|
||||
total: Decimal,
|
||||
/// Day boundary (midnight UTC) for resetting the counter.
|
||||
reset_date: chrono::NaiveDate,
|
||||
}
|
||||
|
||||
impl CostGuard {
|
||||
pub fn new(config: CostGuardConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
daily_cost: Mutex::new(DailyCost {
|
||||
total: Decimal::ZERO,
|
||||
reset_date: chrono::Utc::now().date_naive(),
|
||||
}),
|
||||
action_window: Mutex::new(VecDeque::new()),
|
||||
budget_exceeded: AtomicBool::new(false),
|
||||
model_tokens: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the next action is allowed under the configured limits.
|
||||
///
|
||||
/// Call this BEFORE making an LLM call. Does NOT record the action yet,
|
||||
/// call `record_action` after the action completes.
|
||||
pub async fn check_allowed(&self) -> Result<(), CostLimitExceeded> {
|
||||
// Fast path: if budget already blown, skip the lock
|
||||
if self.budget_exceeded.load(Ordering::Relaxed) {
|
||||
let daily = self.daily_cost.lock().await;
|
||||
let spent_cents = to_cents(daily.total);
|
||||
return Err(CostLimitExceeded::DailyBudget {
|
||||
spent_cents,
|
||||
limit_cents: self.config.max_cost_per_day_cents.unwrap_or(0),
|
||||
});
|
||||
}
|
||||
|
||||
// Check daily budget
|
||||
if let Some(limit_cents) = self.config.max_cost_per_day_cents {
|
||||
let daily = self.daily_cost.lock().await;
|
||||
let spent_cents = to_cents(daily.total);
|
||||
if spent_cents >= limit_cents {
|
||||
self.budget_exceeded.store(true, Ordering::Relaxed);
|
||||
return Err(CostLimitExceeded::DailyBudget {
|
||||
spent_cents,
|
||||
limit_cents,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check hourly rate
|
||||
if let Some(limit) = self.config.max_actions_per_hour {
|
||||
let mut window = self.action_window.lock().await;
|
||||
let cutoff = Instant::now() - std::time::Duration::from_secs(3600);
|
||||
// Drain expired entries
|
||||
while window.front().is_some_and(|t| *t < cutoff) {
|
||||
window.pop_front();
|
||||
}
|
||||
let count = window.len() as u64;
|
||||
if count >= limit {
|
||||
return Err(CostLimitExceeded::HourlyRate {
|
||||
actions: count,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a completed LLM action: its token costs and the action timestamp.
|
||||
///
|
||||
/// Call this AFTER an LLM call completes so that costs are tracked.
|
||||
///
|
||||
/// When `cost_per_token` is `Some`, those rates are used directly (provider-
|
||||
/// sourced pricing). When `None`, falls back to the static `costs::model_cost`
|
||||
/// lookup table, then `costs::default_cost`.
|
||||
pub async fn record_llm_call(
|
||||
&self,
|
||||
model: &str,
|
||||
input_tokens: u32,
|
||||
output_tokens: u32,
|
||||
cost_per_token: Option<(Decimal, Decimal)>,
|
||||
) -> Decimal {
|
||||
let (input_rate, output_rate) = cost_per_token
|
||||
.unwrap_or_else(|| costs::model_cost(model).unwrap_or_else(costs::default_cost));
|
||||
let cost =
|
||||
input_rate * Decimal::from(input_tokens) + output_rate * Decimal::from(output_tokens);
|
||||
|
||||
// Update daily cost (reset if new day)
|
||||
{
|
||||
let mut daily = self.daily_cost.lock().await;
|
||||
let today = chrono::Utc::now().date_naive();
|
||||
if today != daily.reset_date {
|
||||
daily.total = Decimal::ZERO;
|
||||
daily.reset_date = today;
|
||||
self.budget_exceeded.store(false, Ordering::Relaxed);
|
||||
tracing::info!("Cost guard: daily counter reset for {}", today);
|
||||
}
|
||||
daily.total += cost;
|
||||
|
||||
// Check if we just crossed the threshold
|
||||
if let Some(limit_cents) = self.config.max_cost_per_day_cents {
|
||||
let spent_cents = to_cents(daily.total);
|
||||
if spent_cents >= limit_cents {
|
||||
self.budget_exceeded.store(true, Ordering::Relaxed);
|
||||
tracing::warn!(
|
||||
"Daily cost limit reached: ${:.2} of ${:.2}",
|
||||
daily.total,
|
||||
Decimal::from(limit_cents) / dec!(100)
|
||||
);
|
||||
}
|
||||
// Warn at 80% threshold
|
||||
let warn_threshold = limit_cents * 80 / 100;
|
||||
if spent_cents >= warn_threshold && spent_cents < limit_cents {
|
||||
tracing::warn!(
|
||||
"Approaching daily cost limit: ${:.2} of ${:.2} ({}%)",
|
||||
daily.total,
|
||||
Decimal::from(limit_cents) / dec!(100),
|
||||
spent_cents * 100 / limit_cents
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record action in sliding window
|
||||
{
|
||||
let mut window = self.action_window.lock().await;
|
||||
window.push_back(Instant::now());
|
||||
}
|
||||
|
||||
// Track per-model token usage
|
||||
{
|
||||
let mut tokens = self.model_tokens.lock().await;
|
||||
let entry = tokens.entry(model.to_string()).or_default();
|
||||
entry.input_tokens += u64::from(input_tokens);
|
||||
entry.output_tokens += u64::from(output_tokens);
|
||||
entry.cost += cost;
|
||||
}
|
||||
|
||||
cost
|
||||
}
|
||||
|
||||
/// Current daily spend in USD (as Decimal).
|
||||
pub async fn daily_spend(&self) -> Decimal {
|
||||
let daily = self.daily_cost.lock().await;
|
||||
let today = chrono::Utc::now().date_naive();
|
||||
if today != daily.reset_date {
|
||||
Decimal::ZERO
|
||||
} else {
|
||||
daily.total
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of actions in the current hourly window.
|
||||
pub async fn actions_this_hour(&self) -> u64 {
|
||||
let mut window = self.action_window.lock().await;
|
||||
let cutoff = Instant::now() - std::time::Duration::from_secs(3600);
|
||||
while window.front().is_some_and(|t| *t < cutoff) {
|
||||
window.pop_front();
|
||||
}
|
||||
window.len() as u64
|
||||
}
|
||||
|
||||
/// Per-model token usage since startup.
|
||||
pub async fn model_usage(&self) -> HashMap<String, ModelTokens> {
|
||||
self.model_tokens.lock().await.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a Decimal USD amount to whole cents (truncated).
|
||||
fn to_cents(usd: Decimal) -> u64 {
|
||||
let cents = (usd * dec!(100)).trunc();
|
||||
cents.to_string().parse::<u64>().unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unlimited_allows_everything() {
|
||||
let guard = CostGuard::new(CostGuardConfig::default());
|
||||
|
||||
// No limits set, should always be allowed
|
||||
assert!(guard.check_allowed().await.is_ok());
|
||||
|
||||
// Record a big call, still allowed
|
||||
guard
|
||||
.record_llm_call("gpt-4o", 100_000, 100_000, None)
|
||||
.await;
|
||||
assert!(guard.check_allowed().await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_daily_budget_enforcement() {
|
||||
let guard = CostGuard::new(CostGuardConfig {
|
||||
max_cost_per_day_cents: Some(1), // $0.01 limit
|
||||
max_actions_per_hour: None,
|
||||
});
|
||||
|
||||
// First call allowed
|
||||
assert!(guard.check_allowed().await.is_ok());
|
||||
|
||||
// Record a call that costs more than $0.01
|
||||
// gpt-4o: input=$0.0000025/tok, output=$0.00001/tok
|
||||
// 10000 input + 10000 output = $0.025 + $0.10 = $0.125
|
||||
guard.record_llm_call("gpt-4o", 10_000, 10_000, None).await;
|
||||
|
||||
// Now should be blocked
|
||||
let result = guard.check_allowed().await;
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
CostLimitExceeded::DailyBudget { limit_cents, .. } => {
|
||||
assert_eq!(limit_cents, 1);
|
||||
}
|
||||
other => panic!("Expected DailyBudget, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_hourly_rate_enforcement() {
|
||||
let guard = CostGuard::new(CostGuardConfig {
|
||||
max_cost_per_day_cents: None,
|
||||
max_actions_per_hour: Some(3),
|
||||
});
|
||||
|
||||
// First 3 actions allowed
|
||||
for _ in 0..3 {
|
||||
assert!(guard.check_allowed().await.is_ok());
|
||||
guard.record_llm_call("gpt-4o", 10, 10, None).await;
|
||||
}
|
||||
|
||||
// 4th should be blocked
|
||||
let result = guard.check_allowed().await;
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
CostLimitExceeded::HourlyRate { actions, limit } => {
|
||||
assert_eq!(actions, 3);
|
||||
assert_eq!(limit, 3);
|
||||
}
|
||||
other => panic!("Expected HourlyRate, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_daily_spend_tracking() {
|
||||
let guard = CostGuard::new(CostGuardConfig::default());
|
||||
|
||||
assert_eq!(guard.daily_spend().await, Decimal::ZERO);
|
||||
|
||||
let cost = guard.record_llm_call("gpt-4o", 1000, 500, None).await;
|
||||
assert!(cost > Decimal::ZERO);
|
||||
assert_eq!(guard.daily_spend().await, cost);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_actions_this_hour() {
|
||||
let guard = CostGuard::new(CostGuardConfig::default());
|
||||
|
||||
assert_eq!(guard.actions_this_hour().await, 0);
|
||||
|
||||
guard.record_llm_call("gpt-4o", 10, 10, None).await;
|
||||
guard.record_llm_call("gpt-4o", 10, 10, None).await;
|
||||
|
||||
assert_eq!(guard.actions_this_hour().await, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_cents() {
|
||||
assert_eq!(to_cents(dec!(1.50)), 150);
|
||||
assert_eq!(to_cents(dec!(0.01)), 1);
|
||||
assert_eq!(to_cents(Decimal::ZERO), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_limit_display() {
|
||||
let budget = CostLimitExceeded::DailyBudget {
|
||||
spent_cents: 1050,
|
||||
limit_cents: 1000,
|
||||
};
|
||||
assert!(budget.to_string().contains("$10.50"));
|
||||
assert!(budget.to_string().contains("$10.00"));
|
||||
|
||||
let rate = CostLimitExceeded::HourlyRate {
|
||||
actions: 101,
|
||||
limit: 100,
|
||||
};
|
||||
assert!(rate.to_string().contains("101 actions"));
|
||||
assert!(rate.to_string().contains("100 allowed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_model_usage_per_model_tracking() {
|
||||
let guard = CostGuard::new(CostGuardConfig::default());
|
||||
|
||||
// Initially empty
|
||||
assert!(guard.model_usage().await.is_empty());
|
||||
|
||||
// Record calls for two different models
|
||||
guard.record_llm_call("gpt-4o", 1000, 500, None).await;
|
||||
guard.record_llm_call("gpt-4o", 2000, 1000, None).await;
|
||||
guard
|
||||
.record_llm_call("claude-3-5-sonnet-20241022", 500, 200, None)
|
||||
.await;
|
||||
|
||||
let usage = guard.model_usage().await;
|
||||
assert_eq!(usage.len(), 2);
|
||||
|
||||
let gpt = usage.get("gpt-4o").expect("gpt-4o should be tracked");
|
||||
assert_eq!(gpt.input_tokens, 3000);
|
||||
assert_eq!(gpt.output_tokens, 1500);
|
||||
assert!(gpt.cost > Decimal::ZERO);
|
||||
|
||||
let claude = usage
|
||||
.get("claude-3-5-sonnet-20241022")
|
||||
.expect("claude should be tracked");
|
||||
assert_eq!(claude.input_tokens, 500);
|
||||
assert_eq!(claude.output_tokens, 200);
|
||||
assert!(claude.cost > Decimal::ZERO);
|
||||
|
||||
// Costs should differ since models have different pricing
|
||||
assert_ne!(gpt.cost, claude.cost);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+200
-8
@@ -29,8 +29,10 @@ use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::channels::OutgoingResponse;
|
||||
use crate::llm::{ChatMessage, CompletionRequest, LlmProvider};
|
||||
use crate::llm::{ChatMessage, CompletionRequest, LlmProvider, Reasoning};
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::workspace::Workspace;
|
||||
use crate::workspace::hygiene::HygieneConfig;
|
||||
|
||||
/// Configuration for the heartbeat runner.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -96,8 +98,10 @@ pub enum HeartbeatResult {
|
||||
/// Heartbeat runner for proactive periodic execution.
|
||||
pub struct HeartbeatRunner {
|
||||
config: HeartbeatConfig,
|
||||
hygiene_config: HygieneConfig,
|
||||
workspace: Arc<Workspace>,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
|
||||
consecutive_failures: u32,
|
||||
}
|
||||
@@ -106,13 +110,17 @@ impl HeartbeatRunner {
|
||||
/// Create a new heartbeat runner.
|
||||
pub fn new(
|
||||
config: HeartbeatConfig,
|
||||
hygiene_config: HygieneConfig,
|
||||
workspace: Arc<Workspace>,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
hygiene_config,
|
||||
workspace,
|
||||
llm,
|
||||
safety,
|
||||
response_tx: None,
|
||||
consecutive_failures: 0,
|
||||
}
|
||||
@@ -145,6 +153,22 @@ impl HeartbeatRunner {
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
// Run memory hygiene in the background so it never delays the
|
||||
// heartbeat checklist. Failures are logged inside run_if_due.
|
||||
let hygiene_workspace = Arc::clone(&self.workspace);
|
||||
let hygiene_config = self.hygiene_config.clone();
|
||||
tokio::spawn(async move {
|
||||
let report =
|
||||
crate::workspace::hygiene::run_if_due(&hygiene_workspace, &hygiene_config)
|
||||
.await;
|
||||
if report.had_work() {
|
||||
tracing::info!(
|
||||
daily_logs_deleted = report.daily_logs_deleted,
|
||||
"heartbeat: memory hygiene deleted stale documents"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
match self.check_heartbeat().await {
|
||||
HeartbeatResult::Ok => {
|
||||
tracing::debug!("Heartbeat OK");
|
||||
@@ -178,7 +202,7 @@ impl HeartbeatRunner {
|
||||
pub async fn check_heartbeat(&self) -> HeartbeatResult {
|
||||
// Get the heartbeat checklist
|
||||
let checklist = match self.workspace.heartbeat_checklist().await {
|
||||
Ok(Some(content)) if !content.trim().is_empty() => content,
|
||||
Ok(Some(content)) if !is_effectively_empty(&content) => content,
|
||||
Ok(_) => return HeartbeatResult::Skipped,
|
||||
Err(e) => return HeartbeatResult::Failed(format!("Failed to read checklist: {}", e)),
|
||||
};
|
||||
@@ -217,16 +241,40 @@ impl HeartbeatRunner {
|
||||
]
|
||||
};
|
||||
|
||||
let request = CompletionRequest::new(messages)
|
||||
.with_max_tokens(1024)
|
||||
.with_temperature(0.3); // Lower temperature for more focused responses
|
||||
// Use the model's context_length to set max_tokens. The API returns
|
||||
// the total context window; we cap output at half of that (the rest is
|
||||
// the prompt) with a floor of 4096.
|
||||
let max_tokens = match self.llm.model_metadata().await {
|
||||
Ok(meta) => {
|
||||
let from_api = meta.context_length.map(|ctx| ctx / 2).unwrap_or(4096);
|
||||
from_api.max(4096)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Could not fetch model metadata, using default max_tokens: {}",
|
||||
e
|
||||
);
|
||||
4096
|
||||
}
|
||||
};
|
||||
|
||||
let response = match self.llm.complete(request).await {
|
||||
let request = CompletionRequest::new(messages)
|
||||
.with_max_tokens(max_tokens)
|
||||
.with_temperature(0.3);
|
||||
|
||||
let reasoning = Reasoning::new(self.llm.clone(), self.safety.clone());
|
||||
let (content, _usage) = match reasoning.complete(request).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HeartbeatResult::Failed(format!("LLM call failed: {}", e)),
|
||||
};
|
||||
|
||||
let content = response.content.trim();
|
||||
let content = content.trim();
|
||||
|
||||
// Guard against empty content. Reasoning models (e.g. GLM-4.7) may
|
||||
// burn all output tokens on chain-of-thought and return content: null.
|
||||
if content.is_empty() {
|
||||
return HeartbeatResult::Failed("LLM returned empty content.".to_string());
|
||||
}
|
||||
|
||||
// Check if nothing needs attention
|
||||
if content == "HEARTBEAT_OK" || content.contains("HEARTBEAT_OK") {
|
||||
@@ -257,16 +305,57 @@ impl HeartbeatRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if heartbeat content is effectively empty.
|
||||
///
|
||||
/// Returns true if the content contains only:
|
||||
/// - Whitespace
|
||||
/// - Markdown headers (lines starting with #)
|
||||
/// - HTML comments (`<!-- ... -->`)
|
||||
/// - Empty list items (`- [ ]`, `- [x]`, `-`, `*`)
|
||||
///
|
||||
/// This skips the LLM call when the user hasn't added real tasks yet,
|
||||
/// saving API costs.
|
||||
fn is_effectively_empty(content: &str) -> bool {
|
||||
let without_comments = strip_html_comments(content);
|
||||
|
||||
without_comments.lines().all(|line| {
|
||||
let trimmed = line.trim();
|
||||
trimmed.is_empty()
|
||||
|| trimmed.starts_with('#')
|
||||
|| trimmed == "- [ ]"
|
||||
|| trimmed == "- [x]"
|
||||
|| trimmed == "-"
|
||||
|| trimmed == "*"
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove HTML comments from content.
|
||||
fn strip_html_comments(content: &str) -> String {
|
||||
let mut result = String::with_capacity(content.len());
|
||||
let mut rest = content;
|
||||
while let Some(start) = rest.find("<!--") {
|
||||
result.push_str(&rest[..start]);
|
||||
match rest[start..].find("-->") {
|
||||
Some(end) => rest = &rest[start + end + 3..],
|
||||
None => return result, // unclosed comment, treat rest as comment
|
||||
}
|
||||
}
|
||||
result.push_str(rest);
|
||||
result
|
||||
}
|
||||
|
||||
/// Spawn the heartbeat runner as a background task.
|
||||
///
|
||||
/// Returns a handle that can be used to stop the runner.
|
||||
pub fn spawn_heartbeat(
|
||||
config: HeartbeatConfig,
|
||||
hygiene_config: HygieneConfig,
|
||||
workspace: Arc<Workspace>,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
let mut runner = HeartbeatRunner::new(config, workspace, llm);
|
||||
let mut runner = HeartbeatRunner::new(config, hygiene_config, workspace, llm, safety);
|
||||
if let Some(tx) = response_tx {
|
||||
runner = runner.with_response_channel(tx);
|
||||
}
|
||||
@@ -301,4 +390,107 @@ mod tests {
|
||||
let disabled = HeartbeatConfig::default().disabled();
|
||||
assert!(!disabled.enabled);
|
||||
}
|
||||
|
||||
// ==================== strip_html_comments ====================
|
||||
|
||||
#[test]
|
||||
fn test_strip_html_comments_no_comments() {
|
||||
assert_eq!(strip_html_comments("hello world"), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_html_comments_single() {
|
||||
assert_eq!(
|
||||
strip_html_comments("before<!-- gone -->after"),
|
||||
"beforeafter"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_html_comments_multiple() {
|
||||
let input = "a<!-- 1 -->b<!-- 2 -->c";
|
||||
assert_eq!(strip_html_comments(input), "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_html_comments_multiline() {
|
||||
let input = "# Title\n<!-- multi\nline\ncomment -->\nreal content";
|
||||
assert_eq!(strip_html_comments(input), "# Title\n\nreal content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_html_comments_unclosed() {
|
||||
let input = "before<!-- never closed";
|
||||
assert_eq!(strip_html_comments(input), "before");
|
||||
}
|
||||
|
||||
// ==================== is_effectively_empty ====================
|
||||
|
||||
#[test]
|
||||
fn test_effectively_empty_empty_string() {
|
||||
assert!(is_effectively_empty(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effectively_empty_whitespace() {
|
||||
assert!(is_effectively_empty(" \n\n \n "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effectively_empty_headers_only() {
|
||||
assert!(is_effectively_empty("# Title\n## Subtitle\n### Section"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effectively_empty_html_comments_only() {
|
||||
assert!(is_effectively_empty("<!-- this is a comment -->"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effectively_empty_empty_checkboxes() {
|
||||
assert!(is_effectively_empty("# Checklist\n- [ ]\n- [x]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effectively_empty_bare_list_markers() {
|
||||
assert!(is_effectively_empty("-\n*\n-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effectively_empty_seeded_template() {
|
||||
let template = "\
|
||||
# Heartbeat Checklist
|
||||
|
||||
<!-- Keep this file empty to skip heartbeat API calls.
|
||||
Add tasks below when you want the agent to check something periodically.
|
||||
|
||||
Example:
|
||||
- [ ] Check for unread emails needing a reply
|
||||
- [ ] Review today's calendar for upcoming meetings
|
||||
- [ ] Check CI build status for main branch
|
||||
-->";
|
||||
assert!(is_effectively_empty(template));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effectively_empty_real_checklist() {
|
||||
let content = "\
|
||||
# Heartbeat Checklist
|
||||
|
||||
- [ ] Check for unread emails needing a reply
|
||||
- [ ] Review today's calendar for upcoming meetings";
|
||||
assert!(!is_effectively_empty(content));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effectively_empty_mixed_real_and_headers() {
|
||||
let content = "# Title\n\nDo something important";
|
||||
assert!(!is_effectively_empty(content));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effectively_empty_comment_plus_real_content() {
|
||||
let content = "<!-- comment -->\nActual task here";
|
||||
assert!(!is_effectively_empty(content));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Background job monitor that forwards Claude Code output to the main agent loop.
|
||||
//!
|
||||
//! When the main agent kicks off a sandbox job (especially Claude Code), this
|
||||
//! monitor subscribes to the broadcast event channel and injects relevant
|
||||
//! assistant messages back into the channel manager's stream. This lets the
|
||||
//! main agent see what the sub-agent is producing and surface it to the user.
|
||||
//!
|
||||
//! ```text
|
||||
//! Container ──NDJSON──► Orchestrator ──broadcast──► JobMonitor
|
||||
//! │
|
||||
//! inject_tx (mpsc)
|
||||
//! │
|
||||
//! ▼
|
||||
//! Agent Loop
|
||||
//! ```
|
||||
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use tokio::task::JoinHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::channels::IncomingMessage;
|
||||
use crate::channels::web::types::SseEvent;
|
||||
|
||||
/// Spawn a background task that watches for events from a specific job and
|
||||
/// injects assistant messages into the agent loop.
|
||||
///
|
||||
/// The monitor forwards:
|
||||
/// - `SseEvent::JobMessage` (assistant role): injected as incoming messages so
|
||||
/// the main agent can read and relay to the user.
|
||||
/// - `SseEvent::JobResult`: injected as a completion notice, then the task exits.
|
||||
///
|
||||
/// Tool use/result and status events are intentionally skipped (too noisy for
|
||||
/// the main agent's context window).
|
||||
pub fn spawn_job_monitor(
|
||||
job_id: Uuid,
|
||||
mut event_rx: broadcast::Receiver<(Uuid, SseEvent)>,
|
||||
inject_tx: mpsc::Sender<IncomingMessage>,
|
||||
) -> JoinHandle<()> {
|
||||
let short_id = job_id.to_string()[..8].to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
tracing::info!(job_id = %short_id, "Job monitor started successfully");
|
||||
|
||||
loop {
|
||||
match event_rx.recv().await {
|
||||
Ok((ev_job_id, event)) => {
|
||||
if ev_job_id != job_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
match event {
|
||||
SseEvent::JobMessage { role, content, .. } if role == "assistant" => {
|
||||
let msg = IncomingMessage::new(
|
||||
"job_monitor",
|
||||
"system",
|
||||
format!("[Job {}] Claude Code: {}", short_id, content),
|
||||
);
|
||||
if inject_tx.send(msg).await.is_err() {
|
||||
tracing::debug!(
|
||||
job_id = %short_id,
|
||||
"Inject channel closed, stopping monitor"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
SseEvent::JobResult { status, .. } => {
|
||||
let msg = IncomingMessage::new(
|
||||
"job_monitor",
|
||||
"system",
|
||||
format!(
|
||||
"[Job {}] Container finished (status: {})",
|
||||
short_id, status
|
||||
),
|
||||
);
|
||||
let _ = inject_tx.send(msg).await;
|
||||
tracing::debug!(
|
||||
job_id = %short_id,
|
||||
status = %status,
|
||||
"Job monitor exiting (job finished)"
|
||||
);
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
// Skip tool_use, tool_result, status events
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::warn!(
|
||||
job_id = %short_id,
|
||||
skipped = n,
|
||||
"Job monitor lagged, some events were dropped"
|
||||
);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
tracing::debug!(
|
||||
job_id = %short_id,
|
||||
"Broadcast channel closed, stopping monitor"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_monitor_forwards_assistant_messages() {
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
let _handle = spawn_job_monitor(job_id, event_tx.subscribe(), inject_tx);
|
||||
|
||||
// Send an assistant message
|
||||
event_tx
|
||||
.send((
|
||||
job_id,
|
||||
SseEvent::JobMessage {
|
||||
job_id: job_id.to_string(),
|
||||
role: "assistant".to_string(),
|
||||
content: "I found a bug".to_string(),
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let msg = tokio::time::timeout(std::time::Duration::from_secs(1), inject_rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(msg.channel, "job_monitor");
|
||||
assert_eq!(msg.user_id, "system");
|
||||
assert!(msg.content.contains("I found a bug"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_monitor_ignores_other_jobs() {
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
let other_job_id = Uuid::new_v4();
|
||||
let _handle = spawn_job_monitor(job_id, event_tx.subscribe(), inject_tx);
|
||||
|
||||
// Send a message for a different job
|
||||
event_tx
|
||||
.send((
|
||||
other_job_id,
|
||||
SseEvent::JobMessage {
|
||||
job_id: other_job_id.to_string(),
|
||||
role: "assistant".to_string(),
|
||||
content: "wrong job".to_string(),
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
// Should not receive anything
|
||||
let result =
|
||||
tokio::time::timeout(std::time::Duration::from_millis(100), inject_rx.recv()).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"should have timed out, no message expected"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_monitor_exits_on_job_result() {
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
let handle = spawn_job_monitor(job_id, event_tx.subscribe(), inject_tx);
|
||||
|
||||
// Send a completion event
|
||||
event_tx
|
||||
.send((
|
||||
job_id,
|
||||
SseEvent::JobResult {
|
||||
job_id: job_id.to_string(),
|
||||
status: "completed".to_string(),
|
||||
session_id: None,
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
// Should receive the completion message
|
||||
let msg = tokio::time::timeout(std::time::Duration::from_secs(1), inject_rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(msg.content.contains("finished"));
|
||||
|
||||
// The monitor task should exit
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), handle)
|
||||
.await
|
||||
.expect("monitor should have exited")
|
||||
.expect("monitor task should not panic");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_monitor_skips_tool_events() {
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
let _handle = spawn_job_monitor(job_id, event_tx.subscribe(), inject_tx);
|
||||
|
||||
// Send tool use event (should be skipped)
|
||||
event_tx
|
||||
.send((
|
||||
job_id,
|
||||
SseEvent::JobToolUse {
|
||||
job_id: job_id.to_string(),
|
||||
tool_name: "shell".to_string(),
|
||||
input: serde_json::json!({"command": "ls"}),
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
// Send user message (should be skipped)
|
||||
event_tx
|
||||
.send((
|
||||
job_id,
|
||||
SseEvent::JobMessage {
|
||||
job_id: job_id.to_string(),
|
||||
role: "user".to_string(),
|
||||
content: "user prompt".to_string(),
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
// Should not receive anything for tool events or user messages
|
||||
let result =
|
||||
tokio::time::timeout(std::time::Duration::from_millis(100), inject_rx.recv()).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"should have timed out, no message expected"
|
||||
);
|
||||
}
|
||||
}
|
||||
+13
-2
@@ -6,33 +6,44 @@
|
||||
//! - Tool invocation with safety
|
||||
//! - Self-repair for stuck jobs
|
||||
//! - Proactive heartbeat execution
|
||||
//! - Routine-based scheduled and reactive jobs
|
||||
//! - Turn-based session management with undo
|
||||
//! - Context compaction for long conversations
|
||||
|
||||
mod agent_loop;
|
||||
mod commands;
|
||||
pub mod compaction;
|
||||
pub mod context_monitor;
|
||||
pub mod cost_guard;
|
||||
mod dispatcher;
|
||||
mod heartbeat;
|
||||
pub mod job_monitor;
|
||||
mod router;
|
||||
pub mod routine;
|
||||
pub mod routine_engine;
|
||||
mod scheduler;
|
||||
mod self_repair;
|
||||
pub mod session;
|
||||
mod session_manager;
|
||||
pub mod submission;
|
||||
pub mod task;
|
||||
mod thread_ops;
|
||||
pub mod undo;
|
||||
pub mod worker;
|
||||
|
||||
pub(crate) use agent_loop::truncate_for_preview;
|
||||
pub use agent_loop::{Agent, AgentDeps};
|
||||
pub use compaction::{CompactionResult, ContextCompactor};
|
||||
pub use context_monitor::{CompactionStrategy, ContextBreakdown, ContextMonitor};
|
||||
pub use heartbeat::{HeartbeatConfig, HeartbeatResult, HeartbeatRunner, spawn_heartbeat};
|
||||
pub use router::{MessageIntent, Router};
|
||||
pub use routine::{Routine, RoutineAction, RoutineRun, Trigger};
|
||||
pub use routine_engine::RoutineEngine;
|
||||
pub use scheduler::Scheduler;
|
||||
pub use self_repair::{BrokenTool, RepairResult, RepairTask, SelfRepair, StuckJob};
|
||||
pub use session::{PendingApproval, Session, Thread, ThreadState, Turn, TurnState};
|
||||
pub use session::{PendingApproval, PendingAuth, Session, Thread, ThreadState, Turn, TurnState};
|
||||
pub use session_manager::SessionManager;
|
||||
pub use submission::{Submission, SubmissionParser, SubmissionResult};
|
||||
pub use task::{Task, TaskContext, TaskHandler, TaskOutput, TaskStatus};
|
||||
pub use task::{Task, TaskContext, TaskHandler, TaskOutput};
|
||||
pub use undo::{Checkpoint, UndoManager};
|
||||
pub use worker::{Worker, WorkerDeps};
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
//! Core types for the routines system.
|
||||
//!
|
||||
//! A routine is a named, persistent, user-owned task with a trigger and an action.
|
||||
//! Each routine fires independently when its trigger condition is met, with only
|
||||
//! that routine's prompt and context sent to the LLM.
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌──────────┐ ┌─────────┐ ┌──────────────────┐
|
||||
//! │ Trigger │────▶│ Engine │────▶│ Execution Mode │
|
||||
//! │ cron/event│ │guardrail│ │lightweight│full_job│
|
||||
//! │ webhook │ │ check │ └──────────────────┘
|
||||
//! │ manual │ └─────────┘ │
|
||||
//! └──────────┘ ▼
|
||||
//! ┌──────────────┐
|
||||
//! │ Notify user │
|
||||
//! │ if needed │
|
||||
//! └──────────────┘
|
||||
//! ```
|
||||
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::RoutineError;
|
||||
|
||||
/// A routine is a named, persistent, user-owned task with a trigger and an action.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Routine {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub user_id: String,
|
||||
pub enabled: bool,
|
||||
pub trigger: Trigger,
|
||||
pub action: RoutineAction,
|
||||
pub guardrails: RoutineGuardrails,
|
||||
pub notify: NotifyConfig,
|
||||
|
||||
// Runtime state (DB-managed)
|
||||
pub last_run_at: Option<DateTime<Utc>>,
|
||||
pub next_fire_at: Option<DateTime<Utc>>,
|
||||
pub run_count: u64,
|
||||
pub consecutive_failures: u32,
|
||||
pub state: serde_json::Value,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// When a routine should fire.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Trigger {
|
||||
/// Fire on a cron schedule (e.g. "0 9 * * MON-FRI" or "every 2h").
|
||||
Cron { schedule: String },
|
||||
/// Fire when a channel message matches a pattern.
|
||||
Event {
|
||||
/// Optional channel filter (e.g. "telegram", "slack").
|
||||
channel: Option<String>,
|
||||
/// Regex pattern to match against message content.
|
||||
pattern: String,
|
||||
},
|
||||
/// Fire on incoming webhook POST to /hooks/routine/{id}.
|
||||
Webhook {
|
||||
/// Optional webhook path suffix (defaults to routine id).
|
||||
path: Option<String>,
|
||||
/// Optional shared secret for HMAC validation.
|
||||
secret: Option<String>,
|
||||
},
|
||||
/// Only fires via tool call or CLI.
|
||||
Manual,
|
||||
}
|
||||
|
||||
impl Trigger {
|
||||
/// The string tag stored in the DB trigger_type column.
|
||||
pub fn type_tag(&self) -> &'static str {
|
||||
match self {
|
||||
Trigger::Cron { .. } => "cron",
|
||||
Trigger::Event { .. } => "event",
|
||||
Trigger::Webhook { .. } => "webhook",
|
||||
Trigger::Manual => "manual",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a trigger from its DB representation.
|
||||
pub fn from_db(trigger_type: &str, config: serde_json::Value) -> Result<Self, RoutineError> {
|
||||
match trigger_type {
|
||||
"cron" => {
|
||||
let schedule = config
|
||||
.get("schedule")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| RoutineError::MissingField {
|
||||
context: "cron trigger".into(),
|
||||
field: "schedule".into(),
|
||||
})?
|
||||
.to_string();
|
||||
Ok(Trigger::Cron { schedule })
|
||||
}
|
||||
"event" => {
|
||||
let pattern = config
|
||||
.get("pattern")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| RoutineError::MissingField {
|
||||
context: "event trigger".into(),
|
||||
field: "pattern".into(),
|
||||
})?
|
||||
.to_string();
|
||||
let channel = config
|
||||
.get("channel")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
Ok(Trigger::Event { channel, pattern })
|
||||
}
|
||||
"webhook" => {
|
||||
let path = config
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
let secret = config
|
||||
.get("secret")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
Ok(Trigger::Webhook { path, secret })
|
||||
}
|
||||
"manual" => Ok(Trigger::Manual),
|
||||
other => Err(RoutineError::UnknownTriggerType {
|
||||
trigger_type: other.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize trigger-specific config to JSON for DB storage.
|
||||
pub fn to_config_json(&self) -> serde_json::Value {
|
||||
match self {
|
||||
Trigger::Cron { schedule } => serde_json::json!({ "schedule": schedule }),
|
||||
Trigger::Event { channel, pattern } => serde_json::json!({
|
||||
"pattern": pattern,
|
||||
"channel": channel,
|
||||
}),
|
||||
Trigger::Webhook { path, secret } => serde_json::json!({
|
||||
"path": path,
|
||||
"secret": secret,
|
||||
}),
|
||||
Trigger::Manual => serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What happens when a routine fires.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum RoutineAction {
|
||||
/// Single LLM call, no tools. Cheap and fast.
|
||||
Lightweight {
|
||||
/// The prompt sent to the LLM.
|
||||
prompt: String,
|
||||
/// Workspace paths to load as context (e.g. ["context/priorities.md"]).
|
||||
#[serde(default)]
|
||||
context_paths: Vec<String>,
|
||||
/// Max output tokens (default: 4096).
|
||||
#[serde(default = "default_max_tokens")]
|
||||
max_tokens: u32,
|
||||
},
|
||||
/// Full multi-turn worker job with tool access.
|
||||
FullJob {
|
||||
/// Job title for the scheduler.
|
||||
title: String,
|
||||
/// Job description / initial prompt.
|
||||
description: String,
|
||||
/// Max reasoning iterations (default: 10).
|
||||
#[serde(default = "default_max_iterations")]
|
||||
max_iterations: u32,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_max_tokens() -> u32 {
|
||||
4096
|
||||
}
|
||||
|
||||
fn default_max_iterations() -> u32 {
|
||||
10
|
||||
}
|
||||
|
||||
impl RoutineAction {
|
||||
/// The string tag stored in the DB action_type column.
|
||||
pub fn type_tag(&self) -> &'static str {
|
||||
match self {
|
||||
RoutineAction::Lightweight { .. } => "lightweight",
|
||||
RoutineAction::FullJob { .. } => "full_job",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an action from its DB representation.
|
||||
pub fn from_db(action_type: &str, config: serde_json::Value) -> Result<Self, RoutineError> {
|
||||
match action_type {
|
||||
"lightweight" => {
|
||||
let prompt = config
|
||||
.get("prompt")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| RoutineError::MissingField {
|
||||
context: "lightweight action".into(),
|
||||
field: "prompt".into(),
|
||||
})?
|
||||
.to_string();
|
||||
let context_paths = config
|
||||
.get("context_paths")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let max_tokens = config
|
||||
.get("max_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(default_max_tokens() as u64) as u32;
|
||||
Ok(RoutineAction::Lightweight {
|
||||
prompt,
|
||||
context_paths,
|
||||
max_tokens,
|
||||
})
|
||||
}
|
||||
"full_job" => {
|
||||
let title = config
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| RoutineError::MissingField {
|
||||
context: "full_job action".into(),
|
||||
field: "title".into(),
|
||||
})?
|
||||
.to_string();
|
||||
let description = config
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| RoutineError::MissingField {
|
||||
context: "full_job action".into(),
|
||||
field: "description".into(),
|
||||
})?
|
||||
.to_string();
|
||||
let max_iterations = config
|
||||
.get("max_iterations")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(default_max_iterations() as u64)
|
||||
as u32;
|
||||
Ok(RoutineAction::FullJob {
|
||||
title,
|
||||
description,
|
||||
max_iterations,
|
||||
})
|
||||
}
|
||||
other => Err(RoutineError::UnknownActionType {
|
||||
action_type: other.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize action config to JSON for DB storage.
|
||||
pub fn to_config_json(&self) -> serde_json::Value {
|
||||
match self {
|
||||
RoutineAction::Lightweight {
|
||||
prompt,
|
||||
context_paths,
|
||||
max_tokens,
|
||||
} => serde_json::json!({
|
||||
"prompt": prompt,
|
||||
"context_paths": context_paths,
|
||||
"max_tokens": max_tokens,
|
||||
}),
|
||||
RoutineAction::FullJob {
|
||||
title,
|
||||
description,
|
||||
max_iterations,
|
||||
} => serde_json::json!({
|
||||
"title": title,
|
||||
"description": description,
|
||||
"max_iterations": max_iterations,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Guardrails to prevent runaway execution.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RoutineGuardrails {
|
||||
/// Minimum time between fires.
|
||||
pub cooldown: Duration,
|
||||
/// Max simultaneous runs of this routine.
|
||||
pub max_concurrent: u32,
|
||||
/// Window for content-hash dedup (event triggers). None = no dedup.
|
||||
pub dedup_window: Option<Duration>,
|
||||
}
|
||||
|
||||
impl Default for RoutineGuardrails {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cooldown: Duration::from_secs(300),
|
||||
max_concurrent: 1,
|
||||
dedup_window: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Notification preferences for a routine.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotifyConfig {
|
||||
/// Channel to notify on (None = default/broadcast all).
|
||||
pub channel: Option<String>,
|
||||
/// User to notify.
|
||||
pub user: String,
|
||||
/// Notify when routine produces actionable output.
|
||||
pub on_attention: bool,
|
||||
/// Notify when routine errors.
|
||||
pub on_failure: bool,
|
||||
/// Notify when routine runs with no findings.
|
||||
pub on_success: bool,
|
||||
}
|
||||
|
||||
impl Default for NotifyConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
channel: None,
|
||||
user: "default".to_string(),
|
||||
on_attention: true,
|
||||
on_failure: true,
|
||||
on_success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Status of a routine run.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RunStatus {
|
||||
Running,
|
||||
Ok,
|
||||
Attention,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RunStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RunStatus::Running => write!(f, "running"),
|
||||
RunStatus::Ok => write!(f, "ok"),
|
||||
RunStatus::Attention => write!(f, "attention"),
|
||||
RunStatus::Failed => write!(f, "failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for RunStatus {
|
||||
type Err = RoutineError;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"running" => Ok(RunStatus::Running),
|
||||
"ok" => Ok(RunStatus::Ok),
|
||||
"attention" => Ok(RunStatus::Attention),
|
||||
"failed" => Ok(RunStatus::Failed),
|
||||
other => Err(RoutineError::UnknownRunStatus {
|
||||
status: other.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single execution of a routine.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RoutineRun {
|
||||
pub id: Uuid,
|
||||
pub routine_id: Uuid,
|
||||
pub trigger_type: String,
|
||||
pub trigger_detail: Option<String>,
|
||||
pub started_at: DateTime<Utc>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
pub status: RunStatus,
|
||||
pub result_summary: Option<String>,
|
||||
pub tokens_used: Option<i32>,
|
||||
pub job_id: Option<Uuid>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Compute a content hash for event dedup.
|
||||
pub fn content_hash(content: &str) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
content.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
/// Parse a cron expression and compute the next fire time from now.
|
||||
pub fn next_cron_fire(schedule: &str) -> Result<Option<DateTime<Utc>>, RoutineError> {
|
||||
let cron_schedule =
|
||||
cron::Schedule::from_str(schedule).map_err(|e| RoutineError::InvalidCron {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
Ok(cron_schedule.upcoming(Utc).next())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::agent::routine::{
|
||||
RoutineAction, RoutineGuardrails, RunStatus, Trigger, content_hash, next_cron_fire,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_trigger_roundtrip() {
|
||||
let trigger = Trigger::Cron {
|
||||
schedule: "0 9 * * MON-FRI".to_string(),
|
||||
};
|
||||
let json = trigger.to_config_json();
|
||||
let parsed = Trigger::from_db("cron", json).expect("parse cron");
|
||||
assert!(matches!(parsed, Trigger::Cron { schedule } if schedule == "0 9 * * MON-FRI"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_trigger_roundtrip() {
|
||||
let trigger = Trigger::Event {
|
||||
channel: Some("telegram".to_string()),
|
||||
pattern: r"deploy\s+\w+".to_string(),
|
||||
};
|
||||
let json = trigger.to_config_json();
|
||||
let parsed = Trigger::from_db("event", json).expect("parse event");
|
||||
assert!(matches!(parsed, Trigger::Event { channel, pattern }
|
||||
if channel == Some("telegram".to_string()) && pattern == r"deploy\s+\w+"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_lightweight_roundtrip() {
|
||||
let action = RoutineAction::Lightweight {
|
||||
prompt: "Check PRs".to_string(),
|
||||
context_paths: vec!["context/priorities.md".to_string()],
|
||||
max_tokens: 2048,
|
||||
};
|
||||
let json = action.to_config_json();
|
||||
let parsed = RoutineAction::from_db("lightweight", json).expect("parse lightweight");
|
||||
assert!(
|
||||
matches!(parsed, RoutineAction::Lightweight { prompt, context_paths, max_tokens }
|
||||
if prompt == "Check PRs" && context_paths.len() == 1 && max_tokens == 2048)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_full_job_roundtrip() {
|
||||
let action = RoutineAction::FullJob {
|
||||
title: "Deploy review".to_string(),
|
||||
description: "Review and deploy pending changes".to_string(),
|
||||
max_iterations: 5,
|
||||
};
|
||||
let json = action.to_config_json();
|
||||
let parsed = RoutineAction::from_db("full_job", json).expect("parse full_job");
|
||||
assert!(
|
||||
matches!(parsed, RoutineAction::FullJob { title, max_iterations, .. }
|
||||
if title == "Deploy review" && max_iterations == 5)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_status_display_parse() {
|
||||
for status in [
|
||||
RunStatus::Running,
|
||||
RunStatus::Ok,
|
||||
RunStatus::Attention,
|
||||
RunStatus::Failed,
|
||||
] {
|
||||
let s = status.to_string();
|
||||
let parsed: RunStatus = s.parse().expect("parse status");
|
||||
assert_eq!(parsed, status);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_hash_deterministic() {
|
||||
let h1 = content_hash("deploy production");
|
||||
let h2 = content_hash("deploy production");
|
||||
assert_eq!(h1, h2);
|
||||
|
||||
let h3 = content_hash("deploy staging");
|
||||
assert_ne!(h1, h3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_next_cron_fire_valid() {
|
||||
// Every minute should always have a next fire
|
||||
let next = next_cron_fire("* * * * * *").expect("valid cron");
|
||||
assert!(next.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_next_cron_fire_invalid() {
|
||||
let result = next_cron_fire("not a cron");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_guardrails_default() {
|
||||
let g = RoutineGuardrails::default();
|
||||
assert_eq!(g.cooldown.as_secs(), 300);
|
||||
assert_eq!(g.max_concurrent, 1);
|
||||
assert!(g.dedup_window.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trigger_type_tag() {
|
||||
assert_eq!(
|
||||
Trigger::Cron {
|
||||
schedule: String::new()
|
||||
}
|
||||
.type_tag(),
|
||||
"cron"
|
||||
);
|
||||
assert_eq!(
|
||||
Trigger::Event {
|
||||
channel: None,
|
||||
pattern: String::new()
|
||||
}
|
||||
.type_tag(),
|
||||
"event"
|
||||
);
|
||||
assert_eq!(
|
||||
Trigger::Webhook {
|
||||
path: None,
|
||||
secret: None
|
||||
}
|
||||
.type_tag(),
|
||||
"webhook"
|
||||
);
|
||||
assert_eq!(Trigger::Manual.type_tag(), "manual");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user