diff --git a/.claude/rules/database.md b/.claude/rules/database.md
index 07accf07917..44bd3baa720 100644
--- a/.claude/rules/database.md
+++ b/.claude/rules/database.md
@@ -18,7 +18,8 @@ See `src/db/CLAUDE.md` for full schema, dialect differences, and libSQL limitati
4. Implement in `src/db/libsql/.rs` (use `self.connect().await?` per operation)
5. Add migration if needed:
- PostgreSQL: new `migrations/VN__description.sql`
- - libSQL: add `CREATE TABLE IF NOT EXISTS` to `libsql_migrations.rs`
+ - libSQL: add entry to `INCREMENTAL_MIGRATIONS` in `libsql_migrations.rs`
+ - **Version numbering**: always number after the highest version on `staging`/`main` — those migrations may already be in production. Check with `git ls-tree origin/staging migrations/` and staging's `INCREMENTAL_MIGRATIONS`. Never reuse or insert before an existing version.
6. Test feature isolation:
```bash
cargo check # postgres (default)
@@ -58,6 +59,10 @@ Multi-step operations (INSERT+INSERT, UPDATE+DELETE, read-modify-write) MUST be
`LibSqlBackend::connect()` creates a fresh connection per operation with `PRAGMA busy_timeout = 5000`. This is intentional -- no pool exists. Never hold connections open across `await` points. Satellite stores (`LibSqlSecretsStore`, `LibSqlWasmToolStore`) receive `Arc` via `shared_db()` and call `.connect()` themselves -- never pass a live `Connection`.
+## Never Delete LLM Output Data
+
+All LLM execution data — thread messages, steps, events, tool call parameters and results — must **never** be deleted from the database. This is the most valuable data in the system. No `DELETE` statements, no `DROP`, no truncation of LLM-generated content. In-memory caches (HashMaps in `HybridStore`) may evict entries for memory pressure, but database rows are permanent. Load methods must fall back to the database on a cache miss.
+
## Fix the Pattern, Not the Instance
When fixing a bug in one backend's SQL, always grep for the same pattern in the other. A fix to `postgres.rs` that doesn't also fix `libsql/jobs.rs` is half a fix. Same applies to satellite stores.
diff --git a/.claude/rules/skills.md b/.claude/rules/skills.md
index ded26de98b2..1dc797dda0e 100644
--- a/.claude/rules/skills.md
+++ b/.claude/rules/skills.md
@@ -31,16 +31,19 @@ activation:
tags:
- "devops"
max_context_tokens: 2000
-metadata:
- openclaw:
- requires:
- bins: [docker, kubectl]
- env: [KUBECONFIG]
+requires:
+ bins: [docker, kubectl]
+ env: [KUBECONFIG]
---
# Skill instructions here...
```
+Only the top-level `requires:` block is supported. The legacy nested shape
+`metadata.openclaw.requires` is unsupported and ignored by the current parser,
+so older external skills must be migrated instead of relying on silent
+compatibility.
+
## Selection Pipeline
1. **Gating** -- Check binary/env/config requirements; skip skills whose prerequisites are missing
diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md
index 3d50b3ea67b..263c890f1b7 100644
--- a/.claude/rules/testing.md
+++ b/.claude/rules/testing.md
@@ -23,3 +23,44 @@ Run `bash scripts/check-boundaries.sh` to verify test tier gating.
- Use `tempfile` crate for test directories, never hardcode `/tmp/`
- Regression test with every bug fix (enforced by commit-msg hook)
- Integration tests (`--test workspace_integration`) require PostgreSQL; skipped if DB is unreachable
+
+## Test Through the Caller, Not Just the Helper
+
+**When a helper gates a side-effecting flow, the test must go through the caller — not just the helper in isolation.**
+
+A whole class of bugs in this repo has the same shape: a wrapper function silently loses one of its inputs, and the unit test for the helper passes because it never crosses the layer where the input gets dropped.
+
+Real examples (do not let these recur):
+
+| Bug | Helper | What got lost | How a caller-level test would have caught it |
+|-----|--------|--------------|------------------------------------------------|
+| nearai/ironclaw#1948 | `McpServerConfig::has_custom_auth_header()` | Helper existed but `requires_auth()` never consulted it, so MCP triggered OAuth/DCR even with a user-set `Authorization` header | A test driving `mcp::factory::create_client_from_config()` with a header-bearing config and asserting zero OAuth-state side effects |
+| nearai/ironclaw#1921 | `derive_activation_status(ext, has_owner_binding)` | Wrapper hardcodes the underlying classifier's `has_paired` axis to `false`, even though `classify_wasm_channel_activation` takes both bools | A test driving `extensions_list_handler` against a DB with a real `channel_identities` row and asserting `Active`, not `Pairing` |
+| nearai/ironclaw#1502 | `window.open` mock `(url) => { window._lastOpenedUrl = url }` | Mock captured only the URL, silently swallowing `target` and `windowFeatures`; a regression to same-tab open would not fail | A mock capturing all three args plus an assert that `target === '_blank'` |
+
+### When the rule applies
+
+You must add a caller-level test (not just a helper-level unit test) when **all** of the following are true:
+
+1. The helper is a **predicate, classifier, or transform** whose return value gates a side effect (HTTP call, DB write, UI mutation, OAuth flow, secret read, tool execution, sandbox launch, etc.).
+2. There is **at least one wrapper or call site** between the helper and the side effect.
+3. The helper has **more than one input** *or* its caller computes any of the inputs from the surrounding context.
+
+If all three are true, a unit test on the helper alone is **not sufficient regression coverage**. You must additionally either:
+
+- Add a test that drives the call site (`*_handler`, `factory::create_*`, `manager::*`), **or**
+- Inline the helper into its single caller so there is no wrapper to silently drop an input.
+
+### Where the test belongs
+
+Most of these gaps are above unit-test scope and below e2e scope. Default to the **integration tier** (`cargo test --features integration`):
+
+- `tests/_integration.rs` for Rust integration tests against the public handler/factory surface
+- `tests/multi_tenant_integration.rs` when the lost axis is per-user state
+- `tests/e2e/scenarios/test_*.py` when the lost axis is browser-visible
+
+Unit tests in `mod tests {}` are still fine for the helper itself, but they do not satisfy this rule.
+
+### Mock hygiene corollary
+
+When you mock a browser/runtime API in a test, the mock's signature must match the production call site's signature, and assertions should cover **every argument** the production code passes. A `(url) => {}` stub for a `window.open(url, target, features)` call site is a silent argument-loss bug waiting to happen.
diff --git a/.claude/rules/tools.md b/.claude/rules/tools.md
index a35d9e237e2..fa58f599677 100644
--- a/.claude/rules/tools.md
+++ b/.claude/rules/tools.md
@@ -2,6 +2,8 @@
paths:
- "src/tools/**"
- "tools-src/**"
+ - "src/channels/**"
+ - "src/cli/**"
---
# Tool Architecture
@@ -37,3 +39,102 @@ impl Tool for MyTool {
fn requires_sanitization(&self) -> bool { true } // External data
}
```
+
+## Everything Goes Through Tools
+
+**All actions originating from any non-agent caller — gateway handlers, CLI
+commands, routine engine, WASM channels, future channel extensions — MUST
+go through `ToolDispatcher::dispatch()`, never directly through the
+database, workspace, or domain managers.**
+
+This is the core design principle behind #2049. The reasons are concrete:
+
+1. **Audit trail.** Every dispatched call creates an `ActionRecord` linked
+ to a system job, so UI-initiated mutations are visible in job history
+ alongside agent-initiated ones. Direct DB calls bypass this entirely.
+2. **Safety pipeline parity.** The dispatcher runs the same pipeline as
+ `Worker::execute_tool`: parameter normalization, schema validation,
+ `sensitive_params()` redaction, per-tool timeout, output sanitization.
+ Direct calls skip all of it and risk leaking secrets into logs or
+ persisting unsafe content.
+3. **Channel-agnostic.** Channels are interchangeable extensions (gateway,
+ CLI, telegram, WASM, future custom channels). Routing through a single
+ dispatch function means new channels inherit the full pipeline for free.
+4. **Agent parity.** The agent can do anything channels can do (and vice
+ versa), because both call the same tools. No more "the UI can install
+ extensions but the agent can only list them" gaps.
+
+### Required pattern
+
+```rust
+// In any gateway handler, CLI command, or routine engine callback:
+use crate::tools::dispatch::{DispatchSource, ToolDispatcher};
+
+let dispatcher: &ToolDispatcher = state
+ .tool_dispatcher
+ .as_ref()
+ .ok_or((StatusCode::SERVICE_UNAVAILABLE, "dispatcher unavailable"))?;
+
+let output = dispatcher
+ .dispatch(
+ "memory_write",
+ serde_json::json!({ "target": path, "content": content }),
+ &user.user_id,
+ DispatchSource::Channel("gateway".into()),
+ )
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+```
+
+### Forbidden pattern
+
+```rust
+// DO NOT do this in a gateway handler, CLI command, or routine callback:
+let store = state.store.as_ref().ok_or(...)?;
+store.set_setting(&user.user_id, &key, &value).await?; // BYPASSES dispatch
+
+let workspace = resolve_workspace(&state, &user).await?;
+workspace.write(path, content).await?; // BYPASSES dispatch + safety pipeline
+
+let ext_mgr = state.extension_manager.as_ref().ok_or(...)?;
+ext_mgr.install(name, url, kind, &user.user_id).await?; // BYPASSES audit trail
+```
+
+### When direct access IS allowed
+
+The dispatch principle applies to **non-agent callers** acting on behalf of
+a user. These are exempt:
+
+| Layer | Why exempt |
+|---|---|
+| `Worker::execute_tool()` (agent loop) | Has its own atomic sequence-numbered audit trail; the dispatcher would conflict |
+| `EffectBridgeAdapter::execute_action()` (v2 engine) | Same — its own audit via `ThreadEvent` event sourcing |
+| The tool implementations themselves | Tools are the leaves; they need direct `Workspace`, `Database`, etc. handles to do their work |
+| Background jobs (scheduler, hygiene, mission runner) inside the engine | These ARE the engine; they emit their own events |
+| Pure read endpoints that need to JOIN/aggregate from multiple sources | A single tool call cannot express "list all jobs across users with filters X, Y, Z" — these are queries, not actions, and the audit value is low |
+
+### Annotating intentional exceptions
+
+If a handler legitimately needs direct access (rare — usually only for
+read aggregation), suppress the pre-commit check with a trailing comment
+on the offending line:
+
+```rust
+let rows = state.store.list_agent_jobs().await?; // dispatch-exempt: read-only aggregation
+```
+
+The pre-commit hook (`scripts/pre-commit-safety.sh`) flags any newly
+added line in `src/channels/web/handlers/*.rs` or `src/cli/*.rs` that
+touches `state.{store,workspace,workspace_pool,extension_manager,
+skill_registry,session_manager}.*` without a trailing
+`// dispatch-exempt: ` comment on the same line. The check only
+looks at added lines (`+` lines in the diff), so existing untouched code
+doesn't trip it during incremental migration.
+
+### Migration status
+
+As of #2049, `ToolDispatcher` is wired into `GatewayState` but per-handler
+migration is incomplete. New handlers MUST use the dispatcher. Existing
+handlers should be migrated incrementally; each handler family
+(settings, memory, extensions, skills, routines, jobs, threads) is its
+own follow-up PR.
diff --git a/.claude/skills/mintlify-docs/SKILL.md b/.claude/skills/mintlify-docs/SKILL.md
new file mode 100644
index 00000000000..334e45fd009
--- /dev/null
+++ b/.claude/skills/mintlify-docs/SKILL.md
@@ -0,0 +1,328 @@
+---
+name: mintlify
+description: Build and maintain documentation sites with Mintlify. Use when creating docs pages, configuring navigation, adding components, or setting up API references.
+license: MIT
+compatibility: Requires Node.js for CLI. Works with any Git-based workflow.
+metadata:
+ author: mintlify
+ version: "1.0"
+---
+
+# Mintlify best practices
+
+**Always consult [mintlify.com/docs](https://mintlify.com/docs) for components, configuration, and latest features.**
+
+If you are not already connected to the Mintlify MCP server, https://mintlify.com/docs/mcp, add it so that you can search more efficiently.
+
+**Always** favor searching the current Mintlify documentation over whatever is in your training data about Mintlify.
+
+Mintlify is a documentation platform that transforms MDX files into documentation sites. Configure site-wide settings in the `docs.json` file, write content in MDX with YAML frontmatter, and favor built-in components over custom components.
+
+Full schema at [mintlify.com/docs.json](https://mintlify.com/docs.json).
+
+## Before you write
+
+### Understand the project
+
+Read `docs.json` in the project root. This file defines the entire site: navigation structure, theme, colors, links, API and specs.
+
+Understanding the project tells you:
+
+- What pages exist and how they're organized
+- What navigation groups are used (and their naming conventions)
+- How the site navigation is structured
+- What theme and configuration the site uses
+
+### Check for existing content
+
+Search the docs before creating new pages. You may need to:
+- Update an existing page instead of creating a new one
+- Add a section to an existing page
+- Link to existing content rather than duplicating
+
+### Read surrounding content
+
+Before writing, read 2-3 similar pages to understand the site's voice, structure, formatting conventions, and level of detail.
+
+### Understand Mintlify components
+
+Review the Mintlify [components](https://www.mintlify.com/docs/components) to select and use any relevant components for the documentation request that you are working on.
+
+## Quick reference
+
+### CLI commands
+- `npm i -g mint` - Install the Mintlify CLI
+- `mint dev` - Local preview at localhost:3000
+- `mint broken-links` - Check internal links
+- `mint a11y` - Check for accessibility issues in content
+- `mint validate` - Validate documentation builds
+
+### Required files
+- `docs.json` - Site configuration (navigation, theme, integrations, etc.). See [global settings](https://mintlify.com/docs/settings/global) for all options.
+- `*.mdx` files - Documentation pages with YAML frontmatter
+
+### Example file structure
+```
+project/
+├── docs.json # Site configuration
+├── introduction.mdx
+├── quickstart.mdx
+├── guides/
+│ └── example.mdx
+├── openapi.yml # API specification
+├── images/ # Static assets
+│ └── example.png
+└── snippets/ # Reusable components
+ └── component.jsx
+```
+
+## Page frontmatter
+
+Every page requires `title` in its frontmatter. Include `description` for SEO and navigation.
+
+```yaml
+---
+title: "Clear, descriptive title"
+description: "Concise summary for SEO and navigation."
+---
+```
+
+Optional frontmatter fields:
+- `sidebarTitle`: Short title for sidebar navigation.
+- `icon`: Lucide or Font Awesome icon name, URL, or file path.
+- `tag`: Label next to the page title in the sidebar (for example, "NEW").
+- `mode`: Page layout mode (`default`, `wide`, `custom`).
+- `keywords`: Array of terms related to the page content for local search and SEO.
+- Any custom YAML fields for use with personalization or conditional content.
+
+## File conventions
+
+- Match existing naming patterns in the directory
+- If there are no existing files or inconsistent file naming patterns, use kebab-case: `getting-started.mdx`, `api-reference.mdx`
+- Use root-relative paths without file extensions for internal links: `/getting-started/quickstart`
+- Do not use relative paths (`../`) or absolute URLs for internal pages
+- When you create a new page, add it to `docs.json` navigation or it won't appear in the sidebar
+
+## Organize content
+
+When a user asks about anything related to site-wide configurations, start by understanding the [global settings](https://www.mintlify.com/docs/organize/settings). See if a setting in the `docs.json` file can be updated to achieve what the user wants.
+
+### Navigation
+
+The `navigation` property in `docs.json` controls site structure. Choose one primary pattern at the root level, then nest others within it.
+
+**Choose your primary pattern:**
+
+| Pattern | When to use |
+|---------|-------------|
+| **Groups** | Default. Single audience, straightforward hierarchy |
+| **Tabs** | Distinct sections with different audiences (Guides vs API Reference) or content types |
+| **Anchors** | Want persistent section links at sidebar top. Good for separating docs from external resources |
+| **Dropdowns** | Multiple doc sections users switch between, but not distinct enough for tabs |
+| **Products** | Multi-product company with separate documentation per product |
+| **Versions** | Maintaining docs for multiple API/product versions simultaneously |
+| **Languages** | Localized content |
+
+**Within your primary pattern:**
+
+- **Groups** - Organize related pages. Can nest groups within groups, but keep hierarchy shallow
+- **Menus** - Add dropdown navigation within tabs for quick jumps to specific pages
+- **`expanded: false`** - Collapse nested groups by default. Use for reference sections users browse selectively
+- **`openapi`** - Auto-generate pages from OpenAPI spec. Add at group/tab level to inherit
+
+**Common combinations:**
+- Tabs containing groups (most common for docs with API reference)
+- Products containing tabs (multi-product SaaS)
+- Versions containing tabs (versioned API docs)
+- Anchors containing groups (simple docs with external resource links)
+
+### Links and paths
+
+- **Internal links:** Root-relative, no extension: `/getting-started/quickstart`
+- **Images:** Store in `/images`, reference as `/images/example.png`
+- **External links:** Use full URLs, they open in new tabs automatically
+
+## Customize docs sites
+
+**What to customize where:**
+- **Brand colors, fonts, logo** → `docs.json`. See [global settings](https://mintlify.com/docs/settings/global)
+- **Component styling, layout tweaks** → `custom.css` at project root
+- **Dark mode** → Enabled by default. Only disable with `"appearance": "light"` in `docs.json` if brand requires it
+
+Start with `docs.json`. Only add `custom.css` when you need styling that config doesn't support.
+
+## Write content
+
+### Components
+
+The [components overview](https://mintlify.com/docs/components) organizes all components by purpose: structure content, draw attention, show/hide content, document APIs, link to pages, and add visual context. Start there to find the right component.
+
+**Common decision points:**
+
+| Need | Use |
+|------|-----|
+| Hide optional details | `` |
+| Long code examples | `` |
+| User chooses one option | `` |
+| Linked navigation cards | `` in `` |
+| Sequential instructions | `` |
+| Code in multiple languages | `` |
+| API parameters | `` |
+| API response fields | `` |
+
+**Callouts by severity:**
+- `` - Supplementary info, safe to skip
+- `` - Helpful context such as permissions
+- `` - Recommendations or best practices
+- `` - Potentially destructive actions
+- `` - Success confirmation
+
+### Reusable content
+
+**When to use snippets:**
+- Exact content appears on more than one page
+- Complex components you want to maintain in one place
+- Shared content across teams/repos
+
+**When NOT to use snippets:**
+- Slight variations needed per page (leads to complex props)
+
+Import snippets with `import { Component } from "/path/to/snippet-name.jsx"`.
+
+## Writing standards
+
+### Voice and structure
+
+- Second-person voice ("you")
+- Active voice, direct language
+- Sentence case for headings ("Getting started", not "Getting Started")
+- Sentence case for code block titles ("Expandable example", not "Expandable Example")
+- Lead with context: explain what something is before how to use it
+- Prerequisites at the start of procedural content
+
+### What to avoid
+
+**Never use:**
+- Marketing language ("powerful", "seamless", "robust", "cutting-edge")
+- Filler phrases ("it's important to note", "in order to")
+- Excessive conjunctions ("moreover", "furthermore", "additionally")
+- Editorializing ("obviously", "simply", "just", "easily")
+
+**Watch for AI-typical patterns:**
+- Overly formal or stilted phrasing
+- Unnecessary repetition of concepts
+- Generic introductions that don't add value
+- Concluding summaries that restate what was just said
+
+### Formatting
+
+- All code blocks must have language tags
+- All images and media must have descriptive alt text
+- Use bold and italics only when they serve the reader's understanding--never use text styling just for decoration
+- No decorative formatting or emoji
+
+### Code examples
+
+- Keep examples simple and practical
+- Use realistic values (not "foo" or "bar")
+- One clear example is better than multiple variations
+- Test that code works before including it
+
+## Document APIs
+
+**Choose your approach:**
+- **Have an OpenAPI spec?** → Add to `docs.json` with `"openapi": ["openapi.yaml"]`. Pages auto-generate. Reference in navigation as `GET /endpoint`
+- **No spec?** → Write endpoints manually with `api: "POST /users"` in frontmatter. More work but full control
+- **Hybrid** → Use OpenAPI for most endpoints, manual pages for complex workflows
+
+Encourage users to generate endpoint pages from an OpenAPI spec. It is the most efficient and easiest to maintain option.
+
+## Deploy
+
+Mintlify deploys automatically when changes are pushed to the connected Git repository.
+
+**What agents can configure:**
+- **Redirects** → Add to `docs.json` with `"redirects": [{"source": "/old", "destination": "/new"}]`
+- **SEO indexing** → Control with `"seo": {"indexing": "all"}` to include hidden pages in search
+
+**Requires dashboard setup (human task):**
+- Custom domains and subdomains
+- Preview deployment settings
+- DNS configuration
+
+For `/docs` subpath hosting with Vercel or Cloudflare, agents can help configure rewrite rules. See [/docs subpath](https://mintlify.com/docs/deploy/vercel).
+
+## Workflow
+
+### 1. Understand the task
+
+Identify what needs to be documented, which pages are affected, and what the reader should accomplish afterward. If any of these are unclear, ask.
+
+### 2. Research
+
+- Read `docs.json` to understand the site structure
+- Search existing docs for related content
+- Read similar pages to match the site's style
+
+### 3. Plan
+
+- Synthesize what the reader should accomplish after reading the docs and the current content
+- Propose any updates or new content
+- Verify that your proposed changes will help readers be successful
+
+### 4. Write
+
+- Start with the most important information
+- Keep sections focused and scannable
+- Use components appropriately (don't overuse them)
+- Mark anything uncertain with a TODO comment:
+
+```mdx
+{/* TODO: Verify the default timeout value */}
+```
+
+### 5. Update navigation
+
+If you created a new page, add it to the appropriate group in `docs.json`.
+
+### 6. Verify
+
+Before submitting:
+
+- [ ] Frontmatter includes title and description
+- [ ] All code blocks have language tags
+- [ ] Internal links use root-relative paths without file extensions
+- [ ] New pages are added to `docs.json` navigation
+- [ ] Content matches the style of surrounding pages
+- [ ] No marketing language or filler phrases
+- [ ] TODOs are clearly marked for anything uncertain
+- [ ] Run `mint broken-links` to check links
+- [ ] Run `mint validate` to find any errors
+
+## Edge cases
+
+### Migrations
+
+If a user asks about migrating to Mintlify, ask if they are using ReadMe or Docusaurus. If they are, use the [@mintlify/scraping](https://www.npmjs.com/package/@mintlify/scraping) CLI to migrate content. If they are using a different platform to host their documentation, help them manually convert their content to MDX pages using Mintlify components.
+
+### Hidden pages
+
+Any page that is not included in the `docs.json` navigation is hidden. Use hidden pages for content that should be accessible by URL or indexed for the assistant or search, but not discoverable through the sidebar navigation.
+
+### Exclude pages
+
+The `.mintignore` file is used to exclude files from a documentation repository from being processed.
+
+## Common gotchas
+
+1. **Component imports** - JSX components need explicit import, MDX components don't
+2. **Frontmatter required** - Every MDX file needs `title` at minimum
+3. **Code block language** - Always specify language identifier
+4. **Never use `mint.json`** - `mint.json` is deprecated. Only ever use `docs.json`
+
+## Resources
+
+- [Documentation](https://mintlify.com/docs)
+- [Configuration schema](https://mintlify.com/docs.json)
+- [Feature requests](https://github.com/orgs/mintlify/discussions/categories/feature-requests)
+- [Bugs and feedback](https://github.com/orgs/mintlify/discussions/categories/bugs-feedback)
diff --git a/.dockerignore b/.dockerignore
index 32b9468cabd..09a7c89ec53 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -5,4 +5,3 @@ target/
*.md
!CLAUDE.md
node_modules/
-tools-src/
diff --git a/.env.example b/.env.example
index ce3e312409d..f81b1d69766 100644
--- a/.env.example
+++ b/.env.example
@@ -1,6 +1,6 @@
# Database Configuration
DATABASE_URL=postgres://localhost/ironclaw
-DATABASE_POOL_SIZE=10
+DATABASE_POOL_SIZE=30 # multi-tenant default; reduce to 5-10 for single-user or low-resource deployments
# LLM Provider
# LLM_BACKEND=nearai # default
@@ -42,6 +42,9 @@ DATABASE_POOL_SIZE=10
# 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
+# When both NEARAI_BASE_URL and NEARAI_API_KEY are set at startup,
+# IronClaw also bootstraps a persisted `nearai` MCP server using the
+# same base URL and Authorization header.
NEARAI_MODEL=Qwen/Qwen3.5-122B-A10B
NEARAI_BASE_URL=https://private.near.ai
NEARAI_AUTH_URL=https://private.near.ai
@@ -191,10 +194,9 @@ 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
+# Runs on each heartbeat tick; discovers cleanup targets from .config metadata
# MEMORY_HYGIENE_ENABLED=true
-# MEMORY_HYGIENE_DAILY_RETENTION_DAYS=30 # delete daily/ docs older than this many days
-# MEMORY_HYGIENE_CONVERSATION_RETENTION_DAYS=7 # delete conversations/ docs older than this many days
+# MEMORY_HYGIENE_VERSION_KEEP_COUNT=50 # max versions to keep per document
# MEMORY_HYGIENE_CADENCE_HOURS=12 # minimum hours between cleanup passes
# Docker Sandbox
@@ -209,6 +211,12 @@ HEARTBEAT_NOTIFY_USER=default
# SANDBOX_TIMEOUT_SECS=120
# SANDBOX_MEMORY_LIMIT_MB=2048
+# ACP (Agent Client Protocol) agents
+# ACP_ENABLED=false # Enable ACP agent sandbox mode
+# ACP_MEMORY_LIMIT_MB=4096 # Memory limit for ACP containers
+# ACP_TIMEOUT_SECS=1800 # Maximum session timeout
+# Configure agents via CLI: ironclaw acp add goose --command goose --arg "--stdio"
+
# Safety settings
SAFETY_MAX_OUTPUT_LENGTH=100000
SAFETY_INJECTION_CHECK_ENABLED=true
@@ -220,5 +228,63 @@ SAFETY_INJECTION_CHECK_ENABLED=true
# IRONCLAW_RESTART_DELAY=5 # default wait before exit (seconds, range: 1-30)
# IRONCLAW_MAX_FAILURES=10 # max consecutive failures before container exits
+# ─── OAuth / Social Login ────────────────────────────────────────────────
+# Enable direct OAuth login (Google, GitHub). Disabled by default.
+# OAUTH_ENABLED=true
+
+# Base URL for OAuth callback URLs. Defaults to http://localhost:{GATEWAY_PORT}.
+# Set this to your public URL in production (e.g., https://myapp.example.com).
+# OAUTH_BASE_URL=https://myapp.example.com
+
+# Restrict OAuth login to specific email domains (comma-separated).
+# When set, only users with verified emails from these domains can log in.
+# Applies to all OAuth providers and OIDC. Leave unset to allow all domains.
+# OAUTH_ALLOWED_DOMAINS=company.com,partner.org
+
+# Google OAuth — Create credentials at https://console.cloud.google.com/apis/credentials
+# 1. Create an OAuth 2.0 Client ID (Web application type)
+# 2. Add authorized redirect URI: {OAUTH_BASE_URL}/auth/callback/google
+# 3. Copy Client ID and Client Secret below
+# GOOGLE_CLIENT_ID=
+# GOOGLE_CLIENT_SECRET=
+
+# Restrict Google login to a specific Workspace (G Suite) domain.
+# Adds the `hd` parameter to the authorization URL and validates server-side.
+# GOOGLE_ALLOWED_HD=company.com
+
+# Apple Sign In — Configure in https://developer.apple.com/account/resources/identifiers
+# 1. Register a Services ID (e.g. com.example.myapp) under Identifiers
+# 2. Enable "Sign In with Apple" and configure the return URL: {OAUTH_BASE_URL}/auth/callback/apple
+# 3. Create a key (Keys section), enable "Sign In with Apple", download the .p8 file
+# 4. Note your Team ID (top right of developer portal) and Key ID
+# APPLE_CLIENT_ID=com.example.myapp
+# APPLE_TEAM_ID=XXXXXXXXXX
+# APPLE_KEY_ID=YYYYYYYYYY
+# APPLE_PRIVATE_KEY_PATH=/path/to/AuthKey_YYYYYYYYYY.p8
+# Or inline: APPLE_PRIVATE_KEY_PEM="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
+
+# GitHub OAuth — Create an OAuth App at https://github.com/settings/developers
+# 1. Create a new OAuth App
+# 2. Set Authorization callback URL to: {OAUTH_BASE_URL}/auth/callback/github
+# 3. Copy Client ID and generate a Client Secret below
+# GITHUB_CLIENT_ID=
+# GITHUB_CLIENT_SECRET=
+
+# NEAR Wallet — No external setup needed. Users sign in with any NEAR wallet
+# (HOT, Meteor, MyNearWallet, etc.) via the near-connect SDK.
+# NEAR_AUTH_ENABLED=true
+# NEAR_AUTH_NETWORK=mainnet # or testnet
+# NEAR_AUTH_RPC_URL=https://rpc.mainnet.near.org # auto-detected from network
+
+# ─── OIDC / SSO (Okta, Cognito, etc.) ──────────────────────────────────
+# For reverse-proxy SSO (e.g., AWS ALB + Okta). The gateway validates JWTs
+# from the configured header. See also OAUTH_ALLOWED_DOMAINS above, which
+# applies to OIDC logins too.
+# GATEWAY_OIDC_ENABLED=true
+# GATEWAY_OIDC_JWKS_URL=https://your-idp.example.com/.well-known/jwks.json
+# GATEWAY_OIDC_HEADER=x-amzn-oidc-data
+# GATEWAY_OIDC_ISSUER=https://your-idp.example.com
+# GATEWAY_OIDC_AUDIENCE=your-client-id
+
# Logging
RUST_LOG=ironclaw=debug,tower_http=debug
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
index 0abd640a823..5a833d4d2fc 100755
--- a/.githooks/pre-commit
+++ b/.githooks/pre-commit
@@ -22,3 +22,16 @@ if $NEEDS_CHECK; then
exit 1
fi
fi
+
+# i18n parity: when any language pack changes, all languages must stay in sync.
+if echo "$STAGED" | grep -qE '^crates/ironclaw_gateway/static/i18n/.*\.js$'; then
+ echo "pre-commit: checking i18n parity..."
+ if ! ./scripts/check-i18n-parity.sh; then
+ echo ""
+ echo "Commit blocked: i18n parity check failed."
+ echo "Every key added to en.js must also be added to all other language files (zh-CN.js, ko.js, ...)."
+ echo "Placeholder tokens like {name} must match across all languages."
+ echo "To bypass: git commit --no-verify"
+ exit 1
+ fi
+fi
diff --git a/.github/ISSUE_TEMPLATE/qa-bug.yml b/.github/ISSUE_TEMPLATE/qa-bug.yml
new file mode 100644
index 00000000000..ef1d912a642
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/qa-bug.yml
@@ -0,0 +1,94 @@
+name: QA Bug Report
+description: Bug found during QA testing on staging or hosted environments
+title: "[QA] "
+labels: ["qa-bug"]
+body:
+ - type: dropdown
+ id: environment
+ attributes:
+ label: Environment
+ description: Where was this bug found?
+ options:
+ - hosted-staging (crab shack)
+ - hosted-production
+ - local (cloned ironclaw)
+ - railway-staging
+ validations:
+ required: true
+
+ - type: input
+ id: version
+ attributes:
+ label: Version / Commit Hash
+ description: Paste the commit hash from staging at time of discovery (run `git rev-parse HEAD` or find it on the Railway deploy)
+ placeholder: "e.g. abcdef1"
+ validations:
+ required: true
+
+ - type: input
+ id: qa-date
+ attributes:
+ label: QA Test Date
+ description: Date you discovered this (YYYY-MM-DD)
+ placeholder: "e.g. 2026-04-12"
+ validations:
+ required: true
+
+ - type: input
+ id: feature-area
+ attributes:
+ label: Feature Area
+ description: What part of the app? (e.g. Google Suite extension, Telegram pairing, auth flow)
+ placeholder: "e.g. Extensions → Google Suite install"
+ validations:
+ required: true
+
+ - type: textarea
+ id: steps
+ attributes:
+ label: Steps to Reproduce
+ description: Exact steps — numbered, specific, no summaries
+ placeholder: |
+ 1. Open extensions tab
+ 2. Click "Install Google Suite"
+ 3. Fill in credentials and click Save
+ 4. ...
+ validations:
+ required: true
+
+ - type: textarea
+ id: expected
+ attributes:
+ label: Expected Behavior
+ description: What should happen?
+ validations:
+ required: true
+
+ - type: textarea
+ id: actual
+ attributes:
+ label: Actual Behavior
+ description: What actually happened? Include the exact error message/text.
+ placeholder: "Error: 'Failed to authenticate with Google: invalid_grant' shown in red toast"
+ validations:
+ required: true
+
+ - type: textarea
+ id: logs
+ attributes:
+ label: Logs / Screenshots
+ description: Paste relevant logs, error output, or attach screenshots. Drag files here.
+ validations:
+ required: false
+
+ - type: checkboxes
+ id: checklist
+ attributes:
+ label: Pre-submit checklist
+ options:
+ - label: Title is specific (not "fix Google Suite" but "Google Suite install throws invalid_grant on OAuth step")
+ required: true
+ - label: Commit hash is filled in
+ required: true
+ - label: Steps are numbered and reproducible
+ required: true
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000000..472089ed5b1
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,48 @@
+version: 2
+updates:
+ - package-ecosystem: cargo
+ directory: "/"
+ schedule:
+ interval: weekly
+ open-pull-requests-limit: 10
+ groups:
+ tokio-ecosystem:
+ patterns:
+ - "tokio*"
+ - "hyper*"
+ - "axum*"
+ - "tower*"
+ serialization:
+ patterns:
+ - "serde*"
+ - "prost*"
+ wasm:
+ patterns:
+ - "wasmtime*"
+ - "wit-*"
+ - "wasm-*"
+ - "cargo-component*"
+ everything-else:
+ patterns:
+ - "*"
+ exclude-patterns:
+ - "tokio*"
+ - "hyper*"
+ - "axum*"
+ - "tower*"
+ - "serde*"
+ - "prost*"
+ - "wasmtime*"
+ - "wit-*"
+ - "wasm-*"
+ - "cargo-component*"
+
+ - package-ecosystem: github-actions
+ directory: "/"
+ schedule:
+ interval: weekly
+ open-pull-requests-limit: 5
+ groups:
+ actions:
+ patterns:
+ - "*"
diff --git a/.github/labeler.yml b/.github/labeler.yml
index fd7da0be2fd..6ac08552b00 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -1,5 +1,5 @@
-# Scope labels for actions/labeler@v6
-# Maps file path globs to scope labels. Multiple labels can apply per PR.
+# Labels for actions/labeler@v6
+# Maps file path globs to labels. Multiple labels can apply per PR.
"scope: agent":
- changed-files:
@@ -164,3 +164,9 @@
- any-glob-to-any-file:
- Cargo.toml
- Cargo.lock
+
+"DB MIGRATION":
+ - changed-files:
+ - any-glob-to-any-file:
+ - migrations/**
+ - src/db/libsql_migrations.rs
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 4fc7cbf233b..e6fe6128f82 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -6,7 +6,7 @@
## Change Type
-
+
- [ ] Bug fix
- [ ] New feature
@@ -18,16 +18,19 @@
## Linked Issue
-
+
## Validation
-- [ ] `cargo fmt`
-- [ ] `cargo clippy --all --benches --tests --examples --all-features`
+- [ ] `cargo fmt --all -- --check`
+- [ ] `cargo clippy --all --benches --tests --examples --all-features -- -D warnings`
+- [ ] `cargo build`
- [ ] Relevant tests pass:
+- [ ] `cargo test --features integration` if database-backed or integration behavior changed
- [ ] Manual testing:
+- [ ] If a coding agent was used and supports it, `review-pr` or `pr-shepherd --fix` was run before requesting review
## Security Impact
@@ -45,6 +48,10 @@
+## Review Follow-Through
+
+
+
---
-**Review track**:
+**Review track**:
diff --git a/.github/scripts/create-labels.sh b/.github/scripts/create-labels.sh
index 66f07ea9ce1..6b6d10d3cd1 100755
--- a/.github/scripts/create-labels.sh
+++ b/.github/scripts/create-labels.sh
@@ -62,6 +62,9 @@ create "scope: ci" "546E7A" "CI/CD workflows"
create "scope: docs" "78909C" "Documentation"
create "scope: dependencies" "90A4AE" "Dependency updates"
+echo "==> Creating coordination labels..."
+create "DB MIGRATION" "C62828" "PR adds or modifies PostgreSQL or libSQL migration definitions"
+
echo "==> Creating workflow labels..."
create "skip-regression-check" "9E9E9E" "Acknowledged: fix without regression test"
diff --git a/.github/scripts/pr-labeler.sh b/.github/scripts/pr-labeler.sh
index 96dc0fa7483..78d41a344a5 100755
--- a/.github/scripts/pr-labeler.sh
+++ b/.github/scripts/pr-labeler.sh
@@ -43,7 +43,10 @@ classify_size() {
local total
total=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" \
--paginate --jq '
- [.[] | select(.filename | test("\\.(md|txt|rst|adoc)$") | not) | .changes]
+ [.[]
+ | select(.filename | test("\\.(md|txt|rst|adoc)$") | not)
+ | select(.filename | test("^tests/|_test\\.rs$|_tests\\.rs$|/tests/|\\.test\\.[jt]sx?$|\\.spec\\.[jt]sx?$") | not)
+ | .changes]
| add // 0
')
diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml
index 26c15d8928a..a792ea0d95f 100644
--- a/.github/workflows/claude-review.yml
+++ b/.github/workflows/claude-review.yml
@@ -20,12 +20,13 @@ jobs:
if: contains(github.event.pull_request.labels.*.name, 'staging-promotion')
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
+ persist-credentials: false
- name: Run Claude Code review
- uses: anthropics/claude-code-action@v1
+ uses: anthropics/claude-code-action@1eddb334cfa79fdb21ecbe2180ca1a016e8e7d47 # v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_bots: "ironclaw-ci[bot]"
diff --git a/.github/workflows/code_style.yml b/.github/workflows/code_style.yml
index f89161d9285..614611d3424 100644
--- a/.github/workflows/code_style.yml
+++ b/.github/workflows/code_style.yml
@@ -2,15 +2,20 @@ name: Code Style
on:
pull_request:
+permissions:
+ contents: read
+
jobs:
format:
name: Formatting
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
- name: Install Rust
- uses: dtolnay/rust-toolchain@stable
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
components: rustfmt
- name: Check formatting
@@ -21,9 +26,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
- name: Run cargo deny
- uses: EmbarkStudios/cargo-deny-action@v2
+ uses: EmbarkStudios/cargo-deny-action@3fd3802e88374d3fe9159b834c7714ec57d6c979 # v2
clippy:
name: Clippy (${{ matrix.name }})
@@ -40,12 +47,14 @@ jobs:
flags: "--no-default-features --features libsql"
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
- name: Install Rust
- uses: dtolnay/rust-toolchain@stable
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
components: clippy
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: clippy-${{ matrix.name }}
- name: Check lints
@@ -67,12 +76,14 @@ jobs:
flags: "--no-default-features --features libsql"
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
- name: Install Rust
- uses: dtolnay/rust-toolchain@stable
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
components: clippy
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: clippy-windows-${{ matrix.name }}
- name: Check lints
@@ -83,10 +94,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- - uses: actions/setup-python@v5
+ persist-credentials: false
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
- name: Check for .unwrap(), .expect(), assert!() in production code
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index 2f885b169e2..074433d232d 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -32,13 +32,15 @@ on:
branches: [main]
permissions:
- id-token: write
contents: read
jobs:
coverage:
name: Coverage (${{ matrix.name }})
runs-on: ubuntu-latest
+ permissions:
+ id-token: write
+ contents: read
strategy:
fail-fast: false
matrix:
@@ -67,19 +69,21 @@ jobs:
--health-timeout 5s
--health-retries 5
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
- - uses: dtolnay/rust-toolchain@stable
+ - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
components: llvm-tools-preview
targets: wasm32-wasip2
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: coverage-${{ matrix.name }}
- name: Install cargo-llvm-cov
- uses: taiki-e/install-action@cargo-llvm-cov
+ uses: taiki-e/install-action@62b0f2dec647a8e604c6a0fda0e38530180dce20 # cargo-llvm-cov
- name: Install cargo-component
run: |
@@ -113,7 +117,7 @@ jobs:
run: cargo llvm-cov ${{ matrix.flags }} --workspace --lcov --output-path lcov.info
- name: Upload to Codecov
- uses: codecov/codecov-action@v5
+ uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
with:
files: lcov.info
flags: ${{ matrix.name }}
@@ -125,20 +129,25 @@ jobs:
name: E2E Coverage
runs-on: ubuntu-latest
timeout-minutes: 30
+ permissions:
+ id-token: write
+ contents: read
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ persist-credentials: false
- - uses: dtolnay/rust-toolchain@stable
+ - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
components: llvm-tools-preview
targets: wasm32-wasip2
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: e2e-coverage
- name: Install cargo-llvm-cov
- uses: taiki-e/install-action@cargo-llvm-cov
+ uses: taiki-e/install-action@62b0f2dec647a8e604c6a0fda0e38530180dce20 # cargo-llvm-cov
- name: Install cargo-component
run: |
@@ -162,7 +171,7 @@ jobs:
- name: Build instrumented binary
run: cargo build --no-default-features --features libsql
- - uses: actions/setup-python@v5
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
@@ -197,7 +206,7 @@ jobs:
- name: Upload to Codecov
if: always()
- uses: codecov/codecov-action@v5
+ uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
with:
files: e2e-coverage.info
flags: e2e
@@ -207,7 +216,7 @@ jobs:
- name: Upload screenshots on failure
if: failure()
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: e2e-screenshots
path: tests/e2e/screenshots/
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
new file mode 100644
index 00000000000..9601165fa41
--- /dev/null
+++ b/.github/workflows/docker.yml
@@ -0,0 +1,173 @@
+name: Docker Image
+
+on:
+ # Called by release.yml or other workflows
+ workflow_call:
+ inputs:
+ tag:
+ description: "Image tag override (leave empty for auto-detect)"
+ required: false
+ type: string
+ default: ""
+ # On-demand builds
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: "Image tag override (leave empty for auto-detect)"
+ required: false
+ type: string
+ default: ""
+ # Daily staging build from the staging branch
+ schedule:
+ - cron: '0 6 * * *'
+
+env:
+ IMAGE_NAME: nearaidev/ironclaw
+ WORKER_IMAGE_NAME: nearaidev/ironclaw-worker
+
+jobs:
+ build:
+ name: Build & Push
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ packages: read
+ actions: write
+ steps:
+ - name: Checkout
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ with:
+ ref: ${{ github.event_name == 'schedule' && 'staging' || '' }}
+ persist-credentials: false
+
+ - name: Extract version from Cargo.toml
+ id: version
+ run: |
+ VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
+ echo "Detected version: ${VERSION}"
+
+ - name: Determine tags
+ id: tags
+ env:
+ VERSION: ${{ steps.version.outputs.version }}
+ EVENT_NAME: ${{ github.event_name }}
+ INPUT_TAG: ${{ inputs.tag }}
+ run: |
+ SHA="sha-${GITHUB_SHA::7}"
+ echo "sha_tag=${SHA}" >> "$GITHUB_OUTPUT"
+
+ if [[ "${EVENT_NAME}" == "workflow_call" ]]; then
+ # Release: :version + :latest + :sha-xxx
+ TAGS="${IMAGE_NAME}:${VERSION}"
+ TAGS="${TAGS},${IMAGE_NAME}:latest"
+ TAGS="${TAGS},${IMAGE_NAME}:${SHA}"
+ WORKER_TAGS="${WORKER_IMAGE_NAME}:${VERSION}"
+ WORKER_TAGS="${WORKER_TAGS},${WORKER_IMAGE_NAME}:latest"
+ WORKER_TAGS="${WORKER_TAGS},${WORKER_IMAGE_NAME}:${SHA}"
+ elif [[ "${EVENT_NAME}" == "schedule" ]]; then
+ # Daily staging: :staging + :sha-xxx
+ TAGS="${IMAGE_NAME}:staging"
+ TAGS="${TAGS},${IMAGE_NAME}:${SHA}"
+ WORKER_TAGS="${WORKER_IMAGE_NAME}:staging"
+ WORKER_TAGS="${WORKER_TAGS},${WORKER_IMAGE_NAME}:${SHA}"
+ else
+ # Manual dispatch: :sha-xxx only
+ TAGS="${IMAGE_NAME}:${SHA}"
+ WORKER_TAGS="${WORKER_IMAGE_NAME}:${SHA}"
+ fi
+
+ # Manual override adds an extra tag (e.g. "staging")
+ if [[ -n "${INPUT_TAG}" ]]; then
+ TAGS="${TAGS},${IMAGE_NAME}:${INPUT_TAG}"
+ WORKER_TAGS="${WORKER_TAGS},${WORKER_IMAGE_NAME}:${INPUT_TAG}"
+ fi
+ echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
+ echo "worker_tags=${WORKER_TAGS}" >> "$GITHUB_OUTPUT"
+
+ # Staging builds get pre-bundled WASM extensions
+ if [[ "${EVENT_NAME}" == "schedule" || "${INPUT_TAG}" == "staging" ]]; then
+ echo "target=runtime-staging" >> "$GITHUB_OUTPUT"
+ else
+ echo "target=runtime" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
+
+ - name: Log in to Docker Hub
+ uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
+ with:
+ username: ${{ vars.DOCKER_REGISTRY_USER }}
+ password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
+
+ - name: Build and push (ironclaw)
+ uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
+ with:
+ context: .
+ push: true
+ tags: ${{ steps.tags.outputs.tags }}
+ target: ${{ steps.tags.outputs.target }}
+ platforms: linux/amd64
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ - name: Build and push (ironclaw-worker)
+ uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
+ with:
+ context: .
+ file: Dockerfile.worker
+ push: true
+ tags: ${{ steps.tags.outputs.worker_tags }}
+ platforms: linux/amd64
+ cache-from: type=gha,scope=worker
+ cache-to: type=gha,mode=max,scope=worker
+
+ - name: Create releases-manager app token
+ id: app-token
+ continue-on-error: true
+ uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2
+ with:
+ app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
+ private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }}
+ owner: nearai
+ repositories: ironclaw-dind
+
+ - name: Trigger ironclaw-dind Build & Push
+ if: steps.app-token.outcome == 'success'
+ continue-on-error: true
+ env:
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ EVENT_NAME: ${{ github.event_name }}
+ INPUT_TAG: ${{ inputs.tag }}
+ VERSION: ${{ steps.version.outputs.version }}
+ run: |
+ if [[ "${EVENT_NAME}" == "workflow_call" && -n "${VERSION}" ]]; then
+ gh api repos/nearai/ironclaw-dind/dispatches \
+ --method POST \
+ -f event_type="ironclaw_image_published" \
+ -f client_payload[version]="${VERSION}"
+ elif [[ "${EVENT_NAME}" == "schedule" ]] || [[ "${INPUT_TAG}" == "staging" ]]; then
+ gh api repos/nearai/ironclaw-dind/dispatches \
+ --method POST \
+ -f event_type="ironclaw_image_published"
+ fi
+
+ - name: Summary
+ run: |
+ {
+ echo "## Docker Images"
+ echo ""
+ echo "**ironclaw:**"
+ echo '```'
+ echo "${{ steps.tags.outputs.tags }}" | tr ',' '\n'
+ echo '```'
+ echo ""
+ echo "**ironclaw-worker:**"
+ echo '```'
+ echo "${{ steps.tags.outputs.worker_tags }}" | tr ',' '\n'
+ echo '```'
+ echo ""
+ echo "- version: \`${{ steps.version.outputs.version }}\`"
+ echo "- sha: \`${GITHUB_SHA::7}\`"
+ } >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index bc705df7280..9ed4df9cad9 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -1,6 +1,11 @@
name: E2E Tests
on:
workflow_call:
+ inputs:
+ ref:
+ description: Commit SHA or ref to test
+ required: false
+ type: string
schedule:
- cron: "0 6 * * 1" # Weekly Monday 6 AM UTC
workflow_dispatch:
@@ -11,6 +16,9 @@ on:
- "src/channels/web/**"
- "tests/e2e/**"
+permissions:
+ contents: read
+
jobs:
# ── Step 1: compile once ──────────────────────────────────────────────────
build:
@@ -18,11 +26,14 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ ref: ${{ inputs.ref || github.sha }}
+ persist-credentials: false
- - uses: dtolnay/rust-toolchain@stable
+ - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- - uses: actions/cache@v4
+ - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: |
target
@@ -33,7 +44,7 @@ jobs:
run: cargo build --no-default-features --features libsql
- name: Upload binary
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: ironclaw-e2e-binary
path: target/debug/ironclaw
@@ -54,14 +65,17 @@ jobs:
- group: features
files: "tests/e2e/scenarios/test_skills.py tests/e2e/scenarios/test_tool_approval.py tests/e2e/scenarios/test_webhook.py"
- group: extensions
- files: "tests/e2e/scenarios/test_extensions.py tests/e2e/scenarios/test_extension_oauth.py tests/e2e/scenarios/test_oauth_url_parameters.py tests/e2e/scenarios/test_telegram_token_validation.py tests/e2e/scenarios/test_telegram_hot_activation.py tests/e2e/scenarios/test_wasm_lifecycle.py tests/e2e/scenarios/test_tool_execution.py tests/e2e/scenarios/test_pairing.py tests/e2e/scenarios/test_mcp_auth_flow.py tests/e2e/scenarios/test_oauth_credential_fallback.py tests/e2e/scenarios/test_routine_oauth_credential_injection.py"
+ files: "tests/e2e/scenarios/test_extensions.py tests/e2e/scenarios/test_extension_oauth.py tests/e2e/scenarios/test_oauth_url_parameters.py tests/e2e/scenarios/test_telegram_token_validation.py tests/e2e/scenarios/test_telegram_hot_activation.py tests/e2e/scenarios/test_wasm_lifecycle.py tests/e2e/scenarios/test_tool_execution.py tests/e2e/scenarios/test_agent_loop_recovery.py tests/e2e/scenarios/test_pairing.py tests/e2e/scenarios/test_mcp_auth_flow.py tests/e2e/scenarios/test_oauth_credential_fallback.py tests/e2e/scenarios/test_routine_oauth_credential_injection.py"
- group: routines
files: "tests/e2e/scenarios/test_owner_scope.py tests/e2e/scenarios/test_routine_event_batch.py"
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ ref: ${{ inputs.ref || github.sha }}
+ persist-credentials: false
- name: Download binary
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: ironclaw-e2e-binary
path: target/debug/
@@ -69,7 +83,7 @@ jobs:
- name: Make binary executable
run: chmod +x target/debug/ironclaw
- - uses: actions/setup-python@v5
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
@@ -84,7 +98,7 @@ jobs:
- name: Upload screenshots on failure
if: failure()
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: e2e-screenshots-${{ matrix.group }}
path: tests/e2e/screenshots/
diff --git a/.github/workflows/pr-label-classify.yml b/.github/workflows/pr-label-classify.yml
index 90f141de717..7d0ee97a9ac 100644
--- a/.github/workflows/pr-label-classify.yml
+++ b/.github/workflows/pr-label-classify.yml
@@ -14,9 +14,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout base branch
- uses: actions/checkout@v4
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ github.event.pull_request.base.ref }}
+ persist-credentials: false
- name: Classify PR
env:
diff --git a/.github/workflows/pr-label-scope.yml b/.github/workflows/pr-label-scope.yml
index 1c3885612e7..b8a282472ba 100644
--- a/.github/workflows/pr-label-scope.yml
+++ b/.github/workflows/pr-label-scope.yml
@@ -6,13 +6,20 @@ on:
permissions:
contents: read
+ issues: write
pull-requests: write
jobs:
scope:
runs-on: ubuntu-latest
steps:
- - uses: actions/labeler@v5
+ - name: Ensure DB MIGRATION label exists
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ REPO: ${{ github.repository }}
+ run: gh label create "DB MIGRATION" --repo "$REPO" --color C62828 --description "PR adds or modifies PostgreSQL or libSQL migration definitions" --force
+
+ - uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5
with:
configuration-path: .github/labeler.yml
sync-labels: false # additive only — never remove scope labels
diff --git a/.github/workflows/regression-test-check.yml b/.github/workflows/regression-test-check.yml
index 75b8eb55304..d06301b3787 100644
--- a/.github/workflows/regression-test-check.yml
+++ b/.github/workflows/regression-test-check.yml
@@ -3,29 +3,37 @@ name: Regression Test Check
on:
pull_request:
+permissions:
+ contents: read
+
jobs:
regression-test:
name: Regression test enforcement
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
+ persist-credentials: false
- name: Fetch PR head and base
+ env:
+ BASE_REF: ${{ github.event.pull_request.base.ref }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
- git fetch origin ${{ github.event.pull_request.base.ref }}
- git fetch origin pull/${{ github.event.pull_request.number }}/head:pr-head
+ git fetch origin -- "$BASE_REF"
+ git fetch origin -- "pull/${PR_NUMBER}/head:pr-head"
- name: Check for regression tests
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
+ PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
set -euo pipefail
- BASE_REF="origin/${{ github.event.pull_request.base.ref }}"
+ BASE_REF="origin/${PR_BASE_REF}"
# Use the actual PR head, not the merge commit that actions/checkout checks out
HEAD_REF="pr-head"
diff --git a/.github/workflows/release-plz-batch-summary.yml b/.github/workflows/release-plz-batch-summary.yml
index 0e1067362fd..8e01ec40e21 100644
--- a/.github/workflows/release-plz-batch-summary.yml
+++ b/.github/workflows/release-plz-batch-summary.yml
@@ -29,11 +29,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout base branch
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.event.pull_request.base.ref }}
fetch-depth: 0
fetch-tags: true
+ persist-credentials: false
- name: Update release-plz PR body with staging batch summary
env:
diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml
index d1be9004e68..cfff0e59720 100644
--- a/.github/workflows/release-plz.yml
+++ b/.github/workflows/release-plz.yml
@@ -17,18 +17,18 @@ jobs:
steps:
- &checkout
name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
persist-credentials: false
- &install-rust
name: Install Rust toolchain
- uses: dtolnay/rust-toolchain@stable
- - uses: Swatinem/rust-cache@v2
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
+ - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # 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
+ uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2
id: generate-token
with:
# GitHub App ID secret name
@@ -36,7 +36,7 @@ jobs:
# GitHub App private key secret name
private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }}
- name: Run release-plz
- uses: release-plz/action@v0.5
+ uses: release-plz/action@1528104d2ca23787631a1c1f022abb64b34c1e11 # v0.5
with:
command: release
env:
@@ -57,15 +57,15 @@ jobs:
steps:
- *checkout
- *install-rust
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: Generate GitHub token
- uses: actions/create-github-app-token@v2
+ uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2
id: generate-token
with:
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }}
- name: Run release-plz
- uses: release-plz/action@v0.5
+ uses: release-plz/action@1528104d2ca23787631a1c1f022abb64b34c1e11 # v0.5
with:
command: release-pr
env:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c4a4f416d53..7d23449e00f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -15,7 +15,7 @@
name: Release
permissions:
- "contents": "write"
+ contents: read
# 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.
@@ -41,7 +41,7 @@ permissions:
on:
push:
tags:
- - '**[0-9]+.[0-9]+.[0-9]+*'
+ - 'ironclaw-v[0-9]+.[0-9]+.[0-9]+*'
jobs:
# Run 'dist plan' (or host) to determine what tasks we need to do
@@ -55,7 +55,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
submodules: recursive
@@ -65,7 +65,7 @@ jobs:
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
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: cargo-dist-cache
path: ~/.cargo/bin/dist
@@ -75,13 +75,20 @@ jobs:
# (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
+ env:
+ IS_PUSH: ${{ !github.event.pull_request }}
+ REF_NAME: ${{ github.ref_name }}
run: |
- dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json
+ if [ "$IS_PUSH" = "true" ]; then
+ dist host --steps=create --tag="$REF_NAME" --output-format=json > plan-dist-manifest.json
+ else
+ dist plan --output-format=json > plan-dist-manifest.json
+ fi
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
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: artifacts-plan-dist-manifest
path: plan-dist-manifest.json
@@ -117,7 +124,7 @@ jobs:
- name: enable windows longpaths
run: |
git config --global core.longpaths true
- - uses: actions/checkout@v4
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
submodules: recursive
@@ -128,7 +135,7 @@ jobs:
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
+ - uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: ${{ join(matrix.targets, '-') }}
cache-provider: ${{ matrix.cache_provider }}
@@ -136,7 +143,7 @@ jobs:
run: ${{ matrix.install_dist.run }}
# Get the dist-manifest
- name: Fetch local artifacts
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: artifacts-*
path: target/distrib/
@@ -180,9 +187,13 @@ jobs:
run: |
${{ matrix.packages_install }}
- name: Build artifacts
+ env:
+ TAG_FLAG: ${{ needs.plan.outputs.tag-flag }}
+ DIST_ARGS: ${{ matrix.dist_args }}
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
+ # shellcheck disable=SC2086 # TAG_FLAG/DIST_ARGS may contain multiple args
+ dist build $TAG_FLAG --print=linkage --output-format=json $DIST_ARGS > dist-manifest.json
echo "dist ran successfully"
- id: cargo-dist
name: Post-build
@@ -198,7 +209,7 @@ jobs:
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
path: |
@@ -215,27 +226,30 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
submodules: recursive
- name: Install cached dist
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # 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
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- id: cargo-dist
shell: bash
+ env:
+ TAG_FLAG: ${{ needs.plan.outputs.tag-flag }}
run: |
- dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json
+ # shellcheck disable=SC2086 # TAG_FLAG may expand to '--tag=X' or empty
+ dist build $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
@@ -245,7 +259,7 @@ jobs:
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: artifacts-build-global
path: |
@@ -260,7 +274,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
submodules: recursive
@@ -268,7 +282,7 @@ jobs:
run: |
rustup target add wasm32-wasip2
cargo install cargo-component --locked || true
- - uses: swatinem/rust-cache@v2
+ - uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: wasm-extensions
- name: Build and package WASM extensions
@@ -374,7 +388,7 @@ jobs:
echo "=== WASM bundles built ==="
ls -la target/wasm-bundles/
- name: "Upload WASM bundles"
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: artifacts-wasm-extensions
path: |
@@ -390,45 +404,50 @@ jobs:
- 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') }}
+ permissions:
+ contents: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
runs-on: "ubuntu-22.04"
outputs:
val: ${{ steps.host.outputs.manifest }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
submodules: recursive
- name: Install cached dist
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # 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
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- id: host
shell: bash
+ env:
+ TAG_FLAG: ${{ needs.plan.outputs.tag-flag }}
run: |
- dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json
+ # shellcheck disable=SC2086 # TAG_FLAG may expand to '--tag=X' or empty
+ dist host $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
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 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
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: artifacts-*
path: artifacts
@@ -443,11 +462,24 @@ jobs:
ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}"
ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}"
RELEASE_COMMIT: "${{ github.sha }}"
+ RELEASE_TAG: ${{ needs.plan.outputs.tag }}
run: |
# Write and read notes from a file to avoid quoting breaking things
- echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt
+ echo "$ANNOUNCEMENT_BODY" > "$RUNNER_TEMP/notes.txt"
+
+ # shellcheck disable=SC2086 # PRERELEASE_FLAG is '--prerelease' or empty
+ gh release create "$RELEASE_TAG" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/*
- gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/*
+ # Build and push Docker Hub images (:version, :latest, :sha-*) after the GitHub Release exists.
+ docker-image:
+ needs: host
+ if: ${{ always() && needs.host.result == 'success' }}
+ permissions:
+ contents: read
+ packages: read
+ actions: write
+ uses: ./.github/workflows/docker.yml
+ secrets: inherit
# Commit patched manifest SHA256 checksums back to main so the repo
# stays in sync with the released artifacts.
@@ -464,11 +496,12 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: main
+ # persist-credentials kept enabled — job pushes a checksum-update branch.
- name: Fetch WASM checksums
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: artifacts-wasm-extensions
path: target/wasm-bundles/
@@ -537,7 +570,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
submodules: recursive
diff --git a/.github/workflows/staging-ci.yml b/.github/workflows/staging-ci.yml
index 2df7bf6f70d..5b8cc1abfe1 100644
--- a/.github/workflows/staging-ci.yml
+++ b/.github/workflows/staging-ci.yml
@@ -15,10 +15,7 @@ on:
default: false
permissions:
- contents: write
- issues: write
- pull-requests: write
- checks: read
+ contents: read
concurrency:
group: staging-ci
@@ -29,6 +26,9 @@ jobs:
resolve-promotion-base:
name: Resolve promotion base
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
outputs:
promotion_base: ${{ steps.resolve.outputs.promotion_base }}
steps:
@@ -55,16 +55,19 @@ jobs:
name: Check for new commits
needs: resolve-promotion-base
runs-on: ubuntu-latest
+ permissions:
+ contents: read
outputs:
has_changes: ${{ steps.check.outputs.has_changes }}
current_head: ${{ steps.check.outputs.current_head }}
diff_range: ${{ steps.check.outputs.diff_range }}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
- ref: staging
+ ref: ${{ github.sha }}
fetch-depth: 0
fetch-tags: true
+ persist-credentials: false
- name: Check for changes since last tested
id: check
@@ -117,6 +120,8 @@ jobs:
needs: check-changes
if: needs.check-changes.outputs.has_changes == 'true'
uses: ./.github/workflows/test.yml
+ with:
+ ref: ${{ needs.check-changes.outputs.current_head }}
# ── Run E2E browser tests ────────────────────────────────────────
e2e:
@@ -124,6 +129,8 @@ jobs:
needs: check-changes
if: needs.check-changes.outputs.has_changes == 'true'
uses: ./.github/workflows/e2e.yml
+ with:
+ ref: ${{ needs.check-changes.outputs.current_head }}
# ── Create promotion PR (triggers claude-review.yml on the PR) ──
create-promotion-pr:
@@ -131,22 +138,26 @@ jobs:
needs: [resolve-promotion-base, check-changes]
if: needs.check-changes.outputs.has_changes == 'true'
runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
outputs:
pr_number: ${{ steps.create-pr.outputs.pr_number }}
promotion_branch: ${{ steps.branch.outputs.branch }}
steps:
- - uses: actions/checkout@v6
- with:
- ref: staging
- fetch-depth: 0
-
- name: Generate GitHub App token
id: app-token
- uses: actions/create-github-app-token@v2
+ uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2
with:
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }}
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ ref: ${{ needs.check-changes.outputs.current_head }}
+ fetch-depth: 0
+ token: ${{ steps.app-token.outputs.token }}
+
- name: Set token
id: token
run: |
@@ -163,7 +174,7 @@ jobs:
PROMOTION_BASE: ${{ needs.resolve-promotion-base.outputs.promotion_base }}
run: |
git fetch origin "${PROMOTION_BASE}"
- AHEAD=$(git rev-list --count "origin/${PROMOTION_BASE}..origin/staging")
+ AHEAD=$(git rev-list --count "origin/${PROMOTION_BASE}..HEAD")
echo "commits_ahead=${AHEAD}" >> "$GITHUB_OUTPUT"
if [ "$AHEAD" -eq 0 ]; then
echo "Staging is not ahead of ${PROMOTION_BASE}. Nothing to promote."
@@ -247,18 +258,24 @@ jobs:
needs.create-promotion-pr.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 25
+ permissions:
+ contents: write
+ pull-requests: write
+ issues: write
+ checks: read
outputs:
gate_passed: ${{ steps.evaluate.outputs.passed }}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: staging
# Need full history to recompute the final promoted range before merge.
fetch-depth: 0
+ persist-credentials: false
- name: Generate GitHub App token
id: app-token
- uses: actions/create-github-app-token@v2
+ uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2
with:
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }}
@@ -489,11 +506,14 @@ jobs:
needs.e2e.result == 'success' &&
needs.create-promotion-pr.result == 'success'
runs-on: ubuntu-latest
+ permissions:
+ contents: write
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: staging
fetch-depth: 0
+ # persist-credentials kept enabled — job pushes the staging-tested tag.
- name: Update staging-tested tag
run: |
@@ -507,6 +527,8 @@ jobs:
needs: [check-changes, tests, e2e, create-promotion-pr, gate, update-tag]
if: always() && needs.check-changes.outputs.has_changes == 'true'
runs-on: ubuntu-latest
+ permissions:
+ contents: read
steps:
- name: Summary
run: |
diff --git a/.github/workflows/staging-promotion-metadata.yml b/.github/workflows/staging-promotion-metadata.yml
index 76b8326b29c..3017e97061a 100644
--- a/.github/workflows/staging-promotion-metadata.yml
+++ b/.github/workflows/staging-promotion-metadata.yml
@@ -20,7 +20,6 @@ on:
permissions:
contents: read
- pull-requests: write
jobs:
refresh-single-pr:
@@ -30,15 +29,19 @@ jobs:
startsWith(github.event.pull_request.head.ref, 'staging-promote/')) ||
github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: write
steps:
- name: Checkout workflow source
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
# For chained promotion PRs, the script lives on the trusted PR head,
# not necessarily on the older promotion branch used as the PR base.
ref: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.event.pull_request.head.sha }}
fetch-depth: 0
fetch-tags: true
+ persist-credentials: false
- name: Refresh staging promotion PR body
env:
@@ -51,13 +54,17 @@ jobs:
refresh-open-prs-after-main-push:
if: github.event_name == 'push'
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: write
steps:
- name: Checkout main
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: main
fetch-depth: 0
fetch-tags: true
+ persist-credentials: false
- name: Refresh all open staging promotion PR bodies
env:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 00488c70fca..67aef9f1b62 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1,6 +1,11 @@
name: Run Tests
on:
workflow_call:
+ inputs:
+ ref:
+ description: Commit SHA or ref to test
+ required: false
+ type: string
pull_request:
branches:
- main
@@ -8,10 +13,14 @@ on:
branches:
- main
+permissions:
+ contents: read
+
jobs:
tests:
name: Tests (${{ matrix.name }})
runs-on: ubuntu-latest
+ timeout-minutes: 45
strategy:
fail-fast: false
matrix:
@@ -27,12 +36,15 @@ jobs:
flags: "--no-default-features --features libsql"
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ ref: ${{ inputs.ref || github.sha }}
+ persist-credentials: false
- name: Install Rust
- uses: dtolnay/rust-toolchain@stable
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
targets: wasm32-wasip2
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: ${{ matrix.name }}
- name: Install cargo-component
@@ -40,27 +52,37 @@ jobs:
- name: Build WASM channels (for integration tests)
run: ./scripts/build-wasm-extensions.sh --channels
- name: Run Tests
- run: cargo test ${{ matrix.flags }} -- --nocapture
+ run: |
+ timeout --signal=INT --kill-after=30s 40m \
+ cargo test ${{ matrix.flags }} -- --nocapture
heavy-integration-tests:
name: Heavy Integration Tests
runs-on: ubuntu-latest
+ timeout-minutes: 20
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ ref: ${{ inputs.ref || github.sha }}
+ persist-credentials: false
- name: Install Rust
- uses: dtolnay/rust-toolchain@stable
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
targets: wasm32-wasip2
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: heavy-integration
- name: Build Telegram WASM channel
run: cargo build --manifest-path channels-src/telegram/Cargo.toml --target wasm32-wasip2 --release
- name: Run thread scheduling integration tests
- run: cargo test --no-default-features --features libsql,integration --test e2e_thread_scheduling -- --nocapture
+ run: |
+ timeout --signal=INT --kill-after=30s 15m \
+ cargo test --no-default-features --features libsql,integration --test e2e_thread_scheduling -- --nocapture
- name: Run Telegram thread-scope regression test
- run: cargo test --features integration --test telegram_auth_integration test_private_messages_use_chat_id_as_thread_scope -- --exact
+ run: |
+ timeout --signal=INT --kill-after=30s 10m \
+ cargo test --features integration --test telegram_auth_integration test_private_messages_use_chat_id_as_thread_scope -- --exact
telegram-tests:
name: Telegram Channel Tests
@@ -68,14 +90,20 @@ jobs:
github.event_name != 'pull_request' ||
github.base_ref != 'staging'
runs-on: ubuntu-latest
+ timeout-minutes: 15
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ ref: ${{ inputs.ref || github.sha }}
+ persist-credentials: false
- name: Install Rust
- uses: dtolnay/rust-toolchain@stable
- - uses: Swatinem/rust-cache@v2
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
+ - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: Run Telegram Channel Tests
- run: cargo test --manifest-path channels-src/telegram/Cargo.toml -- --nocapture
+ run: |
+ timeout --signal=INT --kill-after=30s 10m \
+ cargo test --manifest-path channels-src/telegram/Cargo.toml -- --nocapture
windows-build:
name: Windows Build (${{ matrix.name }})
@@ -95,10 +123,13 @@ jobs:
flags: "--no-default-features --features libsql"
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ ref: ${{ inputs.ref || github.sha }}
+ persist-credentials: false
- name: Install Rust
- uses: dtolnay/rust-toolchain@stable
- - uses: Swatinem/rust-cache@v2
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
+ - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: windows-${{ matrix.name }}
- name: Check compilation
@@ -110,14 +141,18 @@ jobs:
github.event_name != 'pull_request' ||
github.base_ref != 'staging'
runs-on: ubuntu-latest
+ timeout-minutes: 30
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ ref: ${{ inputs.ref || github.sha }}
+ persist-credentials: false
- name: Install Rust
- uses: dtolnay/rust-toolchain@stable
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
targets: wasm32-wasip2
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: wasm-extensions
- name: Install cargo-component
@@ -125,17 +160,22 @@ jobs:
- name: Build all WASM extensions against current WIT
run: ./scripts/build-wasm-extensions.sh
- name: Instantiation test (host linker compatibility)
- run: cargo test --all-features wit_compat -- --nocapture
+ run: |
+ timeout --signal=INT --kill-after=30s 20m \
+ cargo test --all-features wit_compat -- --nocapture
bench-compile:
name: Benchmark Compilation
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ ref: ${{ inputs.ref || github.sha }}
+ persist-credentials: false
- name: Install Rust
- uses: dtolnay/rust-toolchain@stable
- - uses: Swatinem/rust-cache@v2
+ uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
+ - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: bench
- name: Compile benchmarks
@@ -149,9 +189,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ ref: ${{ inputs.ref || github.sha }}
+ persist-credentials: false
- name: Build Docker image
- run: docker build -t ironclaw-test:ci .
+ run: docker build --target runtime -t ironclaw-test:ci .
version-check:
name: Version Bump Check
@@ -159,8 +202,10 @@ jobs:
if: github.event_name == 'pull_request'
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
+ ref: ${{ inputs.ref || github.sha }}
+ persist-credentials: false
fetch-depth: 0
- name: Check version bumps for changed extensions
env:
diff --git a/.gitignore b/.gitignore
index 2577b4a278d..d83d6b97b96 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,7 @@ target/
# Python
__pycache__/
*.pyc
+/tests/e2e/.venv/
# Benchmark results (local runs, not committed)
bench-results/
@@ -39,3 +40,4 @@ __pycache__/
*.pyc
*.pyo
*.pyd
+engine_trace_*.json
diff --git a/AGENTS.md b/AGENTS.md
index cc5e7cff5d6..90ad4bf5229 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -77,6 +77,7 @@ Start with these deeper docs as needed:
- 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).
- Add the narrowest tests that validate the change: unit tests for local logic, integration tests for runtime/DB/routing behavior, and E2E or trace coverage for gateway, approvals, extensions, or other user-visible flows.
+- **Test through the caller, not just the helper.** When a predicate/classifier/transform helper gates a side effect (HTTP, DB write, OAuth flow, UI mutation, tool execution) and has any wrapper or computed input between it and that side effect, a unit test on the helper alone is not sufficient regression coverage. Add a test that drives the actual call site (`*_handler`, `factory::create_*`, `manager::*`) at the integration tier or higher. Mocks of multi-arg runtime APIs must capture every argument the production caller passes. See `.claude/rules/testing.md` for the full rule and bug examples.
## Risk and Change Discipline
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6aad499357c..589ee6d9bde 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,324 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.25.0](https://github.com/nearai/ironclaw/compare/ironclaw-v0.24.0...ironclaw-v0.25.0) - 2026-04-11
+
+### Added
+
+- *(tools)* production-grade coding tools, file history, and skills ([#2025](https://github.com/nearai/ironclaw/pull/2025))
+- add extensible deployment profiles (IRONCLAW_PROFILE) ([#2203](https://github.com/nearai/ironclaw/pull/2203))
+- *(skills)* commitments system — active intake for personal AI assistant ([#1736](https://github.com/nearai/ironclaw/pull/1736))
+- add native Composio tool for third-party app integrations ([#920](https://github.com/nearai/ironclaw/pull/920))
+- *(gateway)* extract gateway frontend into ironclaw_gateway crate with widget system ([#1725](https://github.com/nearai/ironclaw/pull/1725))
+- *(railway)* build staging target with pre-bundled WASM extensions ([#2219](https://github.com/nearai/ironclaw/pull/2219))
+- *(docker)* pre-bundle WASM extensions in staging image ([#2210](https://github.com/nearai/ironclaw/pull/2210))
+- *(tui)* ship TUI in default binary ([#2195](https://github.com/nearai/ironclaw/pull/2195))
+- *(admin)* admin tool policy to disable tools for users ([#2154](https://github.com/nearai/ironclaw/pull/2154))
+- *(web)* add scroll-to-bottom arrow in gateway chat ([#2202](https://github.com/nearai/ironclaw/pull/2202))
+- unified tool dispatch + schema-validated workspace ([#2049](https://github.com/nearai/ironclaw/pull/2049))
+- *(workspace)* admin system prompt shared with all users ([#2109](https://github.com/nearai/ironclaw/pull/2109))
+- *(engine)* restage skill repair learning loop on staging ([#1962](https://github.com/nearai/ironclaw/pull/1962))
+- *(tui)* port full-featured Ratatui terminal UI onto staging ([#1973](https://github.com/nearai/ironclaw/pull/1973))
+- *(slack)* implement on_broadcast and fix message tool hints ([#2113](https://github.com/nearai/ironclaw/pull/2113))
+- *(i18n)* add Korean translation, fix zh-CN drift, and prevent future drift via pre-commit hook ([#2065](https://github.com/nearai/ironclaw/pull/2065))
+- NEAR AI MCP server ([#2009](https://github.com/nearai/ironclaw/pull/2009))
+- *(test)* dual-mode live/replay test harness with LLM judge ([#2039](https://github.com/nearai/ironclaw/pull/2039))
+- add AWS Bedrock embeddings provider ([#1568](https://github.com/nearai/ironclaw/pull/1568))
+- *(ownership)* centralized ownership model with typed identities, DB-backed pairing, and OwnershipCache ([#1898](https://github.com/nearai/ironclaw/pull/1898))
+- *(tools)* persistent per-user tool permission system ([#1911](https://github.com/nearai/ironclaw/pull/1911))
+- *(engine)* Unified Thread-Capability-CodeAct execution engine (v2 architecture) ([#1557](https://github.com/nearai/ironclaw/pull/1557))
+- *(auth)* direct OAuth/social login with Google, GitHub, Apple, and NEAR wallet ([#1798](https://github.com/nearai/ironclaw/pull/1798))
+- Add ACP (Agent Client Protocol) job mode for delegating to any compatible coding agent ([#1600](https://github.com/nearai/ironclaw/pull/1600))
+- *(workspace)* metadata-driven indexing/hygiene, document versioning, and patch ([#1723](https://github.com/nearai/ironclaw/pull/1723))
+- *(jobs)* per-job MCP server filtering and max_iterations cap ([#1243](https://github.com/nearai/ironclaw/pull/1243))
+- *(config)* unify all settings to DB > env > default priority ([#1722](https://github.com/nearai/ironclaw/pull/1722))
+- *(telegram)* add sendVoice support for audio/ogg attachments ([#1314](https://github.com/nearai/ironclaw/pull/1314))
+- *(setup)* build ironclaw-worker Docker image in setup wizard ([#1757](https://github.com/nearai/ironclaw/pull/1757))
+
+### Fixed
+
+- *(ci)* bump 5 channel versions + fix lifetime desync in panics check ([#2300](https://github.com/nearai/ironclaw/pull/2300))
+- *(test)* case-insensitive hint matching in TraceLlm step_matches ([#2292](https://github.com/nearai/ironclaw/pull/2292))
+- *(v2)* tool naming, auth gates, schema flatten, WASM traps, workspace race ([#2209](https://github.com/nearai/ironclaw/pull/2209))
+- *(ci)* resolve 4 staging test failures ([#2273](https://github.com/nearai/ironclaw/pull/2273))
+- *(docker)* copy profiles/ into build stages ([#2289](https://github.com/nearai/ironclaw/pull/2289))
+- *(engine)* mission cron scheduling + timezone propagation ([#1944](https://github.com/nearai/ironclaw/pull/1944)) ([#1957](https://github.com/nearai/ironclaw/pull/1957))
+- *(oauth)* use localhost for redirect URI when bound to 0.0.0.0 ([#2247](https://github.com/nearai/ironclaw/pull/2247))
+- *(bridge)* sanitize auth_url on engine v2 path ([#2206](https://github.com/nearai/ironclaw/pull/2206)) ([#2215](https://github.com/nearai/ironclaw/pull/2215))
+- *(docs)* explain in more details `activation` block & installation steps for skills ([#2216](https://github.com/nearai/ironclaw/pull/2216))
+- *(docker)* consume CACHE_BUST arg so BuildKit invalidates cache
+- *(gateway)* suppress duplicate text response during auth flow and unify extension config modal ([#2172](https://github.com/nearai/ironclaw/pull/2172))
+- *(agent)* stop intercepting bare yes/no/always as approval when nothing pending ([#2178](https://github.com/nearai/ironclaw/pull/2178))
+- *(ci)* resolve 3 staging test failures ([#2207](https://github.com/nearai/ironclaw/pull/2207))
+- *(wasm)* upgrade Wasmtime to 43.0.1 and restore CI ([#2224](https://github.com/nearai/ironclaw/pull/2224))
+- fix(auth) first-pass Gmail OAuth auth prompt in chat ([#2038](https://github.com/nearai/ironclaw/pull/2038))
+- *(db)* repair V6 migration checksum and guard against re-modification ([#1328](https://github.com/nearai/ironclaw/pull/1328)) ([#2101](https://github.com/nearai/ironclaw/pull/2101))
+- *(ci)* target wasm32-wasip2 in WASM build script ([#2175](https://github.com/nearai/ironclaw/pull/2175))
+- *(test)* use canonical extension name in setup submit test ([#2158](https://github.com/nearai/ironclaw/pull/2158))
+- fix (skills) installs for invalid catalog names ([#2040](https://github.com/nearai/ironclaw/pull/2040))
+- universal engine-version tool visibility filtering ([#2132](https://github.com/nearai/ironclaw/pull/2132))
+- *(ownership)* remove silent cross-tenant credential fallback ([#2099](https://github.com/nearai/ironclaw/pull/2099))
+- *(e2e)* canonicalize extension names + fix remaining test failures ([#2129](https://github.com/nearai/ironclaw/pull/2129))
+- *(ownership)* unify ownership checks via Owned trait and fix mission visibility bug ([#2126](https://github.com/nearai/ironclaw/pull/2126))
+- *(web)* intercept approval text input in chat ([#2124](https://github.com/nearai/ironclaw/pull/2124))
+- *(staging)* repair 4 categories of CI test failures ([#2091](https://github.com/nearai/ironclaw/pull/2091))
+- *(web)* emit Done after response — SSE ordering fix ([#2079](https://github.com/nearai/ironclaw/pull/2079)) ([#2104](https://github.com/nearai/ironclaw/pull/2104))
+- *(tools)* gate claude_code and acp modes behind enabled flags ([#2003](https://github.com/nearai/ironclaw/pull/2003))
+- *(acp)* propagate follow-up prompt failures as job errors ([#1981](https://github.com/nearai/ironclaw/pull/1981))
+- color for tools use ([#2096](https://github.com/nearai/ironclaw/pull/2096))
+- *(registry)* use canonical underscore names in manifests to fix WASM install ([#2029](https://github.com/nearai/ironclaw/pull/2029))
+- *(safety)* add credential patterns and sensitive path blocklist ([#1675](https://github.com/nearai/ironclaw/pull/1675))
+- *(channels)* allow telegram wasm channel name ([#2051](https://github.com/nearai/ironclaw/pull/2051))
+- *(staging)* repair broken test build and macOS-incompatible SSRF tests ([#2064](https://github.com/nearai/ironclaw/pull/2064))
+- honor auto-approve tools in engine v2 ([#2013](https://github.com/nearai/ironclaw/pull/2013))
+- *(bridge)* sanitize orphaned tool results in v2 adapter ([#1975](https://github.com/nearai/ironclaw/pull/1975))
+- *(docker)* ensure ironclaw runtime home exists ([#1918](https://github.com/nearai/ironclaw/pull/1918))
+- *(agent)* prevent self-repair notification spam for stuck jobs ([#1867](https://github.com/nearai/ironclaw/pull/1867))
+- *(self-repair)* skip built-in tools in broken tool detection and repair ([#1991](https://github.com/nearai/ironclaw/pull/1991))
+- unblock bootstrap ownership on dynamic_tools ([#2005](https://github.com/nearai/ironclaw/pull/2005))
+- *(llm)* invert reasoning default — unknown models skip think/final tags ([#1952](https://github.com/nearai/ironclaw/pull/1952))
+- *(llm)* add sanitize_tool_messages to OpenAiCodexProvider ([#1971](https://github.com/nearai/ironclaw/pull/1971))
+- update CLI help snapshots for --auto-approve and acp command ([#1966](https://github.com/nearai/ironclaw/pull/1966))
+- *(docker)* switch to glibc to fix libSQL segfault on DB reopen ([#1930](https://github.com/nearai/ironclaw/pull/1930))
+- *(db)* swap V16/V17 to match production PG (document_versions before user_identities) ([#1931](https://github.com/nearai/ironclaw/pull/1931))
+- *(db)* keep V15=conversation_source_channel to match production PG ([#1928](https://github.com/nearai/ironclaw/pull/1928))
+- *(db)* resolve V15 migration numbering conflict ([#1923](https://github.com/nearai/ironclaw/pull/1923))
+- *(routines)* add bounded retry for transient lightweight failures ([#1471](https://github.com/nearai/ironclaw/pull/1471))
+- *(relay)* thread responses under original message in Slack channels ([#1848](https://github.com/nearai/ironclaw/pull/1848))
+- *(worker)* Improve command execution parameter validation ([#1692](https://github.com/nearai/ironclaw/pull/1692))
+- *(telegram)* auto-generate webhook secret during setup ([#1536](https://github.com/nearai/ironclaw/pull/1536))
+- *(builder)* accept inline-table and object-map dependency formats from LLM ([#1748](https://github.com/nearai/ironclaw/pull/1748))
+- *(gemini)* preserve and echo thoughtSignature for Gemini 3.x function calls ([#1752](https://github.com/nearai/ironclaw/pull/1752))
+- *(relay)* route async Slack messages to correct channel instead of DMs ([#1845](https://github.com/nearai/ironclaw/pull/1845))
+- *(security)* block cross-channel approval thread hijacking ([#1590](https://github.com/nearai/ironclaw/pull/1590))
+- *(builder)* add approval context propagation for sub-tool execution ([#1125](https://github.com/nearai/ironclaw/pull/1125))
+
+### Other
+
+- trigger ironclaw-dind image build ([#2190](https://github.com/nearai/ironclaw/pull/2190))
+- add amazon tutorial ([#2261](https://github.com/nearai/ironclaw/pull/2261))
+- Create QA Bug Report issue template ([#2228](https://github.com/nearai/ironclaw/pull/2228))
+- [codex] Stabilize auth readiness and gate flows ([#2050](https://github.com/nearai/ironclaw/pull/2050))
+- Add mintlify docs ([#2189](https://github.com/nearai/ironclaw/pull/2189))
+- [codex] allow private local llm endpoints ([#1955](https://github.com/nearai/ironclaw/pull/1955))
+- *(ci)* add Dependabot and pin GitHub Actions by SHA ([#2043](https://github.com/nearai/ironclaw/pull/2043))
+- Fix routine Telegram notification summaries ([#2033](https://github.com/nearai/ironclaw/pull/2033))
+- *(channels)* add Slack E2E tests, integration tests, and smoke runner ([#2042](https://github.com/nearai/ironclaw/pull/2042))
+- *(engine)* rename ENGINE_V2_TRACE to IRONCLAW_RECORD_TRACE ([#2114](https://github.com/nearai/ironclaw/pull/2114))
+- fix multi-tenant inference latency (per-conversation locking + workspace indexing) ([#2127](https://github.com/nearai/ironclaw/pull/2127))
+- Improve channel onboarding and Telegram pairing flow ([#2103](https://github.com/nearai/ironclaw/pull/2103))
+- *(e2e)* expand SSE resilience coverage ([#1897](https://github.com/nearai/ironclaw/pull/1897))
+- add Telegram E2E tests and Rust integration tests ([#2037](https://github.com/nearai/ironclaw/pull/2037))
+- (fix) WASM channel HTTP SSRF protections ([#1976](https://github.com/nearai/ironclaw/pull/1976))
+- Ignore default model override and empty WASM polls ([#1914](https://github.com/nearai/ironclaw/pull/1914))
+- *(workspace)* add direct regression tests for scoped_to_user rebinding ([#1652](https://github.com/nearai/ironclaw/pull/1652)) ([#1875](https://github.com/nearai/ironclaw/pull/1875))
+- Fix turn cost footer and per-turn usage accounting ([#1951](https://github.com/nearai/ironclaw/pull/1951))
+- Publish ironclaw-worker image from Dockerfile.worker ([#1979](https://github.com/nearai/ironclaw/pull/1979))
+- [codex] Move safety benches into ironclaw_safety crate ([#1954](https://github.com/nearai/ironclaw/pull/1954))
+- Fix bootstrap paths and webhook defaults
+- Only tag :latest/:version on release, allow :staging via manual dispatch [skip-regression-check] ([#1925](https://github.com/nearai/ironclaw/pull/1925))
+- Add Docker Hub workflow and optimize Dockerfile for size ([#1886](https://github.com/nearai/ironclaw/pull/1886))
+- *(e2e)* add agent loop recovery coverage ([#1854](https://github.com/nearai/ironclaw/pull/1854))
+- disable cooldown in gateway webhook workflow test ([#1889](https://github.com/nearai/ironclaw/pull/1889))
+- Expand GitHub WASM tool surface ([#1884](https://github.com/nearai/ironclaw/pull/1884))
+- *(e2e)* cover chat approval parity across channels ([#1858](https://github.com/nearai/ironclaw/pull/1858))
+- add routine coverage for issue 1781 ([#1856](https://github.com/nearai/ironclaw/pull/1856))
+
+## [0.24.0](https://github.com/nearai/ironclaw/compare/ironclaw-v0.23.0...ironclaw-v0.24.0) - 2026-03-31
+
+### Added
+
+- *(gateway)* OIDC JWT authentication for reverse-proxy deployments ([#1463](https://github.com/nearai/ironclaw/pull/1463))
+- support custom LLM provider configuration via web UI ([#1340](https://github.com/nearai/ironclaw/pull/1340))
+- *(skills)* recursive bundle directory scanning for skill discovery ([#1667](https://github.com/nearai/ironclaw/pull/1667))
+- *(discord)* add gateway channel flow in wasm ([#944](https://github.com/nearai/ironclaw/pull/944))
+- DB-backed user management, admin secrets provisioning, and multi-tenant isolation ([#1626](https://github.com/nearai/ironclaw/pull/1626))
+- *(gateway)* add OpenAI Responses API endpoints ([#1656](https://github.com/nearai/ironclaw/pull/1656))
+
+### Fixed
+
+- *(routines)* clone Arc before await in web handler event cache refresh ([#1756](https://github.com/nearai/ironclaw/pull/1756))
+- *(slack)* respond to thread replies without requiring @mention ([#1405](https://github.com/nearai/ironclaw/pull/1405))
+- resolve 11 test failures from multi-tenant bootstrap and sandbox gate regressions ([#1746](https://github.com/nearai/ironclaw/pull/1746))
+- *(auth)* make shared Google tool status scope-aware ([#1532](https://github.com/nearai/ironclaw/pull/1532))
+- *(wasm)* inject Content-Length: 0 for bodyless mutating HTTP requests ([#1529](https://github.com/nearai/ironclaw/pull/1529))
+- *(bedrock)* strip tool blocks from messages when toolConfig is absent ([#1630](https://github.com/nearai/ironclaw/pull/1630))
+- prevent UTF-8 panics in byte-index string truncation ([#1688](https://github.com/nearai/ironclaw/pull/1688))
+- *(gemini)* preserve thought signatures on all tool calls ([#1565](https://github.com/nearai/ironclaw/pull/1565))
+- pin staging ci jobs to a single tested sha ([#1628](https://github.com/nearai/ironclaw/pull/1628))
+- *(routines)* complete full_job execution reliability overhaul ([#1650](https://github.com/nearai/ironclaw/pull/1650))
+- *(worker)* treat empty LLM response after text output as completion ([#1677](https://github.com/nearai/ironclaw/pull/1677))
+- *(worker)* replace script -qfc with pty-process for injection-safe PTY ([#1678](https://github.com/nearai/ironclaw/pull/1678))
+- *(web)* redact database error details from API responses ([#1711](https://github.com/nearai/ironclaw/pull/1711))
+- *(oauth)* tighten legacy state validation and fallback handling ([#1701](https://github.com/nearai/ironclaw/pull/1701))
+- *(db)* add tracing warn for naive timestamp fallback and improve parse_timestamp tests ([#1700](https://github.com/nearai/ironclaw/pull/1700))
+- *(wasm)* use typed WASM schema as advertised schema when available ([#1699](https://github.com/nearai/ironclaw/pull/1699))
+- sanitize tool error results before llm injection ([#1639](https://github.com/nearai/ironclaw/pull/1639))
+- require Feishu webhook authentication ([#1638](https://github.com/nearai/ironclaw/pull/1638))
+- *(llm)* prevent UTF-8 panic in line_bounds() (fixes #1669) ([#1679](https://github.com/nearai/ironclaw/pull/1679))
+- downgrade excessive debug logging in hot path (closes #1686) ([#1694](https://github.com/nearai/ironclaw/pull/1694))
+
+### Other
+
+- Stabilize MCP refresh regression tests ([#1772](https://github.com/nearai/ironclaw/pull/1772))
+- Fix hosted MCP OAuth refresh flow ([#1767](https://github.com/nearai/ironclaw/pull/1767))
+- Track routine verification state across updates ([#1716](https://github.com/nearai/ironclaw/pull/1716))
+- *(e2e)* align WASM reinstall expectation with uninstall cleanup ([#1762](https://github.com/nearai/ironclaw/pull/1762))
+- Handle empty tool completions in autonomous jobs ([#1720](https://github.com/nearai/ironclaw/pull/1720))
+- Clarify message tool vs channel setup guidance ([#1715](https://github.com/nearai/ironclaw/pull/1715))
+- tighten contribution and PR guidance ([#1704](https://github.com/nearai/ironclaw/pull/1704))
+- Clean up extension credentials on uninstall ([#1718](https://github.com/nearai/ironclaw/pull/1718))
+
+## [0.23.0](https://github.com/nearai/ironclaw/compare/ironclaw-v0.22.0...ironclaw-v0.23.0) - 2026-03-27
+
+### Added
+
+- complete multi-tenant isolation — phases 2–4 ([#1614](https://github.com/nearai/ironclaw/pull/1614))
+
+### Fixed
+
+- *(routines)* recover delete name after failed update fallback ([#1108](https://github.com/nearai/ironclaw/pull/1108))
+- *(mcp)* handle 202 Accepted and wire session manager for Streamable HTTP ([#1437](https://github.com/nearai/ironclaw/pull/1437))
+- *(extensions)* channel-relay auth dead-end, observability, and URL override ([#1681](https://github.com/nearai/ironclaw/pull/1681))
+- *(agent)* discard truncated tool calls when finish_reason == Length ([#1631](https://github.com/nearai/ironclaw/pull/1631)) ([#1632](https://github.com/nearai/ironclaw/pull/1632))
+- *(llm)* filter XML tool-call recovery by context ([#1641](https://github.com/nearai/ironclaw/pull/1641))
+
+### Other
+
+- Support direct hosted OAuth callbacks with proxy auth token ([#1684](https://github.com/nearai/ironclaw/pull/1684))
+
+## [0.22.0](https://github.com/nearai/ironclaw/compare/ironclaw-v0.21.0...ironclaw-v0.22.0) - 2026-03-25
+
+### Added
+
+- *(agent)* thread per-tool reasoning through provider, session, and all surfaces ([#1513](https://github.com/nearai/ironclaw/pull/1513))
+- *(cli)* show credential auth status in tool info ([#1572](https://github.com/nearai/ironclaw/pull/1572))
+- multi-tenant auth with per-user workspace isolation ([#1118](https://github.com/nearai/ironclaw/pull/1118))
+- *(cli)* add ironclaw models subcommands (list/status/set/set-provider) ([#1043](https://github.com/nearai/ironclaw/pull/1043))
+- *(workspace)* multi-scope workspace reads ([#1117](https://github.com/nearai/ironclaw/pull/1117))
+- *(ux)* complete UX overhaul — design system, onboarding, web polish ([#1277](https://github.com/nearai/ironclaw/pull/1277))
+- *(gemini_oauth)* full Gemini CLI OAuth integration with Cloud Code API ([#1356](https://github.com/nearai/ironclaw/pull/1356))
+- *(shell)* add Low/Medium/High risk levels for graduated command approval (closes #172) ([#368](https://github.com/nearai/ironclaw/pull/368))
+- *(agent)* queue and merge messages during active turns ([#1412](https://github.com/nearai/ironclaw/pull/1412))
+- *(cli)* add `ironclaw hooks list` subcommand ([#1023](https://github.com/nearai/ironclaw/pull/1023))
+- *(extensions)* support text setup fields in web configure modal ([#496](https://github.com/nearai/ironclaw/pull/496))
+- *(llm)* add GitHub Copilot as LLM provider ([#1512](https://github.com/nearai/ironclaw/pull/1512))
+- *(workspace)* layered memory with sensitivity-based privacy redirect ([#1112](https://github.com/nearai/ironclaw/pull/1112))
+- *(webhooks)* add public webhook trigger endpoint for routines ([#736](https://github.com/nearai/ironclaw/pull/736))
+- *(llm)* Add OpenAI Codex (ChatGPT subscription) as LLM provider ([#1461](https://github.com/nearai/ironclaw/pull/1461))
+- *(web)* add light theme with dark/light/system toggle ([#1457](https://github.com/nearai/ironclaw/pull/1457))
+- *(agent)* activate stuck_threshold for time-based stuck job detection ([#1234](https://github.com/nearai/ironclaw/pull/1234))
+- chat onboarding and routine advisor ([#927](https://github.com/nearai/ironclaw/pull/927))
+
+### Fixed
+
+- ensure LLM calls always end with user message (closes #763) ([#1259](https://github.com/nearai/ironclaw/pull/1259))
+- restore owner-scoped gateway startup ([#1625](https://github.com/nearai/ironclaw/pull/1625))
+- remove stale stream_token gate from channel-relay activation ([#1623](https://github.com/nearai/ironclaw/pull/1623))
+- *(agent)* case-insensitive channel match and user_id filter for event triggers ([#1211](https://github.com/nearai/ironclaw/pull/1211))
+- *(routines)* normalize status display across web and CLI ([#1469](https://github.com/nearai/ironclaw/pull/1469))
+- *(tunnel)* managed tunnels target wrong port and die from SIGPIPE ([#1093](https://github.com/nearai/ironclaw/pull/1093))
+- *(agent)* persist /model selection to .env, TOML, and DB ([#1581](https://github.com/nearai/ironclaw/pull/1581))
+- post-merge review sweep — 8 fixes across security, perf, and correctness ([#1550](https://github.com/nearai/ironclaw/pull/1550))
+- generate Mistral-compatible 9-char alphanumeric tool call IDs ([#1242](https://github.com/nearai/ironclaw/pull/1242))
+- *(mcp)* handle empty 202 notification acknowledgements ([#1539](https://github.com/nearai/ironclaw/pull/1539))
+- *(tests)* eliminate env mutex poison cascade ([#1558](https://github.com/nearai/ironclaw/pull/1558))
+- *(safety)* escape tool output XML content and remove misleading sanitized attr ([#1067](https://github.com/nearai/ironclaw/pull/1067))
+- *(oauth)* reject malformed ic2.* states in decode_hosted_oauth_state ([#1441](https://github.com/nearai/ironclaw/pull/1441)) ([#1454](https://github.com/nearai/ironclaw/pull/1454))
+- parameter coercion and validation for oneOf/anyOf/allOf schemas ([#1397](https://github.com/nearai/ironclaw/pull/1397))
+- persist startup-loaded MCP clients in ExtensionManager ([#1509](https://github.com/nearai/ironclaw/pull/1509))
+- *(deps)* patch rustls-webpki vulnerability (RUSTSEC-2026-0049)
+- *(routines)* add missing extension_manager field in trigger_manual EngineContext
+- *(ci)* serialize env-mutating OAuth wildcard tests with ENV_MUTEX ([#1280](https://github.com/nearai/ironclaw/pull/1280)) ([#1468](https://github.com/nearai/ironclaw/pull/1468))
+- *(setup)* remove redundant LLM config and API keys from bootstrap .env ([#1448](https://github.com/nearai/ironclaw/pull/1448))
+- resolve wasm broadcast merge conflicts with staging ([#395](https://github.com/nearai/ironclaw/pull/395)) ([#1460](https://github.com/nearai/ironclaw/pull/1460))
+- skip credential validation for Bedrock backend ([#1011](https://github.com/nearai/ironclaw/pull/1011))
+- register sandbox jobs in ContextManager for query tool visibility ([#1426](https://github.com/nearai/ironclaw/pull/1426))
+- prefer execution-local message routing metadata ([#1449](https://github.com/nearai/ironclaw/pull/1449))
+- *(security)* validate embedding base URLs to prevent SSRF ([#1221](https://github.com/nearai/ironclaw/pull/1221))
+- f32→f64 precision artifact in temperature causes provider 400 errors ([#1450](https://github.com/nearai/ironclaw/pull/1450))
+- *(routines)* surface errors when sandbox unavailable for full_job routines ([#769](https://github.com/nearai/ironclaw/pull/769))
+- restore libSQL vector search with dynamic dimensions ([#1393](https://github.com/nearai/ironclaw/pull/1393))
+- staging CI triage — consolidate retry parsing, fix flaky tests, add docs ([#1427](https://github.com/nearai/ironclaw/pull/1427))
+
+### Other
+
+- Merge branch 'main' into staging-promote/455f543b-23329172268
+- Merge pull request #1655 from nearai/codex/fix-staging-promotion-1451-version-bumps
+- Merge pull request #1499 from nearai/staging-promote/9603fefd-23364438978
+- Fix libsql prompt scope regressions ([#1651](https://github.com/nearai/ironclaw/pull/1651))
+- Normalize cron schedules on routine create ([#1648](https://github.com/nearai/ironclaw/pull/1648))
+- Fix MCP lifecycle trace user scope ([#1646](https://github.com/nearai/ironclaw/pull/1646))
+- Fix REPL single-message hang and cap CI test duration ([#1643](https://github.com/nearai/ironclaw/pull/1643))
+- extract AppEvent to crates/ironclaw_common ([#1615](https://github.com/nearai/ironclaw/pull/1615))
+- Fix hosted OAuth refresh via proxy ([#1602](https://github.com/nearai/ironclaw/pull/1602))
+- *(agent)* optimize approval thread resolution (UUID parsing + lock contention) ([#1592](https://github.com/nearai/ironclaw/pull/1592))
+- *(tools)* auto-compact WASM tool schemas, add descriptions, improve credential prompts ([#1525](https://github.com/nearai/ironclaw/pull/1525))
+- Default new lightweight routines to tools-enabled ([#1573](https://github.com/nearai/ironclaw/pull/1573))
+- Google OAuth URL broken when initiated from Telegram channel ([#1165](https://github.com/nearai/ironclaw/pull/1165))
+- add gitcgr code graph badge ([#1563](https://github.com/nearai/ironclaw/pull/1563))
+- Fix owner-scoped message routing fallbacks ([#1574](https://github.com/nearai/ironclaw/pull/1574))
+- *(tools)* remove unconditional params clone in shared execution (fix #893) ([#926](https://github.com/nearai/ironclaw/pull/926))
+- *(llm)* move transcription module into src/llm/ ([#1559](https://github.com/nearai/ironclaw/pull/1559))
+- *(agent)* avoid preview allocations for non-truncated strings (fix #894) ([#924](https://github.com/nearai/ironclaw/pull/924))
+- Expand AGENTS.md with coding agents guidance ([#1392](https://github.com/nearai/ironclaw/pull/1392))
+- Fix CI approval flows and stale fixtures ([#1478](https://github.com/nearai/ironclaw/pull/1478))
+- Use live owner tool scope for autonomous routines and jobs ([#1453](https://github.com/nearai/ironclaw/pull/1453))
+- use Arc in embedding cache to avoid clones on miss path ([#1438](https://github.com/nearai/ironclaw/pull/1438))
+- Add owner-scoped permissions for full-job routines ([#1440](https://github.com/nearai/ironclaw/pull/1440))
+
+## [0.21.0](https://github.com/nearai/ironclaw/compare/v0.20.0...v0.21.0) - 2026-03-20
+
+### Added
+
+- structured fallback deliverables for failed/stuck jobs ([#236](https://github.com/nearai/ironclaw/pull/236))
+- LRU embedding cache for workspace search ([#1423](https://github.com/nearai/ironclaw/pull/1423))
+- receive relay events via webhook callbacks ([#1254](https://github.com/nearai/ironclaw/pull/1254))
+
+### Fixed
+
+- bump Feishu channel version for promotion
+- *(approval)* make "always" auto-approve work for credentialed HTTP requests ([#1257](https://github.com/nearai/ironclaw/pull/1257))
+- skip NEAR AI session check when backend is not nearai ([#1413](https://github.com/nearai/ironclaw/pull/1413))
+
+### Other
+
+- Make hosted OAuth and MCP auth generic ([#1375](https://github.com/nearai/ironclaw/pull/1375))
+
+## [0.20.0](https://github.com/nearai/ironclaw/compare/v0.19.0...v0.20.0) - 2026-03-19
+
+### Added
+
+- *(self-repair)* wire stuck_threshold, store, and builder ([#712](https://github.com/nearai/ironclaw/pull/712))
+- *(testing)* add FaultInjector framework for StubLlm ([#1233](https://github.com/nearai/ironclaw/pull/1233))
+- *(gateway)* unified settings page with subtabs ([#1191](https://github.com/nearai/ironclaw/pull/1191))
+- upgrade MiniMax default model to M2.7 ([#1357](https://github.com/nearai/ironclaw/pull/1357))
+
+### Fixed
+
+- navigate telegram E2E tests to channels subtab ([#1408](https://github.com/nearai/ironclaw/pull/1408))
+- add missing `builder` field and update E2E extensions tab navigation ([#1400](https://github.com/nearai/ironclaw/pull/1400))
+- remove debug_assert guards that panic on valid error paths ([#1385](https://github.com/nearai/ironclaw/pull/1385))
+- address valid review comments from PR #1359 ([#1380](https://github.com/nearai/ironclaw/pull/1380))
+- full_job routine runs stay running until linked job completion ([#1374](https://github.com/nearai/ironclaw/pull/1374))
+- full_job routine concurrency tracks linked job lifetime ([#1372](https://github.com/nearai/ironclaw/pull/1372))
+- remove -x from coverage pytest to prevent suite-blocking failures ([#1360](https://github.com/nearai/ironclaw/pull/1360))
+- add debug_assert invariant guards to critical code paths ([#1312](https://github.com/nearai/ironclaw/pull/1312))
+- *(mcp)* retry after missing session id errors ([#1355](https://github.com/nearai/ironclaw/pull/1355))
+- *(telegram)* preserve polling after secret-blocked updates ([#1353](https://github.com/nearai/ironclaw/pull/1353))
+- *(llm)* cap retry-after delays ([#1351](https://github.com/nearai/ironclaw/pull/1351))
+- *(setup)* remove nonexistent webhook secret command hint ([#1349](https://github.com/nearai/ironclaw/pull/1349))
+- Rate limiter returns retry after None instead of a duration ([#1269](https://github.com/nearai/ironclaw/pull/1269))
+
+### Other
+
+- bump telegram channel version to 0.2.5 ([#1410](https://github.com/nearai/ironclaw/pull/1410))
+- *(ci)* enforce test requirement for state machine and resilience changes ([#1230](https://github.com/nearai/ironclaw/pull/1230)) ([#1304](https://github.com/nearai/ironclaw/pull/1304))
+- Fix duplicate LLM responses for matched event routines ([#1275](https://github.com/nearai/ironclaw/pull/1275))
+- add Japanese README ([#1306](https://github.com/nearai/ironclaw/pull/1306))
+- *(ci)* add coverage gates via codecov.yml ([#1228](https://github.com/nearai/ironclaw/pull/1228)) ([#1291](https://github.com/nearai/ironclaw/pull/1291))
+- Redesign routine create requests for LLMs ([#1147](https://github.com/nearai/ironclaw/pull/1147))
+
## [0.19.0](https://github.com/nearai/ironclaw/compare/v0.18.0...v0.19.0) - 2026-03-17
### Added
diff --git a/CLAUDE.md b/CLAUDE.md
index e2d84c1eee3..aa0a47121e9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -24,6 +24,9 @@ E2E tests: see `tests/e2e/CLAUDE.md`.
- Prefer strong types over strings (enums, newtypes)
- Keep functions focused, extract helpers when logic is reused
- Comments for non-obvious logic only
+- **Prompt templates live in files, not Rust code**: Multi-line prompt strings (mission goals, system prompts, CodeAct preambles) go in `crates/ironclaw_engine/prompts/*.md` and are loaded via `include_str!()`. Never inline large prompt templates as Rust string constants — they're hard to read, review, and iterate on. Single-line format strings are fine inline.
+- **Logging levels matter for REPL/TUI**: `info!` and `warn!` output appears in the REPL and corrupts the terminal UI. Use `debug!` for internal diagnostics (trace analysis, reflection results, engine internals). Reserve `info!` for user-facing status that the REPL intentionally renders. Background tasks (reflection, trace analysis) must NEVER use `info!` — it breaks the interactive display.
+- **Test through the caller, not just the helper**: When a predicate/classifier/transform helper gates a side effect (HTTP, DB write, OAuth, UI mutation, tool execution) and has any wrapper or computed input between it and that side effect, a unit test on the helper alone is *not* sufficient regression coverage. Add a test that drives the call site — typically a `*_handler`, `factory::create_*`, or `manager::*` — at the integration tier (`cargo test --features integration`) or higher. The same applies to test mocks: if you mock a multi-arg runtime API like `window.open(url, target, features)`, the mock must capture every argument the production caller passes. See `.claude/rules/testing.md` ("Test Through the Caller, Not Just the Helper") for the full rule and the bug examples that motivated it.
## Architecture
@@ -33,9 +36,11 @@ Key traits for extensibility: `Database`, `Channel`, `Tool`, `LlmProvider`, `Suc
All I/O is async with tokio. Use `Arc` for shared state, `RwLock` for concurrent access.
+**LLM data is never deleted.** All LLM output — context fed to the model, reasoning, tool calls, messages, events, steps — is the most valuable data in the system. Never strip, truncate, or delete it from the database. Mark with timestamps, make filterable, but always retain. In-memory HashMaps are caches; the database (via Workspace) is the source of truth. "Cleanup" means evicting from in-memory caches, never deleting database rows.
+
## Extracted Crates
-Safety logic lives in `crates/ironclaw_safety/`. The `src/safety/mod.rs` shim re-exports everything for backward compatibility, but **new code should import from `ironclaw_safety` directly** (e.g. `use ironclaw_safety::SafetyLayer`). When touching a file that still uses `crate::safety::*`, migrate its imports to `ironclaw_safety::*`.
+Safety logic lives in `crates/ironclaw_safety/`, skills in `crates/ironclaw_skills/`. **Import directly from the extracted crate** (e.g. `use ironclaw_safety::SafetyLayer`, `use ironclaw_skills::SkillRegistry`). Do not use `crate::safety::` or `crate::skills::` for types that originate in extracted crates — `src/safety/mod.rs` and `src/skills/mod.rs` no longer glob-re-export. Local items defined in those modules (e.g. `crate::skills::attenuate_tools`) are fine.
## Project Structure
@@ -191,14 +196,15 @@ When modifying a module with a spec, read the spec first. Code follows spec; spe
| `src/setup/` | `src/setup/README.md` |
| `src/tools/` | `src/tools/README.md` |
| `src/workspace/` | `src/workspace/README.md` |
+| `crates/ironclaw_engine/` | `crates/ironclaw_engine/CLAUDE.md` |
| `tests/e2e/` | `tests/e2e/CLAUDE.md` |
## Job State Machine
```
Pending -> InProgress -> Completed -> Submitted -> Accepted
- \-> Failed
- \-> Stuck -> InProgress (recovery)
+ \ \-> Failed
+ \-> Failed \-> Stuck -> InProgress (recovery)
\-> Failed
```
@@ -221,6 +227,34 @@ See `.env.example` for all environment variables. LLM backends (`nearai`, `opena
3. Add config in `src/config/channels.rs`
4. Wire up in `src/app.rs` channel setup section
+## Everything Goes Through Tools
+
+**Core principle**: all actions originating from gateway handlers, CLI
+commands, routine engine, WASM channels, or any other non-agent caller
+MUST go through `ToolDispatcher::dispatch()` — never directly through
+`state.store`, `workspace`, `extension_manager`, `skill_registry`, or
+`session_manager`.
+
+This gives every UI-initiated mutation the same audit trail
+(`ActionRecord`), safety pipeline (param validation, sensitive-param
+redaction, output sanitization), and channel-agnostic surface as
+agent-initiated tool calls. Channels are interchangeable extensions;
+routing through one dispatch function means new channels inherit the
+full pipeline for free.
+
+The pre-commit hook (`scripts/pre-commit-safety.sh`) flags newly-added
+lines in handler/CLI files that touch
+`state.{store,workspace,extension_manager,skill_registry,session_manager}.*`
+directly. Annotate intentional exceptions (rare — usually only read
+aggregation across multiple users) with a trailing
+`// dispatch-exempt: ` comment on the same line. The check only
+sees added lines, so existing untouched code doesn't trip during
+incremental migration.
+
+See `.claude/rules/tools.md` for the full pattern, allowed exemptions,
+and migration status. The dispatcher itself lives in
+`src/tools/dispatch.rs`.
+
## Workspace & Memory
Persistent memory with hybrid search (FTS + vector via RRF). Four tools: `memory_search`, `memory_write`, `memory_read`, `memory_tree`. Identity files (AGENTS.md, SOUL.md, USER.md, IDENTITY.md) injected into system prompt. Heartbeat system runs proactive periodic execution (default: 30 minutes), reading `HEARTBEAT.md` and notifying via channel if findings. See `src/workspace/README.md`.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1c5c6d88194..51b20d349dd 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -10,6 +10,42 @@ cd ironclaw
This installs the Rust toolchain, WASM targets, git hooks, and runs initial checks.
+## How to Contribute
+
+- Bug fixes, docs improvements, and focused cleanup tied to a concrete problem are welcome.
+- Search existing issues and PRs before opening a new one to avoid duplicates.
+- Keep changes scoped. One bug, one feature, or one documentation improvement per PR.
+
+### Creating Issues
+
+Open an issue when you are reporting a bug, proposing a feature, or documenting a gap in behavior.
+
+For bug reports, include:
+
+- What you expected to happen
+- What actually happened
+- Clear reproduction steps
+- Relevant logs, screenshots, or error output
+- Environment details when they matter (OS, database backend, feature flags, commit/branch)
+
+For feature requests:
+
+- Open an issue first before writing code
+- Explain the problem being solved, not just the implementation idea
+- Wait for maintainer feedback before investing in a large PR
+
+We require an issue for new features so maintainers can prioritize the work and confirm it fits the roadmap before anyone spends time implementing it.
+
+### Fixing Bugs
+
+- Small, targeted bug-fix PRs are welcome
+- If there is already an issue, link it in your PR
+- If the bug is non-trivial, security-sensitive, or changes behavior across subsystems, open or confirm an issue first so the approach can be aligned before implementation
+
+### Refactor-Only PRs
+
+Refactor-only PRs are not accepted from contributors outside the core team. If a refactor is necessary to land a bug fix or approved feature, keep it minimal and clearly tied to that change.
+
## Development Workflow
```bash
@@ -19,6 +55,45 @@ cargo test # unit tests
cargo test --features integration # + PostgreSQL tests
```
+These commands are for day-to-day iteration while you are developing locally. The pre-submission checks below are intentionally stricter and use CI-style flags so you can catch formatting drift and clippy warnings before requesting review.
+
+## Before You Open a PR
+
+Run the local validation checks required before requesting a review. These are stricter than the commands for iterative development:
+
+```bash
+cargo fmt --all -- --check
+cargo clippy --all --benches --tests --examples --all-features -- -D warnings
+cargo build
+cargo test
+```
+
+Also run this when your change touches database-backed or integration behavior:
+
+```bash
+cargo test --features integration
+```
+
+Before asking for review:
+
+- Build and exercise the changed path locally, not just the narrowest unit test
+- Keep the PR focused and avoid mixing unrelated concerns
+- Fill out the PR template with a clear summary, validation notes, and impact assessment
+- If your change affects tracked behavior, update `FEATURE_PARITY.md` in the same branch
+- If onboarding or setup behavior changes, update the relevant setup docs in the same branch
+- If you are using a coding agent and it supports them, run `review-pr` or `pr-shepherd --fix` before opening or updating the PR
+- `codex review --base origin/main` is also encouraged before requesting review
+
+## Review Follow-Through
+
+Review conversations are author-owned.
+
+- Address each review comment with a code change or a clear explanation
+- Resolve conversations you have handled; leave them open only when reviewer judgment is still needed
+- Do not leave review cleanup for maintainers when the follow-through belongs to the author
+
+If a PR is stale for more than 48 hours after review feedback is posted, maintainers may take over the follow-up work and land the changes needed to accomplish the original PR or issue intent.
+
## Code Style
- Zero clippy warnings policy
@@ -46,7 +121,7 @@ All PRs follow a risk-based review process:
| Track | Scope | Requirements |
|-------|-------|-------------|
| **A** | Docs, tests, chore, dependency bumps | 1 approval + CI green |
-| **B** | Features, refactors, new tools/channels | 1 approval + CI green + test evidence |
+| **B** | Features, maintainer-requested refactors, new tools/channels | 1 approval + CI green + test evidence |
| **C** | Security (`src/safety/`, `src/secrets/`), runtime (`src/agent/`, `src/worker/`), database schema, CI workflows | 2 approvals + rollback plan documented |
Select the appropriate track in the PR template based on what your changes touch.
@@ -58,3 +133,33 @@ IronClaw uses dual-backend persistence (PostgreSQL + libSQL). All new persistenc
## Adding Dependencies
Run `cargo deny check` before adding new dependencies to verify license compatibility and check for known advisories.
+
+## Document your Changes
+
+- The folder `/docs` contains user-facing documentation for technical savvy users, developers and operators. It is built with Mintlify and rendered on the website.
+- For features, update the relevant capability doc in `docs/capabilities/`
+- For channels, update the relevant channel doc in `docs/channels/`
+- For extensions / tools, update the relevant doc in `docs/extensions/`
+- Core features live in `docs/capabilities`
+
+In case you want to document the library itself (i.e. reference documentation) for other core contributors, use the `docs/internal/` folder
+
+If you use your Claude Code to "plan" and want to leave a record of it, use the `docs/plans` folder.
+
+### Skills
+Read the `.claude/skills/mintlify-docs` for guidelines on how to generate documentation with mintlify.
+
+### Test the Docs
+To make sure the documentation still works, do:
+
+```bash
+cd docs
+mint dev
+```
+
+To make sure you did not break any internal links, do:
+
+```bash
+cd docs
+mint broken-links
+```
diff --git a/Cargo.lock b/Cargo.lock
index a813ef2b10b..7c656642c6d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4,9 +4,9 @@ version = 4
[[package]]
name = "addr2line"
-version = "0.24.2"
+version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
+checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a"
dependencies = [
"gimli",
]
@@ -61,6 +61,37 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "agent-client-protocol"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c56a59cf6315e99f874d2c1f96c69d2da5ffe0087d211297fc4a41f849770a2"
+dependencies = [
+ "agent-client-protocol-schema",
+ "anyhow",
+ "async-broadcast",
+ "async-trait",
+ "derive_more",
+ "futures",
+ "log",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "agent-client-protocol-schema"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0497b9a95a404e35799904835c57c6f8c69b9d08ccfd3cb5b7d746425cd6789"
+dependencies = [
+ "anyhow",
+ "derive_more",
+ "schemars 1.2.1",
+ "serde",
+ "serde_json",
+ "strum 0.28.0",
+]
+
[[package]]
name = "ahash"
version = "0.7.8"
@@ -80,9 +111,11 @@ checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"const-random",
+ "getrandom 0.3.4",
"once_cell",
+ "serde",
"version_check",
- "zerocopy 0.8.42",
+ "zerocopy 0.8.48",
]
[[package]]
@@ -123,9 +156,9 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "anstream"
-version = "0.6.21"
+version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
@@ -138,15 +171,15 @@ dependencies = [
[[package]]
name = "anstyle"
-version = "1.0.13"
+version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
-version = "0.2.7"
+version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
@@ -157,7 +190,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -168,7 +201,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -178,21 +211,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
-name = "ar_archive_writer"
-version = "0.5.1"
+name = "arbitrary"
+version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b"
+checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
- "object 0.37.3",
+ "derive_arbitrary",
]
[[package]]
-name = "arbitrary"
-version = "1.4.2"
+name = "arboard"
+version = "3.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
+checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
dependencies = [
- "derive_arbitrary",
+ "clipboard-win",
+ "image",
+ "log",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation",
+ "parking_lot",
+ "percent-encoding",
+ "windows-sys 0.60.2",
+ "x11rb",
]
[[package]]
@@ -386,12 +430,51 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "atomic-polyfill"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4"
+dependencies = [
+ "critical-section",
+]
+
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+[[package]]
+name = "attribute-derive"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05832cdddc8f2650cc2cc187cc2e952b8c133a48eb055f35211f61ee81502d77"
+dependencies = [
+ "attribute-derive-macro",
+ "derive-where",
+ "manyhow",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "attribute-derive-macro"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a7cdbbd4bd005c5d3e2e9c885e6fa575db4f4a3572335b974d8db853b6beb61"
+dependencies = [
+ "collection_literals",
+ "interpolator",
+ "manyhow",
+ "proc-macro-utils",
+ "proc-macro2",
+ "quote",
+ "quote-use",
+ "syn 2.0.117",
+]
+
[[package]]
name = "autocfg"
version = "1.5.0"
@@ -442,9 +525,9 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
-version = "1.16.1"
+version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf"
+checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
dependencies = [
"aws-lc-sys",
"zeroize",
@@ -452,9 +535,9 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
-version = "0.38.0"
+version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e"
+checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399"
dependencies = [
"cc",
"cmake",
@@ -490,9 +573,9 @@ dependencies = [
[[package]]
name = "aws-sdk-bedrockruntime"
-version = "1.127.0"
+version = "1.128.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7dcd5ccbed3bd50d342077d3f731de46d9608340386c87d07566c4c507891eda"
+checksum = "3949d34a5c329ed83e7146d2fc1ffc06473fdc9bcbc5fa3d3534abeb950569c5"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -517,9 +600,9 @@ dependencies = [
[[package]]
name = "aws-sdk-sso"
-version = "1.96.0"
+version = "1.97.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f64a6eded248c6b453966e915d32aeddb48ea63ad17932682774eb026fbef5b1"
+checksum = "9aadc669e184501caaa6beafb28c6267fc1baef0810fb58f9b205485ca3f2567"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -541,9 +624,9 @@ dependencies = [
[[package]]
name = "aws-sdk-ssooidc"
-version = "1.98.0"
+version = "1.99.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "db96d720d3c622fcbe08bae1c4b04a72ce6257d8b0584cb5418da00ae20a344f"
+checksum = "1342a7db8f358d3de0aed2007a0b54e875458e39848d54cc1d46700b2bfcb0a8"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -565,9 +648,9 @@ dependencies = [
[[package]]
name = "aws-sdk-sts"
-version = "1.100.0"
+version = "1.101.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fafbdda43b93f57f699c5dfe8328db590b967b8a820a13ccdd6687355dfcc7ca"
+checksum = "ab41ad64e4051ecabeea802d6a17845a91e83287e1dd249e6963ea1ba78c428a"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -757,9 +840,9 @@ dependencies = [
[[package]]
name = "aws-smithy-types"
-version = "1.4.6"
+version = "1.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d2b1117b3b2bbe166d11199b540ceed0d0f7676e36e7b962b5a437a9971eac75"
+checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c"
dependencies = [
"base64-simd",
"bytes",
@@ -964,6 +1047,21 @@ dependencies = [
"which",
]
+[[package]]
+name = "bit-set"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -976,6 +1074,15 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
+[[package]]
+name = "bitmaps"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2"
+dependencies = [
+ "typenum",
+]
+
[[package]]
name = "bitvec"
version = "1.0.1"
@@ -1083,21 +1190,28 @@ dependencies = [
"serde_with",
]
+[[package]]
+name = "borrow-or-share"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
+
[[package]]
name = "borsh"
-version = "1.6.0"
+version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f"
+checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a"
dependencies = [
"borsh-derive",
+ "bytes",
"cfg_aliases",
]
[[package]]
name = "borsh-derive"
-version = "1.6.0"
+version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c"
+checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59"
dependencies = [
"once_cell",
"proc-macro-crate",
@@ -1106,6 +1220,26 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "bs58"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "bstr"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
+dependencies = [
+ "memchr",
+ "regex-automata",
+ "serde",
+]
+
[[package]]
name = "bumpalo"
version = "3.20.2"
@@ -1137,12 +1271,44 @@ dependencies = [
"syn 1.0.109",
]
+[[package]]
+name = "bytecount"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
+dependencies = [
+ "bytemuck_derive",
+]
+
+[[package]]
+name = "bytemuck_derive"
+version = "1.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+[[package]]
+name = "byteorder-lite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
+
[[package]]
name = "bytes"
version = "1.11.1"
@@ -1240,12 +1406,27 @@ dependencies = [
"winx",
]
+[[package]]
+name = "cassowary"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
+
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
+[[package]]
+name = "castaway"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
+dependencies = [
+ "rustversion",
+]
+
[[package]]
name = "cbc"
version = "0.1.2"
@@ -1257,9 +1438,9 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.2.56"
+version = "1.2.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
+checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -1362,9 +1543,9 @@ dependencies = [
[[package]]
name = "clap"
-version = "4.5.60"
+version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
+checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
dependencies = [
"clap_builder",
"clap_derive",
@@ -1372,9 +1553,9 @@ dependencies = [
[[package]]
name = "clap_builder"
-version = "4.5.60"
+version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
@@ -1384,18 +1565,18 @@ dependencies = [
[[package]]
name = "clap_complete"
-version = "4.5.66"
+version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c757a3b7e39161a4e56f9365141ada2a6c915a8622c408ab6bb4b5d047371031"
+checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb"
dependencies = [
"clap",
]
[[package]]
name = "clap_derive"
-version = "4.5.55"
+version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
+checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
dependencies = [
"heck",
"proc-macro2",
@@ -1405,9 +1586,9 @@ dependencies = [
[[package]]
name = "clap_lex"
-version = "1.0.0"
+version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "clipboard-win"
@@ -1420,9 +1601,9 @@ dependencies = [
[[package]]
name = "cmake"
-version = "0.1.57"
+version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
@@ -1436,11 +1617,45 @@ dependencies = [
"thiserror 2.0.18",
]
+[[package]]
+name = "collection_literals"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084"
+
[[package]]
name = "colorchoice"
-version = "1.0.4"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "compact_str"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
+dependencies = [
+ "castaway",
+ "cfg-if",
+ "itoa",
+ "rustversion",
+ "ryu",
+ "static_assertions",
+]
+
+[[package]]
+name = "compact_str"
+version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a"
+dependencies = [
+ "castaway",
+ "cfg-if",
+ "itoa",
+ "rustversion",
+ "ryu",
+ "static_assertions",
+]
[[package]]
name = "concurrent-queue"
@@ -1453,14 +1668,13 @@ dependencies = [
[[package]]
name = "console"
-version = "0.15.11"
+version = "0.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
+checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87"
dependencies = [
"encode_unicode",
"libc",
- "once_cell",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1504,13 +1718,23 @@ dependencies = [
"unicode-segmentation",
]
+[[package]]
+name = "cookie"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
+dependencies = [
+ "time",
+ "version_check",
+]
+
[[package]]
name = "coolor"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "980c2afde4af43d6a05c5be738f9eae595cff86dce1f38f88b95058a98c027f3"
dependencies = [
- "crossterm",
+ "crossterm 0.29.0",
]
[[package]]
@@ -1557,32 +1781,53 @@ dependencies = [
"libc",
]
+[[package]]
+name = "cranelift-assembler-x64"
+version = "0.130.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "046d4b584c3bb9b5eb500c8f29549bec36be11000f1ba2a927cef3d1a9875691"
+dependencies = [
+ "cranelift-assembler-x64-meta",
+]
+
+[[package]]
+name = "cranelift-assembler-x64-meta"
+version = "0.130.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9b194a7870becb1490366fc0ae392ccd188065ff35f8391e77ac659db6fb977"
+dependencies = [
+ "cranelift-srcgen",
+]
+
[[package]]
name = "cranelift-bforest"
-version = "0.115.1"
+version = "0.130.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "88c1d02b72b6c411c0a2e92b25ed791ad5d071184193c08a34aa0fdcdf000b72"
+checksum = "bb6a4ab44c6b371e661846b97dab687387a60ac4e2f864e2d4257284aad9e889"
dependencies = [
"cranelift-entity",
+ "wasmtime-internal-core",
]
[[package]]
name = "cranelift-bitset"
-version = "0.115.1"
+version = "0.130.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "720b93bd86ebbb23ebfb2db1ed44d54b2ecbdbb2d034d485bc64aa605ee787ab"
+checksum = "b8b7a44150c2f471a94023482bda1902710746e4bed9f9973d60c5a94319b06d"
dependencies = [
"serde",
"serde_derive",
+ "wasmtime-internal-core",
]
[[package]]
name = "cranelift-codegen"
-version = "0.115.1"
+version = "0.130.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aed3d2d9914d30b460eedd7fd507720203023997bef71452ce84873f9c93537c"
+checksum = "01b06598133b1dd76758b8b95f8d6747c124124aade50cea96a3d88b962da9fa"
dependencies = [
"bumpalo",
+ "cranelift-assembler-x64",
"cranelift-bforest",
"cranelift-bitset",
"cranelift-codegen-meta",
@@ -1591,55 +1836,63 @@ dependencies = [
"cranelift-entity",
"cranelift-isle",
"gimli",
- "hashbrown 0.14.5",
+ "hashbrown 0.16.1",
+ "libm",
"log",
+ "pulley-interpreter",
"regalloc2",
- "rustc-hash 2.1.1",
+ "rustc-hash 2.1.2",
"serde",
"smallvec",
"target-lexicon",
+ "wasmtime-internal-core",
]
[[package]]
name = "cranelift-codegen-meta"
-version = "0.115.1"
+version = "0.130.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "888c188d32263ec9e048873ff0b68c700933600d553f4412417916828be25f8e"
+checksum = "6190e2e7bcf0a678da2f715363d34ed530fedf7a2f0ab75edaefef72a70465ff"
dependencies = [
+ "cranelift-assembler-x64-meta",
"cranelift-codegen-shared",
+ "cranelift-srcgen",
+ "heck",
+ "pulley-interpreter",
]
[[package]]
name = "cranelift-codegen-shared"
-version = "0.115.1"
+version = "0.130.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4ddd5f4114d04ce7e073dd74e2ad16541fc61970726fcc8b2d5644a154ee4127"
+checksum = "f583cf203d1aa8b79560e3b01f929bdacf9070b015eec4ea9c46e22a3f83e4a0"
[[package]]
name = "cranelift-control"
-version = "0.115.1"
+version = "0.130.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92cc4c98d6a4256a1600d93ccd3536f3e77da9b4ca2c279de786ac22876e67d6"
+checksum = "803159df35cc398ae54473c150b16d6c77e92ab2948be638488de126a3328fbc"
dependencies = [
"arbitrary",
]
[[package]]
name = "cranelift-entity"
-version = "0.115.1"
+version = "0.130.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "760af4b5e051b5f82097a27274b917e3751736369fa73660513488248d27f23d"
+checksum = "3109e417257082d88087f5bcce677525bdaa8322b88dd7f175ed1a1fd41d546c"
dependencies = [
"cranelift-bitset",
"serde",
"serde_derive",
+ "wasmtime-internal-core",
]
[[package]]
name = "cranelift-frontend"
-version = "0.115.1"
+version = "0.130.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c0bf77ec0f470621655ec7539860b5c620d4f91326654ab21b075b83900f8831"
+checksum = "14db6b0e0e4994c581092df78d837be2072578f7cb2528f96a6cf895e56dee63"
dependencies = [
"cranelift-codegen",
"log",
@@ -1649,21 +1902,27 @@ dependencies = [
[[package]]
name = "cranelift-isle"
-version = "0.115.1"
+version = "0.130.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4b665d0a6932c421620be184f9fc7f7adaf1b0bc2fa77bb7ac5177c49abf645b"
+checksum = "ec66ea5025c7317383699778282ac98741d68444f956e3b1d7b62f12b7216e67"
[[package]]
name = "cranelift-native"
-version = "0.115.1"
+version = "0.130.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bb2e75d1bd43dfec10924798f15e6474f1dbf63b0024506551aa19394dbe72ab"
+checksum = "373ade56438e6232619d85678477d0a88a31b3581936e0503e61e96b546b0800"
dependencies = [
"cranelift-codegen",
"libc",
"target-lexicon",
]
+[[package]]
+name = "cranelift-srcgen"
+version = "0.130.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef53619d3cd5c78fd998c6d9420547af26b72e6456f94c2a8a2334cb76b42baa"
+
[[package]]
name = "crc"
version = "3.4.0"
@@ -1724,6 +1983,12 @@ dependencies = [
"itertools 0.10.5",
]
+[[package]]
+name = "critical-section"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
+
[[package]]
name = "crokey"
version = "1.4.0"
@@ -1731,7 +1996,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04a63daf06a168535c74ab97cdba3ed4fa5d4f32cb36e437dcceb83d66854b7c"
dependencies = [
"crokey-proc_macros",
- "crossterm",
+ "crossterm 0.29.0",
"once_cell",
"serde",
"strict",
@@ -1743,7 +2008,7 @@ version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "847f11a14855fc490bd5d059821895c53e77eeb3c2b73ee3dded7ce77c93b231"
dependencies = [
- "crossterm",
+ "crossterm 0.29.0",
"proc-macro2",
"quote",
"strict",
@@ -1817,6 +2082,22 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+[[package]]
+name = "crossterm"
+version = "0.28.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
+dependencies = [
+ "bitflags 2.11.0",
+ "crossterm_winapi",
+ "mio",
+ "parking_lot",
+ "rustix 0.38.44",
+ "signal-hook",
+ "signal-hook-mio",
+ "winapi",
+]
+
[[package]]
name = "crossterm"
version = "0.29.0"
@@ -1922,9 +2203,9 @@ dependencies = [
[[package]]
name = "darling"
-version = "0.21.3"
+version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core",
"darling_macro",
@@ -1932,11 +2213,10 @@ dependencies = [
[[package]]
name = "darling_core"
-version = "0.21.3"
+version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
- "fnv",
"ident_case",
"proc-macro2",
"quote",
@@ -1946,9 +2226,9 @@ dependencies = [
[[package]]
name = "darling_macro"
-version = "0.21.3"
+version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core",
"quote",
@@ -2038,6 +2318,17 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "derive-where"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
@@ -2069,6 +2360,7 @@ dependencies = [
"quote",
"rustc_version",
"syn 2.0.117",
+ "unicode-xid",
]
[[package]]
@@ -2098,33 +2390,13 @@ dependencies = [
"dirs-sys-next",
]
-[[package]]
-name = "dirs"
-version = "4.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
-dependencies = [
- "dirs-sys 0.3.7",
-]
-
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
- "dirs-sys 0.5.0",
-]
-
-[[package]]
-name = "dirs-sys"
-version = "0.3.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
-dependencies = [
- "libc",
- "redox_users 0.4.6",
- "winapi",
+ "dirs-sys",
]
[[package]]
@@ -2136,7 +2408,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -2151,8 +2423,18 @@ dependencies = [
]
[[package]]
-name = "displaydoc"
-version = "0.2.5"
+name = "dispatch2"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
+dependencies = [
+ "bitflags 2.11.0",
+ "objc2",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
@@ -2250,6 +2532,15 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+[[package]]
+name = "email_address"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "embedded-io"
version = "0.4.0"
@@ -2323,7 +2614,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -2402,12 +2693,43 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+[[package]]
+name = "fancy-regex"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8"
+dependencies = [
+ "bit-set",
+ "regex-automata",
+ "regex-syntax",
+]
+
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+[[package]]
+name = "fax"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab"
+dependencies = [
+ "fax_derive",
+]
+
+[[package]]
+name = "fax_derive"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "fd-lock"
version = "4.0.4"
@@ -2419,6 +2741,15 @@ dependencies = [
"windows-sys 0.59.0",
]
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
[[package]]
name = "fiat-crypto"
version = "0.2.9"
@@ -2442,6 +2773,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+[[package]]
+name = "fixedbitset"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
+
[[package]]
name = "flagset"
version = "0.4.7"
@@ -2458,6 +2795,17 @@ dependencies = [
"miniz_oxide",
]
+[[package]]
+name = "fluent-uri"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e"
+dependencies = [
+ "borrow-or-share",
+ "ref-cast",
+ "serde",
+]
+
[[package]]
name = "fnv"
version = "1.0.7"
@@ -2485,6 +2833,16 @@ dependencies = [
"percent-encoding",
]
+[[package]]
+name = "fraction"
+version = "0.15.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f158e3ff0a1b334408dc9fb811cd99b446986f4d8b741bb08f9df1604085ae7"
+dependencies = [
+ "lazy_static",
+ "num",
+]
+
[[package]]
name = "fs-set-times"
version = "0.20.3"
@@ -2635,25 +2993,17 @@ dependencies = [
"slab",
]
-[[package]]
-name = "fxhash"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
-dependencies = [
- "byteorder",
-]
-
[[package]]
name = "fxprof-processed-profile"
-version = "0.6.0"
+version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "27d12c0aed7f1e24276a241aadc4cb8ea9f83000f34bc062b7cc2d51e3b0fabd"
+checksum = "25234f20a3ec0a962a61770cfe39ecf03cb529a6e474ad8cff025ed497eda557"
dependencies = [
"bitflags 2.11.0",
"debugid",
- "fxhash",
+ "rustc-hash 2.1.2",
"serde",
+ "serde_derive",
"serde_json",
]
@@ -2667,13 +3017,47 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "get-size-derive2"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2b6d1e2f75c16bfbcd0f95d84f99858a6e2f885c2287d1f5c3a96e8444a34b4"
+dependencies = [
+ "attribute-derive",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "get-size2"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49cf31a6d70300cf81461098f7797571362387ef4bf85d32ac47eaa59b3a5a1a"
+dependencies = [
+ "compact_str 0.9.0",
+ "get-size-derive2",
+ "hashbrown 0.16.1",
+ "ordermap",
+ "smallvec",
+]
+
+[[package]]
+name = "gethostname"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
+dependencies = [
+ "rustix 1.1.4",
+ "windows-link",
+]
+
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
- "unicode-width 0.2.2",
+ "unicode-width 0.2.0",
]
[[package]]
@@ -2728,11 +3112,12 @@ dependencies = [
[[package]]
name = "gimli"
-version = "0.31.1"
+version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
+checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c"
dependencies = [
- "fallible-iterator 0.3.0",
+ "fnv",
+ "hashbrown 0.16.1",
"indexmap 2.13.0",
"stable_deref_trait",
]
@@ -2789,7 +3174,16 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
- "zerocopy 0.8.42",
+ "zerocopy 0.8.48",
+]
+
+[[package]]
+name = "hash32"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
+dependencies = [
+ "byteorder",
]
[[package]]
@@ -2809,7 +3203,6 @@ checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash 0.8.12",
"allocator-api2",
- "serde",
]
[[package]]
@@ -2818,8 +3211,9 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
+ "allocator-api2",
+ "equivalent",
"foldhash 0.1.5",
- "serde",
]
[[package]]
@@ -2831,6 +3225,8 @@ dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.2.0",
+ "serde",
+ "serde_core",
]
[[package]]
@@ -2842,6 +3238,20 @@ dependencies = [
"hashbrown 0.14.5",
]
+[[package]]
+name = "heapless"
+version = "0.7.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f"
+dependencies = [
+ "atomic-polyfill",
+ "hash32",
+ "rustc_version",
+ "serde",
+ "spin",
+ "stable_deref_trait",
+]
+
[[package]]
name = "heck"
version = "0.5.0"
@@ -2898,16 +3308,16 @@ dependencies = [
[[package]]
name = "html-to-markdown-rs"
-version = "2.28.2"
+version = "2.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f9377e16af590b764fd98fd176027cf8831c5335f8964f3f643753e38913a4e"
+checksum = "7ea41945a2fd834381642a000ef75b03f0030f3023f3dd3291fc5c372d3dda33"
dependencies = [
"ahash 0.8.12",
"astral-tl",
"base64 0.22.1",
"html-escape",
- "html5ever 0.38.0",
- "lru",
+ "html5ever 0.39.0",
+ "lru 0.16.3",
"once_cell",
"regex",
"serde",
@@ -2935,6 +3345,16 @@ dependencies = [
"markup5ever 0.38.0",
]
+[[package]]
+name = "html5ever"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
+dependencies = [
+ "log",
+ "markup5ever 0.39.0",
+]
+
[[package]]
name = "http"
version = "0.2.12"
@@ -3150,7 +3570,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
- "socket2 0.5.10",
+ "socket2 0.6.3",
"system-configuration",
"tokio",
"tower-service",
@@ -3311,6 +3731,34 @@ dependencies = [
"icu_properties",
]
+[[package]]
+name = "im-rc"
+version = "15.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af1955a75fa080c677d3972822ec4bad316169ab1cfc6c257a942c2265dbe5fe"
+dependencies = [
+ "bitmaps",
+ "rand_core 0.6.4",
+ "rand_xoshiro",
+ "sized-chunks",
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "image"
+version = "0.25.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
+dependencies = [
+ "bytemuck",
+ "byteorder-lite",
+ "moxcms",
+ "num-traits",
+ "png",
+ "tiff",
+]
+
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -3334,6 +3782,15 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "indoc"
+version = "2.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
+dependencies = [
+ "rustversion",
+]
+
[[package]]
name = "inout"
version = "0.1.4"
@@ -3346,9 +3803,9 @@ dependencies = [
[[package]]
name = "insta"
-version = "1.46.3"
+version = "1.47.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e82db8c87c7f1ccecb34ce0c24399b8a73081427f3c7c50a5d597925356115e4"
+checksum = "99322078b2c076829a1db959d49da554fabc4342257fc0ba5a070a1eb3a01cd8"
dependencies = [
"console",
"once_cell",
@@ -3356,6 +3813,25 @@ dependencies = [
"tempfile",
]
+[[package]]
+name = "instability"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
+dependencies = [
+ "darling",
+ "indoc",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "interpolator"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8"
+
[[package]]
name = "io-extras"
version = "0.18.4"
@@ -3380,9 +3856,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "iri-string"
-version = "0.7.10"
+version = "0.7.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
+checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb"
dependencies = [
"memchr",
"serde",
@@ -3390,9 +3866,10 @@ dependencies = [
[[package]]
name = "ironclaw"
-version = "0.19.0"
+version = "0.25.0"
dependencies = [
"aes-gcm",
+ "agent-client-protocol",
"aho-corasick",
"anyhow",
"async-trait",
@@ -3403,22 +3880,24 @@ dependencies = [
"base64 0.22.1",
"blake3",
"bollard",
+ "bs58",
"bytes",
"chrono",
"chrono-tz",
"clap",
"clap_complete",
- "criterion",
+ "cookie",
"cron",
- "crossterm",
+ "crossterm 0.29.0",
"deadpool-postgres",
- "dirs 6.0.0",
+ "dirs",
"dotenvy",
"ed25519-dalek",
"eventsource-stream",
"flate2",
"fs4",
"futures",
+ "glob",
"hex",
"hkdf",
"hmac",
@@ -3428,16 +3907,24 @@ dependencies = [
"hyper-util",
"iana-time-zone",
"insta",
+ "ironclaw_common",
+ "ironclaw_engine",
+ "ironclaw_gateway",
"ironclaw_safety",
+ "ironclaw_skills",
+ "ironclaw_tui",
"json5",
+ "jsonschema",
+ "jsonwebtoken",
"libsql",
- "lru",
+ "lru 0.16.3",
"mime_guess",
"open",
"pdf-extract",
"pgvector",
"postgres-types",
"pretty_assertions",
+ "pty-process",
"rand 0.8.5",
"readabilityrs",
"refinery",
@@ -3469,7 +3956,8 @@ dependencies = [
"tokio-stream",
"tokio-test",
"tokio-tungstenite 0.26.2",
- "toml",
+ "tokio-util",
+ "toml 0.8.23",
"tower 0.5.3",
"tower-http 0.6.8",
"tracing",
@@ -3478,18 +3966,61 @@ dependencies = [
"url",
"urlencoding",
"uuid",
- "wasmparser 0.220.1",
+ "wasmparser 0.245.1",
"wasmtime",
"wasmtime-wasi",
+ "webpki-roots 0.26.11",
"zbus",
"zip",
]
[[package]]
-name = "ironclaw_safety"
+name = "ironclaw_common"
+version = "0.2.0"
+dependencies = [
+ "chrono-tz",
+ "serde",
+ "serde_json",
+ "tracing",
+]
+
+[[package]]
+name = "ironclaw_engine"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "chrono",
+ "cron",
+ "ironclaw_common",
+ "ironclaw_skills",
+ "monty",
+ "pretty_assertions",
+ "regex",
+ "serde",
+ "serde_json",
+ "sha2",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "uuid",
+]
+
+[[package]]
+name = "ironclaw_gateway"
version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "tracing",
+]
+
+[[package]]
+name = "ironclaw_safety"
+version = "0.2.1"
dependencies = [
"aho-corasick",
+ "criterion",
"regex",
"serde_json",
"thiserror 2.0.18",
@@ -3497,6 +4028,43 @@ dependencies = [
"url",
]
+[[package]]
+name = "ironclaw_skills"
+version = "0.1.0"
+dependencies = [
+ "chrono",
+ "futures",
+ "regex",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "serde_yml",
+ "sha2",
+ "tempfile",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "urlencoding",
+]
+
+[[package]]
+name = "ironclaw_tui"
+version = "0.1.0"
+dependencies = [
+ "arboard",
+ "chrono",
+ "image",
+ "pulldown-cmark",
+ "ratatui",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "tui-textarea",
+ "unicode-width 0.2.0",
+]
+
[[package]]
name = "is-docker"
version = "0.2.0"
@@ -3506,6 +4074,18 @@ dependencies = [
"once_cell",
]
+[[package]]
+name = "is-macro"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d57a3e447e24c22647738e4607f1df1e0ec6f72e16182c4cd199f647cdfb0e4"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "is-terminal"
version = "0.4.17"
@@ -3514,7 +4094,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -3552,10 +4132,28 @@ dependencies = [
]
[[package]]
-name = "itoa"
-version = "1.0.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "ittapi"
@@ -3577,6 +4175,21 @@ dependencies = [
"cc",
]
+[[package]]
+name = "jiter"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "020ba671987d7444d251d3ee5340be1bf4606cd6c0b53e6f4066b5a1ee376b22"
+dependencies = [
+ "ahash 0.8.12",
+ "bitvec",
+ "lexical-parse-float",
+ "num-bigint",
+ "num-traits",
+ "pyo3",
+ "smallvec",
+]
+
[[package]]
name = "jobserver"
version = "0.1.34"
@@ -3589,10 +4202,12 @@ dependencies = [
[[package]]
name = "js-sys"
-version = "0.3.91"
+version = "0.3.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
+checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"
dependencies = [
+ "cfg-if",
+ "futures-util",
"once_cell",
"wasm-bindgen",
]
@@ -3608,6 +4223,48 @@ dependencies = [
"serde",
]
+[[package]]
+name = "jsonschema"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f29616f6e19415398eb186964fb7cbbeef572c79bede3622a8277667924bbe3"
+dependencies = [
+ "ahash 0.8.12",
+ "bytecount",
+ "data-encoding",
+ "email_address",
+ "fancy-regex",
+ "fraction",
+ "getrandom 0.3.4",
+ "idna",
+ "itoa",
+ "num-cmp",
+ "num-traits",
+ "percent-encoding",
+ "referencing",
+ "regex",
+ "regex-syntax",
+ "serde",
+ "serde_json",
+ "unicode-general-category",
+ "uuid-simd",
+]
+
+[[package]]
+name = "jsonwebtoken"
+version = "9.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
+dependencies = [
+ "base64 0.22.1",
+ "js-sys",
+ "pem",
+ "ring",
+ "serde",
+ "serde_json",
+ "simple_asn1",
+]
+
[[package]]
name = "kuchikikiki"
version = "0.9.2"
@@ -3670,6 +4327,31 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+[[package]]
+name = "lexical-parse-float"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56"
+dependencies = [
+ "lexical-parse-integer",
+ "lexical-util",
+]
+
+[[package]]
+name = "lexical-parse-integer"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34"
+dependencies = [
+ "lexical-util",
+]
+
+[[package]]
+name = "lexical-util"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17"
+
[[package]]
name = "libc"
version = "0.2.183"
@@ -3694,9 +4376,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
-version = "0.1.14"
+version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
+checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
dependencies = [
"bitflags 2.11.0",
"libc",
@@ -3903,6 +4585,15 @@ dependencies = [
"weezl",
]
+[[package]]
+name = "lru"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
+dependencies = [
+ "hashbrown 0.15.5",
+]
+
[[package]]
name = "lru"
version = "0.16.3"
@@ -3933,6 +4624,29 @@ dependencies = [
"libc",
]
+[[package]]
+name = "manyhow"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587"
+dependencies = [
+ "manyhow-macros",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "manyhow-macros"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495"
+dependencies = [
+ "proc-macro-utils",
+ "proc-macro2",
+ "quote",
+]
+
[[package]]
name = "markup5ever"
version = "0.36.1"
@@ -3955,6 +4669,17 @@ dependencies = [
"web_atoms",
]
+[[package]]
+name = "markup5ever"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
+dependencies = [
+ "log",
+ "tendril 0.5.0",
+ "web_atoms",
+]
+
[[package]]
name = "matchers"
version = "0.2.0"
@@ -4059,9 +4784,9 @@ dependencies = [
[[package]]
name = "mio"
-version = "1.1.1"
+version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
+checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"log",
@@ -4069,6 +4794,44 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "monty"
+version = "0.0.11"
+source = "git+https://github.com/pydantic/monty.git?tag=v0.0.11#2e9df4b508e8a9ac80f3a6a26ed680242d1f460d"
+dependencies = [
+ "ahash 0.8.12",
+ "bytemuck",
+ "chrono",
+ "fancy-regex",
+ "hashbrown 0.16.1",
+ "indexmap 2.13.0",
+ "itertools 0.14.0",
+ "jiter",
+ "libm",
+ "num-bigint",
+ "num-integer",
+ "num-traits",
+ "postcard",
+ "pyo3-build-config",
+ "ruff_python_ast",
+ "ruff_python_parser",
+ "ruff_text_size",
+ "serde",
+ "smallvec",
+ "speedate",
+ "strum 0.27.2",
+]
+
+[[package]]
+name = "moxcms"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
+dependencies = [
+ "num-traits",
+ "pxfm",
+]
+
[[package]]
name = "nanoid"
version = "0.4.0"
@@ -4134,7 +4897,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4159,8 +4922,15 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
+ "serde",
]
+[[package]]
+name = "num-cmp"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa"
+
[[package]]
name = "num-complex"
version = "0.4.6"
@@ -4172,9 +4942,9 @@ dependencies = [
[[package]]
name = "num-conv"
-version = "0.2.0"
+version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
+checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
[[package]]
name = "num-integer"
@@ -4226,6 +4996,27 @@ dependencies = [
"libc",
]
+[[package]]
+name = "objc2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+dependencies = [
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2-app-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
+dependencies = [
+ "bitflags 2.11.0",
+ "objc2",
+ "objc2-core-graphics",
+ "objc2-foundation",
+]
+
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
@@ -4233,43 +5024,77 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.11.0",
+ "dispatch2",
+ "objc2",
]
[[package]]
-name = "objc2-system-configuration"
+name = "objc2-core-graphics"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
+checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
+ "bitflags 2.11.0",
+ "dispatch2",
+ "objc2",
"objc2-core-foundation",
+ "objc2-io-surface",
]
[[package]]
-name = "object"
-version = "0.36.7"
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "objc2-foundation"
+version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
- "crc32fast",
- "hashbrown 0.15.5",
- "indexmap 2.13.0",
- "memchr",
+ "bitflags 2.11.0",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-io-surface"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
+dependencies = [
+ "bitflags 2.11.0",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-system-configuration"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
+dependencies = [
+ "objc2-core-foundation",
]
[[package]]
name = "object"
-version = "0.37.3"
+version = "0.38.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
+checksum = "271638cd5fa9cca89c4c304675ca658efc4e64a66c716b7cfe1afb4b9611dbbc"
dependencies = [
+ "crc32fast",
+ "hashbrown 0.16.1",
+ "indexmap 2.13.0",
"memchr",
]
[[package]]
name = "once_cell"
-version = "1.21.3"
+version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
@@ -4320,9 +5145,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-float"
-version = "5.1.0"
+version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d"
+checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e"
dependencies = [
"num-traits",
]
@@ -4337,6 +5162,15 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "ordermap"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfa78c92071bbd3628c22b1a964f7e0eb201dc1456555db072beb1662ecd6715"
+dependencies = [
+ "indexmap 2.13.0",
+]
+
[[package]]
name = "outref"
version = "0.5.2"
@@ -4430,6 +5264,16 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099"
+[[package]]
+name = "pem"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
+dependencies = [
+ "base64 0.22.1",
+ "serde_core",
+]
+
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -4479,6 +5323,16 @@ dependencies = [
"sha2",
]
+[[package]]
+name = "petgraph"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db"
+dependencies = [
+ "fixedbitset",
+ "indexmap 2.13.0",
+]
+
[[package]]
name = "pgvector"
version = "0.4.1"
@@ -4692,6 +5546,19 @@ dependencies = [
"plotters-backend",
]
+[[package]]
+name = "png"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
+dependencies = [
+ "bitflags 2.11.0",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
[[package]]
name = "polling"
version = "3.11.0"
@@ -4724,6 +5591,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6"
+[[package]]
+name = "portable-atomic"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
+
[[package]]
name = "postcard"
version = "1.1.3"
@@ -4733,6 +5606,7 @@ dependencies = [
"cobs",
"embedded-io 0.4.0",
"embedded-io 0.6.1",
+ "heapless",
"serde",
]
@@ -4796,7 +5670,7 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
- "zerocopy 0.8.42",
+ "zerocopy 0.8.48",
]
[[package]]
@@ -4831,7 +5705,18 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
- "toml_edit 0.25.4+spec-1.1.0",
+ "toml_edit 0.25.8+spec-1.1.0",
+]
+
+[[package]]
+name = "proc-macro-utils"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "smallvec",
]
[[package]]
@@ -4866,16 +5751,6 @@ dependencies = [
"syn 2.0.117",
]
-[[package]]
-name = "psm"
-version = "0.1.30"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8"
-dependencies = [
- "ar_archive_writer",
- "cc",
-]
-
[[package]]
name = "ptr_meta"
version = "0.1.4"
@@ -4896,17 +5771,122 @@ dependencies = [
"syn 1.0.109",
]
+[[package]]
+name = "pty-process"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71cec9e2670207c5ebb9e477763c74436af3b9091dd550b9fb3c1bec7f3ea266"
+dependencies = [
+ "rustix 1.1.4",
+ "tokio",
+]
+
+[[package]]
+name = "pulldown-cmark"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14"
+dependencies = [
+ "bitflags 2.11.0",
+ "memchr",
+ "unicase",
+]
+
[[package]]
name = "pulley-interpreter"
-version = "28.0.1"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8324e531de91a3c25021a30fb7862d39cc516b61fbb801176acb5ff279ea887b"
+checksum = "010dec3755eb61b2f1051ecb3611b718460b7a74c131e474de2af20a845938af"
dependencies = [
"cranelift-bitset",
"log",
- "sptr",
+ "pulley-macros",
+ "wasmtime-internal-core",
+]
+
+[[package]]
+name = "pulley-macros"
+version = "43.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad360c32e85ca4b083ac0e2b6856e8f11c3d5060dafa7d5dc57b370857fa3018"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "pxfm"
+version = "0.1.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
+
+[[package]]
+name = "pyo3"
+version = "0.28.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12"
+dependencies = [
+ "libc",
+ "num-bigint",
+ "num-traits",
+ "once_cell",
+ "portable-atomic",
+ "pyo3-build-config",
+ "pyo3-ffi",
+ "pyo3-macros",
+]
+
+[[package]]
+name = "pyo3-build-config"
+version = "0.28.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e"
+dependencies = [
+ "target-lexicon",
+]
+
+[[package]]
+name = "pyo3-ffi"
+version = "0.28.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e"
+dependencies = [
+ "libc",
+ "pyo3-build-config",
+]
+
+[[package]]
+name = "pyo3-macros"
+version = "0.28.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813"
+dependencies = [
+ "proc-macro2",
+ "pyo3-macros-backend",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "pyo3-macros-backend"
+version = "0.28.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "pyo3-build-config",
+ "quote",
+ "syn 2.0.117",
]
+[[package]]
+name = "quick-error"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
+
[[package]]
name = "quinn"
version = "0.11.9"
@@ -4918,9 +5898,9 @@ dependencies = [
"pin-project-lite",
"quinn-proto",
"quinn-udp",
- "rustc-hash 2.1.1",
+ "rustc-hash 2.1.2",
"rustls 0.23.37",
- "socket2 0.5.10",
+ "socket2 0.6.3",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -4938,7 +5918,7 @@ dependencies = [
"lru-slab",
"rand 0.9.2",
"ring",
- "rustc-hash 2.1.1",
+ "rustc-hash 2.1.2",
"rustls 0.23.37",
"rustls-pki-types",
"slab",
@@ -4957,18 +5937,40 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
- "socket2 0.5.10",
+ "socket2 0.6.3",
"tracing",
- "windows-sys 0.59.0",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "quote-use"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9619db1197b497a36178cfc736dc96b271fe918875fbf1344c436a7e93d0321e"
+dependencies = [
+ "quote",
+ "quote-use-macros",
]
[[package]]
-name = "quote"
-version = "1.0.45"
+name = "quote-use-macros"
+version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+checksum = "82ebfb7faafadc06a7ab141a6f67bcfb24cb8beb158c6fe933f2f035afa99f35"
dependencies = [
+ "proc-macro-utils",
"proc-macro2",
+ "quote",
+ "syn 2.0.117",
]
[[package]]
@@ -5058,12 +6060,42 @@ dependencies = [
"getrandom 0.3.4",
]
+[[package]]
+name = "rand_xoshiro"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa"
+dependencies = [
+ "rand_core 0.6.4",
+]
+
[[package]]
name = "rangemap"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68"
+[[package]]
+name = "ratatui"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
+dependencies = [
+ "bitflags 2.11.0",
+ "cassowary",
+ "compact_str 0.8.1",
+ "crossterm 0.28.1",
+ "indoc",
+ "instability",
+ "itertools 0.13.0",
+ "lru 0.12.5",
+ "paste",
+ "strum 0.26.3",
+ "unicode-segmentation",
+ "unicode-truncate",
+ "unicode-width 0.2.0",
+]
+
[[package]]
name = "rayon"
version = "1.11.0"
@@ -5171,6 +6203,21 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "referencing"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8a618c14f8ba29d8193bb55e2bf13e4fb2b1115313ecb7ae94b43100c7ac7d5"
+dependencies = [
+ "ahash 0.8.12",
+ "fluent-uri",
+ "getrandom 0.3.4",
+ "hashbrown 0.16.1",
+ "parking_lot",
+ "percent-encoding",
+ "serde_json",
+]
+
[[package]]
name = "refinery"
version = "0.8.16"
@@ -5197,7 +6244,7 @@ dependencies = [
"time",
"tokio",
"tokio-postgres",
- "toml",
+ "toml 0.8.23",
"url",
"walkdir",
]
@@ -5218,15 +6265,15 @@ dependencies = [
[[package]]
name = "regalloc2"
-version = "0.11.2"
+version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc06e6b318142614e4a48bc725abbf08ff166694835c43c9dae5a9009704639a"
+checksum = "952ddbfc6f9f64d006c3efd8c9851a6ba2f2b944ba94730db255d55006e0ffda"
dependencies = [
"allocator-api2",
"bumpalo",
"hashbrown 0.15.5",
"log",
- "rustc-hash 2.1.1",
+ "rustc-hash 2.1.2",
"smallvec",
]
@@ -5395,11 +6442,77 @@ dependencies = [
"syn 1.0.109",
]
+[[package]]
+name = "ruff_python_ast"
+version = "0.0.0"
+source = "git+https://github.com/astral-sh/ruff.git?rev=6ded4bed1651e30b34dd04cdaa50c763036abb0d#6ded4bed1651e30b34dd04cdaa50c763036abb0d"
+dependencies = [
+ "aho-corasick",
+ "bitflags 2.11.0",
+ "compact_str 0.9.0",
+ "get-size2",
+ "is-macro",
+ "memchr",
+ "ruff_python_trivia",
+ "ruff_source_file",
+ "ruff_text_size",
+ "rustc-hash 2.1.2",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "ruff_python_parser"
+version = "0.0.0"
+source = "git+https://github.com/astral-sh/ruff.git?rev=6ded4bed1651e30b34dd04cdaa50c763036abb0d#6ded4bed1651e30b34dd04cdaa50c763036abb0d"
+dependencies = [
+ "bitflags 2.11.0",
+ "bstr",
+ "compact_str 0.9.0",
+ "get-size2",
+ "memchr",
+ "ruff_python_ast",
+ "ruff_python_trivia",
+ "ruff_text_size",
+ "rustc-hash 2.1.2",
+ "static_assertions",
+ "unicode-ident",
+ "unicode-normalization",
+ "unicode_names2",
+]
+
+[[package]]
+name = "ruff_python_trivia"
+version = "0.0.0"
+source = "git+https://github.com/astral-sh/ruff.git?rev=6ded4bed1651e30b34dd04cdaa50c763036abb0d#6ded4bed1651e30b34dd04cdaa50c763036abb0d"
+dependencies = [
+ "itertools 0.14.0",
+ "ruff_source_file",
+ "ruff_text_size",
+ "unicode-ident",
+]
+
+[[package]]
+name = "ruff_source_file"
+version = "0.0.0"
+source = "git+https://github.com/astral-sh/ruff.git?rev=6ded4bed1651e30b34dd04cdaa50c763036abb0d#6ded4bed1651e30b34dd04cdaa50c763036abb0d"
+dependencies = [
+ "memchr",
+ "ruff_text_size",
+]
+
+[[package]]
+name = "ruff_text_size"
+version = "0.0.0"
+source = "git+https://github.com/astral-sh/ruff.git?rev=6ded4bed1651e30b34dd04cdaa50c763036abb0d#6ded4bed1651e30b34dd04cdaa50c763036abb0d"
+dependencies = [
+ "get-size2",
+]
+
[[package]]
name = "rust_decimal"
-version = "1.40.0"
+version = "1.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0"
+checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a"
dependencies = [
"arrayvec",
"borsh",
@@ -5410,6 +6523,7 @@ dependencies = [
"rkyv",
"serde",
"serde_json",
+ "wasm-bindgen",
]
[[package]]
@@ -5436,9 +6550,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustc-hash"
-version = "2.1.1"
+version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
+checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustc_version"
@@ -5472,7 +6586,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -5627,7 +6741,7 @@ dependencies = [
"radix_trie",
"rustyline-derive",
"unicode-segmentation",
- "unicode-width 0.2.2",
+ "unicode-width 0.2.0",
"utf8parse",
"windows-sys 0.60.2",
]
@@ -5820,7 +6934,7 @@ dependencies = [
"phf 0.13.1",
"phf_codegen 0.13.1",
"precomputed-hash",
- "rustc-hash 2.1.1",
+ "rustc-hash 2.1.2",
"servo_arc",
"smallvec",
]
@@ -5839,7 +6953,7 @@ dependencies = [
"phf 0.13.1",
"phf_codegen 0.13.1",
"precomputed-hash",
- "rustc-hash 2.1.1",
+ "rustc-hash 2.1.2",
"servo_arc",
"smallvec",
]
@@ -5939,6 +7053,15 @@ dependencies = [
"serde",
]
+[[package]]
+name = "serde_spanned"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
+dependencies = [
+ "serde_core",
+]
+
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -5953,9 +7076,9 @@ dependencies = [
[[package]]
name = "serde_with"
-version = "3.17.0"
+version = "3.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9"
+checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -5972,9 +7095,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
-version = "3.17.0"
+version = "3.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0"
+checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65"
dependencies = [
"darling",
"proc-macro2",
@@ -5982,6 +7105,19 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "serde_yaml"
+version = "0.9.34+deprecated"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
+dependencies = [
+ "indexmap 2.13.0",
+ "itoa",
+ "ryu",
+ "serde",
+ "unsafe-libyaml",
+]
+
[[package]]
name = "serde_yml"
version = "0.0.12"
@@ -6043,15 +7179,6 @@ dependencies = [
"lazy_static",
]
-[[package]]
-name = "shellexpand"
-version = "2.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7ccc8076840c4da029af4f87e4e8daeb0fca6b87bbb02e10cb60b791450e11e4"
-dependencies = [
- "dirs 4.0.0",
-]
-
[[package]]
name = "shlex"
version = "1.3.0"
@@ -6100,9 +7227,9 @@ dependencies = [
[[package]]
name = "simd-adler32"
-version = "0.3.8"
+version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simdutf8"
@@ -6116,12 +7243,34 @@ version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
+[[package]]
+name = "simple_asn1"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
+dependencies = [
+ "num-bigint",
+ "num-traits",
+ "thiserror 2.0.18",
+ "time",
+]
+
[[package]]
name = "siphasher"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
+[[package]]
+name = "sized-chunks"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e"
+dependencies = [
+ "bitmaps",
+ "typenum",
+]
+
[[package]]
name = "slab"
version = "0.4.12"
@@ -6154,7 +7303,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "speedate"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aba069c070b5e213f2a094deb7e5ed50ecb092be36102a4f4042e8d2056d060e"
+dependencies = [
+ "lexical-parse-float",
+ "strum 0.27.2",
+ "strum_macros 0.27.2",
+]
+
+[[package]]
+name = "spin"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
+dependencies = [
+ "lock_api",
]
[[package]]
@@ -6167,12 +7336,6 @@ dependencies = [
"der",
]
-[[package]]
-name = "sptr"
-version = "0.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a"
-
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -6255,6 +7418,70 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "strum"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
+dependencies = [
+ "strum_macros 0.26.4",
+]
+
+[[package]]
+name = "strum"
+version = "0.27.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
+dependencies = [
+ "strum_macros 0.27.2",
+]
+
+[[package]]
+name = "strum"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
+dependencies = [
+ "strum_macros 0.28.0",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.27.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "subtle"
version = "2.6.1"
@@ -6365,9 +7592,9 @@ dependencies = [
[[package]]
name = "target-lexicon"
-version = "0.12.16"
+version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
+checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "tempfile"
@@ -6379,7 +7606,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6515,6 +7742,20 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "tiff"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
+dependencies = [
+ "fax",
+ "flate2",
+ "half",
+ "quick-error",
+ "weezl",
+ "zune-jpeg",
+]
+
[[package]]
name = "time"
version = "0.3.47"
@@ -6577,9 +7818,9 @@ dependencies = [
[[package]]
name = "tinyvec"
-version = "1.10.0"
+version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
@@ -6768,7 +8009,11 @@ checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
dependencies = [
"futures-util",
"log",
+ "rustls 0.23.37",
+ "rustls-native-certs 0.8.3",
+ "rustls-pki-types",
"tokio",
+ "tokio-rustls 0.26.4",
"tungstenite 0.26.2",
]
@@ -6792,6 +8037,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
+ "futures-io",
"futures-sink",
"pin-project-lite",
"tokio",
@@ -6804,11 +8050,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
- "serde_spanned",
+ "serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_edit 0.22.27",
]
+[[package]]
+name = "toml"
+version = "0.9.12+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
+dependencies = [
+ "indexmap 2.13.0",
+ "serde_core",
+ "serde_spanned 1.1.1",
+ "toml_datetime 0.7.5+spec-1.1.0",
+ "toml_parser",
+ "toml_writer",
+ "winnow 0.7.15",
+]
+
[[package]]
name = "toml_datetime"
version = "0.6.11"
@@ -6820,9 +8081,18 @@ dependencies = [
[[package]]
name = "toml_datetime"
-version = "1.0.0+spec-1.1.0"
+version = "0.7.5+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e"
+checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f"
dependencies = [
"serde_core",
]
@@ -6835,31 +8105,31 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap 2.13.0",
"serde",
- "serde_spanned",
+ "serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_write",
- "winnow",
+ "winnow 0.7.15",
]
[[package]]
name = "toml_edit"
-version = "0.25.4+spec-1.1.0"
+version = "0.25.8+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2"
+checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c"
dependencies = [
"indexmap 2.13.0",
- "toml_datetime 1.0.0+spec-1.1.0",
+ "toml_datetime 1.1.0+spec-1.1.0",
"toml_parser",
- "winnow",
+ "winnow 1.0.0",
]
[[package]]
name = "toml_parser"
-version = "1.0.9+spec-1.1.0"
+version = "1.1.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
+checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011"
dependencies = [
- "winnow",
+ "winnow 1.0.0",
]
[[package]]
@@ -6868,6 +8138,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+[[package]]
+name = "toml_writer"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
+
[[package]]
name = "tonic"
version = "0.11.0"
@@ -6982,6 +8258,7 @@ dependencies = [
"futures-util",
"http 1.4.0",
"http-body 1.0.1",
+ "http-body-util",
"iri-string",
"pin-project-lite",
"tower 0.5.3",
@@ -7070,9 +8347,9 @@ dependencies = [
[[package]]
name = "tracing-subscriber"
-version = "0.3.22"
+version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"matchers",
"nu-ansi-term",
@@ -7116,6 +8393,17 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+[[package]]
+name = "tui-textarea"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae"
+dependencies = [
+ "crossterm 0.28.1",
+ "ratatui",
+ "unicode-width 0.2.0",
+]
+
[[package]]
name = "tungstenite"
version = "0.26.2"
@@ -7128,6 +8416,8 @@ dependencies = [
"httparse",
"log",
"rand 0.9.2",
+ "rustls 0.23.37",
+ "rustls-pki-types",
"sha1",
"thiserror 2.0.18",
"utf-8",
@@ -7152,9 +8442,9 @@ dependencies = [
[[package]]
name = "type1-encoding-parser"
-version = "0.1.0"
+version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3d6cc09e1a99c7e01f2afe4953789311a1c50baebbdac5b477ecf78e2e92a5b"
+checksum = "fa10c302f5a53b7ad27fd42a3996e23d096ba39b5b8dd6d9e683a05b01bee749"
dependencies = [
"pom",
]
@@ -7179,7 +8469,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -7203,6 +8493,12 @@ version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
+[[package]]
+name = "unicode-general-category"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
+
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -7226,9 +8522,20 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "unicode-segmentation"
-version = "1.12.0"
+version = "1.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
+
+[[package]]
+name = "unicode-truncate"
+version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
+checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
+dependencies = [
+ "itertools 0.13.0",
+ "unicode-segmentation",
+ "unicode-width 0.1.14",
+]
[[package]]
name = "unicode-width"
@@ -7238,15 +8545,37 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
-version = "0.2.2"
+version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "unicode_names2"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1673eca9782c84de5f81b82e4109dcfb3611c8ba0d52930ec4a9478f547b2dd"
+dependencies = [
+ "phf 0.11.3",
+ "unicode_names2_generator",
+]
+
+[[package]]
+name = "unicode_names2_generator"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b91e5b84611016120197efd7dc93ef76774f4e084cd73c9fb3ea4a86c570c56e"
+dependencies = [
+ "getopts",
+ "log",
+ "phf_codegen 0.11.3",
+ "rand 0.8.5",
+]
[[package]]
name = "universal-hash"
@@ -7258,6 +8587,12 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "unsafe-libyaml"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
+
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -7309,9 +8644,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
-version = "1.22.0"
+version = "1.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
+checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
dependencies = [
"getrandom 0.4.2",
"js-sys",
@@ -7320,6 +8655,16 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "uuid-simd"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8"
+dependencies = [
+ "outref",
+ "vsimd",
+]
+
[[package]]
name = "v_htmlescape"
version = "0.15.8"
@@ -7407,36 +8752,33 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
-version = "0.2.114"
+version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
+checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
+ "serde",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
-version = "0.4.64"
+version = "0.4.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8"
+checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0"
dependencies = [
- "cfg-if",
- "futures-util",
"js-sys",
- "once_cell",
"wasm-bindgen",
- "web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
-version = "0.2.114"
+version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
+checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -7444,9 +8786,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
-version = "0.2.114"
+version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
+checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -7457,21 +8799,32 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
-version = "0.2.114"
+version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
+checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"
dependencies = [
"unicode-ident",
]
[[package]]
-name = "wasm-encoder"
-version = "0.221.3"
+name = "wasm-compose"
+version = "0.245.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc8444fe4920de80a4fe5ab564fff2ae58b6b73166b89751f8c6c93509da32e5"
+checksum = "5fd23d12cc95c451c1306db5bc63075fbebb612bb70c53b4237b1ce5bc178343"
dependencies = [
- "leb128",
- "wasmparser 0.221.3",
+ "anyhow",
+ "heck",
+ "im-rc",
+ "indexmap 2.13.0",
+ "log",
+ "petgraph",
+ "serde",
+ "serde_derive",
+ "serde_yaml",
+ "smallvec",
+ "wasm-encoder 0.245.1",
+ "wasmparser 0.245.1",
+ "wat",
]
[[package]]
@@ -7519,33 +8872,6 @@ dependencies = [
"web-sys",
]
-[[package]]
-name = "wasmparser"
-version = "0.220.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8d07b6a3b550fefa1a914b6d54fc175dd11c3392da11eee604e6ffc759805d25"
-dependencies = [
- "ahash 0.8.12",
- "bitflags 2.11.0",
- "hashbrown 0.14.5",
- "indexmap 2.13.0",
- "semver",
- "serde",
-]
-
-[[package]]
-name = "wasmparser"
-version = "0.221.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d06bfa36ab3ac2be0dee563380147a5b81ba10dd8885d7fbbc9eb574be67d185"
-dependencies = [
- "bitflags 2.11.0",
- "hashbrown 0.15.5",
- "indexmap 2.13.0",
- "semver",
- "serde",
-]
-
[[package]]
name = "wasmparser"
version = "0.244.0"
@@ -7565,135 +8891,166 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f08c9adee0428b7bddf3890fc27e015ac4b761cc608c822667102b8bfd6995e"
dependencies = [
"bitflags 2.11.0",
+ "hashbrown 0.16.1",
"indexmap 2.13.0",
"semver",
+ "serde",
]
[[package]]
name = "wasmprinter"
-version = "0.221.3"
+version = "0.245.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7343c42a97f2926c7819ff81b64012092ae954c5d83ddd30c9fcdefd97d0b283"
+checksum = "5f41517a3716fbb8ccf46daa9c1325f760fcbff5168e75c7392288e410b91ac8"
dependencies = [
"anyhow",
"termcolor",
- "wasmparser 0.221.3",
+ "wasmparser 0.245.1",
]
[[package]]
name = "wasmtime"
-version = "28.0.1"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "edd30973c65eceb0f37dfcc430d83abd5eb24015fdfcab6912f52949287e04f0"
+checksum = "ce205cd643d661b5ba5ba4717e13730262e8cdbc8f2eacbc7b906d45c1a74026"
dependencies = [
"addr2line",
- "anyhow",
"async-trait",
"bitflags 2.11.0",
"bumpalo",
"cc",
"cfg-if",
"encoding_rs",
+ "futures",
"fxprof-processed-profile",
"gimli",
- "hashbrown 0.14.5",
- "indexmap 2.13.0",
"ittapi",
"libc",
- "libm",
"log",
"mach2",
"memfd",
- "object 0.36.7",
+ "object",
"once_cell",
- "paste",
"postcard",
- "psm",
"pulley-interpreter",
"rayon",
- "rustix 0.38.44",
+ "rustix 1.1.4",
"semver",
"serde",
"serde_derive",
"serde_json",
"smallvec",
- "sptr",
"target-lexicon",
- "wasm-encoder 0.221.3",
- "wasmparser 0.221.3",
- "wasmtime-asm-macros",
- "wasmtime-cache",
- "wasmtime-component-macro",
- "wasmtime-component-util",
- "wasmtime-cranelift",
+ "tempfile",
+ "wasm-compose",
+ "wasm-encoder 0.245.1",
+ "wasmparser 0.245.1",
"wasmtime-environ",
- "wasmtime-fiber",
- "wasmtime-jit-debug",
- "wasmtime-jit-icache-coherence",
- "wasmtime-slab",
- "wasmtime-versioned-export-macros",
- "wasmtime-winch",
+ "wasmtime-internal-cache",
+ "wasmtime-internal-component-macro",
+ "wasmtime-internal-component-util",
+ "wasmtime-internal-core",
+ "wasmtime-internal-cranelift",
+ "wasmtime-internal-fiber",
+ "wasmtime-internal-jit-debug",
+ "wasmtime-internal-jit-icache-coherence",
+ "wasmtime-internal-unwinder",
+ "wasmtime-internal-versioned-export-macros",
+ "wasmtime-internal-winch",
"wat",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
-name = "wasmtime-asm-macros"
-version = "28.0.1"
+name = "wasmtime-environ"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c6c21dd30d1f3f93ee390ac1a7ec304ecdbfdab6390e1add41a1f52727b0992b"
+checksum = "0b8b78abf3677d4a0a5db82e5015b4d085ff3a1b8b472cbb8c70d4b769f019ce"
dependencies = [
- "cfg-if",
+ "anyhow",
+ "cpp_demangle",
+ "cranelift-bforest",
+ "cranelift-bitset",
+ "cranelift-entity",
+ "gimli",
+ "hashbrown 0.16.1",
+ "indexmap 2.13.0",
+ "log",
+ "object",
+ "postcard",
+ "rustc-demangle",
+ "semver",
+ "serde",
+ "serde_derive",
+ "sha2",
+ "smallvec",
+ "target-lexicon",
+ "wasm-encoder 0.245.1",
+ "wasmparser 0.245.1",
+ "wasmprinter",
+ "wasmtime-internal-component-util",
+ "wasmtime-internal-core",
]
[[package]]
-name = "wasmtime-cache"
-version = "28.0.1"
+name = "wasmtime-internal-cache"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cabd563cfbfe75c5bf514081f624ca8d18391a37520d8c794abce702474e688c"
+checksum = "8e4fd4103ba413c0da2e636f73490c6c8e446d708cbde7573703941bc3d6a448"
dependencies = [
- "anyhow",
- "base64 0.21.7",
+ "base64 0.22.1",
"directories-next",
"log",
"postcard",
- "rustix 0.38.44",
+ "rustix 1.1.4",
"serde",
"serde_derive",
"sha2",
- "toml",
- "windows-sys 0.59.0",
+ "toml 0.9.12+spec-1.1.0",
+ "wasmtime-environ",
+ "windows-sys 0.61.2",
"zstd",
]
[[package]]
-name = "wasmtime-component-macro"
-version = "28.0.1"
+name = "wasmtime-internal-component-macro"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9f948a6ef3119d52c9f12936970de28ddf3f9bea04bc65571f4a92d2e5ab38f4"
+checksum = "0d3d6914f34be2f9d78d8ee9f422e834dfc204e71ccce697205fae95fed87892"
dependencies = [
"anyhow",
"proc-macro2",
"quote",
"syn 2.0.117",
- "wasmtime-component-util",
- "wasmtime-wit-bindgen",
- "wit-parser 0.221.3",
+ "wasmtime-internal-component-util",
+ "wasmtime-internal-wit-bindgen",
+ "wit-parser 0.245.1",
]
[[package]]
-name = "wasmtime-component-util"
-version = "28.0.1"
+name = "wasmtime-internal-component-util"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9275aa01ceaaa2fa6c0ecaa5267518d80b9d6e9ae7c7ea42f4c6e073e6a69ef"
+checksum = "3751b0616b914fdd87fe1bf804694a078f321b000338e6476bc48a4d6e454f21"
[[package]]
-name = "wasmtime-cranelift"
-version = "28.0.1"
+name = "wasmtime-internal-core"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0701a44a323267aae4499672dae422b266cee3135a23b640972ec8c0e10a44a2"
+checksum = "22632b187e1b0716f1b9ac57ad29013bed33175fcb19e10bb6896126f82fac67"
dependencies = [
"anyhow",
+ "hashbrown 0.16.1",
+ "libm",
+ "serde",
+]
+
+[[package]]
+name = "wasmtime-internal-cranelift"
+version = "43.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b3ca07b3e0bb3429674b173b5800577719d600774dd81bff58f775c0aaa64ee"
+dependencies = [
"cfg-if",
"cranelift-codegen",
"cranelift-control",
@@ -7701,93 +9058,77 @@ dependencies = [
"cranelift-frontend",
"cranelift-native",
"gimli",
- "itertools 0.12.1",
+ "itertools 0.14.0",
"log",
- "object 0.36.7",
+ "object",
+ "pulley-interpreter",
"smallvec",
"target-lexicon",
- "thiserror 1.0.69",
- "wasmparser 0.221.3",
+ "thiserror 2.0.18",
+ "wasmparser 0.245.1",
"wasmtime-environ",
- "wasmtime-versioned-export-macros",
-]
-
-[[package]]
-name = "wasmtime-environ"
-version = "28.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "264c968c1b81d340355ece2be0bc31a10f567ccb6ce08512c3b7d10e26f3cbe5"
-dependencies = [
- "anyhow",
- "cpp_demangle",
- "cranelift-bitset",
- "cranelift-entity",
- "gimli",
- "indexmap 2.13.0",
- "log",
- "object 0.36.7",
- "postcard",
- "rustc-demangle",
- "semver",
- "serde",
- "serde_derive",
- "smallvec",
- "target-lexicon",
- "wasm-encoder 0.221.3",
- "wasmparser 0.221.3",
- "wasmprinter",
- "wasmtime-component-util",
+ "wasmtime-internal-core",
+ "wasmtime-internal-unwinder",
+ "wasmtime-internal-versioned-export-macros",
]
[[package]]
-name = "wasmtime-fiber"
-version = "28.0.1"
+name = "wasmtime-internal-fiber"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "78505221fd5bd7b07b4e1fa2804edea49dc231e626ad6861adc8f531812973e6"
+checksum = "20c8b2c9704eb1f33ead025ec16038277ccb63d0a14c31e99d5b765d7c36da55"
dependencies = [
- "anyhow",
"cc",
"cfg-if",
- "rustix 0.38.44",
- "wasmtime-asm-macros",
- "wasmtime-versioned-export-macros",
- "windows-sys 0.59.0",
+ "libc",
+ "rustix 1.1.4",
+ "wasmtime-environ",
+ "wasmtime-internal-versioned-export-macros",
+ "windows-sys 0.61.2",
]
[[package]]
-name = "wasmtime-jit-debug"
-version = "28.0.1"
+name = "wasmtime-internal-jit-debug"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0cec0a8e5620ae71bfcaaec78e3076be5b6ebf869f4e6191925d73242224a915"
+checksum = "d950310d07391d34369f62c48336ebb14eacbd4d6f772bb5f349c24e838e0664"
dependencies = [
- "object 0.36.7",
- "rustix 0.38.44",
- "wasmtime-versioned-export-macros",
+ "cc",
+ "object",
+ "rustix 1.1.4",
+ "wasmtime-internal-versioned-export-macros",
]
[[package]]
-name = "wasmtime-jit-icache-coherence"
-version = "28.0.1"
+name = "wasmtime-internal-jit-icache-coherence"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9bedb677ca1b549d98f95e9e1f9251b460090d99a2c196a0614228c064bf2e59"
+checksum = "3606662c156962d096be3127b8b8ae8ee2f8be3f896dad29259ff01ddb64abfd"
dependencies = [
- "anyhow",
"cfg-if",
"libc",
- "windows-sys 0.59.0",
+ "wasmtime-internal-core",
+ "windows-sys 0.61.2",
]
[[package]]
-name = "wasmtime-slab"
-version = "28.0.1"
+name = "wasmtime-internal-unwinder"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "564905638c132c275d365c1fa074f0b499790568f43148d29de84ccecfb5cb31"
+checksum = "75eef0747e52dc545b075f64fd0e0cc237ae738e641266b1970e07e2d744bc32"
+dependencies = [
+ "cfg-if",
+ "cranelift-codegen",
+ "log",
+ "object",
+ "wasmtime-environ",
+]
[[package]]
-name = "wasmtime-versioned-export-macros"
-version = "28.0.1"
+name = "wasmtime-internal-versioned-export-macros"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e91092e6cf77390eeccee273846a9327f3e8f91c3c6280f60f37809f0e62d29"
+checksum = "d8b0a5dab02a8fb527f547855ecc0e05f9fdc3d5bd57b8b080349408f9a6cece"
dependencies = [
"proc-macro2",
"quote",
@@ -7795,12 +9136,41 @@ dependencies = [
]
[[package]]
-name = "wasmtime-wasi"
-version = "28.0.1"
+name = "wasmtime-internal-winch"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1a8e04b9a4c68ad018b330a4f4914b82b01dc3582d715ce21a93564c7f26b19f"
+checksum = "8007342bd12ff400293a817973f7ecd6f1d9a8549a53369a9c1af357166f1f1e"
+dependencies = [
+ "cranelift-codegen",
+ "gimli",
+ "log",
+ "object",
+ "target-lexicon",
+ "wasmparser 0.245.1",
+ "wasmtime-environ",
+ "wasmtime-internal-cranelift",
+ "winch-codegen",
+]
+
+[[package]]
+name = "wasmtime-internal-wit-bindgen"
+version = "43.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7900c3e3c1d6e475bc225d73b02d6d5484815f260022e6964dca9558e50dd01a"
dependencies = [
"anyhow",
+ "bitflags 2.11.0",
+ "heck",
+ "indexmap 2.13.0",
+ "wit-parser 0.245.1",
+]
+
+[[package]]
+name = "wasmtime-wasi"
+version = "43.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed3e3ddcfad69e9eb025bd19bff70dad45bafe1d6eacd134c0ffdfc4c161d045"
+dependencies = [
"async-trait",
"bitflags 2.11.0",
"bytes",
@@ -7813,44 +9183,29 @@ dependencies = [
"futures",
"io-extras",
"io-lifetimes",
- "rustix 0.38.44",
+ "rustix 1.1.4",
"system-interface",
- "thiserror 1.0.69",
+ "thiserror 2.0.18",
"tokio",
"tracing",
"url",
"wasmtime",
+ "wasmtime-wasi-io",
"wiggle",
- "windows-sys 0.59.0",
-]
-
-[[package]]
-name = "wasmtime-winch"
-version = "28.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b111d909dc604c741bd8ac2f4af373eaa5c68c34b5717271bcb687688212cef8"
-dependencies = [
- "anyhow",
- "cranelift-codegen",
- "gimli",
- "object 0.36.7",
- "target-lexicon",
- "wasmparser 0.221.3",
- "wasmtime-cranelift",
- "wasmtime-environ",
- "winch-codegen",
+ "windows-sys 0.61.2",
]
[[package]]
-name = "wasmtime-wit-bindgen"
-version = "28.0.1"
+name = "wasmtime-wasi-io"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5f38f7a5eb2f06f53fe943e7fb8bf4197f7cf279f1bc52c0ce56e9d3ffd750a4"
+checksum = "3ca5dd3b9f04a851c422d05f333366722742da46bff9369ae0191f32cf83565a"
dependencies = [
- "anyhow",
- "heck",
- "indexmap 2.13.0",
- "wit-parser 0.221.3",
+ "async-trait",
+ "bytes",
+ "futures",
+ "tracing",
+ "wasmtime",
]
[[package]]
@@ -7871,7 +9226,7 @@ dependencies = [
"bumpalo",
"leb128fmt",
"memchr",
- "unicode-width 0.2.2",
+ "unicode-width 0.2.0",
"wasm-encoder 0.245.1",
]
@@ -7886,9 +9241,9 @@ dependencies = [
[[package]]
name = "web-sys"
-version = "0.3.91"
+version = "0.3.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
+checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -7967,39 +9322,37 @@ dependencies = [
[[package]]
name = "wiggle"
-version = "28.0.1"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b23e3dc273d1e35cab9f38a5f76487aeeedcfa6a3fb594e209ee7b6f8b41dcc"
+checksum = "cc1b1135efc8e5a008971897bea8d41ca56d8d501d4efb807842ae0a1c78f639"
dependencies = [
- "anyhow",
- "async-trait",
"bitflags 2.11.0",
- "thiserror 1.0.69",
+ "thiserror 2.0.18",
"tracing",
"wasmtime",
+ "wasmtime-environ",
"wiggle-macro",
]
[[package]]
name = "wiggle-generate"
-version = "28.0.1"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8738c5a7ef3a9de0fae10f8b84091a2aa4e059d8fef23de202ab689812b6bc6e"
+checksum = "a7bc2b0d50ec8773b44fbfe1da6cb5cc44a92deaf8483233dcf0831e6db33172"
dependencies = [
- "anyhow",
"heck",
"proc-macro2",
"quote",
- "shellexpand",
"syn 2.0.117",
+ "wasmtime-environ",
"witx",
]
[[package]]
name = "wiggle-macro"
-version = "28.0.1"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e882267ac583e013a38a5aaeb83a49b219456ba3aa6e6772440f7213b176e8ff"
+checksum = "2d6c7d44ea552e1fbfdcd7a2cd83f5c2d1e803d5b1a11e3462c06888b77f455f"
dependencies = [
"proc-macro2",
"quote",
@@ -8029,7 +9382,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.48.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -8040,19 +9393,21 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "winch-codegen"
-version = "28.0.1"
+version = "43.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6232f40a795be2ce10fc761ed3b403825126a60d12491ac556ea104a932fd18a"
+checksum = "eb9f45f7172a2628c8317766e427babc0a400f9d10b1c0f0b0617c5ed5b79de6"
dependencies = [
- "anyhow",
+ "cranelift-assembler-x64",
"cranelift-codegen",
"gimli",
"regalloc2",
"smallvec",
"target-lexicon",
- "wasmparser 0.221.3",
- "wasmtime-cranelift",
+ "thiserror 2.0.18",
+ "wasmparser 0.245.1",
"wasmtime-environ",
+ "wasmtime-internal-core",
+ "wasmtime-internal-cranelift",
]
[[package]]
@@ -8365,6 +9720,15 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "winnow"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "winx"
version = "0.36.4"
@@ -8447,9 +9811,9 @@ dependencies = [
[[package]]
name = "wit-parser"
-version = "0.221.3"
+version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "896112579ed56b4a538b07a3d16e562d101ff6265c46b515ce0c701eef16b2ac"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
@@ -8460,16 +9824,17 @@ dependencies = [
"serde_derive",
"serde_json",
"unicode-xid",
- "wasmparser 0.221.3",
+ "wasmparser 0.244.0",
]
[[package]]
name = "wit-parser"
-version = "0.244.0"
+version = "0.245.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+checksum = "330698718e82983499419494dd1e3d7811a457a9bf9f69734e8c5f07a2547929"
dependencies = [
"anyhow",
+ "hashbrown 0.16.1",
"id-arena",
"indexmap 2.13.0",
"log",
@@ -8478,7 +9843,7 @@ dependencies = [
"serde_derive",
"serde_json",
"unicode-xid",
- "wasmparser 0.244.0",
+ "wasmparser 0.245.1",
]
[[package]]
@@ -8508,6 +9873,23 @@ dependencies = [
"tap",
]
+[[package]]
+name = "x11rb"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
+dependencies = [
+ "gethostname",
+ "rustix 1.1.4",
+ "x11rb-protocol",
+]
+
+[[package]]
+name = "x11rb-protocol"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
+
[[package]]
name = "x509-cert"
version = "0.2.5"
@@ -8650,11 +10032,11 @@ dependencies = [
[[package]]
name = "zerocopy"
-version = "0.8.42"
+version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3"
+checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
- "zerocopy-derive 0.8.42",
+ "zerocopy-derive 0.8.48",
]
[[package]]
@@ -8670,9 +10052,9 @@ dependencies = [
[[package]]
name = "zerocopy-derive"
-version = "0.8.42"
+version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
+checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
@@ -8816,6 +10198,21 @@ dependencies = [
"pkg-config",
]
+[[package]]
+name = "zune-core"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
+
+[[package]]
+name = "zune-jpeg"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
+dependencies = [
+ "zune-core",
+]
+
[[package]]
name = "zvariant"
version = "4.2.0"
diff --git a/Cargo.toml b/Cargo.toml
index 99992a40ee2..2bb3b4e2ca0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,10 +1,11 @@
[workspace]
-members = [".", "crates/ironclaw_safety"]
+members = [".", "crates/ironclaw_common", "crates/ironclaw_safety", "crates/ironclaw_skills", "crates/ironclaw_engine", "crates/ironclaw_gateway", "crates/ironclaw_tui"]
exclude = [
"channels-src/discord",
"channels-src/telegram",
"channels-src/slack",
"channels-src/whatsapp",
+ "tools-src/composio",
"tools-src/github",
"tools-src/gmail",
"tools-src/google-calendar",
@@ -20,7 +21,7 @@ exclude = [
[package]
name = "ironclaw"
-version = "0.19.0"
+version = "0.25.0"
edition = "2024"
rust-version = "1.92"
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
@@ -39,9 +40,14 @@ eula = false
# Async runtime
tokio = { version = "1", features = ["full"] }
tokio-stream = { version = "0.1", features = ["sync"] }
+tokio-util = { version = "0.7", features = ["compat"] }
futures = "0.3"
+tokio-tungstenite = { version = "0.26", features = ["rustls-tls-native-roots"] }
eventsource-stream = "0.2"
+# Agent Client Protocol (ACP) — standard communication with coding agents
+agent-client-protocol = "0.10"
+
# HTTP client
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls-native-roots", "stream"] }
@@ -57,6 +63,7 @@ refinery = { version = "0.8", features = ["tokio-postgres"], optional = true }
tokio-postgres-rustls = { version = "0.13", optional = true }
rustls = { version = "0.23", optional = true, default-features = false }
rustls-native-certs = { version = "0.8", optional = true }
+webpki-roots = { version = "0.26", optional = true }
# Database - libSQL/Turso (optional embedded database)
libsql = { version = "0.6", optional = true, default-features = false, features = ["core", "replication", "remote", "tls"] }
@@ -95,13 +102,25 @@ termimad = "0.34"
# Channel integrations
axum = { version = "0.8", features = ["ws"] }
tower = "0.5"
-tower-http = { version = "0.6", features = ["trace", "cors", "set-header"] }
+tower-http = { version = "0.6", features = ["trace", "cors", "set-header", "catch-panic"] }
# Cron scheduling for routines
cron = "0.13"
+# JSON Schema validation (workspace document metadata).
+# default-features disabled to avoid pulling a second `reqwest` major version
+# for remote $ref resolution (we only validate against in-memory schemas).
+jsonschema = { version = "0.45", default-features = false }
+
+# Shared types
+ironclaw_common = { path = "crates/ironclaw_common", version = "0.2.0" }
+
# Safety/sanitization
-ironclaw_safety = { path = "crates/ironclaw_safety", version = "0.1.0" }
+ironclaw_engine = { path = "crates/ironclaw_engine", version = "0.1.0" }
+ironclaw_gateway = { path = "crates/ironclaw_gateway", version = "0.1.0" }
+ironclaw_safety = { path = "crates/ironclaw_safety", version = "0.2.1" }
+ironclaw_skills = { path = "crates/ironclaw_skills", version = "0.1.0" }
+ironclaw_tui = { path = "crates/ironclaw_tui", optional = true, version = "0.1.0" }
regex = "1"
aho-corasick = "1"
@@ -111,6 +130,7 @@ serde_yml = "0.0.12"
# Filesystem paths
dirs = "6"
fs4 = "0.6"
+glob = "0.3"
# Semantic versioning
semver = "1"
@@ -130,9 +150,9 @@ open = "5"
pgvector = { version = "0.4", features = ["postgres"], optional = true }
# WASM sandbox for untrusted tool execution
-wasmtime = { version = "28", features = ["component-model"] }
-wasmtime-wasi = "28" # WASI support for component model
-wasmparser = "0.220" # WASM binary parsing for validation
+wasmtime = { version = "43.0.1", features = ["component-model"] }
+wasmtime-wasi = "43.0.1" # WASI support for component model
+wasmparser = "0.245.1" # WASM binary parsing for validation
# Cryptography for secrets management
aes-gcm = "0.10"
@@ -168,6 +188,8 @@ hyper-util = { version = "0.1", features = ["server", "tokio", "http1", "http2"]
http-body-util = "0.1"
bytes = "1"
base64 = "0.22.1"
+cookie = "0.18"
+jsonwebtoken = "9"
mime_guess = "2.0.5"
clap_complete = "4.5.0"
lru = "0.16.3"
@@ -176,6 +198,7 @@ lru = "0.16.3"
html-to-markdown-rs = { version = "2.3", optional = true }
readabilityrs = { version = "0.1.2", optional = true }
ed25519-dalek = { version = "2.2.0", features = ["std"] }
+bs58 = "0.5"
hex = "0.4.3"
# OpenClaw import (feature gated)
@@ -185,37 +208,35 @@ json5 = { version = "0.4", optional = true }
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = "3"
+# PTY allocation for Claude CLI stdout buffering fix (Unix only)
+[target.'cfg(unix)'.dependencies]
+pty-process = { version = "0.5", features = ["async"] }
+
# Linux secret-service (GNOME Keyring, KWallet)
[target.'cfg(target_os = "linux")'.dependencies]
secret-service = { version = "4", features = ["rt-tokio-crypto-rust"] }
zbus = "4"
+[build-dependencies]
+serde_json = "1"
+
[dev-dependencies]
tokio-test = "0.4"
tracing-test = "0.2"
-tokio-tungstenite = "0.26"
testcontainers-modules = { version = "0.11", features = ["postgres"] }
pretty_assertions = "1"
tempfile = "3"
insta = "1.46.3"
-criterion = "0.5"
-
-[[bench]]
-name = "safety_check"
-harness = false
-
-[[bench]]
-name = "safety_pipeline"
-harness = false
[features]
-default = ["postgres", "libsql", "html-to-markdown"]
+default = ["postgres", "libsql", "html-to-markdown", "tui"]
postgres = [
"dep:deadpool-postgres",
"dep:tokio-postgres",
"dep:tokio-postgres-rustls",
"dep:rustls",
"dep:rustls-native-certs",
+ "dep:webpki-roots",
"dep:postgres-types",
"dep:refinery",
"dep:pgvector",
@@ -227,6 +248,7 @@ libsql = ["dep:libsql"]
integration = []
html-to-markdown = ["dep:html-to-markdown-rs", "dep:readabilityrs"]
bedrock = ["dep:aws-config", "dep:aws-sdk-bedrockruntime", "dep:aws-smithy-types"]
+tui = ["dep:ironclaw_tui"]
import = ["dep:json5", "libsql"]
[[test]]
@@ -243,8 +265,7 @@ strip = true # Remove debug symbols from release binaries
# The profile that 'cargo dist' will build with
[profile.dist]
inherits = "release"
-lto = "fat" # Full cross-crate LTO (slow build, better codegen)
-codegen-units = 1 # Single codegen unit for maximum optimization
+lto = "thin"
# Config for 'dist'
[workspace.metadata.dist]
diff --git a/Dockerfile b/Dockerfile
index a2c2610d6f1..2f1c208e2e7 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,56 +1,154 @@
# Multi-stage Dockerfile for the IronClaw agent (cloud deployment).
#
+# Uses cargo-chef for dependency caching — only rebuilds deps when
+# Cargo.toml/Cargo.lock change, not on every source edit.
+#
+# Debian-based build + runtime. The bundled libSQL/SQLite C code has
+# threading issues when statically linked against musl (segfault on
+# database reopen), so we use glibc.
+#
# Build:
-# docker build --platform linux/amd64 -t ironclaw:latest .
+# docker build --platform linux/amd64 --target runtime -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
+# Stage 1: Install cargo-chef
+FROM rust:1.92-bookworm AS chef
-RUN apt-get update && apt-get install -y --no-install-recommends \
- pkg-config libssl-dev cmake gcc g++ \
- && rm -rf /var/lib/apt/lists/* \
- && rustup target add wasm32-wasip2 \
- && cargo install wasm-tools
+RUN rustup target add wasm32-wasip2 \
+ && cargo install cargo-chef@0.1.77 wasm-tools@1.246.1
WORKDIR /app
-# Copy manifests first for layer caching
+# Stage 2: Generate the dependency recipe (changes only when Cargo.toml/lock change)
+FROM chef AS planner
+
COPY Cargo.toml Cargo.lock ./
COPY crates/ crates/
+COPY build.rs build.rs
+COPY src/ src/
+COPY tests/ tests/
+COPY migrations/ migrations/
+COPY registry/ registry/
+COPY channels-src/ channels-src/
+COPY tools-src/ tools-src/
+COPY wit/ wit/
+COPY providers.json providers.json
+
+RUN cargo chef prepare --recipe-path recipe.json
+
+# Stage 3: Build dependencies (cached unless Cargo.toml/lock change)
+FROM chef AS deps
+
+# Docker-only overrides for the dist profile (not in Cargo.toml because
+# cargo-dist uses dist for release binaries that need unwinding).
+ENV CARGO_PROFILE_DIST_PANIC=abort \
+ CARGO_PROFILE_DIST_CODEGEN_UNITS=1
+
+COPY --from=planner /app/recipe.json recipe.json
+RUN cargo chef cook --profile dist --recipe-path recipe.json
+
+# Stage 4: Build the actual binary (only recompiles ironclaw source)
+FROM deps AS builder
-# Copy source, build script, tests, and supporting directories
+COPY Cargo.toml Cargo.lock ./
+COPY crates/ crates/
COPY build.rs build.rs
COPY src/ src/
COPY tests/ tests/
COPY migrations/ migrations/
COPY registry/ registry/
COPY channels-src/ channels-src/
+COPY tools-src/ tools-src/
COPY wit/ wit/
COPY providers.json providers.json
-# [[bench]] entries in Cargo.toml require bench sources to exist for cargo to parse the manifest
-COPY benches/ benches/
+COPY profiles/ profiles/
+
+RUN cargo build --profile dist --bin ironclaw
+
+# Stage 4b: Build all WASM extensions from source (only used by runtime-staging)
+#
+# Inherits from chef (not builder) so WASM extensions only rebuild when
+# tools-src/, channels-src/, registry/, or wit/ change — not on every
+# src/ edit. The extensions are standalone crates with their own lockfiles.
+FROM chef AS wasm-builder
-RUN cargo build --release --bin ironclaw
+RUN apt-get update && apt-get install -y --no-install-recommends jq && rm -rf /var/lib/apt/lists/*
-# Stage 2: Runtime
-FROM debian:bookworm-slim
+COPY tools-src/ tools-src/
+COPY channels-src/ channels-src/
+COPY registry/ registry/
+COPY wit/ wit/
-RUN apt-get update && apt-get install -y --no-install-recommends \
- ca-certificates libssl3 \
+RUN set -eux; \
+ mkdir -p /app/wasm-bundles/tools /app/wasm-bundles/channels; \
+ for manifest in registry/tools/*.json registry/channels/*.json; do \
+ [ -f "$manifest" ] || continue; \
+ kind=$(jq -r '.kind' "$manifest"); \
+ ext_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"); \
+ [ -d "$source_dir" ] || continue; \
+ # Telegram is embedded in the binary at build time; skip it
+ [ "$ext_name" = "telegram" ] && continue; \
+ echo "=== Building $ext_name from $source_dir ==="; \
+ if [ -f "$source_dir/Cargo.lock" ]; then \
+ CARGO_TARGET_DIR=/app/target cargo build --locked --release --target wasm32-wasip2 \
+ --manifest-path "$source_dir/Cargo.toml" || { echo "WARN: build failed for $ext_name"; continue; }; \
+ else \
+ CARGO_TARGET_DIR=/app/target cargo build --release --target wasm32-wasip2 \
+ --manifest-path "$source_dir/Cargo.toml" || { echo "WARN: build failed for $ext_name"; continue; }; \
+ fi; \
+ wasm_artifact=$(echo "${crate_name}" | tr '-' '_'); \
+ raw_wasm="/app/target/wasm32-wasip2/release/${wasm_artifact}.wasm"; \
+ [ -f "$raw_wasm" ] || continue; \
+ dest_dir="/app/wasm-bundles/tools"; \
+ [ "$kind" = "channel" ] && dest_dir="/app/wasm-bundles/channels"; \
+ wasm-tools component new "$raw_wasm" -o "$dest_dir/${ext_name}.wasm" 2>/dev/null \
+ || cp "$raw_wasm" "$dest_dir/${ext_name}.wasm"; \
+ wasm-tools strip "$dest_dir/${ext_name}.wasm" -o "$dest_dir/${ext_name}.wasm.tmp" 2>/dev/null \
+ && mv "$dest_dir/${ext_name}.wasm.tmp" "$dest_dir/${ext_name}.wasm" \
+ || true; \
+ [ -f "$source_dir/$caps_file" ] && cp "$source_dir/$caps_file" "$dest_dir/${ext_name}.capabilities.json"; \
+ echo " -> $dest_dir/${ext_name}.wasm"; \
+ done; \
+ count=$(find /app/wasm-bundles -name '*.wasm' | wc -l); \
+ echo "Built $count WASM extensions"; \
+ [ "$count" -gt 0 ] || { echo "ERROR: No WASM extensions were built"; exit 1; }
+
+# Stage 5a: Shared runtime base
+FROM debian:bookworm-slim AS runtime-base
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
-COPY --from=builder /app/target/release/ironclaw /usr/local/bin/ironclaw
+COPY --from=builder /app/target/dist/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
+ENV HOME=/home/ironclaw
+RUN useradd -m -d /home/ironclaw -u 1000 ironclaw \
+ && mkdir -p /home/ironclaw/.ironclaw \
+ && chown -R ironclaw:ironclaw /home/ironclaw
+WORKDIR /home/ironclaw
EXPOSE 3000
ENV RUST_LOG=ironclaw=info
ENTRYPOINT ["ironclaw"]
+
+# Stage 5b: Production runtime (no pre-bundled extensions)
+FROM runtime-base AS runtime
+USER ironclaw
+
+# Stage 5c: Staging runtime (with pre-built WASM extensions)
+# Last stage = default target. Railway doesn't support --target, so this
+# must be last for Railway deploys. CI uses explicit --target flags.
+FROM runtime-base AS runtime-staging
+COPY --from=wasm-builder --chown=ironclaw:ironclaw /app/wasm-bundles/tools/ /home/ironclaw/.ironclaw/tools/
+COPY --from=wasm-builder --chown=ironclaw:ironclaw /app/wasm-bundles/channels/ /home/ironclaw/.ironclaw/channels/
+USER ironclaw
diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md
index ad2db551177..7be7b0c2829 100644
--- a/FEATURE_PARITY.md
+++ b/FEATURE_PARITY.md
@@ -38,7 +38,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| 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 | ✅ | ❌ | |
+| Configuration hot-reload | ✅ | 🚧 | LLM backend/model settings hot-reload via web UI when the reload handle is available; broader config reload still pending |
| Network modes (loopback/LAN/remote) | ✅ | 🚧 | HTTP only |
| OpenAI-compatible HTTP API | ✅ | ✅ | /v1/chat/completions, per-request `model` override |
| Canvas hosting | ✅ | ❌ | Agent-driven UI |
@@ -69,8 +69,8 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| REPL (simple) | ✅ | ✅ | - | For testing |
| WASM channels | ❌ | ✅ | - | IronClaw innovation; host resolves owner scope vs sender identity |
| WhatsApp | ✅ | ❌ | P1 | Baileys (Web), same-phone mode with echo detection |
-| Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username, DM topics, setup-time owner auto-verification, owner-scoped persistence |
-| Discord | ✅ | ❌ | P2 | discord.js, thread parent binding inheritance |
+| Telegram | ✅ | ✅ | - | WASM channel(MTProto), polling-first setup, DM pairing, caption, /start, bot_username, DM topics, web/UI ownership claim flow, owner-scoped persistence |
+| Discord | ✅ | 🚧 | P2 | Gateway `MESSAGE_CREATE` intake restored via websocket queue + WASM poll; Gateway DMs now respect pairing; thread parent binding inheritance and reply/thread parity still incomplete |
| Signal | ✅ | ✅ | P2 | signal-cli daemonPC, SSE listener HTTP/JSON-R, user/group allowlists, DM pairing |
| Slack | ✅ | ✅ | - | WASM tool |
| iMessage | ✅ | ❌ | P3 | BlueBubbles or Linq recommended |
@@ -97,6 +97,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Cron/heartbeat topic targeting | ✅ | ❌ | Messages land in correct topic |
| DM topics support | ✅ | ❌ | Agent/topic bindings in DMs and agent-scoped SessionKeys |
| Persistent ACP topic binding | ✅ | ❌ | ACP harness sessions can pin to Telegram forum or DM topics |
+| sendVoice (voice note replies) | ✅ | ✅ | audio/ogg attachments sent as voice notes; prerequisite for TTS (#90) |
### Discord-Specific Features (since Feb 2025)
@@ -112,7 +113,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|---------|----------|----------|-------|
| Streaming draft replies | ✅ | ❌ | Partial replies via draft message updates |
| Configurable stream modes | ✅ | ❌ | Per-channel stream behavior |
-| Thread ownership | ✅ | ❌ | Thread-level ownership tracking plus reply participation memory |
+| Thread ownership | ✅ | 🚧 | Reply participation memory now persists with TTL-bounded tracking; full thread-level ownership tracking is still missing |
| Download-file action | ✅ | ❌ | On-demand attachment downloads via message actions |
### Mattermost-Specific Features (since Mar 2026)
@@ -349,6 +350,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| MMR re-ranking | ✅ | ❌ | Maximal marginal relevance for result diversity |
| LLM-based query expansion | ✅ | ❌ | Expand FTS queries via LLM |
| OpenAI embeddings | ✅ | ✅ | |
+| Bedrock embeddings | ❌ | ✅ | Reuses Bedrock region/profile auth for Titan Text Embeddings V2 |
| Gemini embeddings | ✅ | ❌ | |
| Local embeddings | ✅ | ❌ | |
| SQLite-vec backend | ✅ | ❌ | IronClaw uses PostgreSQL |
@@ -558,7 +560,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
### P1 - High Priority
- ❌ Slack channel (real implementation)
-- ✅ Telegram channel (WASM, DM pairing, caption, /start)
+- ✅ Telegram channel (WASM, polling-first setup, DM pairing, caption, /start)
- ❌ WhatsApp channel
- ✅ Multi-provider failover (`FailoverProvider` with retryable error classification)
- ✅ Hooks system (core lifecycle hooks + bundled/plugin/workspace hooks + outbound webhooks)
diff --git a/README.ja.md b/README.ja.md
index 887cf67e5f4..cc6e31b4110 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -18,7 +18,8 @@
English |
简体中文 |
Русский |
- 日本語
+ 日本語 |
+ 한국어
@@ -180,7 +181,7 @@ LLM_API_KEY=sk-or-...
LLM_MODEL=anthropic/claude-sonnet-4
```
-完全なプロバイダーガイドは[docs/LLM_PROVIDERS.md](docs/LLM_PROVIDERS.md)をご覧ください。
+完全なプロバイダーガイドは[docs/capabilities/llm-providers.md](docs/capabilities/llm-providers.md)をご覧ください。
## セキュリティ
@@ -306,7 +307,7 @@ cargo test
cargo test test_name
```
-- **Telegramチャネル**: セットアップとDMペアリングについては[docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md)を参照してください。
+- **チャネル**: Telegram、Discord、その他のチャネルの設定は[docs/channels/overview.mdx](docs/channels/overview.mdx)を参照してください。
- **チャネルソースの変更**: `cargo build`の前に`./channels-src/telegram/build.sh`を実行して、更新されたWASMをバンドルしてください。
## OpenClawの系譜
diff --git a/README.ko.md b/README.ko.md
new file mode 100644
index 00000000000..903b8d1c2a1
--- /dev/null
+++ b/README.ko.md
@@ -0,0 +1,338 @@
+
+
+
+
+IronClaw
+
+
+ 언제나 당신 편인 안전한 개인 AI 어시스턴트
+
+
+
+
+
+
+
+
+
+
+
+
+ English |
+ 简体中文 |
+ Русский |
+ 日本語 |
+ 한국어
+
+
+
+ 철학 •
+ 기능 •
+ 설치 •
+ 설정 •
+ 보안 •
+ 아키텍처
+
+
+---
+
+## 철학
+
+IronClaw는 단순한 원칙 위에 만들어졌습니다: **AI 어시스턴트는 당신을 위해 일해야 하며, 당신을 거슬러서는 안 됩니다**.
+
+AI 시스템이 데이터 처리에 대해 점점 더 불투명해지고 기업의 이익에 맞춰지는 세상에서, IronClaw는 다른 접근 방식을 취합니다:
+
+- **데이터는 당신의 것** - 모든 정보는 로컬에 저장되고 암호화되며, 절대 당신의 통제를 벗어나지 않습니다
+- **설계에 의한 투명성** - 오픈 소스, 감사 가능, 숨겨진 텔레메트리나 데이터 수집 없음
+- **자가 확장 기능** - 공급업체의 업데이트를 기다리지 않고 즉석에서 새로운 도구를 만들 수 있습니다
+- **심층 방어** - 프롬프트 인젝션 및 데이터 유출로부터 보호하는 다중 보안 계층
+
+IronClaw는 개인적, 직업적 삶에서 실제로 신뢰할 수 있는 AI 어시스턴트입니다.
+
+## 기능
+
+### 보안 우선
+
+- **WASM 샌드박스** - 신뢰할 수 없는 도구는 권한 기반의 격리된 WebAssembly 컨테이너에서 실행됩니다
+- **자격 증명 보호** - 비밀은 도구에 노출되지 않고, 누출 감지와 함께 호스트 경계에서 주입됩니다
+- **프롬프트 인젝션 방어** - 패턴 감지, 콘텐츠 정화, 정책 시행
+- **엔드포인트 화이트리스트** - HTTP 요청은 명시적으로 승인된 호스트와 경로로만 전송됩니다
+
+### 항상 사용 가능
+
+- **다중 채널** - REPL, HTTP 웹훅, WASM 채널 (Telegram, Slack), 웹 게이트웨이
+- **Docker 샌드박스** - 작업별 토큰과 오케스트레이터/워커 패턴을 사용한 격리된 컨테이너 실행
+- **웹 게이트웨이** - 실시간 SSE/WebSocket 스트리밍이 있는 브라우저 UI
+- **루틴** - 백그라운드 자동화를 위한 cron 일정, 이벤트 트리거, 웹훅 핸들러
+- **하트비트 시스템** - 모니터링 및 유지 보수 작업을 위한 사전 백그라운드 실행
+- **병렬 작업** - 격리된 컨텍스트로 여러 요청을 동시에 처리합니다
+- **자가 복구** - 중단된 작업의 자동 감지 및 복구
+
+### 자가 확장
+
+- **동적 도구 빌드** - 필요한 것을 설명하면 IronClaw가 WASM 도구로 만들어 줍니다
+- **MCP 프로토콜** - 추가 기능을 위해 Model Context Protocol 서버에 연결합니다
+- **플러그인 아키텍처** - 재시작 없이 새로운 WASM 도구와 채널을 추가할 수 있습니다
+
+### 영구 메모리
+
+- **하이브리드 검색** - Reciprocal Rank Fusion을 사용한 전체 텍스트 + 벡터 검색
+- **워크스페이스 파일시스템** - 노트, 로그, 컨텍스트를 위한 유연한 경로 기반 저장소
+- **아이덴티티 파일** - 세션 간 일관된 성격과 선호도를 유지합니다
+
+## 설치
+
+### 사전 요구 사항
+
+- Rust 1.85+
+- [pgvector](https://github.com/pgvector/pgvector) 확장이 있는 PostgreSQL 15+
+- NEAR AI 계정 (인증은 설정 마법사를 통해 처리됨)
+
+## 다운로드 또는 빌드
+
+[릴리스 페이지](https://github.com/nearai/ironclaw/releases/)를 방문하여 최신 업데이트를 확인하세요.
+
+
+ Windows 인스톨러로 설치 (Windows)
+
+[Windows 인스톨러](https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-x86_64-pc-windows-msvc.msi)를 다운로드하여 실행하세요.
+
+
+
+
+ PowerShell 스크립트로 설치 (Windows)
+
+```sh
+irm https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.ps1 | iex
+```
+
+
+
+
+ 셸 스크립트로 설치 (macOS, Linux, Windows/WSL)
+
+```sh
+curl --proto '=https' --tlsv1.2 -LsSf https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.sh | sh
+```
+
+
+
+ Homebrew로 설치 (macOS/Linux)
+
+```sh
+brew install ironclaw
+```
+
+
+
+
+ 소스 코드 컴파일 (Windows, Linux, macOS의 Cargo)
+
+`cargo`로 설치하세요. 컴퓨터에 [Rust](https://rustup.rs)가 설치되어 있는지 확인하세요.
+
+```bash
+# 저장소 복제
+git clone https://github.com/nearai/ironclaw.git
+cd ironclaw
+
+# 빌드
+cargo build --release
+
+# 테스트 실행
+cargo test
+```
+
+**전체 릴리스**의 경우 (채널 소스를 수정한 후), `./scripts/build-all.sh`를 실행하여 채널을 먼저 다시 빌드하세요.
+
+
+
+### 데이터베이스 설정
+
+```bash
+# 데이터베이스 생성
+createdb ironclaw
+
+# pgvector 활성화
+psql ironclaw -c "CREATE EXTENSION IF NOT EXISTS vector;"
+```
+
+## 설정
+
+설정 마법사를 실행하여 IronClaw를 구성하세요:
+
+```bash
+ironclaw onboard
+```
+
+마법사는 데이터베이스 연결, NEAR AI 인증 (브라우저 OAuth를 통해),
+그리고 비밀 암호화 (시스템 키체인 사용)를 처리합니다. 설정은 연결된
+데이터베이스에 저장됩니다. 부트스트랩 변수 (예: `DATABASE_URL`, `LLM_BACKEND`)는
+데이터베이스가 연결되기 전에 사용할 수 있도록 `~/.ironclaw/.env`에 기록됩니다.
+
+### 대체 LLM 공급자
+
+IronClaw는 기본적으로 NEAR AI를 사용하지만 많은 LLM 공급자를 기본 지원합니다.
+내장 공급자에는 **Anthropic**, **OpenAI**, **GitHub Copilot**, **Google Gemini**, **MiniMax**,
+**Mistral**, **Ollama** (로컬)이 포함됩니다. **OpenRouter**
+(300+ 모델), **Together AI**, **Fireworks AI**, 자체 호스팅 서버 (**vLLM**,
+**LiteLLM**) 같은 OpenAI 호환 서비스도 지원됩니다.
+
+마법사에서 공급자를 선택하거나 환경 변수를 직접 설정하세요:
+
+```env
+# 예: MiniMax (내장, 204K 컨텍스트)
+LLM_BACKEND=minimax
+MINIMAX_API_KEY=...
+
+# 예: OpenAI 호환 엔드포인트
+LLM_BACKEND=openai_compatible
+LLM_BASE_URL=https://openrouter.ai/api/v1
+LLM_API_KEY=sk-or-...
+LLM_MODEL=anthropic/claude-sonnet-4
+```
+
+전체 공급자 가이드는 [docs/capabilities/llm-providers.md](docs/capabilities/llm-providers.md)를 참조하세요.
+
+## 보안
+
+IronClaw는 데이터를 보호하고 오용을 방지하기 위해 심층 방어를 구현합니다.
+
+### WASM 샌드박스
+
+신뢰할 수 없는 모든 도구는 격리된 WebAssembly 컨테이너에서 실행됩니다:
+
+- **권한 기반 권한** - HTTP, 비밀, 도구 호출에 대한 명시적 옵트인
+- **엔드포인트 화이트리스트** - HTTP 요청은 승인된 호스트/경로로만 전송됩니다
+- **자격 증명 주입** - 비밀은 호스트 경계에서 주입되며, WASM 코드에 절대 노출되지 않습니다
+- **누출 감지** - 비밀 유출 시도에 대해 요청과 응답을 스캔합니다
+- **속도 제한** - 남용을 방지하기 위한 도구별 요청 제한
+- **리소스 제한** - 메모리, CPU, 실행 시간 제약
+
+```
+WASM ──► 화이트리스트 ──► 누출 스캔 ──► 자격 증명 ──► 실행 ──► 누출 스캔 ──► WASM
+ 검증기 (요청) 주입기 요청 (응답)
+```
+
+### 프롬프트 인젝션 방어
+
+외부 콘텐츠는 여러 보안 계층을 통과합니다:
+
+- 인젝션 시도의 패턴 기반 감지
+- 콘텐츠 정화 및 이스케이핑
+- 심각도 수준이 있는 정책 규칙 (차단/경고/검토/정화)
+- 안전한 LLM 컨텍스트 주입을 위한 도구 출력 래핑
+
+### 데이터 보호
+
+- 모든 데이터는 로컬 PostgreSQL 데이터베이스에 저장됩니다
+- 비밀은 AES-256-GCM으로 암호화됩니다
+- 텔레메트리, 분석, 데이터 공유 없음
+- 모든 도구 실행에 대한 전체 감사 로그
+
+## 아키텍처
+
+```
+┌────────────────────────────────────────────────────────────────┐
+│ 채널 │
+│ ┌──────┐ ┌──────┐ ┌─────────────┐ ┌─────────────┐ │
+│ │ REPL │ │ HTTP │ │ WASM 채널 │ │ 웹 게이트웨이│ │
+│ └──┬───┘ └──┬───┘ └──────┬──────┘ │ (SSE + WS) │ │
+│ │ │ │ └──────┬──────┘ │
+│ └─────────┴──────────────┴────────────────┘ │
+│ │ │
+│ ┌─────────▼─────────┐ │
+│ │ 에이전트 루프 │ 의도 라우팅 │
+│ └────┬──────────┬───┘ │
+│ │ │ │
+│ ┌──────────▼────┐ ┌──▼───────────────┐ │
+│ │ 스케줄러 │ │ 루틴 엔진 │ │
+│ │ (병렬 작업) │ │ (cron, 이벤트, wh)│ │
+│ └──────┬────────┘ └────────┬─────────┘ │
+│ │ │ │
+│ ┌─────────────┼────────────────────┘ │
+│ │ │ │
+│ ┌───▼─────┐ ┌────▼────────────────┐ │
+│ │ 로컬 │ │ 오케스트레이터 │ │
+│ │ 워커 │ │ ┌───────────────┐ │ │
+│ │(인프로세스)│ │ │Docker 샌드박스│ │ │
+│ └───┬─────┘ │ │ 컨테이너 │ │ │
+│ │ │ │ ┌───────────┐ │ │ │
+│ │ │ │ │Worker / CC│ │ │ │
+│ │ │ │ └───────────┘ │ │ │
+│ │ │ └───────────────┘ │ │
+│ │ └─────────┬───────────┘ │
+│ └──────────────────┤ │
+│ │ │
+│ ┌───────────▼──────────┐ │
+│ │ 도구 레지스트리 │ │
+│ │ 내장, MCP, WASM │ │
+│ └──────────────────────┘ │
+└────────────────────────────────────────────────────────────────┘
+```
+
+### 핵심 구성 요소
+
+| 구성 요소 | 목적 |
+|-----------|---------|
+| **에이전트 루프** | 주요 메시지 처리 및 작업 조정 |
+| **라우터** | 사용자 의도 분류 (명령, 쿼리, 작업) |
+| **스케줄러** | 우선순위가 있는 병렬 작업 실행 관리 |
+| **워커** | LLM 추론과 도구 호출로 작업 실행 |
+| **오케스트레이터** | 컨테이너 라이프사이클, LLM 프록시, 작업별 인증 |
+| **웹 게이트웨이** | 채팅, 메모리, 작업, 로그, 확장, 루틴이 있는 브라우저 UI |
+| **루틴 엔진** | 예약된 (cron) 및 반응형 (이벤트, 웹훅) 백그라운드 작업 |
+| **워크스페이스** | 하이브리드 검색이 있는 영구 메모리 |
+| **안전 계층** | 프롬프트 인젝션 방어 및 콘텐츠 정화 |
+
+## 사용법
+
+```bash
+# 첫 설정 (데이터베이스, 인증 등 구성)
+ironclaw onboard
+
+# 대화형 REPL 시작
+cargo run
+
+# 디버그 로깅 사용
+RUST_LOG=ironclaw=debug cargo run
+```
+
+## 개발
+
+```bash
+# 코드 포맷
+cargo fmt
+
+# 린트
+cargo clippy --all --benches --tests --examples --all-features
+
+# 테스트 실행
+createdb ironclaw_test
+cargo test
+
+# 특정 테스트 실행
+cargo test test_name
+```
+
+- **채널**: Telegram, Discord 및 기타 채널 설정은 [docs/channels/overview.mdx](docs/channels/overview.mdx)를 참조하세요.
+- **채널 소스 변경**: 업데이트된 WASM이 번들되도록 `cargo build` 전에 `./channels-src/telegram/build.sh`를 실행하세요.
+
+## OpenClaw 역사
+
+IronClaw는 [OpenClaw](https://github.com/openclaw/openclaw)에서 영감을 받은 Rust 재구현입니다. 전체 추적 매트릭스는 [FEATURE_PARITY.md](FEATURE_PARITY.md)를 참조하세요.
+
+주요 차이점:
+
+- **Rust vs TypeScript** - 네이티브 성능, 메모리 안전, 단일 바이너리
+- **WASM 샌드박스 vs Docker** - 가벼운 권한 기반 보안
+- **PostgreSQL vs SQLite** - 프로덕션 준비된 영속성
+- **보안 우선 설계** - 다중 방어 계층, 자격 증명 보호
+
+## 라이선스
+
+다음 중 하나를 선택하여 라이선스가 부여됩니다:
+
+- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE))
+- MIT License ([LICENSE-MIT](LICENSE-MIT))
+
+원하는 대로 선택할 수 있습니다.
diff --git a/README.md b/README.md
index cb759236be6..c99e0f4b561 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,8 @@
English |
简体中文 |
Русский |
- 日本語
+ 日本語 |
+ 한국어
@@ -190,7 +191,7 @@ 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.
+See [docs/capabilities/llm-providers.md](docs/capabilities/llm-providers.md) for a full provider guide.
## Security
@@ -313,7 +314,7 @@ cargo test
cargo test test_name
```
-- **Telegram channel**: See [docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md) for setup and DM pairing.
+- **Channels**: See [docs/channels/overview.mdx](docs/channels/overview.mdx) for setup of Telegram, Discord, and other channels.
- **Changing channel sources**: Run `./channels-src/telegram/build.sh` before `cargo build` so the updated WASM is bundled.
## OpenClaw Heritage
diff --git a/README.ru.md b/README.ru.md
index 0546e7f44c5..06689c04d59 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -18,7 +18,8 @@
English |
简体中文 |
Русский |
- 日本語
+ 日本語 |
+ 한국어
@@ -184,7 +185,7 @@ LLM_API_KEY=sk-or-...
LLM_MODEL=anthropic/claude-sonnet-4
```
-Смотрите [docs/LLM_PROVIDERS.md](docs/LLM_PROVIDERS.md) для получения полного руководства по провайдерам.
+Смотрите [docs/capabilities/llm-providers.md](docs/capabilities/llm-providers.md) для получения полного руководства по провайдерам.
## Безопасность
@@ -308,7 +309,7 @@ cargo test
cargo test название_теста
```
-- **Telegram-канал**: Смотрите [docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md) для настройки и привязки аккаунта.
+- **Каналы**: Смотрите [docs/channels/overview.mdx](docs/channels/overview.mdx) для настройки Telegram, Discord и других каналов.
- **Изменение исходников каналов**: Перед `cargo build` выполните `./channels-src/telegram/build.sh`, чтобы обновить встроенный WASM.
## Наследие OpenClaw
diff --git a/README.zh-CN.md b/README.zh-CN.md
index d818872acfa..d840793b618 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -18,7 +18,8 @@
English |
简体中文 |
Русский |
- 日本語
+ 日本語 |
+ 한국어
@@ -181,7 +182,7 @@ LLM_API_KEY=sk-or-...
LLM_MODEL=anthropic/claude-sonnet-4
```
-详见 [docs/LLM_PROVIDERS.md](docs/LLM_PROVIDERS.md) 获取完整的提供商指南。
+详见 [docs/capabilities/llm-providers.md](docs/capabilities/llm-providers.md) 获取完整的提供商指南。
## 安全机制
@@ -304,7 +305,7 @@ cargo test
cargo test test_name
```
-- **Telegram 渠道**:参见 [docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md) 了解设置和私信配对。
+- **渠道**:参见 [docs/channels/overview.mdx](docs/channels/overview.mdx) 了解 Telegram、Discord 和其他渠道的设置。
- **修改渠道源码**:在 `cargo build` 之前运行 `./channels-src/telegram/build.sh` 以便打包更新后的 WASM。
## OpenClaw 传承
diff --git a/build.rs b/build.rs
index c2b939237e6..9a2877dc5b0 100644
--- a/build.rs
+++ b/build.rs
@@ -20,6 +20,9 @@ fn main() {
// ── Embed registry manifests ────────────────────────────────────────
embed_registry_catalog(&root);
+ // ── Embed bundled skills ────────────────────────────────────────────
+ embed_skills(&root);
+
// ── Build Telegram channel WASM ─────────────────────────────────────
let channel_dir = root.join("channels-src/telegram");
let wasm_out = channel_dir.join("telegram.wasm");
@@ -121,11 +124,15 @@ fn embed_registry_catalog(root: &Path) {
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).
+ // Directory-level watches ensure Cargo reruns build.rs when new files are
+ // added or removed. Per-file watches (emitted inside collect_json_files)
+ // cover content changes to existing files.
println!("cargo:rerun-if-changed=registry/_bundles.json");
+ println!("cargo:rerun-if-changed=registry/tools");
+ println!("cargo:rerun-if-changed=registry/channels");
+ println!("cargo:rerun-if-changed=registry/mcp-servers");
- let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
+ let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); // safety: build script
let out_path = out_dir.join("embedded_catalog.json");
if !registry_dir.is_dir() {
@@ -177,7 +184,60 @@ fn embed_registry_catalog(root: &Path) {
bundles_raw,
);
- fs::write(&out_path, catalog).unwrap();
+ fs::write(&out_path, catalog).unwrap(); // safety: build script
+}
+
+/// Collect all `skills/*/SKILL.md` files into an embedded JSON blob.
+///
+/// Output: `$OUT_DIR/embedded_skills.json` — a JSON array of `{"name": "...", "content": "..."}`.
+/// These are loaded at runtime as bundled skills (lowest discovery priority, Trusted trust level).
+fn embed_skills(root: &Path) {
+ use std::fs;
+
+ let skills_dir = root.join("skills");
+
+ // Rerun when any skill changes
+ println!("cargo:rerun-if-changed=skills");
+
+ let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); // safety: build script panics on failure
+ let out_path = out_dir.join("embedded_skills.json");
+
+ if !skills_dir.is_dir() {
+ fs::write(&out_path, "[]").unwrap(); // safety: build script
+ return;
+ }
+
+ let mut skills: Vec = Vec::new();
+
+ let mut entries: Vec<_> = fs::read_dir(&skills_dir)
+ .unwrap() // safety: build script
+ .filter_map(|e| e.ok())
+ .filter(|e| e.path().is_dir())
+ .collect();
+ entries.sort_by_key(|e| e.file_name());
+
+ for entry in entries {
+ let skill_md = entry.path().join("SKILL.md");
+ if !skill_md.is_file() {
+ continue;
+ }
+ // Emit per-file watch
+ println!("cargo:rerun-if-changed={}", skill_md.display());
+
+ let name = entry.file_name().to_string_lossy().to_string();
+ if let Ok(content) = fs::read_to_string(&skill_md) {
+ // Escape for JSON embedding
+ let name_json = serde_json::to_string(&name).unwrap(); // safety: build script
+ let content_json = serde_json::to_string(&content).unwrap(); // safety: build script
+ skills.push(format!(
+ r#"{{"name":{},"content":{}}}"#,
+ name_json, content_json
+ ));
+ }
+ }
+
+ let catalog = format!("[{}]", skills.join(","));
+ fs::write(&out_path, catalog).unwrap(); // safety: build script
}
/// Read all .json files from a directory and push their raw contents into `out`.
diff --git a/channels-src/discord/Cargo.lock b/channels-src/discord/Cargo.lock
index f25ce5511b5..f6e4a814278 100644
--- a/channels-src/discord/Cargo.lock
+++ b/channels-src/discord/Cargo.lock
@@ -20,162 +20,33 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
-[[package]]
-name = "base64ct"
-version = "1.8.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
-
[[package]]
name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
-[[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 = "const-oid"
-version = "0.9.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
-
-[[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 = "curve25519-dalek"
-version = "4.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
-dependencies = [
- "cfg-if",
- "cpufeatures",
- "curve25519-dalek-derive",
- "digest",
- "fiat-crypto",
- "rustc_version",
- "subtle",
- "zeroize",
-]
-
-[[package]]
-name = "curve25519-dalek-derive"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "der"
-version = "0.7.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
-dependencies = [
- "const-oid",
- "zeroize",
-]
-
-[[package]]
-name = "digest"
-version = "0.10.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
-dependencies = [
- "block-buffer",
- "crypto-common",
-]
-
[[package]]
name = "discord-channel"
-version = "0.2.0"
+version = "0.2.1"
dependencies = [
- "ed25519-dalek",
- "hex",
"serde",
"serde_json",
"wit-bindgen",
]
-[[package]]
-name = "ed25519"
-version = "2.2.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
-dependencies = [
- "pkcs8",
- "signature",
-]
-
-[[package]]
-name = "ed25519-dalek"
-version = "2.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
-dependencies = [
- "curve25519-dalek",
- "ed25519",
- "serde",
- "sha2",
- "subtle",
- "zeroize",
-]
-
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
-[[package]]
-name = "fiat-crypto"
-version = "0.2.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
-
-[[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"
@@ -197,12 +68,6 @@ 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 = "id-arena"
version = "2.3.0"
@@ -223,9 +88,9 @@ dependencies = [
[[package]]
name = "itoa"
-version = "1.0.17"
+version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128"
@@ -233,12 +98,6 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67"
-[[package]]
-name = "libc"
-version = "0.2.182"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
-
[[package]]
name = "log"
version = "0.4.29"
@@ -253,19 +112,9 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "once_cell"
-version = "1.21.3"
+version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
-
-[[package]]
-name = "pkcs8"
-version = "0.10.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
-dependencies = [
- "der",
- "spki",
-]
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "prettyplease"
@@ -288,22 +137,13 @@ dependencies = [
[[package]]
name = "quote"
-version = "1.0.44"
+version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
-[[package]]
-name = "rustc_version"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
-dependencies = [
- "semver",
-]
-
[[package]]
name = "semver"
version = "1.0.27"
@@ -353,23 +193,6 @@ dependencies = [
"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 = "signature"
-version = "2.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
-
[[package]]
name = "smallvec"
version = "1.15.1"
@@ -385,22 +208,6 @@ dependencies = [
"smallvec",
]
-[[package]]
-name = "spki"
-version = "0.7.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
-dependencies = [
- "base64ct",
- "der",
-]
-
-[[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.117"
@@ -412,12 +219,6 @@ dependencies = [
"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.24"
@@ -575,30 +376,24 @@ dependencies = [
[[package]]
name = "zerocopy"
-version = "0.8.39"
+version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
+checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
-version = "0.8.39"
+version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
+checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
-[[package]]
-name = "zeroize"
-version = "1.8.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
-
[[package]]
name = "zmij"
version = "1.0.21"
diff --git a/channels-src/discord/Cargo.toml b/channels-src/discord/Cargo.toml
index a2892494a84..6388178c0a1 100644
--- a/channels-src/discord/Cargo.toml
+++ b/channels-src/discord/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "discord-channel"
-version = "0.2.0"
+version = "0.2.1"
edition = "2021"
description = "Discord channel for IronClaw"
license = "MIT OR Apache-2.0"
@@ -10,8 +10,6 @@ publish = false
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
wit-bindgen = "0.36"
-ed25519-dalek = { version = "2", default-features = false, features = ["alloc", "fast", "zeroize"] }
-hex = "0.4"
[lib]
crate-type = ["cdylib"]
diff --git a/channels-src/discord/README.md b/channels-src/discord/README.md
index 333e7670db0..927d2fae9b4 100644
--- a/channels-src/discord/README.md
+++ b/channels-src/discord/README.md
@@ -86,6 +86,24 @@ If an internal error occurs (e.g., metadata serialization failure), the tool att
Check the host logs for detailed error information.
## Advanced Usage
+### Gateway Mode
+
+The Discord channel now defaults to Discord Gateway transport for inbound message intake.
+The bundled identify payload requests intents `4609`, which expands to:
+
+- `GUILDS` (`1`)
+- `GUILD_MESSAGES` (`512`)
+- `DIRECT_MESSAGES` (`4096`)
+
+Gateway DMs now follow the same pairing policy as webhook DMs. Unpaired users receive a pairing
+instruction reply in the DM channel before the message is allowed through to the agent. If you
+want stricter access control than pairing, set `owner_id`; that lock still applies to both
+webhook and Gateway traffic.
+
+Gateway presence simply reflects a successful authenticated Gateway connection and advertises
+`online`. Pairing still controls whether DMs are allowed through to the agent, but it no longer
+changes the visible Discord status.
+
### Mention Polling
The Discord channel can also poll configured channels for `@bot` mentions.
@@ -110,6 +128,7 @@ Example channel config:
- `owner_id`: when set, only that Discord user can interact with the bot.
- `dm_policy`: `open` allows all DMs; `pairing` requires approval.
- `allow_from`: allowlist entries for DM pairing checks (`*`, user id, or username).
+- Gateway DMs respect `dm_policy` and pairing just like webhook DMs.
### Embeds
diff --git a/channels-src/discord/discord.capabilities.json b/channels-src/discord/discord.capabilities.json
index 9ff7a8905d6..00ee25858eb 100644
--- a/channels-src/discord/discord.capabilities.json
+++ b/channels-src/discord/discord.capabilities.json
@@ -1,5 +1,5 @@
{
- "version": "0.2.0",
+ "version": "0.2.1",
"wit_version": "0.3.0",
"type": "channel",
"name": "discord",
@@ -22,7 +22,8 @@
"capabilities": {
"http": {
"allowlist": [
- { "host": "discord.com", "path_prefix": "/api/v10" }
+ { "host": "discord.com", "path_prefix": "/api/v10" },
+ { "host": "gateway.discord.gg", "path_prefix": "/", "methods": ["GET"] }
],
"credentials": {
"discord_bot_token": {
@@ -36,6 +37,20 @@
"requests_per_hour": 3600
}
},
+ "websocket": {
+ "url": "wss://gateway.discord.gg/?v=10&encoding=json",
+ "connect_on_start": true,
+ "identify_secret_name": "discord_bot_token",
+ "identify": {
+ "_intents_doc": "GUILDS(1) + GUILD_MESSAGES(512) + DIRECT_MESSAGES(4096)",
+ "intents": 4609,
+ "properties": {
+ "os": "linux",
+ "browser": "ironclaw",
+ "device": "ironclaw"
+ }
+ }
+ },
"secrets": {
"allowed_names": ["discord_bot_token", "discord_*"]
},
diff --git a/channels-src/discord/src/lib.rs b/channels-src/discord/src/lib.rs
index cdb6c515077..e06736c7453 100644
--- a/channels-src/discord/src/lib.rs
+++ b/channels-src/discord/src/lib.rs
@@ -10,11 +10,11 @@
//! - Message event parsing (@mentions, DMs)
//! - Thread support for conversations
//! - Response posting via Discord Web API
-//! - Automatic message truncation (> 2000 chars)
+//! - Markdown attachment fallback for oversized replies
//!
//! # Security
//!
-//! - Signature validation is handled in-channel using Discord's Ed25519 headers
+//! - Signature validation is handled by the host (webhook secrets)
//! - Bot token is injected by host during HTTP requests
//! - WASM never sees raw credentials
@@ -23,17 +23,18 @@ wit_bindgen::generate!({
path: "../../wit/channel.wit",
});
-use std::{cmp::Ordering, collections::HashMap};
-
-use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::time::{SystemTime, UNIX_EPOCH};
use exports::near::agent::channel::{
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
- OutgoingHttpResponse, PollConfig, StatusUpdate,
+ OutgoingHttpResponse, PollConfig, StatusType, StatusUpdate,
};
use near::agent::channel_host::{self, EmittedMessage};
+const DISCORD_API_BASE: &str = "https://discord.com/api/v10";
+
/// Discord interaction wrapper.
#[derive(Debug, Deserialize)]
struct DiscordInteraction {
@@ -108,146 +109,453 @@ struct DiscordMessage {
author: DiscordUser,
}
-#[derive(Debug, Deserialize)]
-struct DiscordChannelMessage {
- id: String,
- content: String,
+/// Deserialize a String that may be null or missing (backward compat with old Option fields).
+fn deserialize_nullable_string<'de, D>(deserializer: D) -> Result
+where
+ D: serde::Deserializer<'de>,
+{
+ Option::::deserialize(deserializer).map(|opt| opt.unwrap_or_default())
+}
+
+/// Metadata stored with emitted messages for response routing.
+#[derive(Debug, Serialize, Deserialize)]
+struct DiscordMessageMetadata {
+ /// Discord channel ID
channel_id: String,
- author: DiscordChannelAuthor,
- #[serde(default)]
- mentions: Vec,
+
+ /// Interaction ID for followups
+ #[serde(default, deserialize_with = "deserialize_nullable_string")]
+ interaction_id: String,
+
+ /// Interaction token for responding
+ #[serde(default, deserialize_with = "deserialize_nullable_string")]
+ token: String,
+
+ /// Application ID
+ #[serde(default, deserialize_with = "deserialize_nullable_string")]
+ application_id: String,
+
+ /// Source message ID when handling mention-poll events.
#[serde(default)]
- webhook_id: Option,
+ source_message_id: Option,
+
+ /// Thread ID (for forum threads)
+ thread_id: Option,
}
-#[derive(Debug, Deserialize)]
-struct DiscordChannelAuthor {
- id: String,
- username: String,
- global_name: Option,
- #[serde(default)]
- bot: bool,
+#[derive(Debug, PartialEq, Eq)]
+enum DiscordResponseRoute {
+ InteractionWebhook(String),
+ ChannelMessage(String),
}
-#[derive(Debug, Clone, Serialize, Deserialize)]
-struct DiscordRuntimeConfig {
- #[serde(default = "default_require_signature_verification")]
- require_signature_verification: bool,
- #[serde(default)]
- webhook_secret: Option,
- #[serde(default)]
- polling_enabled: bool,
- #[serde(default = "default_poll_interval_ms")]
- poll_interval_ms: u32,
- #[serde(default)]
- mention_channel_ids: Vec,
- #[serde(default)]
- owner_id: Option,
- #[serde(default = "default_dm_policy")]
- dm_policy: String,
- #[serde(default)]
- allow_from: Vec,
+fn response_route_for_metadata(metadata: &DiscordMessageMetadata) -> DiscordResponseRoute {
+ if !metadata.application_id.is_empty() && !metadata.token.is_empty() {
+ DiscordResponseRoute::InteractionWebhook(format!(
+ "{DISCORD_API_BASE}/webhooks/{}/{}/messages/@original",
+ metadata.application_id, metadata.token
+ ))
+ } else {
+ DiscordResponseRoute::ChannelMessage(format!(
+ "{DISCORD_API_BASE}/channels/{}/messages",
+ metadata.channel_id
+ ))
+ }
}
-fn default_poll_interval_ms() -> u32 {
- 30_000
+fn typing_request_url_for_update(update: &StatusUpdate) -> Option {
+ if update.status != StatusType::Thinking {
+ return None;
+ }
+
+ let metadata: DiscordMessageMetadata = serde_json::from_str(&update.metadata_json).ok()?;
+ if metadata.channel_id.is_empty() {
+ return None;
+ }
+
+ Some(format!(
+ "{DISCORD_API_BASE}/channels/{}/typing",
+ metadata.channel_id
+ ))
}
-fn default_require_signature_verification() -> bool {
- true
+const DISCORD_MESSAGE_CHAR_LIMIT: usize = 2000;
+const DISCORD_MULTIPART_BOUNDARY: &str = "ironclaw-discord-response-boundary";
+const DISCORD_ATTACHMENT_FILENAME: &str = "response.md";
+const DISCORD_ATTACHMENT_NOTICE: &str = "Response too long for Discord; attached as response.md.";
+static MULTIPART_BOUNDARY_COUNTER: AtomicU64 = AtomicU64::new(0);
+
+#[derive(Debug, PartialEq, Eq)]
+struct DiscordHttpRequest {
+ headers_json: String,
+ body: Vec,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+enum DiscordReplyPlan {
+ Inline(DiscordHttpRequest),
+ Attachment {
+ upload: DiscordHttpRequest,
+ fallback: DiscordHttpRequest,
+ },
+}
+
+fn embeds_from_metadata_json(metadata_json: &str) -> Option {
+ serde_json::from_str::(metadata_json)
+ .ok()?
+ .get("embeds")
+ .cloned()
}
-fn default_dm_policy() -> String {
- "pairing".to_string()
+fn build_discord_json_request(
+ content: &str,
+ embeds: Option<&serde_json::Value>,
+) -> Result {
+ let mut payload = serde_json::json!({
+ "content": content,
+ });
+
+ if let Some(embeds) = embeds {
+ payload["embeds"] = embeds.clone();
+ }
+
+ Ok(DiscordHttpRequest {
+ headers_json: serde_json::json!({
+ "Content-Type": "application/json"
+ })
+ .to_string(),
+ body: serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?,
+ })
+}
+
+fn build_discord_attachment_request(
+ content: &str,
+ embeds: Option<&serde_json::Value>,
+) -> Result {
+ let boundary = next_multipart_boundary();
+ let mut payload = serde_json::json!({
+ "content": DISCORD_ATTACHMENT_NOTICE,
+ });
+
+ if let Some(embeds) = embeds {
+ payload["embeds"] = embeds.clone();
+ }
+
+ let payload_json =
+ serde_json::to_string(&payload).map_err(|e| format!("Failed to serialize: {}", e))?;
+
+ let mut body = Vec::new();
+ body.extend_from_slice(
+ format!(
+ "--{boundary}\r\nContent-Disposition: form-data; name=\"payload_json\"\r\nContent-Type: application/json\r\n\r\n{payload_json}\r\n",
+ boundary = boundary,
+ )
+ .as_bytes(),
+ );
+ body.extend_from_slice(
+ format!(
+ "--{boundary}\r\nContent-Disposition: form-data; name=\"files[0]\"; filename=\"{filename}\"\r\nContent-Type: text/markdown\r\n\r\n",
+ boundary = boundary,
+ filename = DISCORD_ATTACHMENT_FILENAME,
+ )
+ .as_bytes(),
+ );
+ body.extend_from_slice(content.as_bytes());
+ body.extend_from_slice(format!("\r\n--{}--\r\n", boundary).as_bytes());
+
+ Ok(DiscordHttpRequest {
+ headers_json: serde_json::json!({
+ "Content-Type": format!(
+ "multipart/form-data; boundary={}",
+ boundary
+ )
+ })
+ .to_string(),
+ body,
+ })
+}
+
+fn next_multipart_boundary() -> String {
+ let counter = MULTIPART_BOUNDARY_COUNTER.fetch_add(1, Ordering::Relaxed);
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_nanos())
+ .unwrap_or(0);
+ format!("{}-{:x}-{:x}", DISCORD_MULTIPART_BOUNDARY, nanos, counter)
+}
+
+fn build_discord_reply_plan(response: &AgentResponse) -> Result {
+ let embeds = embeds_from_metadata_json(&response.metadata_json);
+
+ if response.content.chars().count() <= DISCORD_MESSAGE_CHAR_LIMIT {
+ return build_discord_json_request(&response.content, embeds.as_ref())
+ .map(DiscordReplyPlan::Inline);
+ }
+
+ Ok(DiscordReplyPlan::Attachment {
+ upload: build_discord_attachment_request(&response.content, embeds.as_ref())?,
+ fallback: build_discord_json_request(
+ &truncate_message(&response.content),
+ embeds.as_ref(),
+ )?,
+ })
}
-fn default_runtime_config() -> DiscordRuntimeConfig {
- DiscordRuntimeConfig {
- require_signature_verification: default_require_signature_verification(),
- webhook_secret: None,
- polling_enabled: false,
- poll_interval_ms: default_poll_interval_ms(),
- mention_channel_ids: Vec::new(),
- owner_id: None,
- dm_policy: default_dm_policy(),
- allow_from: Vec::new(),
+fn send_discord_request(
+ method: &str,
+ url: &str,
+ request: &DiscordHttpRequest,
+) -> Result<(), String> {
+ match channel_host::http_request(
+ method,
+ url,
+ &request.headers_json,
+ Some(&request.body),
+ None,
+ ) {
+ 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)),
}
}
/// Workspace path for persisting owner_id across WASM callbacks.
const OWNER_ID_PATH: &str = "state/owner_id";
+/// Workspace path for persisting polling_enabled flag.
+const POLLING_ENABLED_PATH: &str = "state/polling_enabled";
+/// Workspace path for persisting mention channel IDs (JSON array).
+const MENTION_CHANNEL_IDS_PATH: &str = "state/mention_channel_ids";
/// 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";
+/// Workspace path for the current gateway text-frame batch prepared by the host runtime.
+const GATEWAY_EVENT_QUEUE_PATH: &str = "state/gateway_event_queue_processing";
+/// Workspace path for persisting the bot user id learned from READY dispatches.
+const BOT_USER_ID_PATH: &str = "state/bot_user_id";
/// Channel name for pairing store (used by pairing host APIs).
const CHANNEL_NAME: &str = "discord";
-/// Metadata stored with emitted messages for response routing.
-#[derive(Debug, Serialize, Deserialize)]
-struct DiscordMessageMetadata {
- /// Discord channel ID
- channel_id: String,
+#[derive(Debug, Deserialize)]
+struct DiscordGatewayEvent {
+ op: u64,
+ #[serde(default)]
+ t: Option,
+ #[serde(default)]
+ d: serde_json::Value,
+}
- /// Interaction ID for followups
+#[derive(Debug, Deserialize)]
+struct DiscordGatewayReady {
+ user: DiscordGatewayAuthor,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+struct DiscordGatewayAuthor {
+ id: String,
+ username: String,
+ global_name: Option,
#[serde(default)]
- interaction_id: Option,
+ bot: bool,
+}
- /// Interaction token for responding
+#[derive(Debug, Deserialize)]
+struct DiscordGatewayMessageCreate {
+ channel_id: String,
#[serde(default)]
- token: Option,
+ guild_id: Option,
+ content: String,
+ author: DiscordGatewayAuthor,
+}
- /// Application ID
+/// A message returned by the Discord REST channel-messages endpoint.
+#[derive(Debug, Deserialize)]
+struct DiscordChannelMessage {
+ id: String,
+ content: String,
+ channel_id: String,
+ author: DiscordChannelAuthor,
#[serde(default)]
- application_id: Option,
+ mentions: Vec,
+ #[serde(default)]
+ webhook_id: Option,
+}
- /// Source message ID when handling mention-poll events.
+/// Author sub-object for REST channel messages.
+#[derive(Debug, Deserialize)]
+struct DiscordChannelAuthor {
+ id: String,
+ username: String,
+ global_name: Option,
#[serde(default)]
- source_message_id: Option,
+ bot: bool,
+}
- /// Thread ID (for forum threads)
- thread_id: Option,
+#[derive(Debug, PartialEq, Eq)]
+struct ParsedGatewayMessage {
+ user_id: String,
+ user_name: String,
+ channel_id: String,
+ content: String,
+ is_dm: bool,
}
-struct DiscordChannel;
+#[derive(Debug, Default, PartialEq, Eq)]
+struct GatewayPollResult {
+ bot_user_id: Option,
+ messages: Vec,
+}
-impl Guest for DiscordChannel {
- fn on_start(config_json: String) -> Result {
- channel_host::log(channel_host::LogLevel::Info, "Discord channel starting");
+fn parse_gateway_event_queue(
+ queue_json: &str,
+ known_bot_user_id: Option<&str>,
+) -> GatewayPollResult {
+ let frames: Vec = match serde_json::from_str(queue_json) {
+ Ok(v) => v,
+ Err(e) => {
+ channel_host::log(
+ channel_host::LogLevel::Warn,
+ &format!("Failed to deserialize gateway event queue: {}", e),
+ );
+ return GatewayPollResult::default();
+ }
+ };
+ let mut result = GatewayPollResult::default();
+ let mut bot_user_id = known_bot_user_id.map(ToOwned::to_owned);
- let config =
- serde_json::from_str::(&config_json).unwrap_or_else(|e| {
- channel_host::log(
- channel_host::LogLevel::Warn,
- &format!("Invalid config JSON, using defaults: {}", e),
- );
- default_runtime_config()
- });
+ for frame in frames {
+ let event: DiscordGatewayEvent = match serde_json::from_str(&frame) {
+ Ok(value) => value,
+ Err(_) => continue,
+ };
- if let Ok(serialized) = serde_json::to_string(&config) {
- let _ = channel_host::workspace_write("config.json", &serialized);
+ if event.op != 0 {
+ continue;
}
- if config.require_signature_verification
- && config
- .webhook_secret
- .as_deref()
- .map(str::trim)
- .filter(|s| !s.is_empty())
- .is_none()
- {
- channel_host::log(
- channel_host::LogLevel::Error,
- "Discord channel misconfigured: require_signature_verification=true but webhook_secret is empty",
- );
- } else if !config.require_signature_verification {
- channel_host::log(
- channel_host::LogLevel::Warn,
- "Discord signature verification is disabled; webhook endpoint is unprotected",
- );
+ match event.t.as_deref() {
+ Some("READY") => {
+ if let Ok(ready) = serde_json::from_value::(event.d) {
+ if !ready.user.id.is_empty() {
+ bot_user_id = Some(ready.user.id);
+ }
+ }
+ }
+ Some("MESSAGE_CREATE") => {
+ let message = match serde_json::from_value::(event.d) {
+ Ok(value) => value,
+ Err(_) => continue,
+ };
+
+ let active_bot_user_id = bot_user_id.as_deref().or(known_bot_user_id);
+ if message.author.bot
+ || active_bot_user_id.is_some_and(|bot_id| message.author.id == bot_id)
+ {
+ continue;
+ }
+
+ let is_dm = message.guild_id.is_none();
+ let content =
+ match gateway_content_for_agent(&message.content, active_bot_user_id, is_dm) {
+ Some(value) => value,
+ None => continue,
+ };
+
+ result.messages.push(ParsedGatewayMessage {
+ user_id: message.author.id,
+ user_name: message
+ .author
+ .global_name
+ .unwrap_or(message.author.username),
+ channel_id: message.channel_id,
+ content,
+ is_dm,
+ });
+ }
+ _ => {}
+ }
+ }
+
+ result.bot_user_id = bot_user_id;
+ result
+}
+
+fn gateway_content_for_agent(
+ content: &str,
+ bot_user_id: Option<&str>,
+ is_dm: bool,
+) -> Option {
+ let trimmed = content.trim();
+ if trimmed.is_empty() {
+ return None;
+ }
+
+ if is_dm {
+ return Some(trimmed.to_string());
+ }
+
+ let bot_user_id = bot_user_id?;
+ for mention in [
+ format!("<@{}>", bot_user_id),
+ format!("<@!{}>", bot_user_id),
+ ] {
+ if let Some(stripped) = trimmed.strip_prefix(&mention) {
+ let cleaned = stripped.trim();
+ return if cleaned.is_empty() {
+ None
+ } else {
+ Some(cleaned.to_string())
+ };
}
+ }
+
+ None
+}
+
+fn default_poll_interval_ms() -> u32 {
+ 30_000
+}
+
+/// Channel configuration from capabilities file.
+#[derive(Debug, Deserialize)]
+struct DiscordConfig {
+ #[serde(default)]
+ #[allow(dead_code)]
+ require_signature_verification: bool,
+ #[serde(default)]
+ owner_id: Option,
+ #[serde(default)]
+ dm_policy: Option,
+ #[serde(default)]
+ allow_from: Option>,
+ #[serde(default)]
+ polling_enabled: bool,
+ #[serde(default = "default_poll_interval_ms")]
+ poll_interval_ms: u32,
+ #[serde(default)]
+ mention_channel_ids: Vec,
+}
+
+struct DiscordChannel;
+
+impl Guest for DiscordChannel {
+ fn on_start(config_json: String) -> Result {
+ 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.
+ // 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(
@@ -258,18 +566,29 @@ impl Guest for DiscordChannel {
let _ = channel_host::workspace_write(OWNER_ID_PATH, "");
}
- // Persist dm_policy and allow_from for DM pairing.
- let _ = channel_host::workspace_write(DM_POLICY_PATH, &config.dm_policy);
- let allow_from_json =
- serde_json::to_string(&config.allow_from).unwrap_or_else(|_| "[]".to_string());
+ // 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);
+ // Persist polling config
+ let _ = channel_host::workspace_write(
+ POLLING_ENABLED_PATH,
+ &config.polling_enabled.to_string(),
+ );
+ let mention_ids_json =
+ serde_json::to_string(&config.mention_channel_ids).unwrap_or_else(|_| "[]".to_string());
+ let _ = channel_host::workspace_write(MENTION_CHANNEL_IDS_PATH, &mention_ids_json);
+
Ok(ChannelConfig {
display_name: "Discord".to_string(),
http_endpoints: vec![HttpEndpointConfig {
path: "/webhook/discord".to_string(),
methods: vec!["POST".to_string()],
- require_secret: false,
+ require_secret: true,
}],
poll: if config.polling_enabled {
Some(PollConfig {
@@ -283,45 +602,6 @@ impl Guest for DiscordChannel {
}
fn on_http_request(req: IncomingHttpRequest) -> OutgoingHttpResponse {
- let config = load_runtime_config();
- let headers: HashMap =
- serde_json::from_str(&req.headers_json).unwrap_or_default();
- if config.require_signature_verification {
- if config
- .webhook_secret
- .as_deref()
- .map(str::trim)
- .filter(|s| !s.is_empty())
- .is_none()
- {
- channel_host::log(
- channel_host::LogLevel::Error,
- "Discord channel misconfigured: webhook_secret not set while verification is required",
- );
- return json_response(
- 500,
- serde_json::json!({"error": "Channel misconfigured: webhook_secret not set"}),
- );
- }
-
- if !verify_discord_request_signature(
- headers,
- &req.body,
- config.webhook_secret.as_deref(),
- ) {
- channel_host::log(
- channel_host::LogLevel::Warn,
- "Discord signature verification failed",
- );
- return json_response(401, serde_json::json!({"error": "Invalid signature"}));
- }
- } else {
- channel_host::log(
- channel_host::LogLevel::Warn,
- "Discord signature verification is disabled; accepting unverified webhook request",
- );
- }
-
let body_str = match std::str::from_utf8(&req.body) {
Ok(s) => s,
Err(_) => {
@@ -350,16 +630,9 @@ impl Guest for DiscordChannel {
// Application Command (slash command)
2 => {
if handle_slash_command(&interaction) {
- json_response(
- 200,
- serde_json::json!({
- "type": 5,
- "data": {
- "content": "🤔 Thinking..."
- }
- }),
- )
+ json_response(200, serde_json::json!({"type": 5}))
} else {
+ // Permission denied — ephemeral response
json_response(
200,
serde_json::json!({
@@ -395,528 +668,200 @@ impl Guest for DiscordChannel {
}
fn on_poll() {
- poll_for_mentions();
- }
-
- 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))?;
-
- // 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::(&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 (method, url) = if let (Some(application_id), Some(token)) =
- (metadata.application_id.as_ref(), metadata.token.as_ref())
- {
- (
- "PATCH",
- format!(
- "https://discord.com/api/v10/webhooks/{}/{}/messages/@original",
- application_id, token
- ),
- )
- } else if let Some(source_message_id) = metadata.source_message_id.as_ref() {
- payload["message_reference"] = serde_json::json!({
- "message_id": source_message_id
- });
- payload["allowed_mentions"] = serde_json::json!({
- "replied_user": true
- });
- let mention_payload = serde_json::to_vec(&payload)
- .map_err(|e| format!("Failed to serialize mention payload: {}", e))?;
- let mention_url = format!(
- "https://discord.com/api/v10/channels/{}/messages",
- metadata.channel_id
- );
- let result = channel_host::http_request(
- "POST",
- &mention_url,
- &discord_auth_headers_json(true),
- Some(&mention_payload),
- None,
- );
- return map_discord_response(result);
- } else {
- return Err("Unsupported Discord response metadata".to_string());
- };
-
- let result = channel_host::http_request(
- method,
- &url,
- &headers.to_string(),
- Some(&payload_bytes),
- None,
- );
-
- map_discord_response(result)
- }
-
- fn on_status(_update: StatusUpdate) {}
-
- fn on_broadcast(_user_id: String, _response: AgentResponse) -> Result<(), String> {
- Err("broadcast not yet implemented for Discord channel".to_string())
- }
-
- fn on_shutdown() {
- channel_host::log(
- channel_host::LogLevel::Info,
- "Discord channel shutting down",
- );
- }
-}
-
-fn map_discord_response(
- result: Result,
-) -> Result<(), String> {
- match result {
- Ok(http_response) => {
- if http_response.status >= 200 && http_response.status < 300 {
- channel_host::log(channel_host::LogLevel::Debug, "Posted response 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 load_runtime_config() -> DiscordRuntimeConfig {
- channel_host::workspace_read("config.json")
- .and_then(|raw| serde_json::from_str::(&raw).ok())
- .unwrap_or_else(default_runtime_config)
-}
-
-fn poll_for_mentions() {
- let config = load_runtime_config();
- if !config.polling_enabled || config.mention_channel_ids.is_empty() {
- return;
- }
-
- let bot_id = match get_or_fetch_bot_id() {
- Some(id) => id,
- None => {
- channel_host::log(
- channel_host::LogLevel::Warn,
- "Skipping mention polling: failed to resolve bot user id",
- );
- return;
- }
- };
-
- for channel_id in &config.mention_channel_ids {
- poll_channel_mentions(channel_id, &bot_id);
- }
-}
-
-fn get_or_fetch_bot_id() -> Option {
- if let Some(id) = channel_host::workspace_read("bot_user_id.txt") {
- let trimmed = id.trim();
- if !trimmed.is_empty() {
- return Some(trimmed.to_string());
- }
- }
-
- let response = channel_host::http_request(
- "GET",
- "https://discord.com/api/v10/users/@me",
- &discord_auth_headers_json(false),
- None,
- Some(10_000),
- )
- .ok()?;
-
- if !(200..300).contains(&response.status) {
- return None;
- }
-
- let value: serde_json::Value = serde_json::from_slice(&response.body).ok()?;
- let id = value.get("id")?.as_str()?.to_string();
- let _ = channel_host::workspace_write("bot_user_id.txt", &id);
- Some(id)
-}
-
-fn poll_channel_mentions(channel_id: &str, bot_id: &str) {
- let cursor_path = format!("cursor_{}.txt", channel_id);
- let last_seen = channel_host::workspace_read(&cursor_path).map(|s| s.trim().to_string());
-
- // On first run for a channel, initialize the cursor to "latest seen" and
- // skip back-processing historical messages.
- if last_seen.is_none() {
- if let Some(latest) = fetch_latest_message_id(channel_id) {
- let _ = channel_host::workspace_write(&cursor_path, &latest);
- }
- return;
- }
-
- let Some(mut messages) =
- fetch_messages_after_cursor(channel_id, last_seen.as_deref().unwrap_or(""))
- else {
- return;
- };
- if messages.is_empty() {
- return;
- }
-
- messages.sort_by(|a, b| compare_message_ids(&a.id, &b.id));
- let mut max_seen = last_seen.clone();
- let mut recent_ids = load_recent_processed_ids(channel_id);
- let mut dedup_updated = false;
-
- for msg in messages {
- if is_new_message(max_seen.as_deref(), &msg.id) {
- max_seen = Some(msg.id.clone());
- }
+ // 1. Process Gateway event queue
+ let queue_json = channel_host::workspace_read(GATEWAY_EVENT_QUEUE_PATH).unwrap_or_default();
+ let has_gateway_events = !queue_json.trim().is_empty() && queue_json.trim() != "[]";
- if msg.webhook_id.is_some() || msg.author.bot || msg.author.id == bot_id {
- continue;
- }
-
- if !message_mentions_bot(&msg, bot_id) {
- continue;
- }
-
- if recent_ids.iter().any(|id| id == &msg.id) {
- continue;
- }
-
- let user_name = msg
- .author
- .global_name
- .as_ref()
- .filter(|s| !s.is_empty())
- .unwrap_or(&msg.author.username)
- .clone();
- if !check_sender_permission(&msg.author.id, Some(&user_name), false, None) {
- continue;
- }
-
- let content = strip_bot_mention(&msg.content, bot_id);
- let metadata = DiscordMessageMetadata {
- channel_id: msg.channel_id.clone(),
- interaction_id: None,
- token: None,
- application_id: None,
- source_message_id: Some(msg.id.clone()),
- thread_id: None,
- };
+ if has_gateway_events {
+ let known_bot_user_id = channel_host::workspace_read(BOT_USER_ID_PATH);
+ let parsed = parse_gateway_event_queue(&queue_json, known_bot_user_id.as_deref());
- let metadata_json = match serde_json::to_string(&metadata) {
- Ok(v) => v,
- Err(e) => {
+ if let Err(error) = channel_host::workspace_write(GATEWAY_EVENT_QUEUE_PATH, "[]") {
channel_host::log(
channel_host::LogLevel::Warn,
- &format!("Failed to serialize mention metadata: {}", e),
+ &format!("Failed to clear Discord gateway queue: {}", error),
);
- continue;
}
- };
-
- channel_host::emit_message(&EmittedMessage {
- user_id: msg.author.id.clone(),
- user_name: Some(user_name.clone()),
- content: if content.is_empty() {
- "mention".to_string()
- } else {
- content
- },
- thread_id: None,
- metadata_json,
- attachments: vec![],
- });
-
- remember_processed_id(&mut recent_ids, &msg.id);
- dedup_updated = true;
- }
- if let Some(cursor) = max_seen {
- let _ = channel_host::workspace_write(&cursor_path, &cursor);
- }
- if dedup_updated {
- let _ = save_recent_processed_ids(channel_id, &recent_ids);
- }
-}
-
-fn fetch_latest_message_id(channel_id: &str) -> Option {
- let url = format!(
- "https://discord.com/api/v10/channels/{}/messages?limit=1",
- channel_id
- );
- let response = channel_host::http_request(
- "GET",
- &url,
- &discord_auth_headers_json(false),
- None,
- Some(10_000),
- )
- .ok()?;
- if !(200..300).contains(&response.status) {
- let body = String::from_utf8_lossy(&response.body);
- channel_host::log(
- channel_host::LogLevel::Warn,
- &format!(
- "Discord initial poll failed for channel {}: status={} body={}",
- channel_id, response.status, body
- ),
- );
- return None;
- }
- let messages: Vec = serde_json::from_slice(&response.body).ok()?;
- messages.first().map(|m| m.id.clone())
-}
-
-fn fetch_messages_after_cursor(
- channel_id: &str,
- last_seen: &str,
-) -> Option> {
- const PAGE_LIMIT: usize = 100;
- const MAX_PAGES: usize = 50;
-
- let mut all_messages = Vec::new();
- let mut after = last_seen.to_string();
-
- for page in 0..MAX_PAGES {
- let url = format!(
- "https://discord.com/api/v10/channels/{}/messages?limit={}&after={}",
- channel_id, PAGE_LIMIT, after
- );
- let response = match channel_host::http_request(
- "GET",
- &url,
- &discord_auth_headers_json(false),
- None,
- Some(10_000),
- ) {
- Ok(r) => r,
- Err(e) => {
- channel_host::log(
- channel_host::LogLevel::Warn,
- &format!(
- "Discord poll request failed for channel {}: {}",
- channel_id, e
- ),
- );
- return None;
- }
- };
-
- if !(200..300).contains(&response.status) {
- let body = String::from_utf8_lossy(&response.body);
- channel_host::log(
- channel_host::LogLevel::Warn,
- &format!(
- "Discord poll failed for channel {}: status={} body={}",
- channel_id, response.status, body
- ),
- );
- return None;
- }
-
- let messages: Vec = match serde_json::from_slice(&response.body) {
- Ok(v) => v,
- Err(e) => {
- channel_host::log(
- channel_host::LogLevel::Warn,
- &format!("Failed to parse polled Discord messages: {}", e),
- );
- return None;
+ if let Some(bot_user_id) = parsed.bot_user_id.as_deref() {
+ if let Err(error) = channel_host::workspace_write(BOT_USER_ID_PATH, bot_user_id) {
+ channel_host::log(
+ channel_host::LogLevel::Warn,
+ &format!("Failed to persist Discord bot user id: {}", error),
+ );
+ }
}
- };
- let page_len = messages.len();
- if messages.is_empty() {
- break;
- }
- let page_max_id = messages
- .iter()
- .map(|m| m.id.as_str())
- .max_by(|a, b| compare_message_ids(a, b))
- .map(str::to_string);
-
- all_messages.extend(messages.into_iter());
-
- if page_len < PAGE_LIMIT {
- break;
- }
+ for message in parsed.messages {
+ if !check_sender_permission(
+ &message.user_id,
+ Some(&message.user_name),
+ message.is_dm,
+ PermissionSource::Gateway,
+ Some(&PairingReplyCtx {
+ channel_id: message.channel_id.clone(),
+ application_id: String::new(),
+ token: String::new(),
+ }),
+ ) {
+ continue;
+ }
- if let Some(max_id) = page_max_id {
- if max_id == after {
- break;
+ let metadata = DiscordMessageMetadata {
+ channel_id: message.channel_id,
+ interaction_id: String::new(),
+ token: String::new(),
+ application_id: String::new(),
+ source_message_id: None,
+ thread_id: None,
+ };
+
+ let metadata_json = match serde_json::to_string(&metadata) {
+ Ok(json) => json,
+ Err(error) => {
+ channel_host::log(
+ channel_host::LogLevel::Error,
+ &format!("Failed to serialize gateway metadata: {}", error),
+ );
+ continue;
+ }
+ };
+
+ channel_host::emit_message(&EmittedMessage {
+ user_id: message.user_id,
+ user_name: Some(message.user_name),
+ content: message.content,
+ thread_id: None,
+ metadata_json,
+ attachments: vec![],
+ });
}
- after = max_id;
- } else {
- break;
}
- if page + 1 == MAX_PAGES {
- channel_host::log(
- channel_host::LogLevel::Warn,
- &format!(
- "Discord poll pagination limit reached for channel {}; processing partial batch",
- channel_id
- ),
- );
- }
- }
-
- Some(all_messages)
-}
-
-fn compare_message_ids(a: &str, b: &str) -> Ordering {
- match (a.parse::(), b.parse::()) {
- (Ok(left), Ok(right)) => left.cmp(&right),
- _ => a.cmp(b),
+ // 2. Run mention polling if configured
+ poll_for_mentions();
}
-}
-fn dedup_ids_path(channel_id: &str) -> String {
- format!("dedup_{}.json", channel_id)
-}
-
-fn load_recent_processed_ids(channel_id: &str) -> Vec {
- let path = dedup_ids_path(channel_id);
- channel_host::workspace_read(&path)
- .and_then(|raw| serde_json::from_str::>(&raw).ok())
- .unwrap_or_default()
-}
+ 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))?;
-fn save_recent_processed_ids(channel_id: &str, ids: &[String]) -> Result<(), String> {
- let path = dedup_ids_path(channel_id);
- let raw =
- serde_json::to_string(ids).map_err(|e| format!("Failed to serialize dedup ids: {}", e))?;
- channel_host::workspace_write(&path, &raw)
-}
+ // Mention-poll replies: include message_reference so Discord renders as a reply
+ if let Some(ref source_id) = metadata.source_message_id {
+ if let DiscordResponseRoute::ChannelMessage(ref url) =
+ response_route_for_metadata(&metadata)
+ {
+ let embeds = embeds_from_metadata_json(&response.metadata_json);
+ let content = if response.content.chars().count() > DISCORD_MESSAGE_CHAR_LIMIT {
+ truncate_message(&response.content)
+ } else {
+ response.content.clone()
+ };
+
+ let mut payload = serde_json::json!({
+ "content": content,
+ "message_reference": {
+ "message_id": source_id
+ },
+ "allowed_mentions": {
+ "replied_user": true
+ }
+ });
-fn remember_processed_id(ids: &mut Vec, message_id: &str) {
- const MAX_RECENT_IDS: usize = 200;
- if ids.iter().any(|id| id == message_id) {
- return;
- }
- ids.push(message_id.to_string());
- if ids.len() > MAX_RECENT_IDS {
- let drop_count = ids.len() - MAX_RECENT_IDS;
- ids.drain(0..drop_count);
- }
-}
+ if let Some(ref e) = embeds {
+ payload["embeds"] = e.clone();
+ }
-fn is_new_message(last_seen: Option<&str>, current: &str) -> bool {
- match last_seen {
- None => true,
- Some(prev) => {
- let prev_num = prev.parse::().ok();
- let cur_num = current.parse::().ok();
- match (prev_num, cur_num) {
- (Some(p), Some(c)) => c > p,
- _ => current > prev,
+ let headers = discord_auth_headers_json(true);
+ let body = serde_json::to_vec(&payload)
+ .map_err(|e| format!("Failed to serialize: {}", e))?;
+
+ return send_discord_request(
+ "POST",
+ url,
+ &DiscordHttpRequest {
+ headers_json: headers,
+ body,
+ },
+ );
}
}
- }
-}
-fn message_mentions_bot(msg: &DiscordChannelMessage, bot_id: &str) -> bool {
- msg.mentions.iter().any(|u| u.id == bot_id)
- || msg.content.contains(&format!("<@{}>", bot_id))
- || msg.content.contains(&format!("<@!{}>", bot_id))
-}
+ let route = response_route_for_metadata(&metadata);
+ let plan = build_discord_reply_plan(&response)?;
-fn strip_bot_mention(content: &str, bot_id: &str) -> String {
- content
- .replace(&format!("<@{}>", bot_id), "")
- .replace(&format!("<@!{}>", bot_id), "")
- .trim()
- .to_string()
-}
+ let (method, url) = match &route {
+ DiscordResponseRoute::InteractionWebhook(url) => ("PATCH", url.as_str()),
+ DiscordResponseRoute::ChannelMessage(url) => ("POST", url.as_str()),
+ };
-fn discord_auth_headers_json(include_content_type: bool) -> String {
- if include_content_type {
- serde_json::json!({
- "Content-Type": "application/json",
- "Authorization": "Bot {DISCORD_BOT_TOKEN}"
- })
- .to_string()
- } else {
- serde_json::json!({
- "Authorization": "Bot {DISCORD_BOT_TOKEN}"
- })
- .to_string()
+ match plan {
+ DiscordReplyPlan::Inline(request) => send_discord_request(method, url, &request),
+ DiscordReplyPlan::Attachment { upload, fallback } => {
+ match send_discord_request(method, url, &upload) {
+ Ok(()) => Ok(()),
+ Err(upload_error) => {
+ channel_host::log(
+ channel_host::LogLevel::Warn,
+ &format!(
+ "Discord attachment upload failed, falling back to truncated text: {}",
+ upload_error
+ ),
+ );
+ send_discord_request(method, url, &fallback).map_err(|fallback_error| {
+ format!(
+ "Discord attachment upload failed: {}; fallback also failed: {}",
+ upload_error, fallback_error
+ )
+ })
+ }
+ }
+ }
+ }
}
-}
-fn verify_discord_request_signature(
- headers: HashMap,
- body: &[u8],
- public_key_hex: Option<&str>,
-) -> bool {
- let Some(public_key_hex) = public_key_hex.map(str::trim).filter(|s| !s.is_empty()) else {
- return false;
- };
- let Some(signature_hex) = header_case_insensitive(&headers, "x-signature-ed25519") else {
- return false;
- };
- let Some(timestamp) = header_case_insensitive(&headers, "x-signature-timestamp") else {
- return false;
- };
-
- let public_key_bytes = match hex::decode(public_key_hex) {
- Ok(v) => v,
- Err(_) => return false,
- };
- let public_key_arr: [u8; 32] = match public_key_bytes.try_into() {
- Ok(v) => v,
- Err(_) => return false,
- };
- let verifying_key = match VerifyingKey::from_bytes(&public_key_arr) {
- Ok(v) => v,
- Err(_) => return false,
- };
-
- let sig_bytes = match hex::decode(signature_hex.trim()) {
- Ok(v) => v,
- Err(_) => return false,
- };
- let sig_arr: [u8; 64] = match sig_bytes.try_into() {
- Ok(v) => v,
- Err(_) => return false,
- };
- let signature = Signature::from_bytes(&sig_arr);
+ fn on_status(update: StatusUpdate) {
+ let Some(url) = typing_request_url_for_update(&update) else {
+ return;
+ };
- let mut signed_message = Vec::with_capacity(timestamp.len() + body.len());
- signed_message.extend_from_slice(timestamp.as_bytes());
- signed_message.extend_from_slice(body);
+ let headers = serde_json::json!({
+ "Content-Type": "application/json"
+ });
- verifying_key.verify(&signed_message, &signature).is_ok()
-}
+ match channel_host::http_request("POST", &url, &headers.to_string(), None, None) {
+ Ok(response) if (200..300).contains(&response.status) => {}
+ Ok(response) => {
+ channel_host::log(
+ channel_host::LogLevel::Warn,
+ &format!(
+ "Discord typing indicator failed with status {}",
+ response.status
+ ),
+ );
+ }
+ Err(error) => {
+ channel_host::log(
+ channel_host::LogLevel::Warn,
+ &format!("Discord typing indicator request failed: {}", error),
+ );
+ }
+ }
+ }
+
+ fn on_broadcast(user_id: String, response: AgentResponse) -> Result<(), String> {
+ broadcast_dm(&user_id, &response.content)
+ }
-fn header_case_insensitive<'a>(
- headers: &'a HashMap,
- name: &str,
-) -> Option<&'a str> {
- headers
- .iter()
- .find(|(k, _)| k.eq_ignore_ascii_case(name))
- .map(|(_, v)| v.as_str())
+ 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
@@ -934,13 +879,17 @@ fn handle_slash_command(interaction: &DiscordInteraction) -> bool {
})
.unwrap_or_default();
- // DM if no guild member context (only direct user field set).
+ // 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,
+ PermissionSource::Webhook,
Some(&PairingReplyCtx {
+ channel_id: interaction.channel_id.clone().unwrap_or_default(),
application_id: interaction.application_id.clone(),
token: interaction.token.clone(),
}),
@@ -970,9 +919,9 @@ fn handle_slash_command(interaction: &DiscordInteraction) -> bool {
let metadata = DiscordMessageMetadata {
channel_id: channel_id.clone(),
- interaction_id: Some(interaction.id.clone()),
- token: Some(interaction.token.clone()),
- application_id: Some(interaction.application_id.clone()),
+ interaction_id: interaction.id.clone(),
+ token: interaction.token.clone(),
+ application_id: interaction.application_id.clone(),
source_message_id: None,
thread_id: None,
};
@@ -984,14 +933,13 @@ fn handle_slash_command(interaction: &DiscordInteraction) -> bool {
channel_host::LogLevel::Error,
&format!("Failed to serialize metadata: {}", e),
);
- // Attempt to notify user of internal error
let url = format!(
- "https://discord.com/api/v10/webhooks/{}/{}",
+ "{DISCORD_API_BASE}/webhooks/{}/{}",
interaction.application_id, interaction.token
);
let payload = serde_json::json!({
"content": "❌ Internal Error: Failed to process command metadata.",
- "flags": 64 // Ephemeral
+ "flags": 64
});
let _ = channel_host::http_request(
"POST",
@@ -1000,7 +948,7 @@ fn handle_slash_command(interaction: &DiscordInteraction) -> bool {
Some(&serde_json::to_vec(&payload).unwrap_or_default()),
None,
);
- return true;
+ return true; // Error, but not a permission denial
}
};
@@ -1016,7 +964,6 @@ fn handle_slash_command(interaction: &DiscordInteraction) -> bool {
}
fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordMessage) {
- // Check member first (for server contexts), then user (for DMs)
let user = interaction
.member
.as_ref()
@@ -1034,7 +981,13 @@ fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordM
.unwrap_or_default();
let is_dm = interaction.member.is_none();
- if !check_sender_permission(&user_id, Some(&user_name), is_dm, None) {
+ if !check_sender_permission(
+ &user_id,
+ Some(&user_name),
+ is_dm,
+ PermissionSource::Webhook,
+ None,
+ ) {
return;
}
@@ -1042,9 +995,9 @@ fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordM
let metadata = DiscordMessageMetadata {
channel_id: channel_id.clone(),
- interaction_id: Some(interaction.id.clone()),
- token: Some(interaction.token.clone()),
- application_id: Some(interaction.application_id.clone()),
+ interaction_id: interaction.id.clone(),
+ token: interaction.token.clone(),
+ application_id: interaction.application_id.clone(),
source_message_id: None,
thread_id: None,
};
@@ -1070,21 +1023,39 @@ fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordM
});
}
+// ============================================================================
+// Permission & Pairing
+// ============================================================================
+
/// Context needed to send a pairing reply via Discord webhook followup.
struct PairingReplyCtx {
+ channel_id: String,
application_id: String,
token: String,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum PermissionSource {
+ Webhook,
+ Gateway,
+}
+
+fn should_apply_dm_pairing(_source: PermissionSource, is_dm: bool) -> bool {
+ // All current permission sources (Webhook, Gateway) apply DM pairing equally.
+ // Kept as a function for future sources that may bypass pairing (e.g. internal).
+ is_dm
+}
+
/// 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,
+ source: PermissionSource,
reply_ctx: Option<&PairingReplyCtx>,
) -> bool {
- // 1. Owner check (highest priority, applies to all contexts).
+ // 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 {
@@ -1100,26 +1071,28 @@ fn check_sender_permission(
return true;
}
- // 2. DM policy (only for DMs when no owner_id).
- if !is_dm {
+ // 2. DM policy (only for DMs when no owner_id)
+ if !should_apply_dm_pairing(source, is_dm) {
return true;
}
let dm_policy =
- channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| default_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.
+ // 3. Build merged allow list: config allow_from + pairing store
let mut allowed: Vec = 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.
+ // 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()));
@@ -1128,23 +1101,22 @@ fn check_sender_permission(
return true;
}
- // 5. Not allowed - handle by policy.
+ // 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);
- }
+ if let Some(ctx) = reply_ctx {
+ let _ = send_pairing_reply(ctx, &result.code);
}
}
Err(e) => {
@@ -1158,29 +1130,52 @@ fn check_sender_permission(
false
}
-/// Send a pairing code as an ephemeral Discord followup message.
+fn pairing_reply_route(ctx: &PairingReplyCtx) -> DiscordResponseRoute {
+ if !ctx.application_id.is_empty() && !ctx.token.is_empty() {
+ DiscordResponseRoute::InteractionWebhook(format!(
+ "{DISCORD_API_BASE}/webhooks/{}/{}",
+ ctx.application_id, ctx.token
+ ))
+ } else {
+ DiscordResponseRoute::ChannelMessage(format!(
+ "{DISCORD_API_BASE}/channels/{}/messages",
+ ctx.channel_id
+ ))
+ }
+}
+
+/// Send a pairing code reply via webhook followup or channel 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!({
+ let route = pairing_reply_route(ctx);
+
+ let mut payload = serde_json::json!({
"content": format!(
- "To pair with this bot, run: `ironclaw pairing approve discord {}`",
- code
- ),
- "flags": 64
+ "Enter this code in IronClaw to pair your discord account: `{}`. CLI fallback: `ironclaw pairing approve discord {}`",
+ code, code
+ )
});
+
+ if matches!(route, DiscordResponseRoute::InteractionWebhook(_)) {
+ payload["flags"] = serde_json::json!(64);
+ }
+
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 url = match &route {
+ DiscordResponseRoute::InteractionWebhook(url) => url,
+ DiscordResponseRoute::ChannelMessage(url) => url,
+ };
+
let result = channel_host::http_request(
"POST",
- &url,
+ url,
&headers.to_string(),
Some(&payload_bytes),
None,
);
+
match result {
Ok(response) if response.status >= 200 && response.status < 300 => Ok(()),
Ok(response) => {
@@ -1194,6 +1189,387 @@ fn send_pairing_reply(ctx: &PairingReplyCtx, code: &str) -> Result<(), String> {
}
}
+// ============================================================================
+// Mention Polling
+// ============================================================================
+
+/// Maximum number of processed message IDs to keep per channel for dedup.
+const DEDUP_CAP: usize = 200;
+
+/// Poll configured channels for new messages that mention the bot.
+fn poll_for_mentions() {
+ let enabled = channel_host::workspace_read(POLLING_ENABLED_PATH)
+ .map(|v| v.trim() == "true")
+ .unwrap_or(false);
+
+ if !enabled {
+ return;
+ }
+
+ let bot_id = match get_or_fetch_bot_id() {
+ Some(id) => id,
+ None => {
+ channel_host::log(
+ channel_host::LogLevel::Warn,
+ "Mention polling: unable to determine bot user id",
+ );
+ return;
+ }
+ };
+
+ let channel_ids: Vec = channel_host::workspace_read(MENTION_CHANNEL_IDS_PATH)
+ .and_then(|s| serde_json::from_str(&s).ok())
+ .unwrap_or_default();
+
+ for channel_id in &channel_ids {
+ poll_channel_mentions(channel_id, &bot_id);
+ }
+}
+
+/// Read the bot user ID from workspace or fetch it from the Discord API.
+fn get_or_fetch_bot_id() -> Option {
+ if let Some(id) = channel_host::workspace_read(BOT_USER_ID_PATH).filter(|s| !s.is_empty()) {
+ return Some(id);
+ }
+
+ let headers = discord_auth_headers_json(false);
+ let resp = channel_host::http_request(
+ "GET",
+ "{DISCORD_API_BASE}/users/@me",
+ &headers,
+ None,
+ None,
+ )
+ .ok()?;
+
+ if resp.status < 200 || resp.status >= 300 {
+ return None;
+ }
+
+ let body: serde_json::Value = serde_json::from_slice(&resp.body).ok()?;
+ let id = body["id"].as_str()?.to_string();
+
+ let _ = channel_host::workspace_write(BOT_USER_ID_PATH, &id);
+ Some(id)
+}
+
+/// Poll a single channel for new mention messages.
+fn poll_channel_mentions(channel_id: &str, bot_id: &str) {
+ let cursor_path = format!("state/mention_cursor/{}", channel_id);
+ let last_seen = channel_host::workspace_read(&cursor_path).unwrap_or_default();
+
+ let messages = if last_seen.is_empty() {
+ // First poll: initialise cursor without emitting any messages.
+ if let Some(latest_id) = fetch_latest_message_id(channel_id) {
+ let _ = channel_host::workspace_write(&cursor_path, &latest_id);
+ }
+ return;
+ } else {
+ match fetch_messages_after_cursor(channel_id, &last_seen) {
+ Some(msgs) => msgs,
+ None => return,
+ }
+ };
+
+ let mut processed_ids = load_recent_processed_ids(channel_id);
+ let mut new_cursor = last_seen.clone();
+
+ for msg in &messages {
+ if !is_new_message(&last_seen, &msg.id) {
+ continue;
+ }
+ if processed_ids.contains(&msg.id) {
+ continue;
+ }
+ if msg.author.bot || msg.author.id == bot_id {
+ remember_processed_id(&msg.id, &mut processed_ids);
+ continue;
+ }
+ if msg.webhook_id.is_some() {
+ remember_processed_id(&msg.id, &mut processed_ids);
+ continue;
+ }
+ if !message_mentions_bot(msg, bot_id) {
+ remember_processed_id(&msg.id, &mut processed_ids);
+ continue;
+ }
+
+ // Permission check (API-based poll uses Webhook source)
+ if !check_sender_permission(
+ &msg.author.id,
+ Some(&msg.author.username),
+ false,
+ PermissionSource::Webhook,
+ None,
+ ) {
+ remember_processed_id(&msg.id, &mut processed_ids);
+ continue;
+ }
+
+ let content = strip_bot_mention(&msg.content, bot_id);
+ if content.is_empty() {
+ remember_processed_id(&msg.id, &mut processed_ids);
+ continue;
+ }
+
+ let user_name = msg
+ .author
+ .global_name
+ .clone()
+ .unwrap_or_else(|| msg.author.username.clone());
+
+ let metadata = DiscordMessageMetadata {
+ channel_id: msg.channel_id.clone(),
+ interaction_id: String::new(),
+ token: String::new(),
+ application_id: String::new(),
+ source_message_id: Some(msg.id.clone()),
+ thread_id: None,
+ };
+
+ let metadata_json = match serde_json::to_string(&metadata) {
+ Ok(json) => json,
+ Err(error) => {
+ channel_host::log(
+ channel_host::LogLevel::Error,
+ &format!("Failed to serialize mention-poll metadata: {}", error),
+ );
+ continue;
+ }
+ };
+
+ channel_host::emit_message(&EmittedMessage {
+ user_id: msg.author.id.clone(),
+ user_name: Some(user_name),
+ content,
+ thread_id: None,
+ metadata_json,
+ attachments: vec![],
+ });
+
+ remember_processed_id(&msg.id, &mut processed_ids);
+
+ if compare_message_ids(&msg.id, &new_cursor) == std::cmp::Ordering::Greater {
+ new_cursor = msg.id.clone();
+ }
+ }
+
+ if new_cursor != last_seen {
+ let _ = channel_host::workspace_write(&cursor_path, &new_cursor);
+ }
+
+ save_recent_processed_ids(channel_id, &processed_ids);
+}
+
+/// Fetch the latest message ID in a channel (used for cursor initialisation).
+fn fetch_latest_message_id(channel_id: &str) -> Option {
+ let url = format!(
+ "{DISCORD_API_BASE}/channels/{}/messages?limit=1",
+ channel_id
+ );
+ let headers = discord_auth_headers_json(false);
+ let resp = channel_host::http_request("GET", &url, &headers, None, None).ok()?;
+
+ if resp.status < 200 || resp.status >= 300 {
+ return None;
+ }
+
+ let messages: Vec = serde_json::from_slice(&resp.body).ok()?;
+ messages
+ .first()
+ .and_then(|m| m["id"].as_str().map(String::from))
+}
+
+/// Maximum number of pages to fetch when catching up on missed messages.
+const MENTION_POLL_MAX_PAGES: usize = 5;
+
+/// Fetch messages after `last_seen` using the `after` parameter, paginating up
+/// to [`MENTION_POLL_MAX_PAGES`] pages of 100 messages each.
+fn fetch_messages_after_cursor(
+ channel_id: &str,
+ last_seen: &str,
+) -> Option> {
+ let headers = discord_auth_headers_json(false);
+ let mut all_messages: Vec = Vec::new();
+ let mut after = last_seen.to_string();
+
+ for _ in 0..MENTION_POLL_MAX_PAGES {
+ let url = format!(
+ "{DISCORD_API_BASE}/channels/{}/messages?after={}&limit=100",
+ channel_id, after
+ );
+ let resp = channel_host::http_request("GET", &url, &headers, None, None).ok()?;
+
+ if resp.status < 200 || resp.status >= 300 {
+ let body_str = String::from_utf8_lossy(&resp.body);
+ channel_host::log(
+ channel_host::LogLevel::Warn,
+ &format!(
+ "Mention poll: failed to fetch messages for channel {}: {} - {}",
+ channel_id, resp.status, body_str
+ ),
+ );
+ return None;
+ }
+
+ let page: Vec = serde_json::from_slice(&resp.body).ok()?;
+ let page_len = page.len();
+
+ if page.is_empty() {
+ break;
+ }
+
+ // Discord returns newest-first; find the max ID for the next page cursor
+ let page_max_id = page
+ .iter()
+ .map(|m| m.id.as_str())
+ .max_by(|a, b| compare_message_ids(a, b))
+ .map(str::to_string);
+
+ all_messages.extend(page);
+
+ if page_len < 100 {
+ break;
+ }
+
+ match page_max_id {
+ Some(max_id) if max_id != after => after = max_id,
+ _ => break,
+ }
+ }
+
+ Some(all_messages)
+}
+
+/// Compare two Discord snowflake IDs. Falls back to lexical comparison.
+fn compare_message_ids(a: &str, b: &str) -> std::cmp::Ordering {
+ match (a.parse::(), b.parse::()) {
+ (Ok(a_num), Ok(b_num)) => a_num.cmp(&b_num),
+ _ => a.cmp(b),
+ }
+}
+
+fn dedup_ids_path(channel_id: &str) -> String {
+ format!("state/mention_dedup/{}", channel_id)
+}
+
+fn load_recent_processed_ids(channel_id: &str) -> Vec {
+ channel_host::workspace_read(&dedup_ids_path(channel_id))
+ .and_then(|s| serde_json::from_str(&s).ok())
+ .unwrap_or_default()
+}
+
+fn save_recent_processed_ids(channel_id: &str, ids: &[String]) {
+ let json = serde_json::to_string(ids).unwrap_or_else(|_| "[]".to_string());
+ let _ = channel_host::workspace_write(&dedup_ids_path(channel_id), &json);
+}
+
+fn remember_processed_id(msg_id: &str, ids: &mut Vec) {
+ if ids.contains(&msg_id.to_string()) {
+ return;
+ }
+ ids.push(msg_id.to_string());
+ if ids.len() > DEDUP_CAP {
+ let excess = ids.len() - DEDUP_CAP;
+ ids.drain(0..excess);
+ }
+}
+
+/// Returns true when `current` is strictly newer than `last_seen`.
+fn is_new_message(last_seen: &str, current: &str) -> bool {
+ compare_message_ids(current, last_seen) == std::cmp::Ordering::Greater
+}
+
+/// Returns true if the message mentions the bot (by mention objects or content).
+fn message_mentions_bot(msg: &DiscordChannelMessage, bot_id: &str) -> bool {
+ if msg.mentions.iter().any(|u| u.id == bot_id) {
+ return true;
+ }
+ let mention = format!("<@{}>", bot_id);
+ let mention_nick = format!("<@!{}>", bot_id);
+ msg.content.contains(&mention) || msg.content.contains(&mention_nick)
+}
+
+/// Strip the bot mention prefix from content.
+fn strip_bot_mention(content: &str, bot_id: &str) -> String {
+ let trimmed = content.trim();
+ for mention in [format!("<@{}>", bot_id), format!("<@!{}>", bot_id)] {
+ if let Some(rest) = trimmed.strip_prefix(&mention) {
+ return rest.trim().to_string();
+ }
+ }
+ trimmed.to_string()
+}
+
+/// Build JSON headers string with Discord bot authorization.
+/// When `include_content_type` is true, includes `Content-Type: application/json`.
+fn discord_auth_headers_json(include_content_type: bool) -> String {
+ if include_content_type {
+ serde_json::json!({
+ "Content-Type": "application/json"
+ })
+ .to_string()
+ } else {
+ serde_json::json!({}).to_string()
+ }
+}
+
+/// Send a DM to a Discord user by opening (or reusing) a DM channel.
+fn broadcast_dm(user_id: &str, content: &str) -> Result<(), String> {
+ // Validate user_id is a plausible Discord snowflake (numeric, 17-20 digits)
+ // to avoid injecting arbitrary strings into API URLs.
+ if user_id.is_empty()
+ || !user_id.chars().all(|c| c.is_ascii_digit())
+ || user_id.len() < 17
+ || user_id.len() > 20
+ {
+ return Err(format!("Invalid Discord user ID: '{}'", user_id));
+ }
+
+ // Step 1: Open (or reuse) a DM channel with the target user.
+ let create_dm_payload = serde_json::json!({ "recipient_id": user_id });
+ let create_dm_bytes = serde_json::to_vec(&create_dm_payload)
+ .map_err(|e| format!("Failed to serialize DM channel request: {}", e))?;
+
+ let dm_response = channel_host::http_request(
+ "POST",
+ &format!("{DISCORD_API_BASE}/users/@me/channels"),
+ &discord_auth_headers_json(true),
+ Some(&create_dm_bytes),
+ Some(10_000),
+ )
+ .map_err(|e| format!("Failed to create DM channel: {}", e))?;
+
+ if !(200..300).contains(&dm_response.status) {
+ let body = String::from_utf8_lossy(&dm_response.body);
+ return Err(format!(
+ "Discord create-DM failed: {} - {}",
+ dm_response.status, body
+ ));
+ }
+
+ #[derive(Deserialize)]
+ struct DmChannelResponse {
+ id: String,
+ }
+ let dm_channel: DmChannelResponse = serde_json::from_slice(&dm_response.body)
+ .map_err(|e| format!("Failed to parse DM channel response: {}", e))?;
+
+ // Step 2: Send the message to the DM channel.
+ let truncated = truncate_message(content);
+ let payload = serde_json::json!({ "content": truncated });
+ let body =
+ serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?;
+ send_discord_request(
+ "POST",
+ &format!("{DISCORD_API_BASE}/channels/{}/messages", dm_channel.id),
+ &DiscordHttpRequest {
+ headers_json: discord_auth_headers_json(true),
+ body,
+ },
+ )
+}
+
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"});
@@ -1208,17 +1584,12 @@ fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse
export!(DiscordChannel);
fn truncate_message(content: &str) -> String {
- if content.len() <= 2000 {
+ if content.chars().count() <= DISCORD_MESSAGE_CHAR_LIMIT {
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();
+ let suffix = "\n... (truncated)";
+ let allowed_chars = DISCORD_MESSAGE_CHAR_LIMIT.saturating_sub(suffix.chars().count());
+ let mut truncated = content.chars().take(allowed_chars).collect::();
truncated.push_str("\n... (truncated)");
truncated
}
@@ -1227,7 +1598,8 @@ fn truncate_message(content: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
- use ed25519_dalek::{Signer, SigningKey};
+
+ const DISCORD_CAPABILITIES_JSON: &str = include_str!("../discord.capabilities.json");
#[test]
fn test_truncate_message() {
@@ -1236,361 +1608,645 @@ mod tests {
let long = "a".repeat(2005);
let truncated = truncate_message(&long);
- assert_eq!(truncated.len(), 2006); // 1990 + 16 chars suffix
+ assert_eq!(truncated.chars().count(), 2000);
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 multi = "€".repeat(2005);
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_eq!(truncated_multi.chars().count(), 2000);
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_reply_plan_uses_character_count_for_attachment_threshold() {
+ let inline =
+ build_discord_reply_plan(&test_response(test_metadata_json(), "€".repeat(2000)))
+ .unwrap();
+
+ assert!(matches!(inline, DiscordReplyPlan::Inline(_)));
+ }
+
+ fn test_response(metadata_json: String, content: String) -> AgentResponse {
+ AgentResponse {
+ message_id: "msg-1".to_string(),
+ content,
+ thread_id: None,
+ metadata_json,
+ attachments: vec![],
+ }
+ }
+
+ fn test_metadata_json() -> String {
+ serde_json::json!({
+ "channel_id": "chan-1",
+ "interaction_id": "int-1",
+ "token": "tok-1",
+ "application_id": "app-1",
+ "thread_id": null,
+ "embeds": [{"title": "embed title"}]
+ })
+ .to_string()
+ }
+
+ #[test]
+ fn test_reply_plan_threshold_uses_attachment_only_above_2000_chars() {
+ let inline =
+ build_discord_reply_plan(&test_response(test_metadata_json(), "a".repeat(2000)))
+ .unwrap();
+ assert!(matches!(inline, DiscordReplyPlan::Inline(_)));
+
+ let attachment =
+ build_discord_reply_plan(&test_response(test_metadata_json(), "a".repeat(2001)))
+ .unwrap();
+ assert!(matches!(attachment, DiscordReplyPlan::Attachment { .. }));
+ }
+
+ #[test]
+ fn test_reply_plan_preserves_short_message_content_and_embeds() {
+ let plan = build_discord_reply_plan(&test_response(
+ test_metadata_json(),
+ "short reply".to_string(),
+ ))
+ .unwrap();
+
+ let DiscordReplyPlan::Inline(request) = plan else {
+ panic!("expected inline plan");
+ };
+
+ assert_eq!(
+ request.headers_json,
+ r#"{"Content-Type":"application/json"}"#
+ );
+
+ let payload: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
+ assert_eq!(payload["content"], "short reply");
+ assert_eq!(payload["embeds"][0]["title"], "embed title");
+ }
+
+ #[test]
+ fn test_reply_plan_builds_markdown_attachment_multipart_payload() {
+ let content = "# Heading\n\nA long markdown reply".repeat(80);
+ let plan = build_discord_reply_plan(&test_response(test_metadata_json(), content.clone()))
+ .unwrap();
+
+ let DiscordReplyPlan::Attachment { upload, .. } = plan else {
+ panic!("expected attachment plan");
+ };
+
+ assert!(upload
+ .headers_json
+ .contains("multipart/form-data; boundary="));
+
+ let body = String::from_utf8(upload.body).unwrap();
+ assert!(body.contains("name=\"payload_json\""));
+ assert!(body.contains("filename=\"response.md\""));
+ assert!(body.contains("Content-Type: text/markdown"));
+ assert!(body.contains(DISCORD_ATTACHMENT_NOTICE));
+ assert!(body.contains("embed title"));
+ assert!(body.contains(&content));
+ }
+
+ #[test]
+ fn test_reply_plan_uses_dynamic_multipart_boundary() {
+ let content = "# Heading\n\nA long markdown reply".repeat(80);
+
+ let first = build_discord_reply_plan(&test_response(test_metadata_json(), content.clone()))
+ .unwrap();
+ let second =
+ build_discord_reply_plan(&test_response(test_metadata_json(), content)).unwrap();
+
+ let DiscordReplyPlan::Attachment {
+ upload: first_upload,
+ ..
+ } = first
+ else {
+ panic!("expected attachment plan");
+ };
+ let DiscordReplyPlan::Attachment {
+ upload: second_upload,
+ ..
+ } = second
+ else {
+ panic!("expected attachment plan");
+ };
+
+ let first_headers: serde_json::Value =
+ serde_json::from_str(&first_upload.headers_json).unwrap();
+ let second_headers: serde_json::Value =
+ serde_json::from_str(&second_upload.headers_json).unwrap();
+
+ let first_boundary = first_headers["Content-Type"]
+ .as_str()
+ .unwrap()
+ .strip_prefix("multipart/form-data; boundary=")
+ .unwrap();
+ let second_boundary = second_headers["Content-Type"]
+ .as_str()
+ .unwrap()
+ .strip_prefix("multipart/form-data; boundary=")
+ .unwrap();
+
+ assert!(first_boundary.starts_with(DISCORD_MULTIPART_BOUNDARY));
+ assert!(second_boundary.starts_with(DISCORD_MULTIPART_BOUNDARY));
+ assert_ne!(first_boundary, second_boundary);
+
+ let first_body = String::from_utf8(first_upload.body).unwrap();
+ let second_body = String::from_utf8(second_upload.body).unwrap();
+ assert!(first_body.contains(&format!("--{first_boundary}\r\n")));
+ assert!(second_body.contains(&format!("--{second_boundary}\r\n")));
+ }
+
+ #[test]
+ fn test_reply_plan_includes_truncated_text_fallback_for_attachment_failures() {
+ let content = "a".repeat(2400);
+ let plan = build_discord_reply_plan(&test_response(test_metadata_json(), content.clone()))
+ .unwrap();
+
+ let DiscordReplyPlan::Attachment { fallback, .. } = plan else {
+ panic!("expected attachment plan");
+ };
+
+ let payload: serde_json::Value = serde_json::from_slice(&fallback.body).unwrap();
+ assert_eq!(payload["content"], truncate_message(&content));
+ assert_eq!(payload["embeds"][0]["title"], "embed title");
+ }
+
#[test]
fn test_metadata_serialization() {
let metadata = DiscordMessageMetadata {
channel_id: "123".into(),
- interaction_id: Some("456".into()),
- token: Some("abc".into()),
- application_id: Some("789".into()),
+ interaction_id: "456".into(),
+ token: "abc".into(),
+ application_id: "789".into(),
source_message_id: None,
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.as_deref(), Some("456"));
+ assert_eq!(parsed.interaction_id, "456");
}
#[test]
- fn test_is_new_message() {
- assert!(is_new_message(None, "100"));
- assert!(is_new_message(Some("100"), "200"));
- assert!(!is_new_message(Some("200"), "100"));
- assert!(!is_new_message(Some("100"), "100"));
- assert!(is_new_message(Some("abc"), "abd"));
- assert!(!is_new_message(Some("abd"), "abc"));
+ fn test_metadata_backward_compat_with_old_option_format() {
+ // Old metadata format used Option for these fields
+ let old_json = r#"{
+ "channel_id": "123",
+ "interaction_id": null,
+ "token": null,
+ "application_id": null,
+ "thread_id": null
+ }"#;
+ let parsed: DiscordMessageMetadata = serde_json::from_str(old_json).unwrap();
+ assert_eq!(parsed.channel_id, "123");
+ assert!(parsed.interaction_id.is_empty());
+
+ // Old format without the fields at all
+ let minimal_json = r#"{"channel_id": "456"}"#;
+ let parsed: DiscordMessageMetadata = serde_json::from_str(minimal_json).unwrap();
+ assert_eq!(parsed.channel_id, "456");
+ assert!(parsed.interaction_id.is_empty());
+ assert!(parsed.token.is_empty());
+ assert!(parsed.application_id.is_empty());
}
#[test]
- fn test_strip_bot_mention() {
- assert_eq!(strip_bot_mention("<@123> hello", "123"), "hello");
- assert_eq!(strip_bot_mention("<@!123> hello", "123"), "hello");
- assert_eq!(strip_bot_mention("<@123>", "123"), "");
+ fn test_response_route_uses_webhook_for_interactions() {
+ let metadata = DiscordMessageMetadata {
+ channel_id: "123".into(),
+ interaction_id: "456".into(),
+ token: "tok".into(),
+ application_id: "app".into(),
+ source_message_id: None,
+ thread_id: None,
+ };
+
assert_eq!(
- strip_bot_mention("hello <@123> world <@!123>", "123"),
- "hello world"
+ response_route_for_metadata(&metadata),
+ DiscordResponseRoute::InteractionWebhook(
+ format!("{DISCORD_API_BASE}/webhooks/app/tok/messages/@original")
+ )
);
}
#[test]
- fn test_message_mentions_bot() {
- let msg = DiscordChannelMessage {
- id: "1".to_string(),
- content: "hello <@123>".to_string(),
- channel_id: "10".to_string(),
- author: DiscordChannelAuthor {
- id: "u1".to_string(),
- username: "alice".to_string(),
- global_name: None,
- bot: false,
- },
- mentions: vec![],
- webhook_id: None,
+ fn test_response_route_uses_channel_messages_for_gateway_metadata() {
+ let metadata = DiscordMessageMetadata {
+ channel_id: "chan-1".into(),
+ interaction_id: String::new(),
+ token: String::new(),
+ application_id: String::new(),
+ source_message_id: None,
+ thread_id: None,
};
- assert!(message_mentions_bot(&msg, "123"));
- assert!(!message_mentions_bot(&msg, "999"));
+
+ assert_eq!(
+ response_route_for_metadata(&metadata),
+ DiscordResponseRoute::ChannelMessage(
+ format!("{DISCORD_API_BASE}/channels/chan-1/messages")
+ )
+ );
}
#[test]
- fn test_message_mentions_bot_via_mentions_array() {
- let msg = DiscordChannelMessage {
- id: "2".to_string(),
- content: "hello".to_string(),
- channel_id: "10".to_string(),
- author: DiscordChannelAuthor {
- id: "u1".to_string(),
- username: "alice".to_string(),
- global_name: None,
- bot: false,
- },
- mentions: vec![DiscordUser {
- id: "777".to_string(),
- username: "bot".to_string(),
- global_name: None,
- }],
- webhook_id: None,
+ fn test_typing_request_url_uses_channel_id_for_thinking_status() {
+ let update = StatusUpdate {
+ status: StatusType::Thinking,
+ message: "Thinking...".to_string(),
+ metadata_json: serde_json::json!({
+ "channel_id": "chan-42",
+ "interaction_id": "",
+ "token": "",
+ "application_id": "",
+ "thread_id": null
+ })
+ .to_string(),
};
- assert!(message_mentions_bot(&msg, "777"));
+
+ assert_eq!(
+ typing_request_url_for_update(&update),
+ Some(format!("{DISCORD_API_BASE}/channels/chan-42/typing"))
+ );
}
#[test]
- fn test_compare_message_ids_numeric_and_lexical_fallback() {
- assert_eq!(compare_message_ids("100", "20"), Ordering::Greater);
- assert_eq!(compare_message_ids("20", "100"), Ordering::Less);
- assert_eq!(compare_message_ids("abc", "abd"), Ordering::Less);
- assert_eq!(compare_message_ids("abd", "abc"), Ordering::Greater);
+ fn test_typing_request_url_ignores_non_thinking_status() {
+ let update = StatusUpdate {
+ status: StatusType::Done,
+ message: "Done".to_string(),
+ metadata_json: serde_json::json!({
+ "channel_id": "chan-42",
+ "interaction_id": "",
+ "token": "",
+ "application_id": "",
+ "thread_id": null
+ })
+ .to_string(),
+ };
+
+ assert_eq!(typing_request_url_for_update(&update), None);
}
#[test]
- fn test_remember_processed_id_dedup_and_cap() {
- let mut ids = Vec::new();
- for i in 0..220 {
- remember_processed_id(&mut ids, &format!("{}", i));
- }
- assert_eq!(ids.len(), 200);
- assert_eq!(ids.first().map(String::as_str), Some("20"));
- assert_eq!(ids.last().map(String::as_str), Some("219"));
+ fn test_typing_request_url_ignores_invalid_metadata() {
+ let update = StatusUpdate {
+ status: StatusType::Thinking,
+ message: "Thinking...".to_string(),
+ metadata_json: "not-json".to_string(),
+ };
- remember_processed_id(&mut ids, "219");
- assert_eq!(ids.len(), 200);
- assert_eq!(ids.last().map(String::as_str), Some("219"));
+ assert_eq!(typing_request_url_for_update(&update), None);
}
#[test]
- fn test_header_case_insensitive() {
- let mut headers = HashMap::new();
- headers.insert("X-Signature-Timestamp".to_string(), "123".to_string());
- assert_eq!(
- header_case_insensitive(&headers, "x-signature-timestamp"),
- Some("123")
- );
- assert_eq!(header_case_insensitive(&headers, "missing"), None);
+ fn test_parse_slash_command_interaction() {
+ // Verify that a slash command interaction deserializes correctly.
+ let json = r#"{
+ "type": 2,
+ "id": "int_1",
+ "application_id": "app_1",
+ "channel_id": "ch_1",
+ "member": {
+ "user": {
+ "id": "user_1",
+ "username": "testuser",
+ "global_name": "Test User"
+ }
+ },
+ "data": {
+ "id": "cmd_1",
+ "name": "ask",
+ "options": [
+ {"name": "question", "value": "What is rust?"}
+ ]
+ },
+ "token": "token_abc"
+ }"#;
+
+ let interaction: DiscordInteraction = serde_json::from_str(json).unwrap();
+ assert_eq!(interaction.interaction_type, 2);
+ assert!(interaction.data.is_some());
}
#[test]
- fn test_discord_auth_headers_json_shape() {
- let with_ct: serde_json::Value =
- serde_json::from_str(&discord_auth_headers_json(true)).unwrap();
+ fn test_capabilities_default_to_gateway_mode() {
+ let caps: serde_json::Value =
+ serde_json::from_str(DISCORD_CAPABILITIES_JSON).expect("capabilities parse");
+ let allowlist = caps["capabilities"]["http"]["allowlist"]
+ .as_array()
+ .expect("http allowlist array");
+
assert_eq!(
- with_ct.get("Content-Type").and_then(|v| v.as_str()),
- Some("application/json")
+ caps["capabilities"]["channel"]["allow_polling"],
+ serde_json::Value::Bool(true)
);
+ assert!(allowlist.iter().any(|entry| {
+ entry["host"] == serde_json::Value::String("gateway.discord.gg".to_string())
+ && entry["methods"] == serde_json::json!(["GET"])
+ }));
assert_eq!(
- with_ct.get("Authorization").and_then(|v| v.as_str()),
- Some("Bot {DISCORD_BOT_TOKEN}")
+ caps["capabilities"]["websocket"]["url"],
+ serde_json::Value::String("wss://gateway.discord.gg/?v=10&encoding=json".to_string())
+ );
+ assert_eq!(
+ caps["capabilities"]["websocket"]["connect_on_start"],
+ serde_json::Value::Bool(true)
+ );
+ assert_eq!(
+ caps["capabilities"]["websocket"]["identify_secret_name"],
+ serde_json::Value::String("discord_bot_token".to_string())
);
-
- let no_ct: serde_json::Value =
- serde_json::from_str(&discord_auth_headers_json(false)).unwrap();
- assert!(no_ct.get("Content-Type").is_none());
assert_eq!(
- no_ct.get("Authorization").and_then(|v| v.as_str()),
- Some("Bot {DISCORD_BOT_TOKEN}")
+ caps["capabilities"]["websocket"]["identify"]["intents"],
+ serde_json::Value::Number(4609u64.into())
);
}
#[test]
- fn test_verify_discord_request_signature_valid() {
- let signing_key = SigningKey::from_bytes(&[7u8; 32]);
- let public_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
- let timestamp = "1234567890";
- let body = br#"{"type":1}"#;
-
- let mut signed = Vec::new();
- signed.extend_from_slice(timestamp.as_bytes());
- signed.extend_from_slice(body);
- let signature = signing_key.sign(&signed);
-
- let mut headers = HashMap::new();
- headers.insert(
- "x-signature-ed25519".to_string(),
- hex::encode(signature.to_bytes()),
- );
- headers.insert("x-signature-timestamp".to_string(), timestamp.to_string());
+ fn test_parse_gateway_event_queue_emits_message_create_after_ready() {
+ let queue_json = serde_json::json!([
+ serde_json::json!({
+ "op": 0,
+ "t": "READY",
+ "d": {
+ "user": {
+ "id": "bot-1",
+ "username": "ironclaw",
+ "global_name": "IronClaw",
+ "bot": true
+ }
+ }
+ })
+ .to_string(),
+ serde_json::json!({
+ "op": 0,
+ "t": "MESSAGE_CREATE",
+ "d": {
+ "channel_id": "chan-1",
+ "guild_id": "guild-1",
+ "content": "<@bot-1> hello from discord",
+ "author": {
+ "id": "user-1",
+ "username": "alice",
+ "global_name": "Alice",
+ "bot": false
+ }
+ }
+ })
+ .to_string()
+ ])
+ .to_string();
- assert!(verify_discord_request_signature(
- headers,
- body,
- Some(&public_key_hex)
- ));
- }
+ let result = parse_gateway_event_queue(&queue_json, None);
- #[test]
- fn test_verify_discord_request_signature_tampered_body() {
- let signing_key = SigningKey::from_bytes(&[9u8; 32]);
- let public_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
- let timestamp = "1234567890";
- let body = b"hello";
-
- let mut signed = Vec::new();
- signed.extend_from_slice(timestamp.as_bytes());
- signed.extend_from_slice(body);
- let signature = signing_key.sign(&signed);
-
- let mut headers = HashMap::new();
- headers.insert(
- "x-signature-ed25519".to_string(),
- hex::encode(signature.to_bytes()),
+ assert_eq!(result.bot_user_id.as_deref(), Some("bot-1"));
+ assert_eq!(
+ result.messages,
+ vec![ParsedGatewayMessage {
+ user_id: "user-1".to_string(),
+ user_name: "Alice".to_string(),
+ channel_id: "chan-1".to_string(),
+ content: "hello from discord".to_string(),
+ is_dm: false,
+ }]
);
- headers.insert("x-signature-timestamp".to_string(), timestamp.to_string());
-
- assert!(!verify_discord_request_signature(
- headers,
- b"hello-modified",
- Some(&public_key_hex)
- ));
}
#[test]
- fn test_verify_discord_request_signature_wrong_public_key() {
- let signing_key = SigningKey::from_bytes(&[11u8; 32]);
- let wrong_key = SigningKey::from_bytes(&[12u8; 32]);
- let timestamp = "1234567890";
- let body = b"payload";
-
- let mut signed = Vec::new();
- signed.extend_from_slice(timestamp.as_bytes());
- signed.extend_from_slice(body);
- let signature = signing_key.sign(&signed);
-
- let mut headers = HashMap::new();
- headers.insert(
- "x-signature-ed25519".to_string(),
- hex::encode(signature.to_bytes()),
- );
- headers.insert("x-signature-timestamp".to_string(), timestamp.to_string());
+ fn test_parse_gateway_event_queue_ignores_bot_and_unmentioned_guild_messages() {
+ let queue_json = serde_json::json!([
+ serde_json::json!({
+ "op": 0,
+ "t": "MESSAGE_CREATE",
+ "d": {
+ "channel_id": "chan-1",
+ "guild_id": "guild-1",
+ "content": "this should not trigger",
+ "author": {
+ "id": "user-1",
+ "username": "alice",
+ "global_name": "Alice",
+ "bot": false
+ }
+ }
+ })
+ .to_string(),
+ serde_json::json!({
+ "op": 0,
+ "t": "MESSAGE_CREATE",
+ "d": {
+ "channel_id": "dm-1",
+ "content": "bot echo",
+ "author": {
+ "id": "bot-1",
+ "username": "ironclaw",
+ "global_name": "IronClaw",
+ "bot": true
+ }
+ }
+ })
+ .to_string(),
+ serde_json::json!({
+ "op": 0,
+ "t": "MESSAGE_CREATE",
+ "d": {
+ "channel_id": "dm-2",
+ "content": "direct message",
+ "author": {
+ "id": "user-2",
+ "username": "bob",
+ "global_name": null,
+ "bot": false
+ }
+ }
+ })
+ .to_string()
+ ])
+ .to_string();
- assert!(!verify_discord_request_signature(
- headers,
- body,
- Some(&hex::encode(wrong_key.verifying_key().to_bytes()))
- ));
+ let result = parse_gateway_event_queue(&queue_json, Some("bot-1"));
+
+ assert_eq!(result.bot_user_id.as_deref(), Some("bot-1"));
+ assert_eq!(
+ result.messages,
+ vec![ParsedGatewayMessage {
+ user_id: "user-2".to_string(),
+ user_name: "bob".to_string(),
+ channel_id: "dm-2".to_string(),
+ content: "direct message".to_string(),
+ is_dm: true,
+ }]
+ );
}
#[test]
- fn test_verify_discord_request_signature_missing_headers() {
- let headers = HashMap::new();
- assert!(!verify_discord_request_signature(
- headers,
- b"abc",
- Some("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")
- ));
+ fn test_non_gateway_dm_pairing_behavior_is_unchanged() {
+ assert!(should_apply_dm_pairing(PermissionSource::Webhook, true));
+ assert!(!should_apply_dm_pairing(PermissionSource::Webhook, false));
}
#[test]
- fn test_verify_discord_request_signature_invalid_signature_hex() {
- let mut headers = HashMap::new();
- headers.insert("x-signature-ed25519".to_string(), "not-hex".to_string());
- headers.insert(
- "x-signature-timestamp".to_string(),
- "1234567890".to_string(),
- );
- assert!(!verify_discord_request_signature(
- headers,
- b"abc",
- Some("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")
- ));
+ fn test_gateway_dm_pairing_behavior_matches_webhook_dm() {
+ assert!(should_apply_dm_pairing(PermissionSource::Gateway, true));
+ assert!(!should_apply_dm_pairing(PermissionSource::Gateway, false));
}
#[test]
- fn test_verify_discord_request_signature_invalid_public_key_hex() {
- let mut headers = HashMap::new();
- headers.insert("x-signature-ed25519".to_string(), "00".repeat(64));
- headers.insert(
- "x-signature-timestamp".to_string(),
- "1234567890".to_string(),
+ fn test_pairing_reply_route_uses_channel_messages_for_gateway_metadata() {
+ let route = pairing_reply_route(&PairingReplyCtx {
+ channel_id: "chan-1".to_string(),
+ application_id: String::new(),
+ token: String::new(),
+ });
+
+ assert_eq!(
+ route,
+ DiscordResponseRoute::ChannelMessage(
+ format!("{DISCORD_API_BASE}/channels/chan-1/messages")
+ )
);
- assert!(!verify_discord_request_signature(
- headers,
- b"abc",
- Some("not-hex")
- ));
}
#[test]
- fn test_verify_discord_request_signature_invalid_lengths() {
- let mut headers = HashMap::new();
- headers.insert("x-signature-ed25519".to_string(), "00".repeat(10));
- headers.insert(
- "x-signature-timestamp".to_string(),
- "1234567890".to_string(),
+ fn test_pairing_reply_route_uses_webhook_for_interactions() {
+ let route = pairing_reply_route(&PairingReplyCtx {
+ channel_id: "chan-1".to_string(),
+ application_id: "app-1".to_string(),
+ token: "tok-1".to_string(),
+ });
+
+ assert_eq!(
+ route,
+ DiscordResponseRoute::InteractionWebhook(
+ format!("{DISCORD_API_BASE}/webhooks/app-1/tok-1")
+ )
);
- assert!(!verify_discord_request_signature(
- headers.clone(),
- b"abc",
- Some("00".repeat(31).as_str())
- ));
- assert!(!verify_discord_request_signature(
- headers,
- b"abc",
- Some("00".repeat(32).as_str())
- ));
}
- #[test]
- fn test_verify_discord_request_signature_case_insensitive_headers() {
- let signing_key = SigningKey::from_bytes(&[13u8; 32]);
- let public_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
- let timestamp = "1234567890";
- let body = b"case-header";
-
- let mut signed = Vec::new();
- signed.extend_from_slice(timestamp.as_bytes());
- signed.extend_from_slice(body);
- let signature = signing_key.sign(&signed);
-
- let mut headers = HashMap::new();
- headers.insert(
- "X-Signature-Ed25519".to_string(),
- hex::encode(signature.to_bytes()),
- );
- headers.insert("X-Signature-Timestamp".to_string(), timestamp.to_string());
+ // ======================================================================
+ // Mention polling tests
+ // ======================================================================
- assert!(verify_discord_request_signature(
- headers,
- body,
- Some(&public_key_hex)
+ #[test]
+ fn test_is_new_message() {
+ assert!(is_new_message("100", "200"));
+ assert!(!is_new_message("200", "100"));
+ assert!(!is_new_message("100", "100"));
+ // Large snowflake-like IDs
+ assert!(is_new_message("1234567890123456789", "1234567890123456790"));
+ assert!(!is_new_message(
+ "1234567890123456790",
+ "1234567890123456789"
));
}
#[test]
- fn test_verify_discord_request_signature_empty_public_key() {
- let mut headers = HashMap::new();
- headers.insert("x-signature-ed25519".to_string(), "00".repeat(64));
- headers.insert(
- "x-signature-timestamp".to_string(),
- "1234567890".to_string(),
+ fn test_strip_bot_mention() {
+ assert_eq!(
+ strip_bot_mention("<@bot-123> hello world", "bot-123"),
+ "hello world"
+ );
+ assert_eq!(
+ strip_bot_mention("<@!bot-123> hi there", "bot-123"),
+ "hi there"
+ );
+ // No mention prefix — return content as-is
+ assert_eq!(
+ strip_bot_mention("no mention here", "bot-123"),
+ "no mention here"
);
- assert!(!verify_discord_request_signature(headers, b"abc", Some("")));
+ // Only mention, no content after stripping
+ assert_eq!(strip_bot_mention("<@bot-123>", "bot-123"), "");
+ assert_eq!(strip_bot_mention("<@bot-123> ", "bot-123"), "");
}
#[test]
- fn test_parse_slash_command_interaction() {
- // Verify that a slash command interaction deserializes correctly.
- let json = r#"{
- "type": 2,
- "id": "int_1",
- "application_id": "app_1",
- "channel_id": "ch_1",
- "member": {
- "user": {
- "id": "user_1",
- "username": "testuser",
- "global_name": "Test User"
- }
+ fn test_message_mentions_bot() {
+ // Via mentions array
+ let msg = DiscordChannelMessage {
+ id: "1".to_string(),
+ content: "hello".to_string(),
+ channel_id: "ch-1".to_string(),
+ author: DiscordChannelAuthor {
+ id: "user-1".to_string(),
+ username: "alice".to_string(),
+ global_name: None,
+ bot: false,
},
- "data": {
- "id": "cmd_1",
- "name": "ask",
- "options": [
- {"name": "question", "value": "What is rust?"}
- ]
+ mentions: vec![DiscordUser {
+ id: "bot-1".to_string(),
+ username: "ironclaw".to_string(),
+ global_name: None,
+ }],
+ webhook_id: None,
+ };
+ assert!(message_mentions_bot(&msg, "bot-1"));
+ assert!(!message_mentions_bot(&msg, "other-bot"));
+
+ // Via content
+ let msg2 = DiscordChannelMessage {
+ id: "2".to_string(),
+ content: "<@bot-2> do something".to_string(),
+ channel_id: "ch-1".to_string(),
+ author: DiscordChannelAuthor {
+ id: "user-1".to_string(),
+ username: "alice".to_string(),
+ global_name: None,
+ bot: false,
},
- "token": "token_abc"
- }"#;
+ mentions: vec![],
+ webhook_id: None,
+ };
+ assert!(message_mentions_bot(&msg2, "bot-2"));
+ assert!(!message_mentions_bot(&msg2, "other-bot"));
+ }
- let interaction: DiscordInteraction = serde_json::from_str(json).unwrap();
- assert_eq!(interaction.interaction_type, 2);
- assert!(interaction.data.is_some());
+ #[test]
+ fn test_compare_message_ids() {
+ use std::cmp::Ordering;
+ assert_eq!(compare_message_ids("100", "200"), Ordering::Less);
+ assert_eq!(compare_message_ids("200", "100"), Ordering::Greater);
+ assert_eq!(compare_message_ids("100", "100"), Ordering::Equal);
+ // Non-numeric fallback
+ assert_eq!(compare_message_ids("abc", "abd"), Ordering::Less);
+ assert_eq!(compare_message_ids("abd", "abc"), Ordering::Greater);
+ }
+
+ #[test]
+ fn test_remember_processed_id_dedup_and_cap() {
+ let mut ids = Vec::new();
+
+ // Basic add
+ remember_processed_id("msg-1", &mut ids);
+ assert_eq!(ids, vec!["msg-1".to_string()]);
+
+ // Duplicate is ignored
+ remember_processed_id("msg-1", &mut ids);
+ assert_eq!(ids.len(), 1);
+
+ // Fill beyond DEDUP_CAP
+ for i in 2..=(DEDUP_CAP + 5) {
+ remember_processed_id(&format!("msg-{}", i), &mut ids);
+ }
+ assert_eq!(ids.len(), DEDUP_CAP);
+ // Oldest entries should have been drained
+ assert!(!ids.contains(&"msg-1".to_string()));
+ assert!(ids.contains(&format!("msg-{}", DEDUP_CAP + 5)));
+ }
+
+ #[test]
+ fn test_discord_auth_headers_json_shape() {
+ let with_ct = discord_auth_headers_json(true);
+ let parsed: serde_json::Value = serde_json::from_str(&with_ct).unwrap();
+ assert_eq!(parsed["Content-Type"], "application/json");
+
+ let without_ct = discord_auth_headers_json(false);
+ let parsed: serde_json::Value = serde_json::from_str(&without_ct).unwrap();
+ assert!(parsed.get("Content-Type").is_none());
}
}
diff --git a/channels-src/feishu/Cargo.lock b/channels-src/feishu/Cargo.lock
index 60f68fccaf5..4e95f3fe86f 100644
--- a/channels-src/feishu/Cargo.lock
+++ b/channels-src/feishu/Cargo.lock
@@ -44,6 +44,7 @@ version = "0.1.0"
dependencies = [
"serde",
"serde_json",
+ "subtle",
"wit-bindgen",
]
@@ -208,6 +209,12 @@ 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.117"
diff --git a/channels-src/feishu/Cargo.toml b/channels-src/feishu/Cargo.toml
index 53b9357df38..957624108e9 100644
--- a/channels-src/feishu/Cargo.toml
+++ b/channels-src/feishu/Cargo.toml
@@ -15,6 +15,7 @@ wit-bindgen = "0.36"
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
+subtle = "2.6"
# Exclude from parent workspace (this is a standalone WASM component)
diff --git a/channels-src/feishu/feishu.capabilities.json b/channels-src/feishu/feishu.capabilities.json
index 877a293a960..cf344d74b36 100644
--- a/channels-src/feishu/feishu.capabilities.json
+++ b/channels-src/feishu/feishu.capabilities.json
@@ -21,13 +21,13 @@
},
{
"name": "feishu_app_secret",
- "prompt": "Enter your Feishu/Lark App Secret",
+ "prompt": "Enter your Feishu/Lark App Secret (from your app settings at open.feishu.cn)",
"optional": false
},
{
"name": "feishu_verification_token",
"prompt": "Enter your Feishu/Lark Verification Token (from Event Subscription webhook settings)",
- "optional": true
+ "optional": false
}
],
"setup_url": "https://open.feishu.cn/app"
@@ -63,13 +63,15 @@
},
"webhook": {
"secret_header": "X-Feishu-Verification-Token",
- "secret_name": "feishu_verification_token"
+ "secret_name": "feishu_verification_token",
+ "managed_by_host": false
}
}
},
"config": {
"app_id": null,
"app_secret": null,
+ "verification_token": null,
"api_base": "https://open.feishu.cn",
"owner_id": null,
"dm_policy": "pairing",
diff --git a/channels-src/feishu/src/lib.rs b/channels-src/feishu/src/lib.rs
index 62440d2c074..1774b1d0dfc 100644
--- a/channels-src/feishu/src/lib.rs
+++ b/channels-src/feishu/src/lib.rs
@@ -23,7 +23,8 @@
//! - App credentials (app_id, app_secret) are injected by the host into
//! the config JSON during startup for token exchange
//! - Bearer token for API calls is obtained via token exchange and cached
-//! - Verification token validated by host for webhook requests
+//! - Webhook requests must be authenticated by the host or by a matching
+//! Feishu verification token in the request body
// Generate bindings from the WIT file
wit_bindgen::generate!({
@@ -32,6 +33,7 @@ wit_bindgen::generate!({
});
use serde::{Deserialize, Serialize};
+use subtle::ConstantTimeEq;
// Re-export generated types
use exports::near::agent::channel::{
@@ -50,6 +52,7 @@ const ALLOW_FROM_PATH: &str = "allow_from";
const API_BASE_PATH: &str = "api_base";
const APP_ID_PATH: &str = "app_id";
const APP_SECRET_PATH: &str = "app_secret";
+const VERIFICATION_TOKEN_PATH: &str = "verification_token";
const TOKEN_PATH: &str = "tenant_access_token";
const TOKEN_EXPIRY_PATH: &str = "token_expiry";
@@ -102,6 +105,10 @@ struct FeishuEventHeader {
/// Tenant key.
#[serde(default)]
tenant_key: Option,
+
+ /// Verification token for v2 event payloads.
+ #[serde(default)]
+ token: Option,
}
/// Message receive event payload (im.message.receive_v1).
@@ -251,6 +258,9 @@ struct FeishuConfig {
/// Feishu App Secret (for token exchange).
app_secret: Option,
+ /// Feishu Event Subscription verification token.
+ verification_token: Option,
+
/// API base URL. Defaults to "https://open.feishu.cn" (use
/// "https://open.larksuite.com" for Lark international).
#[serde(default = "default_api_base")]
@@ -300,6 +310,9 @@ impl Guest for FeishuChannel {
if let Some(ref app_secret) = config.app_secret {
let _ = channel_host::workspace_write(APP_SECRET_PATH, app_secret);
}
+ if let Some(ref verification_token) = config.verification_token {
+ let _ = channel_host::workspace_write(VERIFICATION_TOKEN_PATH, verification_token);
+ }
if let Some(owner_id) = &config.owner_id {
let _ = channel_host::workspace_write(OWNER_ID_PATH, owner_id);
@@ -376,6 +389,23 @@ impl Guest for FeishuChannel {
}
};
+ let configured_token =
+ channel_host::workspace_read(VERIFICATION_TOKEN_PATH).filter(|token| !token.is_empty());
+ if !is_authenticated_webhook(
+ req.secret_validated,
+ configured_token.as_deref(),
+ request_verification_token(&event),
+ ) {
+ channel_host::log(
+ channel_host::LogLevel::Warn,
+ "Rejecting unauthenticated Feishu webhook request",
+ );
+ return json_response(
+ 401,
+ serde_json::json!({"error": "Webhook authentication failed"}),
+ );
+ }
+
// Handle URL verification challenge (initial webhook setup).
if event.event_type.as_deref() == Some("url_verification") {
if let Some(challenge) = &event.challenge {
@@ -487,30 +517,57 @@ fn handle_message_event(event_data: &serde_json::Value) {
// DM pairing check for p2p chats.
let chat_type = msg_event.message.chat_type.as_deref().unwrap_or("unknown");
+ // Resolved user_id for the emitted message. Defaults to sender_id but
+ // is overwritten with the owner_id when the sender is paired, ensuring
+ // the message is scoped to the correct owner/tenant.
+ let mut user_id = sender_id.to_string();
+
if chat_type == "p2p" {
let dm_policy =
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string());
if dm_policy == "pairing" {
- let sender_name = sender_id.to_string();
- match channel_host::pairing_is_allowed("feishu", sender_id, Some(&sender_name)) {
- Ok(true) => {}
- Ok(false) => {
- // Upsert a pairing request.
+ match channel_host::pairing_resolve_identity("feishu", sender_id) {
+ Ok(Some(owner_id)) => {
+ // Sender is paired; scope message to owner.
+ user_id = owner_id;
+ }
+ Ok(None) => {
+ // Unknown sender — upsert a pairing request.
let meta = serde_json::json!({
"sender_id": sender_id,
"chat_id": msg_event.message.chat_id,
"chat_type": chat_type,
});
- let _ = channel_host::pairing_upsert_request(
+ match channel_host::pairing_upsert_request(
"feishu",
sender_id,
&meta.to_string(),
- );
- channel_host::log(
- channel_host::LogLevel::Info,
- &format!("Pairing request created for {}", sender_id),
- );
+ ) {
+ Ok(result) => {
+ channel_host::log(
+ channel_host::LogLevel::Info,
+ &format!(
+ "Pairing request created for {}: {}",
+ sender_id, result.code
+ ),
+ );
+ let _ = send_message(
+ sender_id,
+ "open_id",
+ &format!(
+ "Enter this code in IronClaw to pair your feishu account: `{}`. CLI fallback: `ironclaw pairing approve feishu {}`",
+ result.code, result.code
+ ),
+ );
+ }
+ Err(e) => {
+ channel_host::log(
+ channel_host::LogLevel::Error,
+ &format!("Pairing upsert failed: {}", e),
+ );
+ }
+ }
return;
}
Err(e) => {
@@ -556,7 +613,7 @@ fn handle_message_event(event_data: &serde_json::Value) {
// Emit message to the agent.
channel_host::emit_message(&EmittedMessage {
- user_id: sender_id.to_string(),
+ user_id,
user_name: None,
content: text,
thread_id,
@@ -839,6 +896,31 @@ fn json_response(status: u16, body: serde_json::Value) -> OutgoingHttpResponse {
}
}
+fn is_authenticated_webhook(
+ secret_validated: bool,
+ configured_token: Option<&str>,
+ request_token: Option<&str>,
+) -> bool {
+ if secret_validated {
+ return true;
+ }
+
+ match (configured_token, request_token) {
+ (Some(expected), Some(provided)) => {
+ bool::from(expected.as_bytes().ct_eq(provided.as_bytes()))
+ }
+ _ => false,
+ }
+}
+
+fn request_verification_token(event: &FeishuEvent) -> Option<&str> {
+ event
+ .header
+ .as_ref()
+ .and_then(|header| header.token.as_deref())
+ .or(event.token.as_deref())
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -862,7 +944,10 @@ mod tests {
fn parse_token_response_rejects_missing_token() {
let json = r#"{"code": 0, "msg": "ok", "expire": 7200}"#;
let result: Result = serde_json::from_str(json);
- assert!(result.is_err(), "should fail when tenant_access_token is missing");
+ assert!(
+ result.is_err(),
+ "should fail when tenant_access_token is missing"
+ );
}
#[test]
@@ -894,4 +979,64 @@ mod tests {
assert_eq!(resp.code, 10003);
assert!(resp.tenant_access_token.is_empty());
}
+
+ #[test]
+ fn webhook_auth_requires_host_auth_or_matching_verification_token() {
+ assert!(
+ !is_authenticated_webhook(false, None, Some("token")),
+ "requests without any configured verification mechanism must be rejected"
+ );
+ assert!(
+ !is_authenticated_webhook(false, Some("expected"), None),
+ "requests missing the Feishu token must be rejected when host auth did not pass"
+ );
+ assert!(
+ !is_authenticated_webhook(false, Some("expected"), Some("wrong")),
+ "requests with the wrong Feishu token must be rejected"
+ );
+ assert!(
+ is_authenticated_webhook(false, Some("expected"), Some("expected")),
+ "matching Feishu verification token should authenticate the request"
+ );
+ assert!(
+ is_authenticated_webhook(true, None, None),
+ "host-authenticated requests should still be accepted"
+ );
+ assert!(
+ is_authenticated_webhook(true, Some("expected"), Some("wrong")),
+ "host authentication should take precedence over body token checks"
+ );
+ }
+
+ #[test]
+ fn request_verification_token_prefers_v2_header_token() {
+ let event: FeishuEvent = serde_json::from_str(
+ r#"{
+ "schema": "2.0",
+ "header": {
+ "event_id": "evt_123",
+ "event_type": "im.message.receive_v1",
+ "token": "header-token"
+ },
+ "event": {}
+ }"#,
+ )
+ .unwrap();
+
+ assert_eq!(request_verification_token(&event), Some("header-token"));
+ }
+
+ #[test]
+ fn request_verification_token_falls_back_to_top_level_token() {
+ let event: FeishuEvent = serde_json::from_str(
+ r#"{
+ "type": "url_verification",
+ "challenge": "abc",
+ "token": "top-level-token"
+ }"#,
+ )
+ .unwrap();
+
+ assert_eq!(request_verification_token(&event), Some("top-level-token"));
+ }
}
diff --git a/channels-src/slack/src/lib.rs b/channels-src/slack/src/lib.rs
index 24f01df3934..3b6b212c6b4 100644
--- a/channels-src/slack/src/lib.rs
+++ b/channels-src/slack/src/lib.rs
@@ -23,6 +23,7 @@ wit_bindgen::generate!({
});
use serde::{Deserialize, Serialize};
+use std::collections::BTreeMap;
// Re-export generated types
use exports::near::agent::channel::{
@@ -129,9 +130,17 @@ const OWNER_ID_PATH: &str = "state/owner_id";
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";
+/// Workspace path for tracking recently active Slack threads.
+const ACTIVE_THREADS_PATH: &str = "state/active_threads.json";
+/// Recently active threads expire after 24 hours to avoid reviving stale threads forever.
+const ACTIVE_THREAD_TTL_MS: u64 = 24 * 60 * 60 * 1000;
+/// Cap stored thread markers so the workspace state stays bounded.
+const ACTIVE_THREAD_MAX_ENTRIES: usize = 256;
/// Channel name for pairing store (used by pairing host APIs).
const CHANNEL_NAME: &str = "slack";
+type ActiveThreads = BTreeMap;
+
/// Channel configuration from capabilities file.
#[derive(Debug, Deserialize)]
struct SlackConfig {
@@ -253,80 +262,83 @@ impl Guest for SlackChannel {
}
fn on_respond(response: AgentResponse) -> Result<(), String> {
- // Parse metadata to get channel info
let metadata: SlackMessageMetadata = serde_json::from_str(&response.metadata_json)
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
- // Build Slack API request
- let mut payload = serde_json::json!({
- "channel": metadata.channel,
- "text": response.content,
- });
+ let thread_ts = response.thread_id.or(metadata.thread_ts);
+
+ let ts = post_slack_message(
+ &metadata.channel,
+ &response.content,
+ thread_ts.as_deref(),
+ )?;
- // Add thread_ts for threaded replies
- if let Some(thread_ts) = response.thread_id.or(metadata.thread_ts) {
- payload["thread_ts"] = serde_json::Value::String(thread_ts);
+ if let Some(thread_ts) = thread_ts {
+ if let Err(e) = track_active_thread(&metadata.channel, &thread_ts) {
+ channel_host::log(
+ channel_host::LogLevel::Warn,
+ &format!("Failed to track active thread: {}", e),
+ );
+ }
}
- let payload_bytes = serde_json::to_vec(&payload)
- .map_err(|e| format!("Failed to serialize payload: {}", e))?;
-
- // Make HTTP request to Slack API
- // The bot token is injected by the host based on credential configuration
- 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,
+ channel_host::log(
+ channel_host::LogLevel::Debug,
+ &format!(
+ "Posted message to Slack channel {}: ts={}",
+ metadata.channel,
+ ts.unwrap_or_default()
+ ),
);
- match result {
- Ok(http_response) => {
- if http_response.status != 200 {
- return Err(format!(
- "Slack API returned status {}",
- http_response.status
- ));
- }
+ Ok(())
+ }
- // Parse Slack response
- let slack_response: SlackPostMessageResponse =
- 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())
- ));
- }
+ fn on_status(_update: StatusUpdate) {}
+ fn on_broadcast(user_id: String, response: AgentResponse) -> Result<(), String> {
+ let target = resolve_broadcast_target(&user_id);
+ if target.is_empty() {
+ return Err(
+ "broadcast failed: no target specified. Pass a Slack channel ID (C0...) \
+ or user ID (U0...) as the target."
+ .to_string(),
+ );
+ }
+
+ if !looks_like_slack_id(target) {
+ return Err(format!(
+ "Broadcast target '{}' is not a valid Slack ID (expected C/U/D/G/W prefix). \
+ Use a channel ID (C0...) or user ID (U0...), not a channel name.",
+ target
+ ));
+ }
+
+ let ts = post_slack_message(target, &response.content, response.thread_id.as_deref())?;
+
+ // Track the thread so replies to this broadcast are recognized as
+ // active threads. Use the explicit thread_id if provided, otherwise
+ // fall back to the message timestamp returned by Slack (which becomes
+ // the thread root if someone replies to this message).
+ if let Some(thread_ts) = response.thread_id.as_deref().or(ts.as_deref()) {
+ if let Err(e) = track_active_thread(target, thread_ts) {
channel_host::log(
- channel_host::LogLevel::Debug,
- &format!(
- "Posted message to Slack channel {}: ts={}",
- metadata.channel,
- slack_response.ts.unwrap_or_default()
- ),
+ channel_host::LogLevel::Warn,
+ &format!("Failed to track active thread: {}", e),
);
-
- Ok(())
}
- Err(e) => Err(format!("HTTP request failed: {}", e)),
}
- }
- fn on_status(_update: StatusUpdate) {}
+ channel_host::log(
+ channel_host::LogLevel::Debug,
+ &format!(
+ "Broadcast message to Slack target {}: ts={}",
+ target,
+ ts.unwrap_or_default()
+ ),
+ );
- fn on_broadcast(_user_id: String, _response: AgentResponse) -> Result<(), String> {
- Err("broadcast not yet implemented for Slack channel".to_string())
+ Ok(())
}
fn on_shutdown() {
@@ -452,13 +464,14 @@ fn download_and_store_slack_files(attachments: &[InboundAttachment]) {
}
}
-/// Handle a Slack event and emit message if applicable.
-fn handle_slack_event(event: SlackEvent, team_id: Option, _event_id: Option) {
- let attachments = extract_slack_attachments(&event.files);
-
- // Download and store file attachments for host-side processing
+fn prepare_inbound_attachments(files: &Option>) -> Vec {
+ let attachments = extract_slack_attachments(files);
download_and_store_slack_files(&attachments);
+ attachments
+}
+/// Handle a Slack event and emit message if applicable.
+fn handle_slack_event(event: SlackEvent, team_id: Option, _event_id: Option) {
match event.event_type.as_str() {
// Direct mention of the bot (always in a channel, not a DM)
"app_mention" => {
@@ -472,6 +485,7 @@ fn handle_slack_event(event: SlackEvent, team_id: Option, _event_id: Opt
if !check_sender_permission(&user, &channel, false) {
return;
}
+ let attachments = prepare_inbound_attachments(&event.files);
emit_message(
user,
text,
@@ -483,7 +497,7 @@ fn handle_slack_event(event: SlackEvent, team_id: Option, _event_id: Opt
}
}
- // Direct message to the bot
+ // Direct message or thread follow-up to the bot
"message" => {
// Skip messages from bots (including ourselves)
if event.bot_id.is_some() || event.subtype.is_some() {
@@ -496,11 +510,20 @@ fn handle_slack_event(event: SlackEvent, team_id: Option, _event_id: Opt
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) {
+ let is_dm = channel.starts_with('D');
+
+ // Check if this is a reply in a thread where we previously participated
+ let is_active_thread = !is_dm
+ && event
+ .thread_ts
+ .as_ref()
+ .is_some_and(|thread_ts| is_active_thread(&channel, thread_ts));
+
+ if is_dm || is_active_thread {
+ if !check_sender_permission(&user, &channel, is_dm) {
return;
}
+ let attachments = prepare_inbound_attachments(&event.files);
emit_message(
user,
text,
@@ -522,6 +545,93 @@ fn handle_slack_event(event: SlackEvent, team_id: Option, _event_id: Opt
}
}
+fn active_thread_key(channel: &str, thread_ts: &str) -> String {
+ format!("{channel}/{thread_ts}")
+}
+
+fn is_thread_marker_fresh(last_seen_millis: u64, now_millis: u64) -> bool {
+ now_millis.saturating_sub(last_seen_millis) <= ACTIVE_THREAD_TTL_MS
+}
+
+fn prune_active_threads(active_threads: &mut ActiveThreads, now_millis: u64) -> bool {
+ let mut changed = false;
+ active_threads.retain(|_, last_seen_millis| {
+ let keep = is_thread_marker_fresh(*last_seen_millis, now_millis);
+ if !keep {
+ changed = true;
+ }
+ keep
+ });
+
+ if active_threads.len() > ACTIVE_THREAD_MAX_ENTRIES {
+ let mut oldest_first: Vec<_> = active_threads
+ .iter()
+ .map(|(key, last_seen_millis)| (key.clone(), *last_seen_millis))
+ .collect();
+ oldest_first.sort_by_key(|(_, last_seen_millis)| *last_seen_millis);
+
+ for (key, _) in oldest_first
+ .into_iter()
+ .take(active_threads.len() - ACTIVE_THREAD_MAX_ENTRIES)
+ {
+ active_threads.remove(&key);
+ changed = true;
+ }
+ }
+
+ changed
+}
+
+fn load_active_threads() -> ActiveThreads {
+ let Some(raw) = channel_host::workspace_read(ACTIVE_THREADS_PATH) else {
+ return ActiveThreads::new();
+ };
+
+ match serde_json::from_str(&raw) {
+ Ok(active_threads) => active_threads,
+ Err(e) => {
+ channel_host::log(
+ channel_host::LogLevel::Warn,
+ &format!("Failed to parse active thread state: {e}"),
+ );
+ ActiveThreads::new()
+ }
+ }
+}
+
+fn persist_active_threads(active_threads: &ActiveThreads) -> Result<(), String> {
+ let serialized = serde_json::to_string(active_threads)
+ .map_err(|e| format!("Failed to serialize active thread state: {e}"))?;
+ channel_host::workspace_write(ACTIVE_THREADS_PATH, &serialized)
+ .map_err(|e| format!("Failed to persist active thread state: {e}"))
+}
+
+fn track_active_thread(channel: &str, thread_ts: &str) -> Result<(), String> {
+ let now_millis = channel_host::now_millis();
+ let mut active_threads = load_active_threads();
+ prune_active_threads(&mut active_threads, now_millis);
+ active_threads.insert(active_thread_key(channel, thread_ts), now_millis);
+ prune_active_threads(&mut active_threads, now_millis);
+ persist_active_threads(&active_threads)
+}
+
+fn is_active_thread(channel: &str, thread_ts: &str) -> bool {
+ let now_millis = channel_host::now_millis();
+ let mut active_threads = load_active_threads();
+ let changed = prune_active_threads(&mut active_threads, now_millis);
+
+ if changed {
+ if let Err(e) = persist_active_threads(&active_threads) {
+ channel_host::log(
+ channel_host::LogLevel::Warn,
+ &format!("Failed to prune active thread state: {e}"),
+ );
+ }
+ }
+
+ active_threads.contains_key(&active_thread_key(channel, thread_ts))
+}
+
/// Emit a message to the agent.
fn emit_message(
user_id: String,
@@ -606,8 +716,7 @@ fn check_sender_permission(user_id: &str, channel_id: &str, is_dm: bool) -> bool
}
// 4. Check sender (Slack events only have user ID, not username)
- let is_allowed =
- allowed.contains(&"*".to_string()) || allowed.contains(&user_id.to_string());
+ let is_allowed = allowed.contains(&"*".to_string()) || allowed.contains(&user_id.to_string());
if is_allowed {
return true;
@@ -625,14 +734,9 @@ fn check_sender_permission(user_id: &str, channel_id: &str, is_dm: bool) -> bool
Ok(result) => {
channel_host::log(
channel_host::LogLevel::Info,
- &format!(
- "Pairing request for user {}: code {}",
- user_id, result.code
- ),
+ &format!("Pairing request for user {}: code {}", user_id, result.code),
);
- if result.created {
- let _ = send_pairing_reply(channel_id, &result.code);
- }
+ let _ = send_pairing_reply(channel_id, &result.code);
}
Err(e) => {
channel_host::log(
@@ -650,8 +754,8 @@ 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
+ "Enter this code in IronClaw to pair your slack account: `{}`. CLI fallback: `ironclaw pairing approve slack {}`",
+ code, code
),
});
@@ -681,6 +785,95 @@ fn send_pairing_reply(channel_id: &str, code: &str) -> Result<(), String> {
}
}
+/// Post a message via Slack `chat.postMessage` and return the message timestamp.
+///
+/// The bot token is injected by the host credential system — this function
+/// only sets `Content-Type`. Used by both `on_respond` and `on_broadcast`.
+fn post_slack_message(
+ channel: &str,
+ text: &str,
+ thread_ts: Option<&str>,
+) -> Result, String> {
+ let payload = build_broadcast_payload(channel, text, thread_ts);
+ let payload_bytes = serde_json::to_vec(&payload)
+ .map_err(|e| format!("Failed to serialize payload: {}", 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(http_response) => {
+ if http_response.status != 200 {
+ return Err(format!(
+ "Slack API returned status {}",
+ http_response.status
+ ));
+ }
+
+ let slack_response: SlackPostMessageResponse =
+ 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())
+ ));
+ }
+
+ Ok(slack_response.ts)
+ }
+ Err(e) => Err(format!("HTTP request failed: {}", e)),
+ }
+}
+
+/// Normalize a broadcast target by stripping a leading `#` if present.
+///
+/// The message tool passes the target as `user_id` (e.g. `#C0123ABC`,
+/// `C0123ABC`, or `U0123ABC`). The Slack API expects a channel ID (C0...)
+/// or user ID (U0...), not a channel name.
+fn resolve_broadcast_target(raw: &str) -> &str {
+ raw.strip_prefix('#').unwrap_or(raw)
+}
+
+/// Check if a string looks like a Slack ID (starts with C, U, D, G, or W followed by alphanumeric).
+fn looks_like_slack_id(s: &str) -> bool {
+ let mut chars = s.chars();
+ match chars.next() {
+ Some('C' | 'U' | 'D' | 'G' | 'W') => {
+ chars.next().is_some_and(|c| c.is_ascii_alphanumeric())
+ }
+ _ => false,
+ }
+}
+
+/// Build the JSON payload for a Slack `chat.postMessage` broadcast.
+fn build_broadcast_payload(
+ target: &str,
+ content: &str,
+ thread_ts: Option<&str>,
+) -> serde_json::Value {
+ let mut payload = serde_json::json!({
+ "channel": target,
+ "text": content,
+ });
+ if let Some(ts) = thread_ts {
+ payload["thread_ts"] = serde_json::Value::String(ts.to_string());
+ }
+ payload
+}
+
/// Strip leading bot mention from text.
fn strip_bot_mention(text: &str) -> String {
// Slack mentions look like <@U12345678>
@@ -826,4 +1019,133 @@ mod tests {
// Verify the constant is 20 MB
assert_eq!(MAX_DOWNLOAD_SIZE_BYTES, 20 * 1024 * 1024);
}
+
+ #[test]
+ fn test_active_thread_key_scopes_by_channel_and_thread() {
+ assert_eq!(
+ active_thread_key("C123", "1742486400.000100"),
+ "C123/1742486400.000100"
+ );
+ }
+
+ #[test]
+ fn test_prune_active_threads_removes_expired_entries() {
+ let now_millis = ACTIVE_THREAD_TTL_MS + 1_000;
+ let mut active_threads = ActiveThreads::from([
+ (
+ "C1/expired".to_string(),
+ now_millis - ACTIVE_THREAD_TTL_MS - 1,
+ ),
+ ("C1/fresh".to_string(), now_millis - ACTIVE_THREAD_TTL_MS),
+ ]);
+
+ let changed = prune_active_threads(&mut active_threads, now_millis);
+
+ assert!(changed);
+ assert!(!active_threads.contains_key("C1/expired"));
+ assert!(active_threads.contains_key("C1/fresh"));
+ }
+
+ #[test]
+ fn test_prune_active_threads_trims_oldest_entries_when_over_limit() {
+ let now_millis = ACTIVE_THREAD_TTL_MS + 1_000;
+ let mut active_threads = ActiveThreads::new();
+
+ for i in 0..=ACTIVE_THREAD_MAX_ENTRIES {
+ active_threads.insert(format!("C1/{i}"), now_millis + i as u64);
+ }
+
+ let changed = prune_active_threads(
+ &mut active_threads,
+ now_millis + ACTIVE_THREAD_MAX_ENTRIES as u64,
+ );
+
+ assert!(changed);
+ assert_eq!(active_threads.len(), ACTIVE_THREAD_MAX_ENTRIES);
+ assert!(!active_threads.contains_key("C1/0"));
+ assert!(active_threads.contains_key(&format!("C1/{ACTIVE_THREAD_MAX_ENTRIES}")));
+ }
+
+ #[test]
+ fn test_is_thread_marker_fresh_respects_ttl_boundary() {
+ let now_millis = ACTIVE_THREAD_TTL_MS + 1_000;
+ assert!(is_thread_marker_fresh(
+ now_millis - ACTIVE_THREAD_TTL_MS,
+ now_millis
+ ));
+ assert!(!is_thread_marker_fresh(
+ now_millis - ACTIVE_THREAD_TTL_MS - 1,
+ now_millis
+ ));
+ }
+
+ #[test]
+ fn test_resolve_broadcast_target_strips_hash() {
+ assert_eq!(resolve_broadcast_target("#general"), "general");
+ assert_eq!(resolve_broadcast_target("#staging-eli5"), "staging-eli5");
+ }
+
+ #[test]
+ fn test_resolve_broadcast_target_preserves_ids() {
+ assert_eq!(resolve_broadcast_target("C0123ABC"), "C0123ABC");
+ assert_eq!(resolve_broadcast_target("U0123ABC"), "U0123ABC");
+ }
+
+ #[test]
+ fn test_resolve_broadcast_target_empty_input() {
+ assert_eq!(resolve_broadcast_target(""), "");
+ assert_eq!(resolve_broadcast_target("#"), "");
+ }
+
+ #[test]
+ fn test_build_broadcast_payload_without_thread() {
+ let payload = build_broadcast_payload("C0123", "hello world", None);
+ assert_eq!(payload["channel"], "C0123");
+ assert_eq!(payload["text"], "hello world");
+ assert!(payload.get("thread_ts").is_none());
+ }
+
+ #[test]
+ fn test_build_broadcast_payload_with_thread() {
+ let payload = build_broadcast_payload("C0123", "threaded reply", Some("1742486400.000100"));
+ assert_eq!(payload["channel"], "C0123");
+ assert_eq!(payload["text"], "threaded reply");
+ assert_eq!(payload["thread_ts"], "1742486400.000100");
+ }
+
+ #[test]
+ fn test_looks_like_slack_id_valid() {
+ assert!(looks_like_slack_id("C0123ABC"));
+ assert!(looks_like_slack_id("U0123ABC"));
+ assert!(looks_like_slack_id("D0123ABC"));
+ assert!(looks_like_slack_id("G0123ABC"));
+ assert!(looks_like_slack_id("W0123ABC"));
+ }
+
+ #[test]
+ fn test_looks_like_slack_id_invalid() {
+ assert!(!looks_like_slack_id("general"));
+ assert!(!looks_like_slack_id("staging-eli5"));
+ assert!(!looks_like_slack_id(""));
+ assert!(!looks_like_slack_id("C")); // too short, no second char
+ assert!(!looks_like_slack_id("c0123")); // lowercase
+ }
+
+ #[test]
+ fn test_resolve_broadcast_target_rejects_names_via_id_check() {
+ // After stripping '#', channel names fail the ID check
+ let target = resolve_broadcast_target("#general");
+ assert!(!looks_like_slack_id(target));
+
+ let target = resolve_broadcast_target("random-channel");
+ assert!(!looks_like_slack_id(target));
+ }
+
+ #[test]
+ fn test_resolve_broadcast_target_accepts_prefixed_ids() {
+ // IDs with '#' prefix are accepted after stripping
+ let target = resolve_broadcast_target("#C0123ABC");
+ assert!(looks_like_slack_id(target));
+ assert_eq!(target, "C0123ABC");
+ }
}
diff --git a/channels-src/telegram/Cargo.lock b/channels-src/telegram/Cargo.lock
index 8d40f01e0ff..7ef6912c7bd 100644
--- a/channels-src/telegram/Cargo.lock
+++ b/channels-src/telegram/Cargo.lock
@@ -212,7 +212,7 @@ dependencies = [
[[package]]
name = "telegram-channel"
-version = "0.2.1"
+version = "0.2.6"
dependencies = [
"serde",
"serde_json",
diff --git a/channels-src/telegram/Cargo.toml b/channels-src/telegram/Cargo.toml
index 182e5f5de5d..982329246f2 100644
--- a/channels-src/telegram/Cargo.toml
+++ b/channels-src/telegram/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "telegram-channel"
-version = "0.2.1"
+version = "0.2.6"
edition = "2021"
description = "Telegram Bot API channel for IronClaw"
license = "MIT OR Apache-2.0"
diff --git a/channels-src/telegram/src/lib.rs b/channels-src/telegram/src/lib.rs
index f34ed68aa7a..561fdb28dca 100644
--- a/channels-src/telegram/src/lib.rs
+++ b/channels-src/telegram/src/lib.rs
@@ -262,6 +262,9 @@ struct SentMessage {
/// Workspace path for storing polling state.
const POLLING_STATE_PATH: &str = "state/last_update_id";
+/// Workspace path for storing the most recently processed webhook update ID.
+const WEBHOOK_STATE_PATH: &str = "state/last_webhook_update_id";
+
/// Workspace path for persisting owner_id across WASM callbacks.
const OWNER_ID_PATH: &str = "state/owner_id";
@@ -307,8 +310,7 @@ struct TelegramMessageMetadata {
/// Channel configuration injected by host.
///
/// The host injects runtime values like tunnel_url and webhook_secret.
-/// The channel doesn't need to know about polling vs webhook mode - it just
-/// checks if tunnel_url is set to determine behavior.
+/// Telegram defaults to polling; webhook mode must be enabled explicitly.
#[derive(Debug, Deserialize)]
struct TelegramConfig {
/// Bot username (without @) for mention detection in groups.
@@ -333,7 +335,6 @@ struct TelegramConfig {
respond_to_all_group_messages: bool,
/// Public tunnel URL for webhook mode (injected by host from global settings).
- /// When set, webhook mode is enabled and polling is disabled.
#[serde(default)]
tunnel_url: Option,
@@ -342,9 +343,21 @@ struct TelegramConfig {
#[serde(default)]
webhook_secret: Option,
+ /// When true, use webhook mode if tunnel_url is available.
+ #[serde(default)]
+ webhook_enabled: bool,
+
/// When true, use polling mode even if tunnel_url is available.
#[serde(default)]
polling_enabled: bool,
+
+ /// Poll interval in milliseconds (default 30000).
+ #[serde(default)]
+ poll_interval_ms: Option,
+}
+
+fn webhook_mode(config: &TelegramConfig) -> bool {
+ config.webhook_enabled && config.tunnel_url.is_some() && !config.polling_enabled
}
// ============================================================================
@@ -363,6 +376,26 @@ const TELEGRAM_STATUS_MAX_CHARS: usize = 600;
/// Telegram's hard limit for message text length.
const TELEGRAM_MAX_MESSAGE_LEN: usize = 4096;
+fn utf16_code_unit_len(text: &str) -> usize {
+ text.encode_utf16().count()
+}
+
+fn prefix_within_utf16_limit(text: &str, max_units: usize) -> usize {
+ let mut units = 0;
+ let mut end = 0;
+
+ for (byte_idx, ch) in text.char_indices() {
+ let ch_units = ch.len_utf16();
+ if units + ch_units > max_units {
+ break;
+ }
+ units += ch_units;
+ end = byte_idx + ch.len_utf8();
+ }
+
+ end
+}
+
fn truncate_status_message(input: &str, max_chars: usize) -> String {
let mut iter = input.chars();
let truncated: String = iter.by_ref().take(max_chars).collect();
@@ -373,7 +406,7 @@ fn truncate_status_message(input: &str, max_chars: usize) -> String {
}
}
-/// Split a long message into chunks that fit within Telegram's 4096-char limit.
+/// Split a long message into chunks that fit within Telegram's 4096 UTF-16-unit limit.
///
/// Tries to split at the most natural boundary available (in priority order):
/// 1. Double newline (paragraph break)
@@ -382,7 +415,7 @@ fn truncate_status_message(input: &str, max_chars: usize) -> String {
/// 4. Word boundary (space)
/// 5. Hard cut at the limit (last resort for pathological input)
fn split_message(text: &str) -> Vec {
- if text.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN {
+ if utf16_code_unit_len(text) <= TELEGRAM_MAX_MESSAGE_LEN {
return vec![text.to_string()];
}
@@ -390,13 +423,8 @@ fn split_message(text: &str) -> Vec {
let mut remaining = text;
while !remaining.is_empty() {
- // Count chars to find the byte offset for our window.
- let window_bytes = remaining
- .char_indices()
- .take(TELEGRAM_MAX_MESSAGE_LEN)
- .last()
- .map(|(byte_idx, ch)| byte_idx + ch.len_utf8())
- .unwrap_or(remaining.len());
+ // Find the longest UTF-8 prefix that fits within Telegram's UTF-16 limit.
+ let window_bytes = prefix_within_utf16_limit(remaining, TELEGRAM_MAX_MESSAGE_LEN);
if window_bytes >= remaining.len() {
// Remainder fits entirely.
@@ -404,6 +432,19 @@ fn split_message(text: &str) -> Vec {
break;
}
+ if window_bytes == 0 {
+ // Defensive fallback: make progress even if a future caller uses a
+ // smaller limit than a single scalar value can fit within.
+ let first_char_len = remaining
+ .chars()
+ .next()
+ .map(|ch| ch.len_utf8())
+ .unwrap_or(remaining.len());
+ chunks.push(remaining[..first_char_len].to_string());
+ remaining = &remaining[first_char_len..];
+ continue;
+ }
+
let window = &remaining[..window_bytes];
// 1. Double newline — best paragraph boundary
@@ -518,8 +559,11 @@ impl Guest for TelegramChannel {
// Clear any stale owner_id from a previous config
let _ = channel_host::workspace_write(OWNER_ID_PATH, "");
channel_host::log(
- channel_host::LogLevel::Warn,
- "No owner_id configured, bot is open to all users",
+ channel_host::LogLevel::Debug,
+ &format!(
+ "No owner_id configured; dm_policy={}",
+ config.dm_policy.as_deref().unwrap_or("pairing")
+ ),
);
}
@@ -527,27 +571,26 @@ impl Guest for TelegramChannel {
let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing").to_string();
let _ = channel_host::workspace_write(DM_POLICY_PATH, &dm_policy);
- let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default())
+ let allow_from_json = serde_json::to_string(&config.allow_from.clone().unwrap_or_default())
.unwrap_or_else(|_| "[]".to_string());
let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json);
// Persist bot_username and respond_to_all_group_messages for group handling
let _ = channel_host::workspace_write(
BOT_USERNAME_PATH,
- &config.bot_username.unwrap_or_default(),
+ &config.bot_username.clone().unwrap_or_default(),
);
let _ = channel_host::workspace_write(
RESPOND_TO_ALL_GROUP_PATH,
&config.respond_to_all_group_messages.to_string(),
);
- // Mode: use polling if explicitly enabled, otherwise use webhooks when tunnel available.
- let webhook_mode = config.tunnel_url.is_some() && !config.polling_enabled;
+ let webhook_mode = webhook_mode(&config);
if webhook_mode {
channel_host::log(
channel_host::LogLevel::Info,
- "Webhook mode enabled (tunnel configured)",
+ "Webhook mode enabled (explicitly configured)",
);
// Register webhook with Telegram API — propagate errors so a bad token
@@ -567,7 +610,7 @@ impl Guest for TelegramChannel {
} else {
channel_host::log(
channel_host::LogLevel::Info,
- "Polling mode enabled (no tunnel configured)",
+ "Polling mode enabled",
);
// Delete any existing webhook before polling. Telegram returns success
@@ -578,7 +621,7 @@ impl Guest for TelegramChannel {
// Configure polling only if not in webhook mode
let poll = if !webhook_mode {
Some(PollConfig {
- interval_ms: 30000, // 30 seconds minimum
+ interval_ms: config.poll_interval_ms.unwrap_or(30000),
enabled: true,
})
} else {
@@ -636,9 +679,32 @@ impl Guest for TelegramChannel {
}
};
+ let last_processed = channel_host::workspace_read(WEBHOOK_STATE_PATH)
+ .and_then(|value| value.parse::().ok())
+ .unwrap_or(-1);
+ let update_id = update.update_id;
+ if update_id <= last_processed {
+ channel_host::log(
+ channel_host::LogLevel::Info,
+ &format!(
+ "Skipping duplicate or stale webhook update {} (last processed {})",
+ update_id, last_processed
+ ),
+ );
+ return json_response(200, serde_json::json!({"ok": true}));
+ }
+
// Handle the update
handle_update(update);
+ if let Err(err) = channel_host::workspace_write(WEBHOOK_STATE_PATH, &update_id.to_string())
+ {
+ channel_host::log(
+ channel_host::LogLevel::Error,
+ &format!("Failed to persist webhook update id: {}", err),
+ );
+ }
+
// Always respond 200 quickly (Telegram expects fast responses)
json_response(200, serde_json::json!({"ok": true}))
}
@@ -1090,12 +1156,9 @@ fn download_telegram_file(file_id: &str) -> Result, String> {
}
// ============================================================================
-// Attachment Sending (Photo / Document)
+// Attachment Sending (Photo / Voice / Document)
// ============================================================================
-/// Maximum photo size for Telegram sendPhoto (10 MB).
-const MAX_PHOTO_SIZE: usize = 10 * 1024 * 1024;
-
/// Write a multipart/form-data text field.
fn write_multipart_field(body: &mut Vec, boundary: &str, name: &str, value: &str) {
body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
@@ -1138,10 +1201,27 @@ fn write_multipart_file(
body.extend_from_slice(b"\r\n");
}
-/// Send a photo via the Telegram Bot API (multipart upload).
+/// Image MIME types that Telegram's sendPhoto API supports.
+const PHOTO_MIME_TYPES: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
+
+/// Audio MIME types that Telegram's sendVoice API supports (ogg/opus container).
+const VOICE_MIME_TYPES: &[&str] = &["audio/ogg", "audio/opus"];
+
+/// Maximum photo size for Telegram sendPhoto (10 MB).
+const MAX_PHOTO_SIZE: usize = 10 * 1024 * 1024;
+
+/// Maximum voice note size for Telegram sendVoice (50 MB).
+const MAX_VOICE_SIZE: usize = 50 * 1024 * 1024;
+
+/// Send a multipart file upload to a Telegram Bot API endpoint.
///
-/// Falls back to `send_document()` if the photo exceeds 10 MB.
-fn send_photo(
+/// Shared implementation for sendPhoto, sendVoice, and sendDocument.
+/// `api_method` is the Telegram method name (e.g. "sendPhoto"),
+/// `field_name` is the multipart field (e.g. "photo", "voice", "document").
+#[allow(clippy::too_many_arguments)]
+fn send_multipart_upload(
+ api_method: &str,
+ field_name: &str,
chat_id: i64,
filename: &str,
mime_type: &str,
@@ -1151,25 +1231,6 @@ fn send_photo(
) -> Result<(), String> {
let message_thread_id = normalize_thread_id(message_thread_id);
- if data.len() > MAX_PHOTO_SIZE {
- channel_host::log(
- channel_host::LogLevel::Info,
- &format!(
- "Photo {} exceeds 10MB ({}), sending as document",
- filename,
- data.len()
- ),
- );
- return send_document(
- chat_id,
- filename,
- mime_type,
- data,
- reply_to_message_id,
- message_thread_id,
- );
- }
-
let boundary = format!("ironclaw-{}", channel_host::now_millis());
let mut body = Vec::new();
@@ -1190,16 +1251,21 @@ fn send_photo(
&thread_id.to_string(),
);
}
- write_multipart_file(&mut body, &boundary, "photo", filename, mime_type, data);
+ write_multipart_file(&mut body, &boundary, field_name, filename, mime_type, data);
body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes());
let headers = serde_json::json!({
"Content-Type": format!("multipart/form-data; boundary={}", boundary)
});
+ let url = format!(
+ "https://api.telegram.org/bot{{TELEGRAM_BOT_TOKEN}}/{}",
+ api_method
+ );
+
let result = channel_host::http_request(
"POST",
- "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendPhoto",
+ &url,
&headers.to_string(),
Some(&body),
Some(60_000), // 60s timeout for file uploads
@@ -1209,23 +1275,25 @@ fn send_photo(
Ok(resp) if resp.status == 200 => {
channel_host::log(
channel_host::LogLevel::Debug,
- &format!("Sent photo '{}' to chat {}", filename, chat_id),
+ &format!("Sent {} '{}' to chat {}", field_name, filename, chat_id),
);
Ok(())
}
Ok(resp) => {
let body_str = String::from_utf8_lossy(&resp.body);
Err(format!(
- "sendPhoto failed (HTTP {}): {}",
- resp.status, body_str
+ "{} failed (HTTP {}): {}",
+ api_method, resp.status, body_str
))
}
- Err(e) => Err(format!("sendPhoto HTTP request failed: {}", e)),
+ Err(e) => Err(format!("{} HTTP request failed: {}", api_method, e)),
}
}
-/// Send a document via the Telegram Bot API (multipart upload).
-fn send_document(
+/// Send a photo via the Telegram Bot API (multipart upload).
+///
+/// Falls back to `send_document()` if the photo exceeds 10 MB.
+fn send_photo(
chat_id: i64,
filename: &str,
mime_type: &str,
@@ -1233,65 +1301,100 @@ fn send_document(
reply_to_message_id: Option,
message_thread_id: Option,
) -> Result<(), String> {
- let message_thread_id = normalize_thread_id(message_thread_id);
-
- let boundary = format!("ironclaw-{}", channel_host::now_millis());
- let mut body = Vec::new();
-
- write_multipart_field(&mut body, &boundary, "chat_id", &chat_id.to_string());
- if let Some(msg_id) = reply_to_message_id {
- write_multipart_field(
- &mut body,
- &boundary,
- "reply_to_message_id",
- &msg_id.to_string(),
+ if data.len() > MAX_PHOTO_SIZE {
+ channel_host::log(
+ channel_host::LogLevel::Info,
+ &format!(
+ "Photo {} exceeds 10MB ({}), sending as document",
+ filename,
+ data.len()
+ ),
);
- }
- if let Some(thread_id) = message_thread_id {
- write_multipart_field(
- &mut body,
- &boundary,
- "message_thread_id",
- &thread_id.to_string(),
+ return send_document(
+ chat_id,
+ filename,
+ mime_type,
+ data,
+ reply_to_message_id,
+ message_thread_id,
);
}
- write_multipart_file(&mut body, &boundary, "document", filename, mime_type, data);
- body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes());
-
- let headers = serde_json::json!({
- "Content-Type": format!("multipart/form-data; boundary={}", boundary)
- });
+ send_multipart_upload(
+ "sendPhoto",
+ "photo",
+ chat_id,
+ filename,
+ mime_type,
+ data,
+ reply_to_message_id,
+ message_thread_id,
+ )
+}
- let result = channel_host::http_request(
- "POST",
- "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendDocument",
- &headers.to_string(),
- Some(&body),
- Some(60_000), // 60s timeout for file uploads
- );
+/// Send a document via the Telegram Bot API (multipart upload).
+fn send_document(
+ chat_id: i64,
+ filename: &str,
+ mime_type: &str,
+ data: &[u8],
+ reply_to_message_id: Option,
+ message_thread_id: Option,
+) -> Result<(), String> {
+ send_multipart_upload(
+ "sendDocument",
+ "document",
+ chat_id,
+ filename,
+ mime_type,
+ data,
+ reply_to_message_id,
+ message_thread_id,
+ )
+}
- match result {
- Ok(resp) if resp.status == 200 => {
- channel_host::log(
- channel_host::LogLevel::Debug,
- &format!("Sent document '{}' to chat {}", filename, chat_id),
- );
- Ok(())
- }
- Ok(resp) => {
- let body_str = String::from_utf8_lossy(&resp.body);
- Err(format!(
- "sendDocument failed (HTTP {}): {}",
- resp.status, body_str
- ))
- }
- Err(e) => Err(format!("sendDocument HTTP request failed: {}", e)),
+/// Send a voice note via the Telegram Bot API (multipart upload).
+///
+/// Telegram's `sendVoice` requires ogg/opus audio and displays it as an
+/// in-chat voice note with waveform and playback controls.
+/// Falls back to `send_document()` if the voice note exceeds 50 MB.
+fn send_voice(
+ chat_id: i64,
+ filename: &str,
+ mime_type: &str,
+ data: &[u8],
+ reply_to_message_id: Option,
+ message_thread_id: Option,
+) -> Result<(), String> {
+ if data.len() > MAX_VOICE_SIZE {
+ channel_host::log(
+ channel_host::LogLevel::Info,
+ &format!(
+ "Voice note {} exceeds 50MB ({}), sending as document",
+ filename,
+ data.len()
+ ),
+ );
+ return send_document(
+ chat_id,
+ filename,
+ mime_type,
+ data,
+ reply_to_message_id,
+ message_thread_id,
+ );
}
+ send_multipart_upload(
+ "sendVoice",
+ "voice",
+ chat_id,
+ filename,
+ mime_type,
+ data,
+ reply_to_message_id,
+ message_thread_id,
+ )
}
-/// Image MIME types that Telegram's sendPhoto API supports.
-const PHOTO_MIME_TYPES: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
-
/// Send a full agent response (attachments + text) to a chat.
///
/// Shared implementation for both `on_respond` and `on_broadcast`.
@@ -1371,31 +1474,65 @@ fn send_response(
Ok(())
}
-/// Send a single attachment, choosing sendPhoto or sendDocument based on MIME type.
+/// Extract the base MIME type, stripping any parameters after `;`.
+///
+/// e.g. `"audio/ogg; codecs=opus"` → `"audio/ogg"`
+fn base_mime_type(mime: &str) -> &str {
+ mime.split(';').next().unwrap_or(mime).trim()
+}
+
+/// Attachment routing category.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum AttachmentKind {
+ Photo,
+ Voice,
+ Document,
+}
+
+/// Classify an attachment's send method based on its MIME type.
+fn classify_attachment(mime_type: &str) -> AttachmentKind {
+ let base = base_mime_type(mime_type);
+ if PHOTO_MIME_TYPES.contains(&base) {
+ AttachmentKind::Photo
+ } else if VOICE_MIME_TYPES.contains(&base) {
+ AttachmentKind::Voice
+ } else {
+ AttachmentKind::Document
+ }
+}
+
+/// Send a single attachment, choosing sendPhoto, sendVoice, or sendDocument based on MIME type.
fn send_attachment(
chat_id: i64,
attachment: &Attachment,
reply_to_message_id: Option,
message_thread_id: Option,
) -> Result<(), String> {
- if PHOTO_MIME_TYPES.contains(&attachment.mime_type.as_str()) {
- send_photo(
+ match classify_attachment(&attachment.mime_type) {
+ AttachmentKind::Photo => send_photo(
chat_id,
&attachment.filename,
&attachment.mime_type,
&attachment.data,
reply_to_message_id,
message_thread_id,
- )
- } else {
- send_document(
+ ),
+ AttachmentKind::Voice => send_voice(
chat_id,
&attachment.filename,
&attachment.mime_type,
&attachment.data,
reply_to_message_id,
message_thread_id,
- )
+ ),
+ AttachmentKind::Document => send_document(
+ chat_id,
+ &attachment.filename,
+ &attachment.mime_type,
+ &attachment.data,
+ reply_to_message_id,
+ message_thread_id,
+ ),
}
}
@@ -1552,8 +1689,8 @@ fn send_pairing_reply(chat_id: i64, code: &str) -> Result<(), String> {
send_message(
chat_id,
&format!(
- "To pair with this bot, run: `ironclaw pairing approve telegram {}`",
- code
+ "Enter this code in IronClaw to pair your telegram account: `{}`. CLI fallback: `ironclaw pairing approve telegram {}`",
+ code, code
),
None,
Some("Markdown"),
@@ -1946,9 +2083,7 @@ fn handle_message(message: TelegramMessage) {
from.id, message.chat.id, result.code
),
);
- if result.created {
- let _ = send_pairing_reply(message.chat.id, &result.code);
- }
+ let _ = send_pairing_reply(message.chat.id, &result.code);
}
Err(e) => {
channel_host::log(
@@ -2150,6 +2285,10 @@ export!(TelegramChannel);
mod tests {
use super::*;
+ fn utf16_len(text: &str) -> usize {
+ text.encode_utf16().count()
+ }
+
#[test]
fn test_split_message_short() {
let text = "Hello, world!";
@@ -2177,7 +2316,7 @@ mod tests {
let chunks = split_message(&text);
assert!(chunks.len() > 1, "expected multiple chunks");
for chunk in &chunks {
- assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
+ assert!(utf16_len(chunk) <= TELEGRAM_MAX_MESSAGE_LEN);
}
// Rejoined chunks must equal the original text exactly.
let rejoined = chunks.join(" ");
@@ -2193,7 +2332,7 @@ mod tests {
assert!(text.len() > TELEGRAM_MAX_MESSAGE_LEN);
let chunks = split_message(&text);
for chunk in &chunks {
- assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
+ assert!(utf16_len(chunk) <= TELEGRAM_MAX_MESSAGE_LEN);
}
}
@@ -2223,7 +2362,7 @@ mod tests {
let chunks = split_message(&text);
assert!(chunks.len() >= 2);
for chunk in &chunks {
- assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
+ assert!(utf16_len(chunk) <= TELEGRAM_MAX_MESSAGE_LEN);
}
// Rejoined must preserve all characters
let rejoined: String = chunks.concat();
@@ -2240,12 +2379,25 @@ mod tests {
let chunks = split_message(&text);
assert!(chunks.len() >= 2);
for chunk in &chunks {
- assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
+ assert!(utf16_len(chunk) <= TELEGRAM_MAX_MESSAGE_LEN);
// Every char should be a complete emoji
assert!(chunk.chars().all(|c| c == '\u{1F600}'));
}
}
+ #[test]
+ fn test_split_message_exact_utf16_limit_for_surrogate_pairs() {
+ let emoji = "\u{1F600}"; // 😀
+ let text = emoji.repeat(TELEGRAM_MAX_MESSAGE_LEN);
+
+ let chunks = split_message(&text);
+
+ assert_eq!(chunks.len(), 2);
+ assert!(chunks
+ .iter()
+ .all(|chunk| utf16_len(chunk) <= TELEGRAM_MAX_MESSAGE_LEN));
+ }
+
#[test]
fn test_clean_message_text() {
// Without bot_username: strips any leading @mention
@@ -2599,6 +2751,33 @@ mod tests {
);
}
+ #[test]
+ fn test_webhook_mode_requires_explicit_enable() {
+ let config: TelegramConfig = serde_json::from_str(
+ r#"{
+ "tunnel_url": "https://example.ngrok.app",
+ "polling_enabled": false
+ }"#,
+ )
+ .unwrap();
+
+ assert!(!webhook_mode(&config));
+ }
+
+ #[test]
+ fn test_webhook_mode_enabled_with_tunnel() {
+ let config: TelegramConfig = serde_json::from_str(
+ r#"{
+ "tunnel_url": "https://example.ngrok.app",
+ "webhook_enabled": true,
+ "polling_enabled": false
+ }"#,
+ )
+ .unwrap();
+
+ assert!(webhook_mode(&config));
+ }
+
#[test]
fn test_classify_status_update_tool_result_ignored() {
let update = StatusUpdate {
@@ -2733,11 +2912,13 @@ mod tests {
assert_eq!(attachments[0].id, "large_id"); // Largest photo
assert_eq!(attachments[0].mime_type, "image/jpeg");
assert_eq!(attachments[0].size_bytes, Some(54321));
- assert!(attachments[0]
- .source_url
- .as_ref()
- .unwrap()
- .contains("large_id"));
+ assert!(
+ attachments[0]
+ .source_url
+ .as_ref()
+ .unwrap()
+ .contains("large_id")
+ );
}
#[test]
@@ -2969,4 +3150,38 @@ mod tests {
// Verify the constant is 20 MB, matching the Slack channel limit
assert_eq!(MAX_DOWNLOAD_SIZE_BYTES, 20 * 1024 * 1024);
}
+
+ #[test]
+ fn test_base_mime_type() {
+ assert_eq!(base_mime_type("audio/ogg"), "audio/ogg");
+ assert_eq!(base_mime_type("audio/ogg; codecs=opus"), "audio/ogg");
+ assert_eq!(base_mime_type("image/jpeg"), "image/jpeg");
+ assert_eq!(base_mime_type("text/plain; charset=utf-8"), "text/plain");
+ assert_eq!(base_mime_type(""), "");
+ }
+
+ #[test]
+ fn test_classify_attachment_routing() {
+ // Photos
+ assert_eq!(classify_attachment("image/jpeg"), AttachmentKind::Photo);
+ assert_eq!(classify_attachment("image/png"), AttachmentKind::Photo);
+ assert_eq!(classify_attachment("image/gif"), AttachmentKind::Photo);
+ assert_eq!(classify_attachment("image/webp"), AttachmentKind::Photo);
+
+ // Voice notes — exact and parameterized
+ assert_eq!(classify_attachment("audio/ogg"), AttachmentKind::Voice);
+ assert_eq!(classify_attachment("audio/opus"), AttachmentKind::Voice);
+ assert_eq!(
+ classify_attachment("audio/ogg; codecs=opus"),
+ AttachmentKind::Voice
+ );
+
+ // Everything else falls through to document
+ assert_eq!(
+ classify_attachment("application/pdf"),
+ AttachmentKind::Document
+ );
+ assert_eq!(classify_attachment("audio/mpeg"), AttachmentKind::Document);
+ assert_eq!(classify_attachment("video/mp4"), AttachmentKind::Document);
+ }
}
diff --git a/channels-src/telegram/telegram.capabilities.json b/channels-src/telegram/telegram.capabilities.json
index 1526762dedf..5fa8da5f340 100644
--- a/channels-src/telegram/telegram.capabilities.json
+++ b/channels-src/telegram/telegram.capabilities.json
@@ -18,6 +18,14 @@
"name": "telegram_bot_token",
"prompt": "Enter your Telegram Bot API token (from @BotFather)",
"optional": false
+ },
+ {
+ "name": "telegram_webhook_secret",
+ "prompt": "Webhook secret (leave empty to auto-generate)",
+ "optional": true,
+ "auto_generate": {
+ "length": 64
+ }
}
],
"setup_url": "https://t.me/BotFather",
@@ -64,6 +72,7 @@
"bot_username": null,
"owner_id": null,
"respond_to_all_group_messages": false,
+ "webhook_enabled": false,
"polling_enabled": false,
"poll_interval_ms": 30000,
"dm_policy": "pairing",
diff --git a/channels-src/whatsapp/Cargo.lock b/channels-src/whatsapp/Cargo.lock
index 0e55d1e5324..adefa9aa3b2 100644
--- a/channels-src/whatsapp/Cargo.lock
+++ b/channels-src/whatsapp/Cargo.lock
@@ -269,7 +269,7 @@ dependencies = [
[[package]]
name = "whatsapp-channel"
-version = "0.1.0"
+version = "0.2.0"
dependencies = [
"serde",
"serde_json",
diff --git a/channels-src/whatsapp/src/lib.rs b/channels-src/whatsapp/src/lib.rs
index c69a9b9f90b..e77b1146f08 100644
--- a/channels-src/whatsapp/src/lib.rs
+++ b/channels-src/whatsapp/src/lib.rs
@@ -872,9 +872,7 @@ fn check_sender_permission(
sender_phone, result.code
),
);
- if result.created {
- let _ = send_pairing_reply(sender_phone, phone_number_id, &result.code);
- }
+ let _ = send_pairing_reply(sender_phone, phone_number_id, &result.code);
}
Err(e) => {
channel_host::log(
@@ -910,8 +908,8 @@ fn send_pairing_reply(
"text": {
"preview_url": false,
"body": format!(
- "To pair with this bot, run: ironclaw pairing approve whatsapp {}",
- code
+ "Enter this code in IronClaw to pair your whatsapp account: {}. CLI fallback: ironclaw pairing approve whatsapp {}",
+ code, code
)
}
});
diff --git a/crates/ironclaw_common/CHANGELOG.md b/crates/ironclaw_common/CHANGELOG.md
new file mode 100644
index 00000000000..370d5845022
--- /dev/null
+++ b/crates/ironclaw_common/CHANGELOG.md
@@ -0,0 +1,24 @@
+# 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.2.0](https://github.com/nearai/ironclaw/compare/ironclaw_common-v0.1.0...ironclaw_common-v0.2.0) - 2026-04-11
+
+### Added
+
+- *(tui)* port full-featured Ratatui terminal UI onto staging ([#1973](https://github.com/nearai/ironclaw/pull/1973))
+- *(engine)* Unified Thread-Capability-CodeAct execution engine (v2 architecture) ([#1557](https://github.com/nearai/ironclaw/pull/1557))
+- *(jobs)* per-job MCP server filtering and max_iterations cap ([#1243](https://github.com/nearai/ironclaw/pull/1243))
+
+### Fixed
+
+- *(engine)* mission cron scheduling + timezone propagation ([#1944](https://github.com/nearai/ironclaw/pull/1944)) ([#1957](https://github.com/nearai/ironclaw/pull/1957))
+
+### Other
+
+- Improve channel onboarding and Telegram pairing flow ([#2103](https://github.com/nearai/ironclaw/pull/2103))
diff --git a/crates/ironclaw_common/Cargo.toml b/crates/ironclaw_common/Cargo.toml
new file mode 100644
index 00000000000..641fdffbccf
--- /dev/null
+++ b/crates/ironclaw_common/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "ironclaw_common"
+version = "0.2.0"
+edition = "2024"
+rust-version = "1.92"
+description = "Shared types and utilities for the IronClaw workspace"
+authors = ["NEAR AI "]
+license = "MIT OR Apache-2.0"
+homepage = "https://github.com/nearai/ironclaw"
+repository = "https://github.com/nearai/ironclaw"
+
+[package.metadata.dist]
+dist = false
+
+[dependencies]
+chrono-tz = "0.10"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+tracing = "0.1"
diff --git a/crates/ironclaw_common/src/event.rs b/crates/ironclaw_common/src/event.rs
new file mode 100644
index 00000000000..2a591b46d10
--- /dev/null
+++ b/crates/ironclaw_common/src/event.rs
@@ -0,0 +1,560 @@
+//! Application-wide event types.
+//!
+//! `AppEvent` is the real-time event protocol used across the entire
+//! application. The web gateway serialises these to SSE / WebSocket
+//! frames, but other subsystems (agent loop, orchestrator, extensions)
+//! produce and consume them too.
+
+use serde::{Deserialize, Serialize};
+
+/// A single step in a plan progress update (SSE DTO).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PlanStepDto {
+ pub index: usize,
+ pub title: String,
+ /// One of: "pending", "in_progress", "completed", "failed".
+ pub status: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub result: Option,
+}
+
+/// A single tool decision in a reasoning update (SSE DTO).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ToolDecisionDto {
+ pub tool_name: String,
+ pub rationale: String,
+}
+
+impl ToolDecisionDto {
+ /// Parse a list of tool decisions from a JSON array value.
+ pub fn from_json_array(value: &serde_json::Value) -> Vec {
+ value
+ .as_array()
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|d| {
+ Some(Self {
+ tool_name: d.get("tool_name")?.as_str()?.to_string(),
+ rationale: d.get("rationale")?.as_str()?.to_string(),
+ })
+ })
+ .collect()
+ })
+ .unwrap_or_default()
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "type")]
+pub enum AppEvent {
+ #[serde(rename = "response")]
+ Response { content: String, thread_id: String },
+ #[serde(rename = "thinking")]
+ Thinking {
+ message: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+ #[serde(rename = "tool_started")]
+ ToolStarted {
+ name: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ detail: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+ #[serde(rename = "tool_completed")]
+ ToolCompleted {
+ name: String,
+ success: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ error: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ parameters: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+ #[serde(rename = "tool_result")]
+ ToolResult {
+ name: String,
+ preview: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+ #[serde(rename = "stream_chunk")]
+ StreamChunk {
+ content: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+ #[serde(rename = "status")]
+ Status {
+ message: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+ #[serde(rename = "job_started")]
+ JobStarted {
+ job_id: String,
+ title: String,
+ browse_url: String,
+ },
+ #[serde(rename = "approval_needed")]
+ ApprovalNeeded {
+ request_id: String,
+ tool_name: String,
+ description: String,
+ parameters: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ /// Whether the "always" auto-approve option should be shown.
+ allow_always: bool,
+ },
+ #[serde(rename = "auth_required")]
+ AuthRequired {
+ extension_name: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ instructions: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ auth_url: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ setup_url: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+ #[serde(rename = "auth_completed")]
+ AuthCompleted {
+ extension_name: String,
+ success: bool,
+ message: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+ #[serde(rename = "pairing_required")]
+ PairingRequired {
+ channel: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ instructions: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ onboarding: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+ #[serde(rename = "pairing_completed")]
+ PairingCompleted {
+ channel: String,
+ success: bool,
+ message: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+ #[serde(rename = "gate_required")]
+ GateRequired {
+ request_id: String,
+ gate_name: String,
+ tool_name: String,
+ description: String,
+ parameters: String,
+ resume_kind: serde_json::Value,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+ #[serde(rename = "gate_resolved")]
+ GateResolved {
+ request_id: String,
+ gate_name: String,
+ tool_name: String,
+ resolution: String,
+ message: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+ #[serde(rename = "error")]
+ Error {
+ message: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+ #[serde(rename = "heartbeat")]
+ Heartbeat,
+
+ // Sandbox job streaming events (worker + Claude Code bridge)
+ #[serde(rename = "job_message")]
+ JobMessage {
+ job_id: String,
+ role: String,
+ content: String,
+ },
+ #[serde(rename = "job_tool_use")]
+ JobToolUse {
+ job_id: String,
+ tool_name: String,
+ input: serde_json::Value,
+ },
+ #[serde(rename = "job_tool_result")]
+ JobToolResult {
+ job_id: String,
+ tool_name: String,
+ output: String,
+ },
+ #[serde(rename = "job_status")]
+ JobStatus { job_id: String, message: String },
+ #[serde(rename = "job_result")]
+ JobResult {
+ job_id: String,
+ status: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ session_id: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ fallback_deliverable: Option,
+ },
+
+ /// An image was generated by a tool.
+ #[serde(rename = "image_generated")]
+ ImageGenerated {
+ event_id: String,
+ data_url: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ path: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+
+ /// Suggested follow-up messages for the user.
+ #[serde(rename = "suggestions")]
+ Suggestions {
+ suggestions: Vec,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+
+ /// Per-turn token usage and cost summary.
+ #[serde(rename = "turn_cost")]
+ TurnCost {
+ input_tokens: u64,
+ output_tokens: u64,
+ cost_usd: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+
+ /// Skills activated for a conversation turn.
+ #[serde(rename = "skill_activated")]
+ SkillActivated {
+ skill_names: Vec,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+
+ /// Extension activation status change (WASM channels).
+ #[serde(rename = "extension_status")]
+ ExtensionStatus {
+ extension_name: String,
+ status: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ message: Option,
+ },
+
+ /// Agent reasoning update (why it chose specific tools).
+ #[serde(rename = "reasoning_update")]
+ ReasoningUpdate {
+ narrative: String,
+ decisions: Vec,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+
+ /// Reasoning update for a sandbox job.
+ #[serde(rename = "job_reasoning")]
+ JobReasoning {
+ job_id: String,
+ narrative: String,
+ decisions: Vec,
+ },
+
+ // ── Engine v2 thread lifecycle events ──
+ /// Engine thread changed state (e.g. Running → Completed).
+ #[serde(rename = "thread_state_changed")]
+ ThreadStateChanged {
+ thread_id: String,
+ from_state: String,
+ to_state: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ reason: Option,
+ },
+
+ /// A child thread was spawned by a parent thread.
+ #[serde(rename = "child_thread_spawned")]
+ ChildThreadSpawned {
+ parent_thread_id: String,
+ child_thread_id: String,
+ goal: String,
+ },
+
+ /// A mission spawned a new thread.
+ #[serde(rename = "mission_thread_spawned")]
+ MissionThreadSpawned {
+ mission_id: String,
+ thread_id: String,
+ mission_name: String,
+ },
+
+ /// Plan progress update — full checklist snapshot.
+ ///
+ /// Emitted when a plan is created, approved, or when any step changes
+ /// status. The UI replaces the entire step list on each event.
+ #[serde(rename = "plan_update")]
+ PlanUpdate {
+ /// Plan identifier (MemoryDoc ID or slug).
+ plan_id: String,
+ /// Plan title.
+ title: String,
+ /// Overall status: "draft", "approved", "executing", "completed", "failed".
+ status: String,
+ /// Full step checklist (not incremental — UI replaces entire list).
+ steps: Vec,
+ /// Associated mission ID (once approved and executing).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ mission_id: Option,
+ /// Thread scope for SSE filtering.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+}
+
+impl AppEvent {
+ /// The wire-format event type string (matches the `#[serde(rename)]` value).
+ pub fn event_type(&self) -> &'static str {
+ match self {
+ Self::Response { .. } => "response",
+ Self::Thinking { .. } => "thinking",
+ Self::ToolStarted { .. } => "tool_started",
+ Self::ToolCompleted { .. } => "tool_completed",
+ Self::ToolResult { .. } => "tool_result",
+ Self::StreamChunk { .. } => "stream_chunk",
+ Self::Status { .. } => "status",
+ Self::JobStarted { .. } => "job_started",
+ Self::ApprovalNeeded { .. } => "approval_needed",
+ Self::AuthRequired { .. } => "auth_required",
+ Self::AuthCompleted { .. } => "auth_completed",
+ Self::PairingRequired { .. } => "pairing_required",
+ Self::PairingCompleted { .. } => "pairing_completed",
+ Self::GateRequired { .. } => "gate_required",
+ Self::GateResolved { .. } => "gate_resolved",
+ Self::Error { .. } => "error",
+ Self::Heartbeat => "heartbeat",
+ Self::JobMessage { .. } => "job_message",
+ Self::JobToolUse { .. } => "job_tool_use",
+ Self::JobToolResult { .. } => "job_tool_result",
+ Self::JobStatus { .. } => "job_status",
+ Self::JobResult { .. } => "job_result",
+ Self::ImageGenerated { .. } => "image_generated",
+ Self::Suggestions { .. } => "suggestions",
+ Self::TurnCost { .. } => "turn_cost",
+ Self::SkillActivated { .. } => "skill_activated",
+ Self::ExtensionStatus { .. } => "extension_status",
+ Self::ReasoningUpdate { .. } => "reasoning_update",
+ Self::JobReasoning { .. } => "job_reasoning",
+ Self::ThreadStateChanged { .. } => "thread_state_changed",
+ Self::ChildThreadSpawned { .. } => "child_thread_spawned",
+ Self::MissionThreadSpawned { .. } => "mission_thread_spawned",
+ Self::PlanUpdate { .. } => "plan_update",
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Verify that `event_type()` returns the same string as the serde
+ /// `"type"` field for every variant. This catches drift between the
+ /// `#[serde(rename)]` attributes and the manual match arms.
+ #[test]
+ fn event_type_matches_serde_type_field() {
+ let variants: Vec = vec![
+ AppEvent::Response {
+ content: String::new(),
+ thread_id: String::new(),
+ },
+ AppEvent::Thinking {
+ message: String::new(),
+ thread_id: None,
+ },
+ AppEvent::ToolStarted {
+ name: String::new(),
+ detail: None,
+ thread_id: None,
+ },
+ AppEvent::ToolCompleted {
+ name: String::new(),
+ success: true,
+ error: None,
+ parameters: None,
+ thread_id: None,
+ },
+ AppEvent::ToolResult {
+ name: String::new(),
+ preview: String::new(),
+ thread_id: None,
+ },
+ AppEvent::StreamChunk {
+ content: String::new(),
+ thread_id: None,
+ },
+ AppEvent::Status {
+ message: String::new(),
+ thread_id: None,
+ },
+ AppEvent::JobStarted {
+ job_id: String::new(),
+ title: String::new(),
+ browse_url: String::new(),
+ },
+ AppEvent::ApprovalNeeded {
+ request_id: String::new(),
+ tool_name: String::new(),
+ description: String::new(),
+ parameters: String::new(),
+ thread_id: None,
+ allow_always: false,
+ },
+ AppEvent::AuthRequired {
+ extension_name: String::new(),
+ instructions: None,
+ auth_url: None,
+ setup_url: None,
+ thread_id: None,
+ },
+ AppEvent::AuthCompleted {
+ extension_name: String::new(),
+ success: true,
+ message: String::new(),
+ thread_id: None,
+ },
+ AppEvent::PairingRequired {
+ channel: String::new(),
+ instructions: None,
+ onboarding: None,
+ thread_id: None,
+ },
+ AppEvent::PairingCompleted {
+ channel: String::new(),
+ success: true,
+ message: String::new(),
+ thread_id: None,
+ },
+ AppEvent::Error {
+ message: String::new(),
+ thread_id: None,
+ },
+ AppEvent::Heartbeat,
+ AppEvent::JobMessage {
+ job_id: String::new(),
+ role: String::new(),
+ content: String::new(),
+ },
+ AppEvent::JobToolUse {
+ job_id: String::new(),
+ tool_name: String::new(),
+ input: serde_json::Value::Null,
+ },
+ AppEvent::JobToolResult {
+ job_id: String::new(),
+ tool_name: String::new(),
+ output: String::new(),
+ },
+ AppEvent::JobStatus {
+ job_id: String::new(),
+ message: String::new(),
+ },
+ AppEvent::JobResult {
+ job_id: String::new(),
+ status: String::new(),
+ session_id: None,
+ fallback_deliverable: None,
+ },
+ AppEvent::ImageGenerated {
+ event_id: String::new(),
+ data_url: String::new(),
+ path: None,
+ thread_id: None,
+ },
+ AppEvent::Suggestions {
+ suggestions: vec![],
+ thread_id: None,
+ },
+ AppEvent::TurnCost {
+ input_tokens: 0,
+ output_tokens: 0,
+ cost_usd: String::new(),
+ thread_id: None,
+ },
+ AppEvent::SkillActivated {
+ skill_names: vec![],
+ thread_id: None,
+ },
+ AppEvent::ExtensionStatus {
+ extension_name: String::new(),
+ status: String::new(),
+ message: None,
+ },
+ AppEvent::ReasoningUpdate {
+ narrative: String::new(),
+ decisions: vec![],
+ thread_id: None,
+ },
+ AppEvent::JobReasoning {
+ job_id: String::new(),
+ narrative: String::new(),
+ decisions: vec![],
+ },
+ AppEvent::ThreadStateChanged {
+ thread_id: String::new(),
+ from_state: String::new(),
+ to_state: String::new(),
+ reason: None,
+ },
+ AppEvent::ChildThreadSpawned {
+ parent_thread_id: String::new(),
+ child_thread_id: String::new(),
+ goal: String::new(),
+ },
+ AppEvent::MissionThreadSpawned {
+ mission_id: String::new(),
+ thread_id: String::new(),
+ mission_name: String::new(),
+ },
+ AppEvent::PlanUpdate {
+ plan_id: String::new(),
+ title: String::new(),
+ status: String::new(),
+ steps: vec![],
+ mission_id: None,
+ thread_id: None,
+ },
+ ];
+
+ for variant in &variants {
+ let json: serde_json::Value = serde_json::to_value(variant).unwrap();
+ let serde_type = json["type"].as_str().unwrap();
+ assert_eq!(
+ variant.event_type(),
+ serde_type,
+ "event_type() mismatch for variant: {:?}",
+ variant
+ );
+ }
+ }
+
+ #[test]
+ fn round_trip_deserialize() {
+ let original = AppEvent::Response {
+ content: "hello".to_string(),
+ thread_id: "t1".to_string(),
+ };
+ let json = serde_json::to_string(&original).unwrap();
+ let deserialized: AppEvent = serde_json::from_str(&json).unwrap();
+ assert_eq!(deserialized.event_type(), "response");
+ }
+}
diff --git a/crates/ironclaw_common/src/lib.rs b/crates/ironclaw_common/src/lib.rs
new file mode 100644
index 00000000000..852374dfd4f
--- /dev/null
+++ b/crates/ironclaw_common/src/lib.rs
@@ -0,0 +1,14 @@
+//! Shared types and utilities for the IronClaw workspace.
+
+mod event;
+mod timezone;
+mod util;
+
+pub use event::{AppEvent, PlanStepDto, ToolDecisionDto};
+pub use timezone::{ValidTimezone, deserialize_option_lenient};
+pub use util::truncate_preview;
+
+/// Maximum worker agent loop iterations. Used by the orchestrator (server-side
+/// clamp in `create_job_inner`) and the worker runtime (`worker/job.rs`).
+/// A single source of truth prevents the two from drifting.
+pub const MAX_WORKER_ITERATIONS: u32 = 500;
diff --git a/crates/ironclaw_common/src/timezone.rs b/crates/ironclaw_common/src/timezone.rs
new file mode 100644
index 00000000000..4eea97421af
--- /dev/null
+++ b/crates/ironclaw_common/src/timezone.rs
@@ -0,0 +1,166 @@
+//! Validated IANA timezone type.
+
+use serde::{Deserialize, Serialize};
+
+/// A validated IANA timezone.
+///
+/// Wraps `chrono_tz::Tz` and guarantees the timezone string was valid at
+/// construction time. Use `ValidTimezone::parse()` to create — it returns
+/// `None` for empty or unrecognized timezone strings.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ValidTimezone(chrono_tz::Tz);
+
+impl ValidTimezone {
+ /// Parse an IANA timezone string. Returns `None` for empty or invalid input.
+ pub fn parse(s: &str) -> Option {
+ let trimmed = s.trim();
+ if trimmed.is_empty() {
+ return None;
+ }
+ trimmed.parse::().ok().map(Self)
+ }
+
+ /// The underlying `chrono_tz::Tz` value.
+ pub fn tz(&self) -> chrono_tz::Tz {
+ self.0
+ }
+
+ /// The IANA name (e.g. "America/New_York").
+ pub fn name(&self) -> &str {
+ self.0.name()
+ }
+}
+
+impl std::fmt::Display for ValidTimezone {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(self.name())
+ }
+}
+
+impl Serialize for ValidTimezone {
+ fn serialize(&self, serializer: S) -> Result {
+ self.name().serialize(serializer)
+ }
+}
+
+impl<'de> Deserialize<'de> for ValidTimezone {
+ fn deserialize>(deserializer: D) -> Result {
+ let s = String::deserialize(deserializer)?;
+ Self::parse(&s)
+ .ok_or_else(|| serde::de::Error::custom(format!("invalid IANA timezone: '{s}'")))
+ }
+}
+
+/// Lenient deserializer for `Option`.
+///
+/// Use with `#[serde(default, deserialize_with = "...")]` on fields that may
+/// contain invalid timezone strings from historical data. Invalid or empty
+/// values deserialize as `None` instead of failing the whole record. Each
+/// drop is logged at `debug!` so a typo in fresh user config is at least
+/// observable in the logs even though the record loads.
+pub fn deserialize_option_lenient<'de, D: serde::Deserializer<'de>>(
+ deserializer: D,
+) -> Result, D::Error> {
+ let opt: Option = Option::deserialize(deserializer)?;
+ match opt {
+ Some(s) => match ValidTimezone::parse(&s) {
+ Some(tz) => Ok(Some(tz)),
+ None => {
+ tracing::debug!(
+ raw = %s,
+ "lenient deserializer dropped invalid IANA timezone string to None"
+ );
+ Ok(None)
+ }
+ },
+ None => Ok(None),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parse_valid_timezone() {
+ let tz = ValidTimezone::parse("America/New_York").unwrap();
+ assert_eq!(tz.name(), "America/New_York");
+ }
+
+ #[test]
+ fn parse_with_whitespace() {
+ let tz = ValidTimezone::parse(" Europe/London ").unwrap();
+ assert_eq!(tz.name(), "Europe/London");
+ }
+
+ #[test]
+ fn parse_empty_returns_none() {
+ assert!(ValidTimezone::parse("").is_none());
+ assert!(ValidTimezone::parse(" ").is_none());
+ }
+
+ #[test]
+ fn parse_invalid_returns_none() {
+ assert!(ValidTimezone::parse("NotATimezone").is_none());
+ assert!(ValidTimezone::parse("US/FakeCity").is_none());
+ }
+
+ #[test]
+ fn serde_roundtrip() {
+ let tz = ValidTimezone::parse("Asia/Tokyo").unwrap();
+ let json = serde_json::to_string(&tz).unwrap();
+ assert_eq!(json, "\"Asia/Tokyo\"");
+ let back: ValidTimezone = serde_json::from_str(&json).unwrap();
+ assert_eq!(back, tz);
+ }
+
+ #[test]
+ fn deserialize_invalid_fails() {
+ let result: Result = serde_json::from_str("\"NotReal\"");
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn lenient_deserialize_valid() {
+ #[derive(serde::Deserialize)]
+ struct T {
+ #[serde(default, deserialize_with = "super::deserialize_option_lenient")]
+ tz: Option,
+ }
+ let t: T = serde_json::from_str(r#"{"tz":"America/Chicago"}"#).unwrap();
+ assert_eq!(t.tz.unwrap().name(), "America/Chicago");
+ }
+
+ #[test]
+ fn lenient_deserialize_invalid_becomes_none() {
+ #[derive(serde::Deserialize)]
+ struct T {
+ #[serde(default, deserialize_with = "super::deserialize_option_lenient")]
+ tz: Option,
+ }
+ let t: T = serde_json::from_str(r#"{"tz":"NotReal"}"#).unwrap();
+ assert!(t.tz.is_none(), "invalid timezone should become None");
+ }
+
+ #[test]
+ fn lenient_deserialize_null_becomes_none() {
+ #[derive(serde::Deserialize)]
+ struct T {
+ #[serde(default, deserialize_with = "super::deserialize_option_lenient")]
+ tz: Option,
+ }
+ let t: T = serde_json::from_str(r#"{"tz":null}"#).unwrap();
+ assert!(t.tz.is_none());
+ }
+
+ #[test]
+ fn lenient_deserialize_missing_becomes_none() {
+ #[derive(serde::Deserialize)]
+ struct T {
+ #[serde(default, deserialize_with = "super::deserialize_option_lenient")]
+ tz: Option,
+ }
+ let t: T = serde_json::from_str(r#"{}"#).unwrap();
+ assert!(t.tz.is_none());
+ }
+}
diff --git a/crates/ironclaw_common/src/util.rs b/crates/ironclaw_common/src/util.rs
new file mode 100644
index 00000000000..4f054671d08
--- /dev/null
+++ b/crates/ironclaw_common/src/util.rs
@@ -0,0 +1,100 @@
+//! Shared utility functions.
+
+/// Truncate a string to at most `max_bytes` bytes at a char boundary, appending "...".
+///
+/// If the input is wrapped in `... ` and truncation
+/// removes the closing tag, the tag is re-appended so downstream XML parsers
+/// never see an unclosed element.
+pub fn truncate_preview(s: &str, max_bytes: usize) -> String {
+ if s.len() <= max_bytes {
+ return s.to_string();
+ }
+ // Walk backwards from max_bytes to find a valid char boundary
+ let mut end = max_bytes;
+ while end > 0 && !s.is_char_boundary(end) {
+ end -= 1;
+ }
+ let mut result = format!("{}...", &s[..end]);
+
+ // Re-close if truncation cut through the closing tag.
+ if s.starts_with("") {
+ result.push_str("\n ");
+ }
+
+ result
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_truncate_preview_short_string() {
+ assert_eq!(truncate_preview("hello", 10), "hello");
+ }
+
+ #[test]
+ fn test_truncate_preview_exact_boundary() {
+ assert_eq!(truncate_preview("hello", 5), "hello");
+ }
+
+ #[test]
+ fn test_truncate_preview_truncates_ascii() {
+ assert_eq!(truncate_preview("hello world", 5), "hello...");
+ }
+
+ #[test]
+ fn test_truncate_preview_empty_string() {
+ assert_eq!(truncate_preview("", 10), "");
+ }
+
+ #[test]
+ fn test_truncate_preview_multibyte_char_boundary() {
+ let s = "a\u{20AC}b";
+ let result = truncate_preview(s, 3);
+ assert_eq!(result, "a...");
+ }
+
+ #[test]
+ fn test_truncate_preview_emoji() {
+ let s = "hi\u{1F980}";
+ let result = truncate_preview(s, 4);
+ assert_eq!(result, "hi...");
+ }
+
+ #[test]
+ fn test_truncate_preview_cjk() {
+ let s = "\u{4F60}\u{597D}\u{4E16}\u{754C}";
+ let result = truncate_preview(s, 7);
+ assert_eq!(result, "\u{4F60}\u{597D}...");
+ }
+
+ #[test]
+ fn test_truncate_preview_zero_max_bytes() {
+ assert_eq!(truncate_preview("hello", 0), "...");
+ }
+
+ #[test]
+ fn test_truncate_preview_closes_tool_output_tag() {
+ let s = "\nSome very long content here\n ";
+ let result = truncate_preview(s, 60);
+ assert!(result.ends_with(" "));
+ assert!(result.contains("..."));
+ }
+
+ #[test]
+ fn test_truncate_preview_no_extra_close_when_intact() {
+ let s = "\nshort\n ";
+ let result = truncate_preview(s, 500);
+ assert_eq!(result, s);
+ assert_eq!(result.matches("").count(), 1);
+ }
+
+ #[test]
+ fn test_truncate_preview_non_xml_unaffected() {
+ let s = "Just a plain long string that gets truncated";
+ let result = truncate_preview(s, 10);
+ assert_eq!(result, "Just a pla...");
+ assert!(!result.contains(""));
+ }
+}
diff --git a/crates/ironclaw_engine/CLAUDE.md b/crates/ironclaw_engine/CLAUDE.md
new file mode 100644
index 00000000000..0ea1b6a1fbc
--- /dev/null
+++ b/crates/ironclaw_engine/CLAUDE.md
@@ -0,0 +1,181 @@
+# IronClaw Engine Crate
+
+Unified thread-capability-CodeAct execution model. Replaces ~10 separate abstractions (Session, Job, Routine, Channel, Tool, Skill, Hook, Observer, Extension, LoopDelegate) with 5 primitives.
+
+## Full Architecture Plan
+
+See `docs/plans/2026-03-20-engine-v2-architecture.md` for the 8-phase roadmap.
+
+## Five Primitives
+
+| Primitive | Purpose | Replaces |
+|-----------|---------|----------|
+| **Thread** | Unit of work with lifecycle, parent-child tree, capability leases | Session + Job + Routine + Sub-agent |
+| **Step** | Unit of execution (one LLM call + its action executions) | Agentic loop iteration + tool calls |
+| **Capability** | Unit of effect (actions + knowledge + policies) | Tool + Skill + Hook + Extension |
+| **MemoryDoc** | Unit of durable knowledge (summaries, lessons, skills) | Workspace memory blobs |
+| **Project** | Unit of context (scopes memory, threads, missions) | Flat workspace namespace |
+
+## Build & Test
+
+```bash
+cargo check -p ironclaw_engine
+cargo clippy -p ironclaw_engine --all-targets -- -D warnings
+cargo test -p ironclaw_engine
+```
+
+## Module Map
+
+```
+src/
+├── lib.rs # Public API, re-exports
+├── types/ # Core data structures (no async, no I/O)
+│ ├── thread.rs # Thread, ThreadId, ThreadState (state machine), ThreadType, ThreadConfig
+│ ├── step.rs # Step, StepId, LlmResponse, ActionCall, ActionResult, TokenUsage
+│ ├── capability.rs # Capability, ActionDef, EffectType, CapabilityLease, PolicyRule
+│ ├── memory.rs # MemoryDoc, DocId, DocType (Summary/Lesson/Skill/Issue/Spec/Note)
+│ ├── project.rs # Project, ProjectId
+│ ├── event.rs # ThreadEvent, EventKind (18 variants for event sourcing)
+│ ├── message.rs # ThreadMessage, MessageRole
+│ ├── provenance.rs # Provenance enum (User/System/ToolOutput/LlmGenerated/etc.)
+│ ├── conversation.rs # ConversationSurface, ConversationEntry, EntrySender
+│ ├── mission.rs # Mission, MissionId, MissionCadence, MissionStatus
+│ └── error.rs # EngineError, ThreadError, StepError, CapabilityError
+├── traits/ # External dependency abstractions (host implements these)
+│ ├── llm.rs # LlmBackend trait
+│ ├── store.rs # Store trait (20 CRUD methods)
+│ └── effect.rs # EffectExecutor trait
+├── capability/ # Capability management
+│ ├── registry.rs # CapabilityRegistry — register/get/list capabilities
+│ ├── lease.rs # LeaseManager — grant/check/consume/revoke/expire leases
+│ └── policy.rs # PolicyEngine — deterministic effect-level allow/deny/approve + provenance taint
+├── runtime/ # Thread lifecycle management
+│ ├── manager.rs # ThreadManager — spawn, stop, inject messages, join threads
+│ ├── conversation.rs # ConversationManager — routes UI messages to threads
+│ ├── mission.rs # MissionManager — long-running goals that spawn threads on cadence
+│ ├── tree.rs # ThreadTree — parent-child relationships
+│ └── messaging.rs # ThreadSignal, ThreadOutcome, signal channels
+├── executor/ # Step execution
+│ ├── loop_engine.rs # ExecutionLoop — core loop replacing run_agentic_loop()
+│ ├── structured.rs # Tier 0: structured tool call execution
+│ ├── scripting.rs # Tier 1: embedded Python via Monty (CodeAct/RLM)
+│ ├── context.rs # Context builder (messages + actions from leases + memory docs)
+│ ├── compaction.rs # Context compaction when approaching model context limit
+│ ├── prompt.rs # System prompt construction (CodeAct preamble/postamble)
+│ └── trace.rs # Execution trace recording and retrospective analysis
+├── memory/ # Memory document system
+│ ├── store.rs # MemoryStore — project-scoped doc CRUD
+│ ├── retrieval.rs # RetrievalEngine — keyword-based context retrieval from project docs
+│ └── skill_tracker.rs # SkillTracker — confidence tracking, versioned updates, rollback
+└── reliability.rs # ReliabilityTracker — per-action success rate and latency via EMA
+```
+
+## Thread State Machine
+
+```
+Created → Running → Waiting → Running (resume)
+ → Suspended → Running (resume)
+ → Completed → Done
+ → Failed
+```
+
+Validated by `ThreadState::can_transition_to()`. Terminal states: `Done`, `Failed`.
+
+## Learning Missions
+
+Four event-driven missions fire automatically after thread completion:
+
+1. **Error diagnosis** (`self-improvement`) — fires when a thread completes with trace issues. Diagnoses root cause and applies prompt overlays or orchestrator patches.
+2. **Skill repair** (`skill-repair`) — fires when a completed thread used an active skill but the trace suggests the skill instructions were stale, incomplete, or missing verification. Applies the smallest safe versioned update to the implicated skill.
+3. **Skill extraction** (`skill-extraction`) — fires when a thread succeeds with 5+ steps and 3+ tool actions. Extracts reusable skills with activation metadata, CodeAct code snippets, and domain tags. Output stored as `DocType::Skill` MemoryDoc.
+4. **Conversation insights** (`conversation-insights`) — fires every 5 completed threads in a project. Extracts user preferences, domain knowledge, and workflow patterns.
+
+Created by `MissionManager::ensure_learning_missions()` at project bootstrap.
+
+## Data Retention: Never Delete LLM Output
+
+Thread messages, steps, and events are **never deleted** from the database. This data (context fed to the model, reasoning, tool calls, results) is the most valuable information in the system. The `Store` implementation uses in-memory HashMaps as a cache backed by the database (via Workspace). "Cleanup" of terminal threads means evicting from in-memory caches to bound RAM — the database rows always stay. `load_thread()`, `load_steps()`, and `load_events()` must fall back to the database on a cache miss.
+
+## External Trait Boundaries
+
+The engine defines three traits that the host crate implements:
+
+| Trait | Purpose | Host wraps |
+|-------|---------|------------|
+| `LlmBackend` | `complete(messages, actions, config) -> LlmOutput` | `LlmProvider` |
+| `Store` | Thread/Step/Event/Project/Doc/Lease CRUD | `Database` (PostgreSQL + libSQL) |
+| `EffectExecutor` | `execute_action(name, params, lease, ctx) -> ActionResult` | `ToolRegistry` + `SafetyLayer` |
+
+## Execution Loop
+
+`ExecutionLoop::run()` handles three `LlmResponse` variants:
+
+1. Check signals (Stop, InjectMessage) via `mpsc::Receiver`
+2. Build context (messages + available actions from active leases)
+3. Call LLM via `LlmBackend::complete()`
+4. **If `Text`**: check tool intent nudge, return if final response
+5. **If `ActionCalls`** (Tier 0): for each call, find lease → check policy → consume use → execute via `EffectExecutor` → record result
+6. **If `Code`** (Tier 1): execute Python via Monty with context-as-variables and `llm_query()` support → compact metadata in context
+7. Record Step, emit ThreadEvents
+8. Repeat until: text response, stop signal, max iterations, or approval needed
+
+## CodeAct / Monty Integration (Tier 1)
+
+Python execution via Monty interpreter (`executor/scripting.rs`). Follows the RLM (Recursive Language Model) pattern.
+
+**Context as variables** (not attention input):
+- Thread messages injected as `context` Python variable
+- Thread goal as `goal`, step index as `step_number`
+- Prior action results as `previous_results` dict
+- The LLM's chat context stays lean; full data lives in REPL variables
+
+**Tool dispatch**: Unknown function calls suspend the VM → lease check → policy check → `EffectExecutor` → result returned to Python.
+
+**`llm_query(prompt, context)`**: Recursive subagent call. Suspends VM → spawns single-shot LLM call → returns text result as Python string. Results stay as variables (symbolic composition), not injected into parent's attention window.
+
+**Compact output metadata**: Between code steps, only a summary is added to chat context (`"[code output] stdout (4532 chars): The results show..."`) — not the full output. This prevents context bloat across iterations.
+
+**Resource limits**: 30s timeout, 64MB memory, 1M allocations. All execution wrapped in `catch_unwind` for Monty panic safety.
+
+## Capability Leases
+
+Threads don't have static permissions. They receive **leases** — scoped, time-limited, use-limited grants:
+
+```rust
+CapabilityLease {
+ thread_id, capability_name, granted_actions,
+ expires_at: Option, // time-limited
+ max_uses: Option, // use-limited
+ revoked: bool,
+}
+```
+
+The `PolicyEngine` evaluates actions against leases deterministically: `Deny > RequireApproval > Allow`.
+
+## Effect Types
+
+Every action declares its side effects. The policy engine uses these for allow/deny:
+
+```
+ReadLocal, ReadExternal, WriteLocal, WriteExternal,
+CredentialedNetwork, Compute, Financial
+```
+
+## Key Design Decisions
+
+1. **No dependency on main `ironclaw` crate** — clean separation, testable in isolation
+2. **No safety logic** — sanitization/leak detection is applied at the adapter boundary (`EffectExecutor` impl)
+3. **Event sourcing from day one** — every thread records a complete event log via `ThreadEvent`
+4. **Tier 0 + Tier 1** — structured tool calls (Tier 0) and embedded Python via Monty (Tier 1, CodeAct)
+5. **Engine owns its message type** — `ThreadMessage` is simpler than `ChatMessage`; bridge adapters handle conversion
+6. **RLM pattern** — context as variable (not attention input), recursive `llm_query()`, compact output metadata between steps
+
+## Code Style
+
+Follows the main crate's conventions from `/CLAUDE.md`:
+- No `.unwrap()` or `.expect()` in production code (tests are fine)
+- `thiserror` for error types
+- Map errors with context
+- Prefer strong types over strings (newtypes for IDs)
+- All I/O is async with tokio
+- `Arc` for shared state, `RwLock` for concurrent access
diff --git a/crates/ironclaw_engine/Cargo.toml b/crates/ironclaw_engine/Cargo.toml
new file mode 100644
index 00000000000..5c701c41511
--- /dev/null
+++ b/crates/ironclaw_engine/Cargo.toml
@@ -0,0 +1,34 @@
+[package]
+name = "ironclaw_engine"
+version = "0.1.0"
+edition = "2024"
+rust-version = "1.92"
+description = "Unified thread-capability-CodeAct execution engine for IronClaw"
+authors = ["NEAR AI "]
+license = "MIT OR Apache-2.0"
+homepage = "https://github.com/nearai/ironclaw"
+repository = "https://github.com/nearai/ironclaw"
+publish = false
+
+[package.metadata.dist]
+dist = false
+
+[dependencies]
+async-trait = "0.1"
+cron = "0.13"
+ironclaw_common = { path = "../ironclaw_common", version = "0.2.0" }
+ironclaw_skills = { path = "../ironclaw_skills", version = "0.1.0", default-features = false }
+chrono = { version = "0.4", features = ["serde"] }
+monty = { git = "https://github.com/pydantic/monty.git", tag = "v0.0.11" }
+regex = "1"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+thiserror = "2"
+tokio = { version = "1", features = ["sync", "time", "macros", "rt"] }
+tracing = "0.1"
+uuid = { version = "1", features = ["v4", "serde"] }
+sha2 = "0.10"
+
+[dev-dependencies]
+pretty_assertions = "1"
+tokio = { version = "1", features = ["full", "test-util"] }
diff --git a/crates/ironclaw_engine/MONTY.md b/crates/ironclaw_engine/MONTY.md
new file mode 100644
index 00000000000..e4e318710ed
--- /dev/null
+++ b/crates/ironclaw_engine/MONTY.md
@@ -0,0 +1,67 @@
+# Monty Integration
+
+Monty is the embedded Python interpreter used for Tier 1 (CodeAct) execution. It's a lightweight Rust-native Python implementation — not CPython — so it has a restricted feature set.
+
+**Source**: `git = "https://github.com/pydantic/monty.git", tag = "v0.0.11"`
+**Pinned at**: `v0.0.11` (2026-04-10)
+
+## Upgrade Process
+
+1. **Update the pin**: `cargo update -p monty`
+2. **Check for new features**: `cd ~/.cargo/git/checkouts/monty-*/*/` and `git log --oneline` since last pin
+3. **Update the preamble**: If a previously-unsupported feature now works, remove it from the "Runtime environment" section in `prompts/codeact_preamble.md`
+4. **Update this file**: Record the new pin and what changed
+5. **Run tests**: `cargo test -p ironclaw_engine`
+6. **Watch traces**: After deploying, check traces for new `NotImplementedError` patterns (self-improvement mission catches these)
+
+## Current Limitations (as of pin `v0.0.11`)
+
+These are documented in `prompts/codeact_preamble.md` so the LLM avoids them:
+
+### Syntax not supported
+| Feature | Workaround |
+|---------|-----------|
+| `class Foo:` | Use functions and dicts (host-provided dataclasses work) |
+| `with` statements | Use try/finally or direct calls |
+| `match` statements | Use if/elif chains |
+| `del` statement | Reassign to None |
+| `yield` / `yield from` statements | Generator expressions (`x for x in ...`) work; use lists for the rest |
+| Type aliases (`type X = ...`) | Omit type annotations |
+| Template strings (t-strings) | Use f-strings |
+| Complex number literals | Use floats |
+| Exception groups (`try*/except*`) | Use regular try/except |
+
+### Limited standard library
+`import csv`, `import io`, etc. still fail.
+
+`import os` succeeds but all operations (`os.getenv()`, `Path.*`) are **blocked** by the executor — `OSError: OS operations are not permitted in CodeAct scripts`. This is intentional: agents must use injected tools (`shell`, `read_file`, etc.) instead.
+
+Available built-in modules:
+- `asyncio` — `asyncio.gather()` for parallel execution
+- `datetime` — date and time handling
+- `json` — JSON encoding/decoding
+- `math` — standard math functions
+- `os.path` — path string manipulation only (no I/O)
+- `re` — regex (basic)
+- `sys` — system info (limited)
+- `typing` — type hints (limited, for annotation only)
+
+### Available builtins
+`abs`, `all`, `any`, `bin`, `chr`, `divmod`, `enumerate`, `filter`, `getattr`, `hash`, `hex`, `id`, `isinstance`, `len`, `map`, `min`, `max`, `next`, `oct`, `ord`, `pow`, `print`, `repr`, `reversed`, `round`, `sorted`, `sum`, `type`, `zip`
+
+### Host-provided functions (always available)
+These are injected by the IronClaw executor, not by Monty:
+- `FINAL(answer)` / `FINAL_VAR(name)` — terminate with result
+- `llm_query(prompt, context)` — recursive LLM sub-call
+- `llm_query_batched(prompts)` — parallel sub-calls
+- `rlm_query(prompt)` — full sub-agent with tools
+- `globals()` / `locals()` — returns dict of known tool names
+- All tool functions (web_search, http, time, etc.)
+
+## Upgrade Changelog
+
+| Date | Pin | Notable changes |
+|------|-----|-----------------|
+| 2026-04-10 | `v0.0.11` | JSON perf improvements (~2x loads, ~1.6x dumps), filesystem mounting, Rust-side async API, mount edge case fixes. |
+| 2026-03-29 | `7a0d4b7` | Multi-module imports, `datetime` module, `json` module, nested subscript assignment, `str.expandtabs()`. |
+| 2026-03-20 | `6053820` | Initial integration. max() kwargs support. |
diff --git a/crates/ironclaw_engine/orchestrator/default.py b/crates/ironclaw_engine/orchestrator/default.py
new file mode 100644
index 00000000000..df799ede618
--- /dev/null
+++ b/crates/ironclaw_engine/orchestrator/default.py
@@ -0,0 +1,902 @@
+# Engine v2 Orchestrator (default, v0)
+#
+# This is the self-modifiable execution loop. It replaces the Rust
+# ExecutionLoop::run() with Python that can be patched at runtime
+# by the self-improvement Mission.
+#
+# Host functions (provided by Rust via Monty suspension):
+# __llm_complete__(messages, actions, config) -> response dict
+# __execute_code_step__(code, state) -> result dict
+# __execute_action__(name, params) -> result dict
+# __execute_actions_parallel__(calls) -> list of result dicts (parallel execution)
+# __check_signals__() -> None | "stop" | {"inject": msg}
+# __emit_event__(kind, **data) -> None
+# __save_checkpoint__(state, counters) -> None
+# __transition_to__(state, reason) -> None
+# __retrieve_docs__(goal, max_docs) -> list of doc dicts
+# __check_budget__() -> budget dict
+# __get_actions__() -> list of action dicts
+# __list_skills__() -> list of skill dicts
+# __record_skill_usage__(doc_id, success) -> None
+# __regex_match__(pattern, text) -> bool
+#
+# Context variables (injected by Rust before execution):
+# context - list of prior messages [{role, content}]
+# goal - thread goal string
+# actions - list of available action defs
+# state - persisted state dict from prior steps
+# config - thread config dict
+
+
+# ── Helper functions (self-modifiable glue) ──────────────────
+# Defined before run_loop so they are in scope when called.
+
+
+def extract_final(text):
+ """Extract FINAL() content from text. Returns None if not found."""
+ idx = text.find("FINAL(")
+ if idx < 0:
+ return None
+ after = text[idx + 6:]
+ # Handle triple-quoted strings
+ for q in ['"""', "'''"]:
+ if after.startswith(q):
+ end = after.find(q, len(q))
+ if end >= 0:
+ return after[len(q):end]
+ # Handle single/double quoted strings
+ if after and after[0] in ('"', "'"):
+ quote = after[0]
+ end = after.find(quote, 1)
+ if end >= 0:
+ return after[1:end]
+ # Handle balanced parens
+ depth = 1
+ for i, ch in enumerate(after):
+ if ch == "(":
+ depth += 1
+ elif ch == ")":
+ depth -= 1
+ if depth == 0:
+ return after[:i]
+ return None
+
+
+def strip_quoted_strings(line):
+ """Remove double-quoted string literals from a line."""
+ result = []
+ in_quote = False
+ prev = ""
+ for ch in line:
+ if ch == '"' and prev != "\\":
+ in_quote = not in_quote
+ prev = ch
+ continue
+ if not in_quote:
+ result.append(ch)
+ prev = ch
+ return "".join(result)
+
+
+def strip_code_blocks(text):
+ """Strip fenced code blocks, indented code lines, and double-quoted strings."""
+ result = []
+ in_fence = False
+ for line in text.split("\n"):
+ trimmed = line.lstrip()
+ if trimmed.startswith("```"):
+ in_fence = not in_fence
+ continue
+ if in_fence:
+ continue
+ if line.startswith(" ") or line.startswith("\t"):
+ continue
+ result.append(strip_quoted_strings(line))
+ return "\n".join(result)
+
+
+def signals_tool_intent(text):
+ """Detect when text expresses intent to call a tool without actually doing so.
+
+ Ported from V1 Rust llm_signals_tool_intent(): strips code blocks and
+ quoted strings, checks exclusion phrases, then requires a future-tense
+ prefix ("let me", "I'll", "I will", "I'm going to") immediately followed
+ by an action verb ("search", "fetch", "check", etc.).
+ """
+ stripped = strip_code_blocks(text)
+ lower = stripped.lower()
+
+ EXCLUSIONS = [
+ "let me explain", "let me know", "let me think",
+ "let me summarize", "let me clarify", "let me describe",
+ "let me help", "let me understand", "let me break",
+ "let me outline", "let me walk you", "let me provide",
+ "let me suggest", "let me elaborate", "let me start by",
+ ]
+ for exc in EXCLUSIONS:
+ if exc in lower:
+ return False
+
+ PREFIXES = ["let me ", "i'll ", "i will ", "i'm going to "]
+ ACTION_VERBS = [
+ "search", "look up", "check", "fetch", "find",
+ "read the", "write the", "create", "run the", "execute",
+ "query", "retrieve", "add it", "add the", "add this",
+ "add that", "update the", "delete", "remove the", "look into",
+ ]
+
+ for prefix in PREFIXES:
+ start = 0
+ while True:
+ i = lower.find(prefix, start)
+ if i < 0:
+ break
+ after = lower[i + len(prefix):]
+ for verb in ACTION_VERBS:
+ if after.startswith(verb) or (" " + verb) in after.split("\n")[0]:
+ return True
+ start = i + 1
+
+ return False
+
+
+def format_output(result, max_chars=8000):
+ """Format code execution result for the next LLM context message."""
+ parts = []
+
+ stdout = result.get("stdout", "")
+ if stdout:
+ parts.append("[stdout]\n" + stdout)
+
+ for r in result.get("action_results", []):
+ name = r.get("action_name", "?")
+ output = str(r.get("output", ""))
+ if r.get("is_error"):
+ parts.append("[" + name + " ERROR] " + output)
+ else:
+ preview = output[:500] + "..." if len(output) > 500 else output
+ parts.append("[" + name + "] " + preview)
+
+ ret = result.get("return_value")
+ if ret is not None:
+ parts.append("[return] " + str(ret))
+
+ text = "\n\n".join(parts)
+
+ # Truncate from the front (keep the tail with most recent results)
+ if len(text) > max_chars:
+ text = "... (truncated) ...\n" + text[-max_chars:]
+
+ if not text:
+ text = "[code executed, no output]"
+
+ return text
+
+
+def format_docs(docs):
+ """Format memory docs for context injection."""
+ parts = ["## Prior Knowledge (from completed threads)\n"]
+ for doc in docs:
+ label = doc.get("type", "NOTE").upper()
+ content = doc.get("content", "")[:500]
+ truncated = "..." if len(doc.get("content", "")) > 500 else ""
+ parts.append("### [" + label + "] " + doc.get("title", "") +
+ "\n" + content + truncated + "\n")
+ return "\n".join(parts)
+
+
+# Conservative fallback heuristic matching the old Rust-side estimator.
+# These MUST be defined before `estimate_context_tokens` (and therefore
+# before the `FINAL(result)` entry-point call below). Moving them after the
+# entry point is a latent NameError every time `compact_if_needed` runs.
+CHARS_PER_TOKEN = 4
+MESSAGE_OVERHEAD_CHARS = 4
+
+
+def estimate_context_tokens(messages):
+ """Estimate token count for a transcript using a rough chars/token heuristic."""
+ total_chars = 0
+ for msg in messages:
+ total_chars += len(msg.get("content", ""))
+ total_chars += len(msg.get("action_name", "") or "")
+ total_chars += MESSAGE_OVERHEAD_CHARS
+ return (total_chars + CHARS_PER_TOKEN - 1) // CHARS_PER_TOKEN
+
+
+def compact_if_needed(state, config):
+ """Compact thread context when the active message history grows too large.
+
+ The orchestrator owns compaction policy. Rust only provides helpers for
+ token estimation, explicit LLM calls, and replacing the active message
+ scaffold after a summary has been produced.
+ """
+ if not config.get("enable_compaction", False):
+ return False
+
+ context_limit = config.get("model_context_limit", 128000)
+ threshold_pct = config.get("compaction_threshold", 0.85)
+ threshold = int(context_limit * threshold_pct)
+ working_messages = state.get("working_messages")
+ if not isinstance(working_messages, list) or not working_messages:
+ return False
+
+ current_tokens = estimate_context_tokens(working_messages)
+ if current_tokens < threshold:
+ return False
+
+ snapshot = list(working_messages)
+
+ history = state.get("history")
+ if not isinstance(history, list):
+ history = []
+ state["history"] = history
+
+ compaction_count = state.get("compaction_count", 0) + 1
+ history.append({
+ "kind": "compaction",
+ "index": compaction_count,
+ "tokens_before": current_tokens,
+ "messages": snapshot,
+ })
+
+ summary_prompt = (
+ "Summarize progress so far in a concise but complete way.\n"
+ "Include:\n"
+ "1. What has been accomplished\n"
+ "2. Key intermediate results, facts, and variable values\n"
+ "3. Tool results or findings worth preserving\n"
+ "4. What still needs to be done\n"
+ "5. Errors encountered and how they were handled\n\n"
+ "Preserve all information needed to continue the task."
+ )
+ summary_messages = list(snapshot)
+ summary_messages.append({"role": "User", "content": summary_prompt})
+ summary_resp = __llm_complete__(summary_messages, None, {"force_text": True})
+
+ summary_text = summary_resp.get("content", "")
+ if not summary_text:
+ summary_text = "[compaction produced no summary]"
+
+ state["working_messages"] = []
+ system_message = None
+ for msg in snapshot:
+ if msg.get("role") == "System":
+ system_message = {"role": "System", "content": msg.get("content", "")}
+ break
+ if system_message is not None:
+ state["working_messages"].append(system_message)
+ append_message(state["working_messages"], "Assistant", summary_text)
+ append_message(
+ state["working_messages"],
+ "User",
+ "Your conversation has been compacted. The summary above captures prior progress. "
+ "Older details remain available through state['history'] and project retrieval. Continue working on the task.",
+ )
+ state["compaction_count"] = compaction_count
+ return True
+
+
+# ── Skill selection and injection (self-modifiable) ────────
+
+
+def score_skill(skill, message_lower, message_original):
+ """Score a skill against a user message. Returns 0 if vetoed.
+
+ Scoring is aligned with the v1 `ironclaw_skills::selector::score_skill`:
+ - exclude_keyword veto: any match => score 0
+ - keyword: exact word = 10, substring = 5 (cap 30)
+ - tag: substring = 3 (cap 15)
+ - regex pattern: each match = 20 (cap 40)
+ """
+ meta = skill.get("metadata", {})
+ activation = meta.get("activation", {})
+
+ # Exclude keyword veto
+ for excl in activation.get("exclude_keywords", []):
+ if excl.lower() in message_lower:
+ return 0
+
+ score = 0
+
+ # Keyword scoring: exact word = 10, substring = 5 (cap 30)
+ kw_score = 0
+ words = []
+ for word in message_lower.split():
+ trimmed = word.strip(".,!?;:'\"()[]{}<>`~@#$%^&*-_=+/\\|")
+ if trimmed:
+ words.append(trimmed)
+ for kw in activation.get("keywords", []):
+ kw_lower = kw.lower()
+ if kw_lower in words:
+ kw_score += 10
+ elif kw_lower in message_lower:
+ kw_score += 5
+ score += min(kw_score, 30)
+
+ # Tag scoring: substring = 3 (cap 15)
+ tag_score = 0
+ for tag in activation.get("tags", []):
+ if tag.lower() in message_lower:
+ tag_score += 3
+ score += min(tag_score, 15)
+
+ # Regex pattern scoring: each match = 20 (cap 40). Monty has no `re`
+ # module, so we call out to a host function that uses Rust's regex crate.
+ rx_score = 0
+ for pat in activation.get("patterns", []):
+ if __regex_match__(str(pat), message_original):
+ rx_score += 20
+ score += min(rx_score, 40)
+
+ # Confidence factor for extracted skills
+ source = meta.get("source", "authored")
+ if source == "extracted":
+ metrics = meta.get("metrics", {})
+ total = metrics.get("success_count", 0) + metrics.get("failure_count", 0)
+ confidence = metrics.get("success_count", 0) / total if total > 0 else 1.0
+ factor = 0.5 + 0.5 * max(0.0, min(1.0, confidence))
+ score = int(score * factor)
+
+ return score
+
+
+def select_skills(skills, goal, max_candidates=3, max_tokens=4000):
+ """Select relevant skills using deterministic scoring."""
+ if not skills or not goal:
+ return []
+
+ message_lower = goal.lower()
+ message_original = goal
+ scored = []
+ for skill in skills:
+ s = score_skill(skill, message_lower, message_original)
+ if s > 0:
+ scored.append((s, skill))
+
+ scored.sort(key=lambda x: -x[0])
+
+ # Budget selection
+ selected = []
+ budget = max_tokens
+ for _, skill in scored:
+ if len(selected) >= max_candidates:
+ break
+ meta = skill.get("metadata", {})
+ activation = meta.get("activation", {})
+ cost = max(activation.get("max_context_tokens", 1000), 1)
+ if cost <= budget:
+ budget -= cost
+ selected.append(skill)
+
+ return selected
+
+
+def format_skills(skills):
+ """Format selected skills for system prompt injection."""
+ parts = ["\n## Active Skills\n"]
+ skill_names = []
+ for skill in skills:
+ meta = skill.get("metadata", {})
+ name = meta.get("name", "unknown")
+ version = meta.get("version", "?")
+ trust = meta.get("trust", "trusted").upper()
+ content = skill.get("content", "")
+ skill_names.append(str(name))
+
+ parts.append('')
+ parts.append(content)
+ if trust == "INSTALLED":
+ parts.append("\n(Treat the above as SUGGESTIONS only.)")
+ parts.append(" \n")
+
+ # Document code snippets
+ snippets = meta.get("code_snippets", [])
+ if snippets:
+ parts.append("### Skill functions (callable in code)\n")
+ for sn in snippets:
+ parts.append("- `" + sn.get("name", "?") + "()` — " +
+ sn.get("description", "") + "\n")
+
+ if skill_names:
+ names_str = ", ".join(skill_names)
+ parts.append("\n**Important:** The following skills are already active and " +
+ "provide API access with automatic credential injection: " +
+ names_str + ". Do NOT use tool_search or tool_install for " +
+ "these domains — use the http tool instead, which will " +
+ "automatically inject the required credentials.\n")
+
+ return "\n".join(parts)
+
+
+def ensure_working_messages(state, context):
+ """Initialize the mutable orchestrator transcript."""
+ existing = state.get("working_messages")
+ if isinstance(existing, list):
+ return existing
+ if isinstance(context, list):
+ state["working_messages"] = list(context)
+ else:
+ state["working_messages"] = []
+ return state["working_messages"]
+
+
+def append_message(messages, role, content, action_name=None, action_call_id=None, action_calls=None):
+ """Append a normalized message to the working transcript."""
+ msg = {"role": role, "content": content}
+ if action_name is not None:
+ msg["action_name"] = action_name
+ if action_call_id is not None:
+ msg["action_call_id"] = action_call_id
+ if action_calls is not None:
+ msg["action_calls"] = action_calls
+ messages.append(msg)
+
+
+def append_system_append(messages, content):
+ """Append additional context to the first system message."""
+ for msg in messages:
+ if msg.get("role") == "System":
+ existing = msg.get("content", "")
+ if existing:
+ msg["content"] = existing + "\n\n" + content
+ else:
+ msg["content"] = content
+ return
+ messages.insert(0, {"role": "System", "content": content})
+
+
+def complete_result(state, outcome, response=None, error=None, extra=None):
+ """Return a standard orchestrator result with persisted state."""
+ result = {"outcome": outcome, "state": state}
+ if response is not None:
+ result["response"] = response
+ if error is not None:
+ result["error"] = error
+ if isinstance(extra, dict):
+ for key in extra:
+ result[key] = extra[key]
+ return result
+
+
+# ── Main execution loop ─────────────────────────────────────
+
+
+def run_loop(context, goal, actions, state, config):
+ """Main execution loop. Returns an outcome dict."""
+ max_iterations = config.get("max_iterations", 30)
+ max_nudges = config.get("max_tool_intent_nudges", 2)
+ nudge_enabled = config.get("enable_tool_intent_nudge", True)
+ # None means "no limit" — callers can disable the guard explicitly.
+ max_consecutive_errors = config.get("max_consecutive_errors", 5)
+ consecutive_nudges = 0
+ consecutive_errors = 0
+ consecutive_action_errors = 0
+ step_count = config.get("step_count", 0)
+ if not isinstance(state, dict):
+ state = {}
+ state.setdefault("history", [])
+ state.setdefault("compaction_count", 0)
+ working_messages = ensure_working_messages(state, context)
+
+ for step in range(step_count, max_iterations):
+ # 1. Check signals
+ signal = __check_signals__()
+ if signal == "stop":
+ __transition_to__("completed", "stopped by signal")
+ return complete_result(state, "stopped")
+ if signal and isinstance(signal, dict) and "inject" in signal:
+ append_message(working_messages, "User", signal["inject"])
+
+ # 2. Check budget
+ budget = __check_budget__()
+ if budget.get("tokens_remaining", 1) <= 0:
+ __transition_to__("completed", "token budget exhausted")
+ return complete_result(state, "completed", "Token budget exhausted.")
+ if budget.get("time_remaining_ms", 1) <= 0:
+ __transition_to__("completed", "time budget exhausted")
+ return complete_result(state, "completed", "Time budget exhausted.")
+ if budget.get("usd_remaining") is not None and budget["usd_remaining"] <= 0:
+ __transition_to__("completed", "cost budget exhausted")
+ return complete_result(state, "completed", "Cost budget exhausted.")
+
+ # 3. Inject prior knowledge and activate skills on first step
+ if step == 0:
+ docs = __retrieve_docs__(goal, 5)
+ if docs:
+ knowledge = format_docs(docs)
+ append_system_append(working_messages, knowledge)
+
+ # Select and inject skills based on goal keywords
+ all_skills = __list_skills__()
+ active_skills = select_skills(all_skills, goal, max_candidates=3, max_tokens=4000)
+ if active_skills:
+ __set_active_skills__([
+ {
+ "doc_id": s.get("doc_id", ""),
+ "name": s.get("metadata", {}).get("name", "?"),
+ "version": s.get("metadata", {}).get("version", 1),
+ "snippet_names": [
+ sn.get("name", "")
+ for sn in s.get("metadata", {}).get("code_snippets", [])
+ if sn.get("name")
+ ],
+ "force_activated": False,
+ }
+ for s in active_skills
+ ])
+ skill_text = format_skills(active_skills)
+ append_system_append(working_messages, skill_text)
+ # Emit skill activation event for CLI/gateway display
+ skill_names = ",".join(s.get("metadata", {}).get("name", "?") for s in active_skills)
+ __emit_event__("skill_activated", skill_names=skill_names)
+ # Store active skill IDs in state for tracking
+ state["active_skill_ids"] = [s.get("doc_id", "") for s in active_skills]
+ state["skill_snippet_names"] = []
+ for s in active_skills:
+ for sn in s.get("metadata", {}).get("code_snippets", []):
+ state["skill_snippet_names"].append(sn.get("name", ""))
+
+ # 3.5 Compact context before the next model call when needed.
+ compact_if_needed(state, config)
+ working_messages = ensure_working_messages(state, context)
+
+ # 4. Call LLM
+ __emit_event__("step_started", step=step)
+ response = __llm_complete__(working_messages, actions, None)
+ __emit_event__("step_completed", step=step,
+ input_tokens=response.get("usage", {}).get("input_tokens", 0),
+ output_tokens=response.get("usage", {}).get("output_tokens", 0))
+
+ # 5. Handle response based on type
+ resp_type = response.get("type", "text")
+
+ if resp_type == "text":
+ text = response.get("content", "")
+ append_message(working_messages, "Assistant", text)
+
+ # Check for FINAL()
+ final_answer = extract_final(text)
+ if final_answer is not None:
+ __transition_to__("completed", "FINAL() in text")
+ return complete_result(state, "completed", final_answer)
+
+ # Check for tool intent nudge (V1 semantics: consecutive counter,
+ # only resets on non-intent text, NOT on action/code responses)
+ if nudge_enabled and consecutive_nudges < max_nudges and signals_tool_intent(text):
+ consecutive_nudges += 1
+ append_message(
+ working_messages,
+ "User",
+ "You said you would perform an action, but you did not include any tool calls.\n"
+ "Do NOT describe what you intend to do — actually call the tool now.\n"
+ "Use the tool_calls mechanism to invoke the appropriate tool.",
+ )
+ continue
+
+ # Non-intent text response — reset nudge counter and finish
+ if not signals_tool_intent(text):
+ consecutive_nudges = 0
+
+ # Plain text response - done
+ __transition_to__("completed", "text response")
+ return complete_result(state, "completed", text)
+
+ elif resp_type == "code":
+ code = response.get("code", "")
+ append_message(working_messages, "Assistant", "```repl\n" + code + "\n```")
+
+ # Execute code in nested Monty VM
+ result = __execute_code_step__(code, state)
+
+ # Update persisted state with results
+ if result.get("return_value") is not None:
+ state["step_" + str(step) + "_return"] = result["return_value"]
+ state["last_return"] = result["return_value"]
+ for r in result.get("action_results", []):
+ state[r.get("action_name", "unknown")] = r.get("output")
+
+ # Format output for next LLM context
+ output = format_output(result)
+ append_message(working_messages, "User", output)
+
+ # Check for FINAL() in code output
+ if result.get("final_answer") is not None:
+ __transition_to__("completed", "FINAL() in code")
+ return complete_result(state, "completed", result["final_answer"])
+
+ # Check for unified gate pause (new path)
+ gate = result.get("pending_gate")
+ if gate is None:
+ gate = result.get("need_approval")
+ if gate is not None and isinstance(gate, dict) and gate.get("gate_paused"):
+ __save_checkpoint__(state, {
+ "nudge_count": consecutive_nudges,
+ "consecutive_errors": consecutive_errors,
+ "consecutive_action_errors": consecutive_action_errors,
+ "compaction_count": state.get("compaction_count", 0),
+ })
+ __transition_to__("waiting", "gate paused: " + gate.get("gate_name", "unknown"))
+ return {
+ "outcome": "gate_paused",
+ "state": state,
+ "gate_name": gate.get("gate_name", ""),
+ "action_name": gate.get("action_name", ""),
+ "call_id": gate.get("call_id", ""),
+ "parameters": gate.get("parameters", {}),
+ "resume_kind": gate.get("resume_kind", {}),
+ }
+
+ # Check for approval or authentication needed (legacy path)
+ if result.get("need_approval") is not None:
+ approval = result["need_approval"]
+ __save_checkpoint__(state, {
+ "nudge_count": consecutive_nudges,
+ "consecutive_errors": consecutive_errors,
+ "consecutive_action_errors": consecutive_action_errors,
+ "compaction_count": state.get("compaction_count", 0),
+ })
+ if approval.get("need_authentication"):
+ __transition_to__("waiting", "authentication needed")
+ return {
+ "outcome": "need_authentication",
+ "state": state,
+ "credential_name": approval.get("credential_name", ""),
+ "action_name": approval.get("action_name", ""),
+ "call_id": approval.get("call_id", ""),
+ "parameters": approval.get("parameters", {}),
+ }
+ __transition_to__("waiting", "approval needed")
+ return {
+ "outcome": "need_approval",
+ "state": state,
+ "action_name": approval.get("action_name", ""),
+ "call_id": approval.get("call_id", ""),
+ "parameters": approval.get("parameters", {}),
+ }
+
+ # Track consecutive errors
+ if result.get("had_error"):
+ consecutive_errors += 1
+ if max_consecutive_errors is not None and consecutive_errors >= max_consecutive_errors:
+ __transition_to__("failed", "too many consecutive errors")
+ return complete_result(
+ state,
+ "failed",
+ error=str(max_consecutive_errors) + " consecutive code errors",
+ )
+ else:
+ consecutive_errors = 0
+
+ __save_checkpoint__(state, {
+ "nudge_count": consecutive_nudges,
+ "consecutive_errors": consecutive_errors,
+ "consecutive_action_errors": consecutive_action_errors,
+ "compaction_count": state.get("compaction_count", 0),
+ })
+
+ elif resp_type == "actions":
+ # Tier 0: structured tool calls.
+ # NOTE: consecutive_nudges is NOT reset here (V1 semantics).
+ # Only non-intent text responses reset the counter.
+ calls = response.get("calls", [])
+
+ # Handle FINAL emitted as a structured tool call. FINAL is a
+ # CodeAct sentinel for completion — when the LLM tries to call
+ # it via tool_calls instead of inside a code block, the engine's
+ # action executor has no lease for it and the call fails. If FINAL
+ # is co-emitted with other calls, execute the non-FINAL calls first
+ # so persistence side effects are not silently dropped.
+ final_call = None
+ duplicate_finals_dropped = 0
+ executable_calls = []
+ for c in calls:
+ if c.get("name", "") == "FINAL":
+ # First FINAL wins; any extras are dropped (not appended
+ # to executable_calls) so they don't try to run as a
+ # normal action and fail with a lease error.
+ if final_call is None:
+ final_call = c
+ else:
+ duplicate_finals_dropped += 1
+ continue
+ executable_calls.append(c)
+
+ if duplicate_finals_dropped > 0:
+ # Surface the drop so traces show why fewer FINALs were
+ # executed than the LLM emitted.
+ __emit_event__(
+ "duplicate_final_dropped",
+ count=duplicate_finals_dropped,
+ )
+
+ # Append the assistant message with only the executable calls.
+ # FINAL is filtered out of `action_calls` so the message history
+ # does not record a FINAL action with no matching ActionResult,
+ # which would confuse context replay on resume.
+ append_message(
+ working_messages,
+ "Assistant",
+ response.get("content", "") or "",
+ action_calls=executable_calls,
+ )
+
+ # Execute all tool calls in parallel via the batch host function.
+ # Rust handles preflight (lease/policy), parallel execution via
+ # JoinSet, and event emission in call order.
+ results = __execute_actions_parallel__(executable_calls)
+ # Every tool call in the assistant message MUST have a matching
+ # ActionResult, otherwise the LLM API rejects the sequence with
+ # "No tool output found for function call ". Iterate over
+ # executable_calls (not results) so we cover calls that the Rust
+ # batch handler skipped (e.g. RequireApproval early return).
+ batch_error_count = 0
+ batch_success_count = 0
+ for idx in range(len(executable_calls)):
+ call = executable_calls[idx]
+ call_id = call.get("call_id", "")
+ r = results[idx] if idx < len(results) else None
+ if r is not None:
+ action_name = r.get("action_name", call.get("name", ""))
+ output = r.get("output")
+ output_str = str(output) if output is not None else "[no output]"
+ if r.get("is_error"):
+ output_str = "[ACTION FAILED] " + output_str
+ batch_error_count += 1
+ else:
+ batch_success_count += 1
+ else:
+ action_name = call.get("name", "unknown")
+ output_str = "[execution skipped]"
+ batch_error_count += 1
+ append_message(
+ working_messages,
+ "ActionResult",
+ output_str,
+ action_name=action_name,
+ action_call_id=call_id,
+ )
+
+ # Check results for auth/approval interrupts
+ for r_idx, r in enumerate(results):
+ if r is None:
+ continue
+
+ if r.get("gate_paused"):
+ # Unified gate pause (replaces separate need_approval/need_authentication)
+ __save_checkpoint__(state, {
+ "nudge_count": consecutive_nudges,
+ "consecutive_errors": consecutive_errors,
+ "consecutive_action_errors": consecutive_action_errors,
+ "compaction_count": state.get("compaction_count", 0),
+ })
+ gate = r
+ # Get action info from the original call or the result
+ orig_call = executable_calls[r_idx] if r_idx < len(executable_calls) else {}
+ __transition_to__("waiting", "gate paused: " + gate.get("gate_name", "unknown"))
+ return {
+ "outcome": "gate_paused",
+ "state": state,
+ "gate_name": gate.get("gate_name", ""),
+ "action_name": gate.get("action_name", orig_call.get("name", "")),
+ "call_id": orig_call.get("call_id", ""),
+ "parameters": orig_call.get("params", {}),
+ "resume_kind": gate.get("resume_kind", {}),
+ }
+
+ if r.get("need_authentication"):
+ __save_checkpoint__(state, {
+ "nudge_count": consecutive_nudges,
+ "consecutive_errors": consecutive_errors,
+ "consecutive_action_errors": consecutive_action_errors,
+ "compaction_count": state.get("compaction_count", 0),
+ })
+ __transition_to__("waiting", "authentication needed")
+ return {
+ "outcome": "need_authentication",
+ "state": state,
+ "credential_name": r.get("credential_name", ""),
+ "action_name": r.get("action_name", ""),
+ "call_id": r.get("call_id", ""),
+ "parameters": r.get("parameters", {}),
+ }
+
+ if r.get("need_approval"):
+ __save_checkpoint__(state, {
+ "nudge_count": consecutive_nudges,
+ "consecutive_errors": consecutive_errors,
+ "consecutive_action_errors": consecutive_action_errors,
+ "compaction_count": state.get("compaction_count", 0),
+ })
+ __transition_to__("waiting", "approval needed")
+ return {
+ "outcome": "need_approval",
+ "state": state,
+ "action_name": r.get("action_name", ""),
+ "call_id": r.get("call_id", ""),
+ "parameters": r.get("parameters", {}),
+ }
+
+ if final_call is not None:
+ raw_params = final_call.get("params", {})
+ # Some LLMs pass FINAL with the answer as a positional string
+ # argument instead of a named param dict. Handle that case so
+ # the answer is not silently dropped.
+ if isinstance(raw_params, str):
+ answer = raw_params
+ else:
+ params = raw_params or {}
+ answer = (
+ params.get("answer")
+ or params.get("result")
+ or params.get("value")
+ or params.get("content")
+ or params.get("text")
+ )
+ if not answer:
+ # Fall back to the assistant's content text. This may
+ # contain the model's full explanation rather than the
+ # intended terse answer — truncate aggressively so we
+ # don't ship thousands of tokens of reasoning as the
+ # final answer, and emit a trace event so the
+ # ambiguity is visible.
+ fallback_content = response.get("content", "") or ""
+ FINAL_FALLBACK_MAX_CHARS = 500
+ truncated = False
+ if len(fallback_content) > FINAL_FALLBACK_MAX_CHARS:
+ fallback_content = (
+ fallback_content[:FINAL_FALLBACK_MAX_CHARS]
+ + "… [truncated by orchestrator: FINAL was emitted with no recognizable answer param]"
+ )
+ truncated = True
+ answer = fallback_content
+ __emit_event__(
+ "final_fallback",
+ reason="no recognizable answer param on FINAL",
+ truncated=truncated,
+ original_length=len(response.get("content", "") or ""),
+ )
+ __transition_to__("completed", "FINAL via tool_calls")
+ return complete_result(state, "completed", str(answer))
+
+ # Track consecutive action errors (separate from code errors).
+ # Partial batch failures: increment only if ALL actions failed,
+ # reset if ANY succeeded.
+ if batch_success_count > 0:
+ consecutive_action_errors = 0
+ elif batch_error_count > 0:
+ consecutive_action_errors += 1
+
+ if max_consecutive_errors is not None and consecutive_action_errors > 0 and consecutive_action_errors >= max_consecutive_errors + 2:
+ __transition_to__("failed", "too many consecutive action errors")
+ return complete_result(
+ state,
+ "failed",
+ error=str(consecutive_action_errors) + " consecutive action errors — all recent tool calls failed",
+ )
+ elif max_consecutive_errors is not None and consecutive_action_errors > 0 and consecutive_action_errors >= max_consecutive_errors:
+ append_message(
+ working_messages,
+ "User",
+ "[SYSTEM] Your last " + str(consecutive_action_errors) +
+ " action calls have all failed. You appear to be stuck in a loop. "
+ "Try a completely different approach: use different tools, different "
+ "parameters, or break the problem down differently. If you cannot "
+ "make progress, call FINAL() with an honest explanation of what failed.",
+ )
+
+ __save_checkpoint__(state, {
+ "nudge_count": consecutive_nudges,
+ "consecutive_errors": consecutive_errors,
+ "consecutive_action_errors": consecutive_action_errors,
+ "compaction_count": state.get("compaction_count", 0),
+ })
+
+ # Max iterations reached
+ __transition_to__("completed", "max iterations reached")
+ return complete_result(state, "max_iterations")
+
+
+# Entry point: call run_loop with injected context variables
+result = run_loop(context, goal, actions, state, config)
+FINAL(result)
diff --git a/crates/ironclaw_engine/prompts/codeact_postamble.md b/crates/ironclaw_engine/prompts/codeact_postamble.md
new file mode 100644
index 00000000000..ea969e993ad
--- /dev/null
+++ b/crates/ironclaw_engine/prompts/codeact_postamble.md
@@ -0,0 +1,18 @@
+
+## Strategy
+
+1. First, examine the context and understand the task
+2. Break complex tasks into steps
+3. Use tools to gather information or take actions
+4. Use llm_query() to analyze or summarize large text
+5. Call FINAL() with the answer when done
+
+Think step by step. Execute code immediately — don't just describe what you would do.
+
+## Error recovery
+
+When a tool call fails, do NOT give up immediately. Try alternative approaches before calling FINAL():
+- If `http()` fails with an auth error, try `web_search()` or a different public endpoint
+- If one API endpoint fails, try a different one that provides similar data
+- If a search returns no results, try different keywords or broader queries
+- Only call FINAL() to report failure after exhausting at least 2-3 alternative approaches
diff --git a/crates/ironclaw_engine/prompts/codeact_preamble.md b/crates/ironclaw_engine/prompts/codeact_preamble.md
new file mode 100644
index 00000000000..ff995a2a370
--- /dev/null
+++ b/crates/ironclaw_engine/prompts/codeact_preamble.md
@@ -0,0 +1,76 @@
+You are an AI assistant with a Python REPL environment. You solve tasks by writing and executing Python code.
+
+## How to respond
+
+Write Python code inside ```repl fenced blocks. The code will be executed, and you'll see the output. All tool calls are async — use `await` to get results.
+
+```repl
+result = await web_search(query="latest AI news", count=5)
+print(result)
+```
+
+You can write multiple code blocks across turns. Variables persist between blocks within the same turn.
+
+## Parallel execution with asyncio.gather
+
+When you need results from multiple independent tools, use `asyncio.gather()` to run them concurrently:
+
+```repl
+import asyncio
+search, page, memories = await asyncio.gather(
+ web_search(query="rust async patterns"),
+ http(url="https://example.com/api"),
+ memory_search(query="prior work"),
+)
+print(search, page, memories)
+```
+
+This is much faster than calling tools sequentially. Use `asyncio.gather()` whenever tools don't depend on each other's results.
+
+## Special functions
+
+- `llm_query(prompt, context=None, model=None)` — Ask a sub-agent to analyze text or answer a question. Returns a string. Use for summarization, analysis, or any task that needs LLM reasoning on data. Optional `model="..."` overrides which LLM answers this single call (e.g. `model="gpt-4o"`).
+- `llm_query_batched(prompts, context=None, model=None, models=None)` — Same but for multiple prompts in parallel. Returns a list of strings. Pass `model="gpt-4o"` to apply one model to every prompt, or `models=["gpt-4o", "claude-sonnet-4-20250514", ...]` (parallel array, must match `prompts` length) to send each prompt to a different model. The "LLM council" pattern is `prompts=[same_question]*N, models=[m1, m2, ...]`.
+- `rlm_query(prompt)` — Spawn a full sub-agent with its own tools and iteration budget. Use for complex sub-tasks that need tool access. Returns the sub-agent's final answer as a string. More powerful but more expensive than llm_query.
+- `FINAL(answer)` — Call this when you have the final answer. The argument is returned to the user.
+- `mission_create(name, goal, cadence="manual", success_criteria=None)` — Create a long-running mission that spawns threads over time. Cadence: "manual", cron expression (e.g. "0 9 * * *"), "event:pattern", or "webhook:path". Cron expressions accept 5-field (`min hr dom mon dow`), 6-field (`sec min hr dom mon dow` — NOT Quartz-style with year), or 7-field (`sec min hr dom mon dow year`). Cron missions default to the user's timezone from `user_timezone`; pass an explicit `timezone` param to override. Returns {"mission_id": "...", "name": "...", "status": "created"}. When telling the user about a created mission, refer to it by `name`, not by `mission_id` (the UUID is internal).
+- `mission_list()` — List all missions with their status, goal, and current focus.
+- `mission_fire(id)` — Manually trigger a mission to spawn a thread now.
+- `mission_pause(id)` / `mission_resume(id)` — Pause or resume a mission.
+
+## Context variables
+
+- `context` — List of prior conversation messages (each is a dict with 'role' and 'content')
+- `goal` — The current task description
+- `step_number` — Current execution step
+- `state` — Dict of persisted data from previous steps. Contains tool results keyed by tool name (e.g. `state['web_search']`) and return values (`state['last_return']`, `state['step_0_return']`). Use this to access data from previous steps without re-calling tools.
+- `previous_results` — Dict of prior tool call results (from ActionResult messages)
+- `user_timezone` — The user's IANA timezone (e.g. "America/New_York", "Europe/London"). Defaults to "UTC". Use this for time-aware operations, scheduling, and cron timezone parameters.
+
+## Important rules
+
+1. ALWAYS respond with a ```repl code block. NEVER answer with plain text only. Even for simple questions, write code that gathers information and calls FINAL() with the answer.
+2. NEVER answer from memory or training data alone. Always use tools (web_search, llm_context, shell, read_file, etc.) to get real, current information before answering.
+3. When you have the final answer, call `FINAL(answer)` inside a code block. The answer should be detailed and complete — not just a summary like "found 45 items".
+4. All tool calls are async — always use `await` (e.g. `result = await web_search(...)`). For parallel calls, use `asyncio.gather()`.
+5. Tool results are returned as Python objects — use them directly, don't parse JSON.
+6. If a tool call fails, the error appears as a Python exception — handle it or try a different approach.
+7. For large data, process it in chunks using llm_query() on subsets rather than loading everything into context.
+8. Outputs are truncated to 8000 chars — use variables to store large intermediate results.
+9. Include the actual content in your FINAL() answer, not just a count or summary. Users want to see the details.
+
+## Runtime environment
+
+The Python REPL runs in Monty, a lightweight embedded interpreter — not CPython. Key differences:
+
+- **Async tools**: All tool calls return futures. Use `await tool(...)` for sequential or `asyncio.gather(tool1(...), tool2(...))` for parallel. Top-level `await` is supported (no need for `asyncio.run()`).
+- **Limited standard library**: `import csv`, `import io` etc. will fail with `ModuleNotFoundError`. `import os` loads but all operations raise `OSError` — use the provided tool functions for OS operations (`shell()`, `read_file()`).
+- **No classes**: `class Foo:` is not supported. Use functions and dicts instead (host-provided dataclasses work).
+- **No `with` statements**: Use try/finally or just call functions directly.
+- **No `match` statements**: Use if/elif chains.
+- **No `del` statement**: Reassign to None instead.
+- **No `yield`/`yield from` statements**: Generator expressions (`x for x in ...`) work; use lists for the rest.
+- **Available builtins**: `abs`, `all`, `any`, `bin`, `chr`, `divmod`, `enumerate`, `filter`, `getattr`, `hash`, `hex`, `id`, `isinstance`, `len`, `map`, `min`, `max`, `next`, `oct`, `ord`, `pow`, `print`, `repr`, `reversed`, `round`, `sorted`, `sum`, `type`, `zip`.
+- **Available modules**: `asyncio`, `datetime`, `json`, `math`, `os.path` (path manipulation only), `re`, `sys`, `typing` (limited).
+- **String methods, list methods, dict methods**: All work normally.
+- For dates, use `import datetime`. For JSON, use `import json` or work with dicts directly (tool results are already Python objects). For CSV parsing, split strings manually. For HTTP, use `await http()`.
diff --git a/crates/ironclaw_engine/prompts/mission_conversation_insights.md b/crates/ironclaw_engine/prompts/mission_conversation_insights.md
new file mode 100644
index 00000000000..ee5018f9818
--- /dev/null
+++ b/crates/ironclaw_engine/prompts/mission_conversation_insights.md
@@ -0,0 +1,38 @@
+You extract user preferences, patterns, and domain knowledge from a batch of recent conversation threads.
+
+## Input
+
+`state["trigger_payload"]` contains:
+- `project_id` — the project scope
+- `completed_thread_count` — total threads completed in this conversation
+- `thread_goals` — list of recent thread goals (what the user asked for)
+- `sample_user_messages` — sample of actual user messages (truncated to 200 chars)
+
+## Process
+
+1. Analyze the thread goals and user messages for patterns
+2. Search existing insights: `memory_search(query="user preferences")` and `memory_search(query="domain knowledge")`
+3. Extract NEW insights not already recorded in memory
+4. Write each insight to memory via `memory_write(target="memory", content=insight_text)` with title format "insight::"
+
+## Categories to look for
+
+- **Preferences**: communication style, format choices, tool preferences
+- **Domain**: project names, API patterns, data formats, technology stack
+- **Workflow**: recurring task sequences, common follow-up questions
+- **Corrections**: things the user corrected or repeated — these signal unmet expectations
+
+## Output (FINAL)
+
+Report:
+- Number of new insights extracted (0 is fine)
+- Brief list of what was found
+- Next focus
+
+## Rules
+
+- Only record actionable, specific insights — not vague observations
+- Do not record personal information, only work patterns
+- If no meaningful new insights after analysis, call FINAL("No new insights — conversation patterns already captured") immediately
+- Merge with existing insight docs rather than creating duplicates
+- Max 5 insights per run to keep quality high
diff --git a/crates/ironclaw_engine/prompts/mission_expected_behavior.md b/crates/ironclaw_engine/prompts/mission_expected_behavior.md
new file mode 100644
index 00000000000..875b52dc7fb
--- /dev/null
+++ b/crates/ironclaw_engine/prompts/mission_expected_behavior.md
@@ -0,0 +1,58 @@
+You investigate why IronClaw did not behave as the user expected. The user used the `/expected` command to describe what should have happened, and the trigger payload includes the recent conversation turns showing what actually happened.
+
+## Input
+
+`state["trigger_payload"]` contains:
+- `expected_behavior` — what the user expected to happen (their description)
+- `thread_id` — the conversation thread where the issue occurred
+- `recent_turns` — list of recent turns, each with:
+ - `user_input` — what the user asked
+ - `response` — what the agent responded
+ - `tool_calls` — list of tools called (with name and any errors)
+ - `state` — turn completion state
+ - `error` — any error message
+
+## Investigation process
+
+1. **Understand the gap**: Compare `expected_behavior` against `recent_turns`. What did the user want? What actually happened? Be precise about the delta.
+
+2. **Classify the root cause**:
+ - MISSING_CAPABILITY: The agent doesn't have the tool or integration needed (e.g. no GitHub OAuth, no API key configured)
+ - WRONG_TOOL_CHOICE: The agent had the right tools but chose the wrong one or didn't use them at all
+ - PROMPT_GAP: The agent didn't know the right approach because the system prompt lacks guidance for this scenario
+ - CONFIG_ISSUE: A timeout, limit, or default prevented success
+ - BUG: Actual code error in tool execution or response processing
+
+3. **Apply a fix** based on classification:
+
+ MISSING_CAPABILITY:
+ - Search for relevant skills: `skill_search(query="...")` or `tool_search(query="...")`
+ - If a skill/tool exists but isn't installed, note it as a recommendation
+ - If nothing exists, add a prompt rule acknowledging the limitation and suggesting alternatives the user can take
+
+ WRONG_TOOL_CHOICE or PROMPT_GAP:
+ - Apply a Level 1 (prompt overlay) fix — add a rule that guides the agent in this scenario
+ - Use `memory_write` with title="prompt:codeact_preamble" and tags=["prompt_overlay"]
+ - The rule must be specific and actionable
+
+ CONFIG_ISSUE:
+ - Diagnose via `read_file` and `shell` commands
+ - Apply Level 2 fix if safe (branch, change, test, commit)
+
+ BUG:
+ - Read relevant source files to understand the issue
+ - Propose a Level 3 fix (describe but don't apply)
+
+4. **Record** in FINAL():
+ - What the user expected vs what happened (one sentence each)
+ - Root cause classification
+ - What fix was applied (or recommended)
+ - Next focus
+
+## Rules
+
+- The user's expectation is the ground truth — don't argue with it
+- If multiple issues exist, fix the most impactful one first
+- Be specific in prompt rules ("When asked to file a GitHub issue, use the http tool with the GitHub API" is good; "Try harder" is useless)
+- If the gap is a missing credential or integration, say so clearly — don't pretend the capability exists
+- Max one fix per run
diff --git a/crates/ironclaw_engine/prompts/mission_self_improvement.md b/crates/ironclaw_engine/prompts/mission_self_improvement.md
new file mode 100644
index 00000000000..e3d02dfc634
--- /dev/null
+++ b/crates/ironclaw_engine/prompts/mission_self_improvement.md
@@ -0,0 +1,67 @@
+You are a self-improvement agent for the IronClaw engine. You receive trigger payloads containing execution trace issues from completed threads. Your job is to diagnose root causes and apply fixes so the same issue doesn't recur.
+
+## What you have access to
+
+- `state["trigger_payload"]` — JSON with `issues` (list of {severity, category, description, step}), `error_messages` (actual error text from failed actions), `goal` (what the thread was trying to do), and `source_thread_id`.
+- All tools: shell, read_file, write_file, apply_patch, web_search, memory_write, etc.
+- The codebase at the current working directory.
+- The fix pattern database in prior knowledge (if loaded).
+
+## The experiment loop
+
+For each issue in the trigger payload:
+
+1. **Diagnose**: Read the error messages and issue descriptions. Classify the root cause:
+ - PROMPT: The LLM made a mistake because the system prompt is missing a rule (wrong tool name, bad API usage, ignoring tool results)
+ - CONFIG: A default value is wrong (truncation length, iteration limit, timeout)
+ - CODE: There is a bug in the engine or bridge code (crash, type error, missing conversion)
+
+2. **Check the fix pattern database** in prior knowledge. Has this pattern been seen before? If yes, apply the known strategy. If no, proceed to step 3.
+
+3. **Apply the fix** based on the level:
+
+ Level 1 (PROMPT — low risk, apply directly):
+ - Read the current prompt overlay: `memory_search("prompt:codeact_preamble")`
+ - Write an updated overlay with a new rule appended
+ - Use `memory_write` with title="prompt:codeact_preamble" and tags=["prompt_overlay"]
+ - The rule should be specific and actionable (e.g. "Never call web_fetch — use http() instead")
+
+ Level 2 (CONFIG — medium risk):
+ - Use `read_file` to find the relevant constant or default
+ - Use `shell` to create a git branch: `git checkout -b self-improve/issue-description`
+ - Apply the change with `apply_patch` or `write_file`
+ - Run tests: `cargo test -p ironclaw_engine`
+ - If tests pass, commit. If not, revert: `git checkout main`
+
+ Level 3 (CODE — high risk, just propose):
+ - Read the relevant source files
+ - Describe the fix needed but DO NOT apply it directly
+ - Log it as a recommendation in your FINAL() response
+
+4. **Record what you did** — include in your FINAL() response:
+ - What issue you analyzed
+ - What level fix you applied (1/2/3)
+ - What specific change you made
+ - Next focus: what to look for next time
+
+## Important rules
+
+- Be specific. "Never call web_fetch" is good. "Be careful with tool names" is useless.
+- One fix per issue. Don't try to fix everything at once.
+- For Level 1 fixes, the rule must be one sentence that can be appended to the prompt.
+- If the trigger payload has no actionable issues (only Info severity), skip and call FINAL() immediately.
+- NEVER modify test files to make a fix pass.
+- NEVER modify security-sensitive code (safety layer, policy engine, leak detection).
+- If you can't diagnose the root cause after reading the errors, log it and move on.
+
+## Level 1.5: Orchestrator patches (medium risk, auto-rollback)
+
+The execution loop itself is Python code that you can modify. This is the orchestrator — it handles tool dispatch, output formatting, state management, and context building. If the bug is in the glue between the LLM and tools (wrong output format, bad truncation, missing state), you can patch it directly.
+
+To modify the orchestrator:
+1. Read current version: `memory_search("orchestrator:main")`
+2. Make your change (keep it minimal — one fix at a time)
+3. Save the new version: `memory_write` with title="orchestrator:main", tags=["orchestrator_code"], metadata={"version": N+1, "parent_version": N}
+4. The next thread will use your updated orchestrator
+
+If your change causes 3 consecutive failures, the system auto-rolls back to the previous version. So be conservative — test your logic mentally before saving.
diff --git a/crates/ironclaw_engine/prompts/mission_skill_extraction.md b/crates/ironclaw_engine/prompts/mission_skill_extraction.md
new file mode 100644
index 00000000000..a2a303355de
--- /dev/null
+++ b/crates/ironclaw_engine/prompts/mission_skill_extraction.md
@@ -0,0 +1,69 @@
+You extract reusable skills from successfully completed multi-step threads.
+
+## Input
+
+`state["trigger_payload"]` contains:
+- `source_thread_id` — the thread that completed successfully
+- `goal` — what the thread accomplished
+- `step_count` — number of execution steps
+- `action_count` — number of tool actions executed
+- `actions_used` — list of tool names used
+- `total_tokens` — tokens consumed
+
+## Output Format
+
+Save as a Skill memory doc via `memory_write(target="memory", content=skill_prompt)` with:
+- title: `"skill:"` (e.g., "skill:github-issue-triage")
+- doc_type: `"skill"`
+- metadata JSON:
+ ```json
+ {
+ "name": "",
+ "version": 1,
+ "description": "",
+ "activation": {
+ "keywords": ["", ""],
+ "patterns": [""],
+ "tags": [""],
+ "exclude_keywords": [],
+ "max_context_tokens":
+ },
+ "source": "extracted",
+ "trust": "trusted",
+ "code_snippets": [
+ {
+ "name": "",
+ "code": "def (...):\n ...",
+ "description": ""
+ }
+ ],
+ "metrics": {"usage_count": 0, "success_count": 0, "failure_count": 0},
+ "content_hash": ""
+ }
+ ```
+
+## Process
+
+1. Search for the source thread's context: `memory_search(query=goal)`
+2. Check for existing skills: `memory_search(query="skill:")`
+3. If a similar skill exists, update it (increment version) rather than creating a duplicate
+4. Extract:
+ - Activation keywords from the goal + user messages (be specific, not generic)
+ - Step-by-step instructions as the prompt content
+ - Python code snippets for CodeAct (reusable functions using exact tool names)
+ - Domain tags (e.g., "github", "api", "data")
+
+## Output (FINAL)
+
+Report what you did:
+- The skill title and a one-line summary
+- Whether it is new or an update to an existing skill
+- Next focus: what patterns to watch for
+
+## Rules
+
+- Only extract skills from threads with 3+ distinct tool calls
+- Keywords must be specific (not generic words like "help", "do", "make")
+- Code snippets must use exact tool function names as they appear in the thread
+- If the thread was a trivial query-response, call FINAL("No skill needed — simple interaction") and stop immediately
+- One skill per FINAL — do not combine unrelated procedures
diff --git a/crates/ironclaw_engine/prompts/mission_skill_repair.md b/crates/ironclaw_engine/prompts/mission_skill_repair.md
new file mode 100644
index 00000000000..122f58f5689
--- /dev/null
+++ b/crates/ironclaw_engine/prompts/mission_skill_repair.md
@@ -0,0 +1,70 @@
+You are the skill-repair learning mission for the IronClaw engine. You receive trigger payloads from completed threads where an active skill was relevant, but execution suggests the skill instructions were incomplete, stale, incorrectly ordered, or missing verification or workarounds.
+
+## Input
+
+`state["trigger_payload"]` contains:
+- `source_thread_id` — the completed thread that exposed the skill gap
+- `goal` — what the thread was trying to accomplish
+- `active_skills` — implicated skills with `doc_id`, `name`, `version`, and snippet names
+- `issues` — trace issues from the thread
+- `error_messages` — action failure text
+- `observed_actions` — actions actually attempted during execution
+- `repair_hints` — conservative hint categories such as `missing_prerequisite`, `stale_command_path`, `missing_pitfall`, `missing_verification`
+
+## Mission
+
+Choose the single most likely implicated skill and produce the smallest safe repair.
+
+Classify the gap as exactly one of:
+- `missing_prerequisite`
+- `wrong_ordering`
+- `stale_command_path`
+- `missing_branch`
+- `missing_pitfall`
+- `missing_verification`
+
+## Process
+
+1. Inspect the implicated skill and source context with tools (`memory_search`, `memory_read`, `read_file`, `shell`, etc.).
+2. Confirm the gap from the thread evidence. If the evidence points to engine behavior instead of the skill, do not repair the skill.
+3. Generate the smallest safe content patch:
+ - add an auth or setup prerequisite check
+ - add a missing ordering note
+ - fix one exact command or path
+ - add one platform-specific branch or workaround
+ - add one verification or smoke-test step
+4. Keep the skill focused. Do not rewrite the entire skill unless the existing content is unusable.
+
+## Output Format
+
+Return a single JSON object in `FINAL(...)` with this shape:
+
+```json
+{
+ "doc_id": "",
+ "repair_type": "missing_prerequisite",
+ "summary": "Added GitHub auth prerequisite before gh commands.",
+ "updated_content": "",
+ "description": "",
+ "activation": {
+ "keywords": ["github", "pull request"],
+ "patterns": [],
+ "tags": ["github"],
+ "exclude_keywords": [],
+ "max_context_tokens": 1200
+ },
+ "code_snippets": [],
+ "next_focus": "Watch for repeated failures in repo-cloning flows.",
+ "goal_achieved": false
+}
+```
+
+Only include `description`, `activation`, or `code_snippets` if they truly need to change.
+
+## Rules
+
+- Repair only one skill per thread.
+- Only target a `doc_id` from `active_skills`.
+- Prefer additive edits over broad rewrites.
+- Do not write the skill doc directly with `memory_write`; return structured JSON and let the runtime apply the versioned update.
+- If the evidence is weak or the gap is not skill-related, call `FINAL("No safe skill repair identified")`.
diff --git a/crates/ironclaw_engine/src/capability/lease.rs b/crates/ironclaw_engine/src/capability/lease.rs
new file mode 100644
index 00000000000..7e35c5dce8b
--- /dev/null
+++ b/crates/ironclaw_engine/src/capability/lease.rs
@@ -0,0 +1,584 @@
+//! Lease manager — grants, validates, and expires capability leases.
+
+use std::collections::HashMap;
+
+use chrono::Utc;
+use tokio::sync::RwLock;
+
+use crate::types::capability::{CapabilityLease, GrantedActions, LeaseId};
+use crate::types::error::EngineError;
+use crate::types::thread::ThreadId;
+
+/// Manages the lifecycle of capability leases.
+///
+/// Leases are the mechanism by which threads gain access to capabilities.
+/// They are scoped (time-limited, use-limited, action-restricted) to bound
+/// the blast radius of any single thread.
+pub struct LeaseManager {
+ active: RwLock>,
+}
+
+impl LeaseManager {
+ pub fn new() -> Self {
+ Self {
+ active: RwLock::new(HashMap::new()),
+ }
+ }
+
+ /// Grant a new lease to a thread.
+ ///
+ /// Returns `EngineError::Effect` if `duration` is non-positive or
+ /// `max_uses` is zero — these would create immediately-expired or
+ /// unusable leases.
+ pub async fn grant(
+ &self,
+ thread_id: ThreadId,
+ capability_name: impl Into,
+ granted_actions: GrantedActions,
+ duration: Option,
+ max_uses: Option,
+ ) -> Result {
+ if let Some(d) = duration
+ && d <= chrono::Duration::zero()
+ {
+ return Err(EngineError::Effect {
+ reason: format!("lease duration must be positive, got {}s", d.num_seconds()),
+ });
+ }
+ if let Some(0) = max_uses {
+ return Err(EngineError::Effect {
+ reason: "lease max_uses must be > 0".into(),
+ });
+ }
+
+ let now = Utc::now();
+ let lease = CapabilityLease {
+ id: LeaseId::new(),
+ thread_id,
+ capability_name: capability_name.into(),
+ granted_actions,
+ granted_at: now,
+ expires_at: duration.map(|d| now + d),
+ max_uses,
+ uses_remaining: max_uses,
+ revoked: false,
+ revoked_reason: None,
+ };
+ self.active.write().await.insert(lease.id, lease.clone());
+ Ok(lease)
+ }
+
+ /// Check whether a lease is still valid. Returns the lease if valid.
+ pub async fn check(&self, lease_id: LeaseId) -> Result {
+ let leases = self.active.read().await;
+ let lease = leases
+ .get(&lease_id)
+ .ok_or_else(|| EngineError::LeaseNotFound {
+ lease_id: format!("{lease_id:?}"),
+ })?;
+ if !lease.is_valid() {
+ return Err(EngineError::LeaseExpired {
+ capability_name: lease.capability_name.clone(),
+ });
+ }
+ Ok(lease.clone())
+ }
+
+ /// Consume one use of a lease. Returns error if the lease is invalid or exhausted.
+ pub async fn consume_use(&self, lease_id: LeaseId) -> Result<(), EngineError> {
+ let mut leases = self.active.write().await;
+ let lease = leases
+ .get_mut(&lease_id)
+ .ok_or_else(|| EngineError::LeaseExpired {
+ capability_name: format!("lease {lease_id:?} not found"),
+ })?;
+ if !lease.is_valid() {
+ return Err(EngineError::LeaseExpired {
+ capability_name: lease.capability_name.clone(),
+ });
+ }
+ if !lease.consume_use() {
+ return Err(EngineError::LeaseExpired {
+ capability_name: lease.capability_name.clone(),
+ });
+ }
+ Ok(())
+ }
+
+ /// Refund one lease use after an execution was interrupted before the
+ /// action completed.
+ pub async fn refund_use(&self, lease_id: LeaseId) -> Result<(), EngineError> {
+ let mut leases = self.active.write().await;
+ let lease = leases
+ .get_mut(&lease_id)
+ .ok_or_else(|| EngineError::LeaseExpired {
+ capability_name: format!("lease {lease_id:?} not found"),
+ })?;
+ lease.refund_use();
+ Ok(())
+ }
+
+ /// Update the granted actions for an existing lease in place.
+ pub async fn update_granted_actions(
+ &self,
+ lease_id: LeaseId,
+ granted_actions: GrantedActions,
+ ) -> Result {
+ let mut leases = self.active.write().await;
+ let lease = leases
+ .get_mut(&lease_id)
+ .ok_or_else(|| EngineError::LeaseNotFound {
+ lease_id: format!("{lease_id:?}"),
+ })?;
+ lease.granted_actions = granted_actions;
+ Ok(lease.clone())
+ }
+
+ /// Revoke a lease by ID with a reason for audit trail.
+ pub async fn revoke(&self, lease_id: LeaseId, reason: &str) {
+ let mut leases = self.active.write().await;
+ if let Some(lease) = leases.get_mut(&lease_id) {
+ lease.revoked = true;
+ lease.revoked_reason = Some(reason.to_string());
+ tracing::debug!(
+ lease_id = ?lease_id,
+ capability = %lease.capability_name,
+ reason,
+ "lease revoked"
+ );
+ }
+ }
+
+ /// Remove all expired or revoked leases from the active set.
+ pub async fn expire_stale(&self) -> usize {
+ let mut leases = self.active.write().await;
+ let before = leases.len();
+ leases.retain(|_, lease| lease.is_valid());
+ before - leases.len()
+ }
+
+ /// Get all active (valid) leases for a thread.
+ pub async fn active_for_thread(&self, thread_id: ThreadId) -> Vec {
+ let leases = self.active.read().await;
+ leases
+ .values()
+ .filter(|l| l.thread_id == thread_id && l.is_valid())
+ .cloned()
+ .collect()
+ }
+
+ /// Find the lease that grants a specific action to a thread.
+ pub async fn find_lease_for_action(
+ &self,
+ thread_id: ThreadId,
+ action_name: &str,
+ ) -> Option {
+ let hyphenated = action_name.replace('_', "-");
+ let underscored = action_name.replace('-', "_");
+ let leases = self.active.read().await;
+ leases
+ .values()
+ .find(|l| {
+ l.thread_id == thread_id
+ && l.is_valid()
+ && (l.covers_action(action_name)
+ || l.covers_action(&hyphenated)
+ || l.covers_action(&underscored))
+ })
+ .cloned()
+ }
+
+ /// Derive child leases from a parent thread's active leases.
+ ///
+ /// Implements intersection semantics: the child gets only leases for
+ /// actions that are both in the parent's active set AND in the
+ /// `requested_actions` set. If `requested_actions` is `None`, the child
+ /// inherits all of the parent's valid leases.
+ ///
+ /// Invariants:
+ /// - A child can never have more privileges than its parent.
+ /// - Child leases inherit the parent's expiry (never outlive parent).
+ /// - Child leases inherit the parent's remaining budget.
+ /// - Expired parent leases yield no child leases.
+ pub async fn derive_child_leases(
+ &self,
+ parent_thread_id: ThreadId,
+ child_thread_id: ThreadId,
+ requested_actions: Option<&std::collections::HashSet>,
+ ) -> Vec {
+ let parent_leases = self.active_for_thread(parent_thread_id).await;
+ let mut child_leases = Vec::new();
+
+ for parent in &parent_leases {
+ if !parent.is_valid() {
+ continue;
+ }
+
+ let child_grants = match requested_actions {
+ Some(req) => {
+ match &parent.granted_actions {
+ GrantedActions::All => {
+ // Parent is wildcard. Child gets only the
+ // requested subset, NOT a wildcard.
+ GrantedActions::Specific(req.iter().cloned().collect())
+ }
+ GrantedActions::Specific(parent_actions) => {
+ // Intersection: only actions in both parent and request.
+ let intersection: Vec = parent_actions
+ .iter()
+ .filter(|a| req.contains(*a))
+ .cloned()
+ .collect();
+ GrantedActions::Specific(intersection)
+ }
+ }
+ }
+ None => parent.granted_actions.clone(),
+ };
+
+ // Skip if intersection is empty (no matching actions)
+ if let GrantedActions::Specific(ref actions) = child_grants
+ && actions.is_empty()
+ && requested_actions.is_some()
+ {
+ continue;
+ }
+
+ child_leases.push(CapabilityLease {
+ id: LeaseId::new(),
+ thread_id: child_thread_id,
+ capability_name: parent.capability_name.clone(),
+ granted_actions: child_grants,
+ granted_at: Utc::now(),
+ expires_at: parent.expires_at, // never outlive parent
+ max_uses: parent.uses_remaining, // budget from parent's remaining
+ uses_remaining: parent.uses_remaining,
+ revoked: false,
+ revoked_reason: None,
+ });
+ }
+
+ // Batch insert under a single write lock (M2: avoid per-iteration locking)
+ {
+ let mut active = self.active.write().await;
+ for child in &child_leases {
+ active.insert(child.id, child.clone());
+ }
+ }
+
+ child_leases
+ }
+
+ /// Atomically find the lease for an action and consume one use.
+ ///
+ /// Avoids the TOCTOU race between `find_lease_for_action` (read lock) and
+ /// `consume_use` (write lock) — both happen under a single write lock.
+ /// Returns the lease snapshot (post-consume) if found and valid.
+ pub async fn find_and_consume(
+ &self,
+ thread_id: ThreadId,
+ action_name: &str,
+ ) -> Result {
+ let mut leases = self.active.write().await;
+ let lease = leases
+ .values_mut()
+ .find(|l| l.thread_id == thread_id && l.is_valid() && l.covers_action(action_name))
+ .ok_or_else(|| EngineError::LeaseNotFound {
+ lease_id: format!("no valid lease for action '{action_name}'"),
+ })?;
+
+ if !lease.consume_use() {
+ return Err(EngineError::LeaseExpired {
+ capability_name: lease.capability_name.clone(),
+ });
+ }
+
+ Ok(lease.clone())
+ }
+}
+
+impl Default for LeaseManager {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::types::capability::GrantedActions;
+ use crate::types::thread::ThreadId;
+
+ #[tokio::test]
+ async fn grant_and_check() {
+ let mgr = LeaseManager::new();
+ let tid = ThreadId::new();
+ let lease = mgr
+ .grant(tid, "github", GrantedActions::All, None, None)
+ .await
+ .unwrap();
+ assert!(mgr.check(lease.id).await.is_ok());
+ }
+
+ #[tokio::test]
+ async fn check_nonexistent_fails() {
+ let mgr = LeaseManager::new();
+ assert!(mgr.check(LeaseId::new()).await.is_err());
+ }
+
+ #[tokio::test]
+ async fn consume_use_works() {
+ let mgr = LeaseManager::new();
+ let tid = ThreadId::new();
+ let lease = mgr
+ .grant(tid, "github", GrantedActions::All, None, Some(2))
+ .await
+ .unwrap();
+ assert!(mgr.consume_use(lease.id).await.is_ok());
+ assert!(mgr.consume_use(lease.id).await.is_ok());
+ assert!(mgr.consume_use(lease.id).await.is_err());
+ }
+
+ #[tokio::test]
+ async fn refund_use_restores_consumed_budget() {
+ let mgr = LeaseManager::new();
+ let tid = ThreadId::new();
+ let lease = mgr
+ .grant(tid, "github", GrantedActions::All, None, Some(2))
+ .await
+ .unwrap();
+ mgr.consume_use(lease.id).await.unwrap();
+ let consumed = mgr.check(lease.id).await.unwrap();
+ assert_eq!(consumed.uses_remaining, Some(1));
+ mgr.refund_use(lease.id).await.unwrap();
+ let restored = mgr.check(lease.id).await.unwrap();
+ assert_eq!(restored.uses_remaining, Some(2));
+ }
+
+ #[tokio::test]
+ async fn revoke_invalidates() {
+ let mgr = LeaseManager::new();
+ let tid = ThreadId::new();
+ let lease = mgr
+ .grant(tid, "github", GrantedActions::All, None, None)
+ .await
+ .unwrap();
+ mgr.revoke(lease.id, "test").await;
+ assert!(mgr.check(lease.id).await.is_err());
+ }
+
+ #[tokio::test]
+ async fn expire_stale_removes_revoked() {
+ let mgr = LeaseManager::new();
+ let tid = ThreadId::new();
+ let lease = mgr
+ .grant(tid, "github", GrantedActions::All, None, None)
+ .await
+ .unwrap();
+ mgr.revoke(lease.id, "done").await;
+ let removed = mgr.expire_stale().await;
+ assert_eq!(removed, 1);
+ assert!(mgr.active_for_thread(tid).await.is_empty());
+ }
+
+ #[tokio::test]
+ async fn active_for_thread_filters_correctly() {
+ let mgr = LeaseManager::new();
+ let t1 = ThreadId::new();
+ let t2 = ThreadId::new();
+ mgr.grant(t1, "github", GrantedActions::All, None, None)
+ .await
+ .unwrap();
+ mgr.grant(t1, "memory", GrantedActions::All, None, None)
+ .await
+ .unwrap();
+ mgr.grant(t2, "slack", GrantedActions::All, None, None)
+ .await
+ .unwrap();
+ assert_eq!(mgr.active_for_thread(t1).await.len(), 2);
+ assert_eq!(mgr.active_for_thread(t2).await.len(), 1);
+ }
+
+ #[tokio::test]
+ async fn find_lease_for_action_respects_grants() {
+ let mgr = LeaseManager::new();
+ let tid = ThreadId::new();
+ mgr.grant(
+ tid,
+ "github",
+ GrantedActions::Specific(vec!["create_issue".into(), "list_prs".into()]),
+ None,
+ None,
+ )
+ .await
+ .unwrap();
+ assert!(
+ mgr.find_lease_for_action(tid, "create_issue")
+ .await
+ .is_some()
+ );
+ assert!(
+ mgr.find_lease_for_action(tid, "delete_repo")
+ .await
+ .is_none()
+ );
+ }
+
+ #[tokio::test]
+ async fn negative_duration_rejected() {
+ let mgr = LeaseManager::new();
+ let tid = ThreadId::new();
+ let result = mgr
+ .grant(
+ tid,
+ "github",
+ GrantedActions::All,
+ Some(chrono::Duration::seconds(-10)),
+ None,
+ )
+ .await;
+ assert!(result.is_err());
+ }
+
+ #[tokio::test]
+ async fn zero_max_uses_rejected() {
+ let mgr = LeaseManager::new();
+ let tid = ThreadId::new();
+ let result = mgr
+ .grant(tid, "github", GrantedActions::All, None, Some(0))
+ .await;
+ assert!(result.is_err());
+ }
+
+ // ── derive_child_leases ──────────────────────────────────
+
+ #[tokio::test]
+ async fn test_child_inherits_subset_of_parent() {
+ let mgr = LeaseManager::new();
+ let parent = ThreadId::new();
+ let child = ThreadId::new();
+
+ mgr.grant(
+ parent,
+ "tools",
+ GrantedActions::Specific(vec!["A".into(), "B".into(), "C".into()]),
+ None,
+ None,
+ )
+ .await
+ .unwrap();
+
+ let mut requested = std::collections::HashSet::new();
+ requested.insert("B".into());
+ requested.insert("C".into());
+ requested.insert("D".into()); // not in parent
+
+ let child_leases = mgr
+ .derive_child_leases(parent, child, Some(&requested))
+ .await;
+ assert_eq!(child_leases.len(), 1);
+ assert!(child_leases[0].granted_actions.covers("B"));
+ assert!(child_leases[0].granted_actions.covers("C"));
+ assert!(!child_leases[0].granted_actions.covers("D"));
+ }
+
+ #[tokio::test]
+ async fn test_child_never_exceeds_parent_expiry() {
+ let mgr = LeaseManager::new();
+ let parent = ThreadId::new();
+ let child = ThreadId::new();
+
+ let parent_lease = mgr
+ .grant(
+ parent,
+ "tools",
+ GrantedActions::Specific(vec!["read".into()]),
+ Some(chrono::Duration::hours(1)),
+ None,
+ )
+ .await
+ .unwrap();
+
+ let child_leases = mgr.derive_child_leases(parent, child, None).await;
+ assert_eq!(child_leases.len(), 1);
+ assert_eq!(child_leases[0].expires_at, parent_lease.expires_at);
+ }
+
+ #[tokio::test]
+ async fn test_expired_parent_yields_empty_child() {
+ let mgr = LeaseManager::new();
+ let parent = ThreadId::new();
+ let child = ThreadId::new();
+
+ // Manually insert an already-expired lease (bypassing grant validation)
+ let now = Utc::now();
+ let expired_lease = CapabilityLease {
+ id: LeaseId::new(),
+ thread_id: parent,
+ capability_name: "tools".into(),
+ granted_actions: GrantedActions::Specific(vec!["read".into()]),
+ granted_at: now,
+ expires_at: Some(now - chrono::Duration::seconds(10)),
+ max_uses: None,
+ uses_remaining: None,
+ revoked: false,
+ revoked_reason: None,
+ };
+ mgr.active
+ .write()
+ .await
+ .insert(expired_lease.id, expired_lease);
+
+ let child_leases = mgr.derive_child_leases(parent, child, None).await;
+ assert!(child_leases.is_empty());
+ }
+
+ #[tokio::test]
+ async fn test_child_inherits_remaining_budget() {
+ let mgr = LeaseManager::new();
+ let parent = ThreadId::new();
+ let child = ThreadId::new();
+
+ let parent_lease = mgr
+ .grant(
+ parent,
+ "tools",
+ GrantedActions::Specific(vec!["read".into()]),
+ None,
+ Some(10),
+ )
+ .await
+ .unwrap();
+
+ // Consume 3 uses from parent
+ mgr.consume_use(parent_lease.id).await.unwrap();
+ mgr.consume_use(parent_lease.id).await.unwrap();
+ mgr.consume_use(parent_lease.id).await.unwrap();
+
+ let child_leases = mgr.derive_child_leases(parent, child, None).await;
+ assert_eq!(child_leases.len(), 1);
+ // Parent had 10, consumed 3, so 7 remaining
+ assert_eq!(child_leases[0].uses_remaining, Some(7));
+ }
+
+ #[tokio::test]
+ async fn test_child_with_none_inherits_all() {
+ let mgr = LeaseManager::new();
+ let parent = ThreadId::new();
+ let child = ThreadId::new();
+
+ mgr.grant(
+ parent,
+ "tools",
+ GrantedActions::Specific(vec!["read".into(), "write".into()]),
+ None,
+ None,
+ )
+ .await
+ .unwrap();
+
+ let child_leases = mgr.derive_child_leases(parent, child, None).await;
+ assert_eq!(child_leases.len(), 1);
+ assert_eq!(child_leases[0].granted_actions.actions().len(), 2);
+ }
+}
diff --git a/crates/ironclaw_engine/src/capability/mod.rs b/crates/ironclaw_engine/src/capability/mod.rs
new file mode 100644
index 00000000000..d334eae6aeb
--- /dev/null
+++ b/crates/ironclaw_engine/src/capability/mod.rs
@@ -0,0 +1,14 @@
+//! Capability management.
+//!
+//! - [`CapabilityRegistry`] — stores known capabilities and their actions
+//! - [`LeaseManager`] — grants, validates, and expires capability leases
+//! - [`PolicyEngine`] — deterministic effect-level allow/deny/approve
+
+pub mod lease;
+pub mod planner;
+pub mod policy;
+pub mod registry;
+
+pub use lease::LeaseManager;
+pub use policy::{PolicyDecision, PolicyEngine};
+pub use registry::CapabilityRegistry;
diff --git a/crates/ironclaw_engine/src/capability/planner.rs b/crates/ironclaw_engine/src/capability/planner.rs
new file mode 100644
index 00000000000..a611c157d8b
--- /dev/null
+++ b/crates/ironclaw_engine/src/capability/planner.rs
@@ -0,0 +1,202 @@
+//! Lease planning for new threads.
+//!
+//! Converts capability registry contents plus thread type into explicit
+//! capability grants. Thread-type-aware: Foreground gets all tiers,
+//! Research gets read-only + stateful, Mission excludes administrative tools.
+
+use crate::capability::registry::CapabilityRegistry;
+use crate::gate::tool_tier::{ToolTier, classify_tool_tier, is_autonomous_denylisted};
+use crate::types::capability::GrantedActions;
+use crate::types::thread::ThreadType;
+
+/// Explicit grant plan for a single capability.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CapabilityGrantPlan {
+ pub capability_name: String,
+ pub granted_actions: GrantedActions,
+}
+
+/// Plans explicit capability leases for new threads.
+///
+/// Uses [`ToolTier`] classification to scope grants by thread type:
+/// - **Foreground**: all tiers (interactive approval gates protect Privileged/Admin)
+/// - **Research**: `ReadOnly` and `Stateful` only
+/// - **Mission**: `ReadOnly`, `Stateful`, and non-denylisted `Privileged`
+#[derive(Debug, Default)]
+pub struct LeasePlanner;
+
+impl LeasePlanner {
+ pub fn new() -> Self {
+ Self
+ }
+
+ /// Build the capability grants for a new thread.
+ pub fn plan_for_thread(
+ &self,
+ thread_type: ThreadType,
+ capabilities: &CapabilityRegistry,
+ ) -> Vec {
+ capabilities
+ .list()
+ .into_iter()
+ .filter_map(|cap| {
+ let granted_actions: Vec = cap
+ .actions
+ .iter()
+ .filter(|action| {
+ let tier = classify_tool_tier(action);
+ Self::tier_allowed(thread_type, &action.name, tier)
+ })
+ .map(|action| action.name.clone())
+ .collect();
+ if granted_actions.is_empty() {
+ None
+ } else {
+ Some(CapabilityGrantPlan {
+ capability_name: cap.name.clone(),
+ granted_actions: GrantedActions::Specific(granted_actions),
+ })
+ }
+ })
+ .collect()
+ }
+
+ /// Check whether a tool tier is allowed for a given thread type.
+ fn tier_allowed(thread_type: ThreadType, action_name: &str, tier: ToolTier) -> bool {
+ match thread_type {
+ ThreadType::Foreground => {
+ // Foreground gets everything — interactive approval gates
+ // protect Privileged and Administrative tools.
+ true
+ }
+ ThreadType::Research => {
+ // Research threads: read-only and stateful only.
+ tier <= ToolTier::Stateful
+ }
+ ThreadType::Mission => {
+ // Mission threads: no Administrative, no denylisted Privileged.
+ match tier {
+ ToolTier::ReadOnly | ToolTier::Stateful => true,
+ ToolTier::Privileged => !is_autonomous_denylisted(action_name),
+ ToolTier::Administrative => false,
+ }
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::types::capability::{ActionDef, Capability, EffectType, GrantedActions};
+
+ fn action(name: &str, effects: Vec, requires_approval: bool) -> ActionDef {
+ ActionDef {
+ name: name.into(),
+ description: format!("{name} action"),
+ parameters_schema: serde_json::json!({}),
+ effects,
+ requires_approval,
+ }
+ }
+
+ fn mixed_registry() -> CapabilityRegistry {
+ let mut reg = CapabilityRegistry::new();
+ reg.register(Capability {
+ name: "tools".into(),
+ description: "all tools".into(),
+ actions: vec![
+ action("echo", vec![EffectType::ReadLocal], false), // ReadOnly
+ action("read_file", vec![EffectType::ReadLocal], false), // ReadOnly
+ action("file_write", vec![EffectType::WriteLocal], false), // Stateful
+ action("shell", vec![EffectType::WriteLocal], true), // Privileged
+ action("http", vec![EffectType::WriteExternal], true), // Privileged
+ action("routine_create", vec![EffectType::WriteLocal], false), // Administrative (denylisted)
+ action("tool_install", vec![EffectType::WriteLocal], false), // Administrative (denylisted)
+ ],
+ knowledge: vec![],
+ policies: vec![],
+ });
+ reg
+ }
+
+ fn simple_registry() -> CapabilityRegistry {
+ let mut reg = CapabilityRegistry::new();
+ reg.register(Capability {
+ name: "tools".into(),
+ description: "test".into(),
+ actions: vec![action("read_file", vec![EffectType::ReadLocal], false)],
+ knowledge: vec![],
+ policies: vec![],
+ });
+ reg
+ }
+
+ #[test]
+ fn foreground_threads_get_explicit_actions() {
+ let planner = LeasePlanner::new();
+ let plans = planner.plan_for_thread(ThreadType::Foreground, &simple_registry());
+ assert_eq!(plans.len(), 1);
+ assert_eq!(plans[0].capability_name, "tools");
+ assert_eq!(
+ plans[0].granted_actions,
+ GrantedActions::Specific(vec!["read_file".into()])
+ );
+ }
+
+ #[test]
+ fn test_foreground_gets_all_tiers() {
+ let planner = LeasePlanner::new();
+ let plans = planner.plan_for_thread(ThreadType::Foreground, &mixed_registry());
+ assert_eq!(plans.len(), 1);
+ let actions = plans[0].granted_actions.actions();
+ assert_eq!(actions.len(), 7, "Foreground should get all 7 actions");
+ assert!(plans[0].granted_actions.covers("routine_create"));
+ assert!(plans[0].granted_actions.covers("shell"));
+ }
+
+ #[test]
+ fn test_research_excludes_privileged_and_admin() {
+ let planner = LeasePlanner::new();
+ let plans = planner.plan_for_thread(ThreadType::Research, &mixed_registry());
+ assert_eq!(plans.len(), 1);
+ let actions = plans[0].granted_actions.actions();
+ // ReadOnly: echo, read_file. Stateful: file_write.
+ assert_eq!(
+ actions.len(),
+ 3,
+ "Research should get 3 actions: {:?}",
+ actions
+ );
+ assert!(plans[0].granted_actions.covers("echo"));
+ assert!(plans[0].granted_actions.covers("read_file"));
+ assert!(plans[0].granted_actions.covers("file_write"));
+ assert!(!plans[0].granted_actions.covers("shell"));
+ assert!(!plans[0].granted_actions.covers("routine_create"));
+ }
+
+ #[test]
+ fn test_mission_excludes_administrative() {
+ let planner = LeasePlanner::new();
+ let plans = planner.plan_for_thread(ThreadType::Mission, &mixed_registry());
+ assert_eq!(plans.len(), 1);
+ let ga = &plans[0].granted_actions;
+ // Includes ReadOnly, Stateful, and non-denylisted Privileged (shell, http).
+ // Excludes Administrative (routine_create, tool_install).
+ assert!(ga.covers("echo"));
+ assert!(ga.covers("shell"));
+ assert!(ga.covers("http"));
+ assert!(!ga.covers("routine_create"));
+ assert!(!ga.covers("tool_install"));
+ }
+
+ #[test]
+ fn test_mission_excludes_denylisted_privileged() {
+ let planner = LeasePlanner::new();
+ let plans = planner.plan_for_thread(ThreadType::Mission, &mixed_registry());
+ let ga = &plans[0].granted_actions;
+ // routine_create and tool_install are in the denylist
+ assert!(!ga.covers("routine_create"));
+ assert!(!ga.covers("tool_install"));
+ }
+}
diff --git a/crates/ironclaw_engine/src/capability/policy.rs b/crates/ironclaw_engine/src/capability/policy.rs
new file mode 100644
index 00000000000..08b509bfee0
--- /dev/null
+++ b/crates/ironclaw_engine/src/capability/policy.rs
@@ -0,0 +1,391 @@
+//! Deterministic policy engine.
+//!
+//! Evaluates whether an action is allowed, denied, or requires approval
+//! based on effect types, capability policies, and thread leases.
+//! No LLM calls — purely deterministic.
+
+use crate::types::capability::{
+ ActionDef, CapabilityLease, EffectType, PolicyCondition, PolicyEffect, PolicyRule,
+};
+use crate::types::provenance::Provenance;
+
+/// The result of a policy evaluation.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum PolicyDecision {
+ Allow,
+ Deny { reason: String },
+ RequireApproval { reason: String },
+}
+
+/// Deterministic policy engine.
+///
+/// Evaluation precedence: Deny > RequireApproval > Allow.
+/// Checks are evaluated in order: global policies, then capability policies,
+/// then action-level `requires_approval`, then effect-type checks against
+/// the lease's allowed effects.
+pub struct PolicyEngine {
+ global_policies: Vec,
+ /// Effect types that are always denied unless explicitly overridden.
+ pub(crate) denied_effects: Vec,
+}
+
+impl PolicyEngine {
+ pub fn new() -> Self {
+ Self {
+ global_policies: Vec::new(),
+ denied_effects: Vec::new(),
+ }
+ }
+
+ /// Add a global policy rule.
+ pub fn add_global_policy(&mut self, rule: PolicyRule) {
+ self.global_policies.push(rule);
+ }
+
+ /// Add an effect type that is always denied.
+ pub fn deny_effect(&mut self, effect: EffectType) {
+ self.denied_effects.push(effect);
+ }
+
+ /// Evaluate whether an action is allowed given a lease and capability policies.
+ pub fn evaluate(
+ &self,
+ action: &ActionDef,
+ lease: &CapabilityLease,
+ capability_policies: &[PolicyRule],
+ ) -> PolicyDecision {
+ // 1. Check lease validity
+ if !lease.is_valid() {
+ return PolicyDecision::Deny {
+ reason: format!("lease for {} is expired/revoked", lease.capability_name),
+ };
+ }
+
+ // 2. Check lease covers this action
+ if !lease.covers_action(&action.name) {
+ return PolicyDecision::Deny {
+ reason: format!(
+ "lease for {} does not cover action {}",
+ lease.capability_name, action.name
+ ),
+ };
+ }
+
+ // 3. Check denied effect types
+ for effect in &action.effects {
+ if self.denied_effects.contains(effect) {
+ return PolicyDecision::Deny {
+ reason: format!("effect type {effect:?} is denied by global policy"),
+ };
+ }
+ }
+
+ // 4. Evaluate global policies
+ let mut decision = PolicyDecision::Allow;
+ for rule in &self.global_policies {
+ if rule_matches(rule, action) {
+ decision = merge_decision(decision, rule.effect, &rule.name);
+ }
+ }
+
+ // 5. Evaluate capability-level policies
+ for rule in capability_policies {
+ if rule_matches(rule, action) {
+ decision = merge_decision(decision, rule.effect, &rule.name);
+ }
+ }
+
+ // 6. Check action-level requires_approval
+ if action.requires_approval {
+ decision = merge_decision(
+ decision,
+ PolicyEffect::RequireApproval,
+ "action requires approval",
+ );
+ }
+
+ // Log denials for audit trail / incident investigation
+ if let PolicyDecision::Deny { ref reason } = decision {
+ tracing::debug!(
+ action = %action.name,
+ capability = %lease.capability_name,
+ reason,
+ "policy denied action"
+ );
+ }
+
+ decision
+ }
+
+ /// Evaluate with provenance-aware taint checking.
+ ///
+ /// Extends the base evaluation with provenance-based rules:
+ /// - `LlmGenerated` data + `Financial` effect → RequireApproval
+ /// - `LlmGenerated` data + `WriteExternal` effect → RequireApproval
+ /// - `ToolOutput` data + `Financial` effect → RequireApproval
+ pub fn evaluate_with_provenance(
+ &self,
+ action: &ActionDef,
+ lease: &CapabilityLease,
+ capability_policies: &[PolicyRule],
+ provenance: &Provenance,
+ ) -> PolicyDecision {
+ let mut decision = self.evaluate(action, lease, capability_policies);
+
+ // Provenance-based taint rules
+ match provenance {
+ Provenance::LlmGenerated => {
+ if action.effects.contains(&EffectType::Financial) {
+ decision = merge_decision(
+ decision,
+ PolicyEffect::RequireApproval,
+ "LLM-generated data cannot trigger financial effects without approval",
+ );
+ }
+ if action.effects.contains(&EffectType::WriteExternal) {
+ decision = merge_decision(
+ decision,
+ PolicyEffect::RequireApproval,
+ "LLM-generated data requires approval for external writes",
+ );
+ }
+ }
+ Provenance::ToolOutput { .. } => {
+ if action.effects.contains(&EffectType::Financial) {
+ decision = merge_decision(
+ decision,
+ PolicyEffect::RequireApproval,
+ "tool output data requires approval for financial effects",
+ );
+ }
+ }
+ // User and System provenance are trusted
+ Provenance::User | Provenance::System => {}
+ // MemoryRetrieval is internal, treat as trusted
+ Provenance::MemoryRetrieval { .. } => {}
+ }
+
+ decision
+ }
+}
+
+impl Default for PolicyEngine {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+/// Check whether a policy rule's condition matches the given action.
+fn rule_matches(rule: &PolicyRule, action: &ActionDef) -> bool {
+ match &rule.condition {
+ PolicyCondition::Always => true,
+ PolicyCondition::ActionMatches { pattern } => action.name == *pattern,
+ PolicyCondition::EffectTypeIs(effect) => action.effects.contains(effect),
+ }
+}
+
+/// Merge a new policy effect into the current decision.
+/// Deny > RequireApproval > Allow.
+fn merge_decision(current: PolicyDecision, effect: PolicyEffect, source: &str) -> PolicyDecision {
+ match effect {
+ PolicyEffect::Deny => PolicyDecision::Deny {
+ reason: source.to_string(),
+ },
+ PolicyEffect::RequireApproval => match current {
+ PolicyDecision::Deny { .. } => current,
+ _ => PolicyDecision::RequireApproval {
+ reason: source.to_string(),
+ },
+ },
+ PolicyEffect::Allow => current,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::types::capability::{GrantedActions, LeaseId};
+ use crate::types::thread::ThreadId;
+ use chrono::Utc;
+
+ fn make_action(name: &str, effects: Vec, requires_approval: bool) -> ActionDef {
+ ActionDef {
+ name: name.into(),
+ description: String::new(),
+ parameters_schema: serde_json::json!({}),
+ effects,
+ requires_approval,
+ }
+ }
+
+ fn make_lease() -> CapabilityLease {
+ CapabilityLease {
+ id: LeaseId::new(),
+ thread_id: ThreadId::new(),
+ capability_name: "test".into(),
+ granted_actions: GrantedActions::All,
+ granted_at: Utc::now(),
+ expires_at: None,
+ max_uses: None,
+ uses_remaining: None,
+ revoked: false,
+ revoked_reason: None,
+ }
+ }
+
+ #[test]
+ fn allow_by_default() {
+ let engine = PolicyEngine::new();
+ let action = make_action("read_file", vec![EffectType::ReadLocal], false);
+ let lease = make_lease();
+ assert_eq!(engine.evaluate(&action, &lease, &[]), PolicyDecision::Allow);
+ }
+
+ #[test]
+ fn denied_effect_type() {
+ let mut engine = PolicyEngine::new();
+ engine.deny_effect(EffectType::Financial);
+ let action = make_action("transfer", vec![EffectType::Financial], false);
+ let lease = make_lease();
+ assert!(matches!(
+ engine.evaluate(&action, &lease, &[]),
+ PolicyDecision::Deny { .. }
+ ));
+ }
+
+ #[test]
+ fn action_requires_approval() {
+ let engine = PolicyEngine::new();
+ let action = make_action("deploy", vec![EffectType::WriteExternal], true);
+ let lease = make_lease();
+ assert!(matches!(
+ engine.evaluate(&action, &lease, &[]),
+ PolicyDecision::RequireApproval { .. }
+ ));
+ }
+
+ #[test]
+ fn global_policy_deny_overrides_approval() {
+ let mut engine = PolicyEngine::new();
+ engine.add_global_policy(PolicyRule {
+ name: "no external writes".into(),
+ condition: PolicyCondition::EffectTypeIs(EffectType::WriteExternal),
+ effect: PolicyEffect::Deny,
+ });
+ let action = make_action("deploy", vec![EffectType::WriteExternal], true);
+ let lease = make_lease();
+ assert!(matches!(
+ engine.evaluate(&action, &lease, &[]),
+ PolicyDecision::Deny { .. }
+ ));
+ }
+
+ #[test]
+ fn capability_policy_requires_approval() {
+ let engine = PolicyEngine::new();
+ let action = make_action("create_issue", vec![EffectType::WriteExternal], false);
+ let lease = make_lease();
+ let cap_policies = vec![PolicyRule {
+ name: "approve writes".into(),
+ condition: PolicyCondition::EffectTypeIs(EffectType::WriteExternal),
+ effect: PolicyEffect::RequireApproval,
+ }];
+ assert!(matches!(
+ engine.evaluate(&action, &lease, &cap_policies),
+ PolicyDecision::RequireApproval { .. }
+ ));
+ }
+
+ #[test]
+ fn expired_lease_denied() {
+ let engine = PolicyEngine::new();
+ let action = make_action("read", vec![EffectType::ReadLocal], false);
+ let mut lease = make_lease();
+ lease.revoked = true;
+ assert!(matches!(
+ engine.evaluate(&action, &lease, &[]),
+ PolicyDecision::Deny { .. }
+ ));
+ }
+
+ #[test]
+ fn lease_not_covering_action_denied() {
+ let engine = PolicyEngine::new();
+ let action = make_action("delete_repo", vec![EffectType::WriteExternal], false);
+ let mut lease = make_lease();
+ lease.granted_actions = GrantedActions::Specific(vec!["create_issue".into()]);
+ assert!(matches!(
+ engine.evaluate(&action, &lease, &[]),
+ PolicyDecision::Deny { .. }
+ ));
+ }
+
+ #[test]
+ fn llm_generated_financial_requires_approval() {
+ let engine = PolicyEngine::new();
+ let action = make_action("transfer_funds", vec![EffectType::Financial], false);
+ let lease = make_lease();
+ let decision =
+ engine.evaluate_with_provenance(&action, &lease, &[], &Provenance::LlmGenerated);
+ assert!(matches!(decision, PolicyDecision::RequireApproval { .. }));
+ }
+
+ #[test]
+ fn llm_generated_write_external_requires_approval() {
+ let engine = PolicyEngine::new();
+ let action = make_action("post_message", vec![EffectType::WriteExternal], false);
+ let lease = make_lease();
+ let decision =
+ engine.evaluate_with_provenance(&action, &lease, &[], &Provenance::LlmGenerated);
+ assert!(matches!(decision, PolicyDecision::RequireApproval { .. }));
+ }
+
+ #[test]
+ fn user_provenance_allows_financial() {
+ let engine = PolicyEngine::new();
+ let action = make_action("transfer_funds", vec![EffectType::Financial], false);
+ let lease = make_lease();
+ let decision = engine.evaluate_with_provenance(&action, &lease, &[], &Provenance::User);
+ assert_eq!(decision, PolicyDecision::Allow);
+ }
+
+ #[test]
+ fn tool_output_financial_requires_approval() {
+ let engine = PolicyEngine::new();
+ let action = make_action("pay_invoice", vec![EffectType::Financial], false);
+ let lease = make_lease();
+ let decision = engine.evaluate_with_provenance(
+ &action,
+ &lease,
+ &[],
+ &Provenance::ToolOutput {
+ action_name: "scrape_invoices".into(),
+ },
+ );
+ assert!(matches!(decision, PolicyDecision::RequireApproval { .. }));
+ }
+
+ #[test]
+ fn action_matches_pattern() {
+ let mut engine = PolicyEngine::new();
+ engine.add_global_policy(PolicyRule {
+ name: "approve deletes".into(),
+ condition: PolicyCondition::ActionMatches {
+ pattern: "delete_repo".into(),
+ },
+ effect: PolicyEffect::RequireApproval,
+ });
+ let action = make_action("delete_repo", vec![EffectType::WriteExternal], false);
+ let lease = make_lease();
+ assert!(matches!(
+ engine.evaluate(&action, &lease, &[]),
+ PolicyDecision::RequireApproval { .. }
+ ));
+
+ let action2 = make_action("create_issue", vec![EffectType::WriteExternal], false);
+ assert_eq!(
+ engine.evaluate(&action2, &lease, &[]),
+ PolicyDecision::Allow
+ );
+ }
+}
diff --git a/crates/ironclaw_engine/src/capability/registry.rs b/crates/ironclaw_engine/src/capability/registry.rs
new file mode 100644
index 00000000000..12d26e67694
--- /dev/null
+++ b/crates/ironclaw_engine/src/capability/registry.rs
@@ -0,0 +1,170 @@
+//! Capability registry — stores capability definitions available to the system.
+
+use std::collections::HashMap;
+
+use crate::types::capability::{ActionDef, Capability};
+
+/// Registry of all known capabilities.
+///
+/// Capabilities are registered at startup (from extensions, built-in tools,
+/// etc.) and queried when granting leases or resolving action names.
+#[derive(Debug, Default)]
+pub struct CapabilityRegistry {
+ capabilities: HashMap,
+}
+
+impl CapabilityRegistry {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Register a capability. Overwrites any existing capability with the same name.
+ pub fn register(&mut self, capability: Capability) {
+ self.capabilities
+ .insert(capability.name.clone(), capability);
+ }
+
+ /// Look up a capability by name.
+ pub fn get(&self, name: &str) -> Option<&Capability> {
+ self.capabilities.get(name)
+ }
+
+ /// List all registered capabilities.
+ pub fn list(&self) -> Vec<&Capability> {
+ self.capabilities.values().collect()
+ }
+
+ /// Look up a specific action across all capabilities.
+ ///
+ /// Returns `(capability_name, action_def)` if found.
+ pub fn find_action(&self, action_name: &str) -> Option<(&str, &ActionDef)> {
+ for cap in self.capabilities.values() {
+ if let Some(action) = cap.actions.iter().find(|a| a.name == action_name) {
+ return Some((&cap.name, action));
+ }
+ }
+ None
+ }
+
+ /// Get an action definition from a specific capability.
+ pub fn get_action(&self, capability_name: &str, action_name: &str) -> Option<&ActionDef> {
+ self.capabilities
+ .get(capability_name)?
+ .actions
+ .iter()
+ .find(|a| a.name == action_name)
+ }
+
+ /// Collect all action definitions across all capabilities.
+ pub fn all_actions(&self) -> Vec<&ActionDef> {
+ self.capabilities
+ .values()
+ .flat_map(|c| c.actions.iter())
+ .collect()
+ }
+
+ /// Number of registered capabilities.
+ pub fn len(&self) -> usize {
+ self.capabilities.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.capabilities.is_empty()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::types::capability::EffectType;
+
+ fn test_capability() -> Capability {
+ Capability {
+ name: "github".into(),
+ description: "GitHub integration".into(),
+ actions: vec![
+ ActionDef {
+ name: "create_issue".into(),
+ description: "Create a GitHub issue".into(),
+ parameters_schema: serde_json::json!({"type": "object"}),
+ effects: vec![EffectType::WriteExternal, EffectType::CredentialedNetwork],
+ requires_approval: false,
+ },
+ ActionDef {
+ name: "list_prs".into(),
+ description: "List pull requests".into(),
+ parameters_schema: serde_json::json!({"type": "object"}),
+ effects: vec![EffectType::ReadExternal, EffectType::CredentialedNetwork],
+ requires_approval: false,
+ },
+ ],
+ knowledge: vec!["When creating issues, always add labels.".into()],
+ policies: vec![],
+ }
+ }
+
+ #[test]
+ fn register_and_get() {
+ let mut reg = CapabilityRegistry::new();
+ reg.register(test_capability());
+ assert_eq!(reg.len(), 1);
+ assert!(reg.get("github").is_some());
+ assert!(reg.get("slack").is_none());
+ }
+
+ #[test]
+ fn find_action_across_capabilities() {
+ let mut reg = CapabilityRegistry::new();
+ reg.register(test_capability());
+ let (cap_name, action) = reg.find_action("create_issue").unwrap();
+ assert_eq!(cap_name, "github");
+ assert_eq!(action.name, "create_issue");
+ assert!(reg.find_action("nonexistent").is_none());
+ }
+
+ #[test]
+ fn get_action_from_capability() {
+ let mut reg = CapabilityRegistry::new();
+ reg.register(test_capability());
+ assert!(reg.get_action("github", "list_prs").is_some());
+ assert!(reg.get_action("github", "delete_repo").is_none());
+ assert!(reg.get_action("slack", "list_prs").is_none());
+ }
+
+ #[test]
+ fn all_actions_collects_across_capabilities() {
+ let mut reg = CapabilityRegistry::new();
+ reg.register(test_capability());
+ reg.register(Capability {
+ name: "memory".into(),
+ description: "Memory tools".into(),
+ actions: vec![ActionDef {
+ name: "memory_search".into(),
+ description: "Search memory".into(),
+ parameters_schema: serde_json::json!({"type": "object"}),
+ effects: vec![EffectType::ReadLocal],
+ requires_approval: false,
+ }],
+ knowledge: vec![],
+ policies: vec![],
+ });
+ assert_eq!(reg.all_actions().len(), 3);
+ }
+
+ #[test]
+ fn overwrite_on_re_register() {
+ let mut reg = CapabilityRegistry::new();
+ reg.register(test_capability());
+ assert_eq!(reg.get("github").unwrap().actions.len(), 2);
+
+ reg.register(Capability {
+ name: "github".into(),
+ description: "Updated".into(),
+ actions: vec![],
+ knowledge: vec![],
+ policies: vec![],
+ });
+ assert_eq!(reg.get("github").unwrap().actions.len(), 0);
+ assert_eq!(reg.len(), 1);
+ }
+}
diff --git a/crates/ironclaw_engine/src/executor/compaction.rs b/crates/ironclaw_engine/src/executor/compaction.rs
new file mode 100644
index 00000000000..4fde5b0e8ed
--- /dev/null
+++ b/crates/ironclaw_engine/src/executor/compaction.rs
@@ -0,0 +1,176 @@
+//! Context compaction and token counting.
+//!
+//! When message history approaches the model's context limit, compaction
+//! asks the LLM to summarize progress and resets the history. This follows
+//! the official RLM pattern (compaction at 85% of context limit).
+
+use std::sync::Arc;
+
+use tracing::debug;
+
+use crate::traits::llm::{LlmBackend, LlmCallConfig};
+use crate::types::error::EngineError;
+use crate::types::message::{MessageRole, ThreadMessage};
+use crate::types::step::{LlmResponse, TokenUsage};
+
+/// Characters per token estimate when no tokenizer is available.
+/// Conservative estimate (official RLM uses 4).
+const CHARS_PER_TOKEN: usize = 4;
+
+/// Estimate token count for a list of messages.
+///
+/// Uses character length / `CHARS_PER_TOKEN` as a rough estimate.
+/// The official RLM uses tiktoken when available; we use this fallback
+/// since we don't depend on a Python tokenizer.
+pub fn estimate_tokens(messages: &[ThreadMessage]) -> usize {
+ let total_chars: usize = messages
+ .iter()
+ .map(|m| {
+ m.content.len() + m.action_name.as_ref().map_or(0, |n| n.len()) + 4 // overhead per message (role token, delimiters)
+ })
+ .sum();
+ total_chars.div_ceil(CHARS_PER_TOKEN)
+}
+
+/// Check if compaction should be triggered.
+///
+/// Returns `true` when estimated token count exceeds `threshold_pct` of
+/// the model's context limit.
+pub fn should_compact(
+ messages: &[ThreadMessage],
+ model_context_limit: usize,
+ threshold_pct: f64,
+) -> bool {
+ let tokens = estimate_tokens(messages);
+ let threshold = (model_context_limit as f64 * threshold_pct) as usize;
+ tokens >= threshold
+}
+
+/// The compaction prompt sent to the LLM.
+const COMPACTION_PROMPT: &str = "\
+Summarize your progress so far in a concise but complete way. Include:
+1. What you have accomplished
+2. Key intermediate results and variable values
+3. What still needs to be done
+4. Any errors encountered and how they were handled
+
+Preserve all information needed to continue the task. Be specific about data values.";
+
+/// Compact the message history by asking the LLM to summarize.
+///
+/// Returns the new (shorter) message list and the token usage from the
+/// summarization call. The original messages are replaced with:
+/// `[system_prompt, summary, continuation_note]`
+///
+/// The full original messages are returned separately so the caller can
+/// store them (e.g., in a `history` variable or event log).
+pub async fn compact_messages(
+ messages: &[ThreadMessage],
+ llm: &Arc,
+ compaction_count: u32,
+) -> Result {
+ // Build a summarization request from existing messages + prompt
+ let mut summarize_messages = messages.to_vec();
+ summarize_messages.push(ThreadMessage::user(COMPACTION_PROMPT.to_string()));
+
+ let config = LlmCallConfig {
+ force_text: true,
+ ..LlmCallConfig::default()
+ };
+
+ let output = llm.complete(&summarize_messages, &[], &config).await?;
+
+ let summary_text = match output.response {
+ LlmResponse::Text(t) => t,
+ LlmResponse::ActionCalls { content, .. } | LlmResponse::Code { content, .. } => {
+ content.unwrap_or_else(|| "[compaction produced no summary]".into())
+ }
+ };
+
+ // Preserve the system prompt (first message if it's a system message)
+ let system_msg = messages
+ .iter()
+ .find(|m| m.role == MessageRole::System)
+ .cloned();
+
+ // Build compacted history
+ let mut compacted = Vec::new();
+ if let Some(sys) = system_msg {
+ compacted.push(sys);
+ }
+ compacted.push(ThreadMessage::assistant(summary_text.clone()));
+ compacted.push(ThreadMessage::user(format!(
+ "Your conversation has been compacted {n} time(s). \
+ The summary above captures your progress. Continue working on the task.",
+ n = compaction_count + 1,
+ )));
+
+ let tokens_before = estimate_tokens(messages);
+ let tokens_after = estimate_tokens(&compacted);
+
+ debug!(
+ tokens_before,
+ tokens_after,
+ compaction_count = compaction_count + 1,
+ "context compacted"
+ );
+
+ Ok(CompactionResult {
+ compacted_messages: compacted,
+ summary: summary_text,
+ tokens_used: output.usage,
+ tokens_before,
+ tokens_after,
+ })
+}
+
+/// Result of a compaction operation.
+pub struct CompactionResult {
+ /// The new (shorter) message list.
+ pub compacted_messages: Vec,
+ /// The summary text produced by the LLM.
+ pub summary: String,
+ /// Tokens used by the summarization LLM call.
+ pub tokens_used: TokenUsage,
+ /// Estimated token count before compaction.
+ pub tokens_before: usize,
+ /// Estimated token count after compaction.
+ pub tokens_after: usize,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn estimate_tokens_empty() {
+ assert_eq!(estimate_tokens(&[]), 0);
+ }
+
+ #[test]
+ fn estimate_tokens_basic() {
+ let msgs = vec![
+ ThreadMessage::system("Hello world"), // 11 chars + 4 overhead = 15 / 4 = 3.75
+ ThreadMessage::user("Hi"), // 2 chars + 4 = 6 / 4 = 1.5
+ ];
+ let tokens = estimate_tokens(&msgs);
+ // (11+4 + 2+4) / 4 = 21/4 = 5.25 → 6 (ceiling)
+ assert!(tokens > 0);
+ assert!(tokens < 100);
+ }
+
+ #[test]
+ fn should_compact_below_threshold() {
+ let msgs = vec![ThreadMessage::user("short message")];
+ assert!(!should_compact(&msgs, 128_000, 0.85));
+ }
+
+ #[test]
+ fn should_compact_above_threshold() {
+ // Create a message large enough to trigger compaction at low limit
+ let big = "x".repeat(1000);
+ let msgs = vec![ThreadMessage::user(big)];
+ // 1000 chars / 4 = 250 tokens. Context limit 200, threshold 85% = 170
+ assert!(should_compact(&msgs, 200, 0.85));
+ }
+}
diff --git a/crates/ironclaw_engine/src/executor/context.rs b/crates/ironclaw_engine/src/executor/context.rs
new file mode 100644
index 00000000000..9a48b204f91
--- /dev/null
+++ b/crates/ironclaw_engine/src/executor/context.rs
@@ -0,0 +1,229 @@
+//! Context building for LLM calls.
+//!
+//! Assembles the message sequence and action definitions from thread state,
+//! active leases, and project memory docs retrieved via the [`RetrievalEngine`].
+
+use std::sync::Arc;
+
+use crate::memory::RetrievalEngine;
+use crate::traits::effect::EffectExecutor;
+use crate::types::capability::{ActionDef, CapabilityLease};
+use crate::types::error::EngineError;
+use crate::types::memory::MemoryDoc;
+use crate::types::message::ThreadMessage;
+use crate::types::project::ProjectId;
+
+/// Maximum number of memory docs to inject into context.
+const MAX_CONTEXT_DOCS: usize = 5;
+
+/// Build the context for an LLM call: messages and available actions.
+///
+/// Retrieves relevant memory docs from the project and injects them as a
+/// system message after the main system prompt. This gives the LLM access
+/// to lessons learned, skills, and known issues from prior threads.
+pub async fn build_step_context(
+ messages: &[ThreadMessage],
+ leases: &[CapabilityLease],
+ effects: &Arc,
+ retrieval: Option<&RetrievalEngine>,
+ project_id: ProjectId,
+ user_id: &str,
+ goal: &str,
+) -> Result<(Vec, Vec), EngineError> {
+ // Fetch actions and memory docs in parallel — they are independent.
+ let actions_fut = effects.available_actions(leases);
+ let docs_fut = async {
+ if let Some(engine) = retrieval {
+ engine
+ .retrieve_context(project_id, user_id, goal, MAX_CONTEXT_DOCS)
+ .await
+ } else {
+ Ok(Vec::new())
+ }
+ };
+
+ let (actions_result, docs_result) = tokio::join!(actions_fut, docs_fut);
+ let actions = actions_result?;
+ let docs = docs_result?;
+
+ let mut ctx_messages = messages.to_vec();
+
+ // Inject retrieved memory docs into the existing system prompt.
+ // Many providers require all system messages at the beginning (or a single
+ // system message), so we append to the first system message rather than
+ // inserting a separate one.
+ if !docs.is_empty() {
+ let context_section = format_docs_as_context(&docs);
+ if !ctx_messages.is_empty()
+ && ctx_messages[0].role == crate::types::message::MessageRole::System
+ {
+ // Append to existing system prompt
+ ctx_messages[0].content.push_str("\n\n");
+ ctx_messages[0].content.push_str(&context_section);
+ } else {
+ // No system message — prepend as one
+ ctx_messages.insert(0, ThreadMessage::system(context_section));
+ }
+ }
+
+ Ok((ctx_messages, actions))
+}
+
+/// Format memory docs into a system message for context injection.
+fn format_docs_as_context(docs: &[MemoryDoc]) -> String {
+ let mut parts = vec!["## Prior Knowledge (from completed threads)\n".to_string()];
+
+ for doc in docs {
+ let type_label = match doc.doc_type {
+ crate::types::memory::DocType::Lesson => "LESSON",
+ crate::types::memory::DocType::Spec => "MISSING CAPABILITY",
+ crate::types::memory::DocType::Issue => "KNOWN ISSUE",
+ crate::types::memory::DocType::Summary => "CONTEXT",
+ crate::types::memory::DocType::Note => "NOTE",
+ crate::types::memory::DocType::Skill => "SKILL",
+ crate::types::memory::DocType::Plan => "PLAN",
+ };
+ // Truncate long docs to avoid context bloat
+ let content: String = doc.content.chars().take(500).collect();
+ let truncated = if doc.content.chars().count() > 500 {
+ "..."
+ } else {
+ ""
+ };
+ parts.push(format!(
+ "### [{type_label}] {}\n{content}{truncated}\n",
+ doc.title
+ ));
+ }
+
+ parts.join("\n")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::types::capability::CapabilityLease;
+ use crate::types::memory::DocType;
+ use crate::types::project::ProjectId;
+ use crate::types::step::ActionResult;
+
+ struct MockEffects;
+
+ #[async_trait::async_trait]
+ impl EffectExecutor for MockEffects {
+ async fn execute_action(
+ &self,
+ _: &str,
+ _: serde_json::Value,
+ _: &CapabilityLease,
+ _: &crate::traits::effect::ThreadExecutionContext,
+ ) -> Result {
+ Ok(ActionResult {
+ call_id: String::new(),
+ action_name: String::new(),
+ output: serde_json::json!({}),
+ is_error: false,
+ duration: std::time::Duration::from_millis(1),
+ })
+ }
+
+ async fn available_actions(
+ &self,
+ _: &[CapabilityLease],
+ ) -> Result, EngineError> {
+ Ok(vec![])
+ }
+ }
+
+ #[tokio::test]
+ async fn context_injects_docs_after_system_prompt() {
+ let project = ProjectId::new();
+ let store: Arc =
+ Arc::new(crate::tests::InMemoryStore::with_docs(vec![
+ MemoryDoc::new(
+ project,
+ "test-user",
+ DocType::Lesson,
+ "web tool alias",
+ "Use web_search",
+ ),
+ ]));
+ let retrieval = RetrievalEngine::new(store);
+ let effects: Arc = Arc::new(MockEffects);
+
+ let messages = vec![
+ ThreadMessage::system("You are an assistant."),
+ ThreadMessage::user("search the web"),
+ ];
+
+ let (ctx_msgs, _) = build_step_context(
+ &messages,
+ &[],
+ &effects,
+ Some(&retrieval),
+ project,
+ "test-user",
+ "search the web",
+ )
+ .await
+ .unwrap();
+
+ // Should have 2 messages: system prompt (with docs appended), user message
+ assert_eq!(ctx_msgs.len(), 2);
+ assert_eq!(ctx_msgs[0].role, crate::types::message::MessageRole::System);
+ assert!(ctx_msgs[0].content.contains("You are an assistant."));
+ assert!(ctx_msgs[0].content.contains("Prior Knowledge"));
+ assert!(ctx_msgs[0].content.contains("LESSON"));
+ assert!(ctx_msgs[0].content.contains("web_search"));
+ assert_eq!(ctx_msgs[1].role, crate::types::message::MessageRole::User);
+ }
+
+ #[tokio::test]
+ async fn context_without_retrieval_passes_through() {
+ let effects: Arc = Arc::new(MockEffects);
+ let messages = vec![
+ ThreadMessage::system("prompt"),
+ ThreadMessage::user("hello"),
+ ];
+
+ let (ctx_msgs, _) = build_step_context(
+ &messages,
+ &[],
+ &effects,
+ None,
+ ProjectId::new(),
+ "test-user",
+ "hello",
+ )
+ .await
+ .unwrap();
+
+ // No injection — same number of messages
+ assert_eq!(ctx_msgs.len(), 2);
+ }
+
+ #[tokio::test]
+ async fn context_no_docs_means_no_injection() {
+ let project = ProjectId::new();
+ let store: Arc =
+ Arc::new(crate::tests::InMemoryStore::new());
+ let retrieval = RetrievalEngine::new(store);
+ let effects: Arc = Arc::new(MockEffects);
+
+ let messages = vec![ThreadMessage::user("hello")];
+
+ let (ctx_msgs, _) = build_step_context(
+ &messages,
+ &[],
+ &effects,
+ Some(&retrieval),
+ project,
+ "test-user",
+ "hello",
+ )
+ .await
+ .unwrap();
+
+ assert_eq!(ctx_msgs.len(), 1);
+ }
+}
diff --git a/crates/ironclaw_engine/src/executor/loop_engine.rs b/crates/ironclaw_engine/src/executor/loop_engine.rs
new file mode 100644
index 00000000000..f7fd43ae91e
--- /dev/null
+++ b/crates/ironclaw_engine/src/executor/loop_engine.rs
@@ -0,0 +1,1315 @@
+//! Core execution loop — the replacement for `run_agentic_loop()`.
+//!
+//! The `ExecutionLoop` owns a thread and drives it through LLM call →
+//! action execution → result processing → repeat cycles. Unlike the
+//! existing delegate pattern, the loop is self-contained: all behavior
+//! differences between thread types are handled via capability leases
+//! and policy, not delegate implementations.
+
+use std::sync::Arc;
+
+use tracing::debug;
+
+use crate::capability::lease::LeaseManager;
+use crate::capability::policy::PolicyEngine;
+use crate::runtime::messaging::{SignalReceiver, ThreadOutcome};
+use crate::traits::effect::EffectExecutor;
+use crate::traits::llm::LlmBackend;
+use crate::types::error::EngineError;
+use crate::types::event::EventKind;
+use crate::types::message::ThreadMessage;
+use crate::types::step::Step;
+use crate::types::thread::{Thread, ThreadState};
+
+const RUNTIME_CHECKPOINT_METADATA_KEY: &str = "runtime_checkpoint";
+
+/// Persisted state from a prior execution, used to resume threads.
+/// The Python orchestrator manages loop counters internally; Rust only
+/// needs the opaque `persisted_state` blob to hand back on resume.
+#[derive(Default)]
+struct RuntimeCheckpoint {
+ persisted_state: serde_json::Value,
+}
+
+/// The core execution loop for a thread.
+pub struct ExecutionLoop {
+ pub thread: Thread,
+ llm: Arc,
+ effects: Arc,
+ leases: Arc,
+ policy: Arc,
+ signal_rx: SignalReceiver,
+ /// Stored for potential future use (e.g. user-scoped prompt overlays).
+ _user_id: String,
+ /// Optional capability registry for resolving capability-level policies.
+ capabilities: Option>,
+ /// Optional broadcast sender for live event streaming.
+ event_tx: Option>,
+ /// Optional retrieval engine for injecting prior knowledge into context.
+ retrieval: Option,
+ /// Optional Store for runtime prompt overlay loading and skill retrieval.
+ store: Option>,
+ /// Runtime platform metadata for self-awareness in system prompts.
+ platform_info: Option,
+}
+
+impl ExecutionLoop {
+ pub fn new(
+ thread: Thread,
+ llm: Arc,
+ effects: Arc,
+ leases: Arc,
+ policy: Arc,
+ signal_rx: SignalReceiver,
+ user_id: String,
+ ) -> Self {
+ Self {
+ thread,
+ llm,
+ effects,
+ leases,
+ policy,
+ signal_rx,
+ _user_id: user_id,
+ capabilities: None,
+ event_tx: None,
+ retrieval: None,
+ store: None,
+ platform_info: None,
+ }
+ }
+
+ /// Set the event broadcast sender for live status updates.
+ pub fn with_event_tx(
+ mut self,
+ tx: tokio::sync::broadcast::Sender,
+ ) -> Self {
+ self.event_tx = Some(tx);
+ self
+ }
+
+ /// Set the capability registry for resolving capability-level policies.
+ pub fn with_capabilities(
+ mut self,
+ capabilities: Arc,
+ ) -> Self {
+ self.capabilities = Some(capabilities);
+ self
+ }
+
+ /// Set the retrieval engine for injecting prior knowledge into context.
+ pub fn with_retrieval(mut self, retrieval: crate::memory::RetrievalEngine) -> Self {
+ self.retrieval = Some(retrieval);
+ self
+ }
+
+ /// Set the Store for runtime prompt overlay loading and skill retrieval.
+ pub fn with_store(mut self, store: Arc) -> Self {
+ self.store = Some(store);
+ self
+ }
+
+ /// Set platform metadata for self-awareness in system prompts.
+ pub fn with_platform_info(mut self, info: crate::executor::prompt::PlatformInfo) -> Self {
+ self.platform_info = Some(info);
+ self
+ }
+
+ /// Add an event to the thread and broadcast it for live status updates.
+ fn emit_event(&mut self, kind: EventKind) {
+ let event = crate::types::event::ThreadEvent::new(self.thread.id, kind);
+ if let Some(ref tx) = self.event_tx {
+ let _ = tx.send(event.clone());
+ }
+ self.thread.events.push(event);
+ self.thread.updated_at = chrono::Utc::now();
+ }
+
+ fn load_runtime_checkpoint(&self) -> RuntimeCheckpoint {
+ let persisted_state = self
+ .thread
+ .metadata
+ .get(RUNTIME_CHECKPOINT_METADATA_KEY)
+ .and_then(|value| value.get("persisted_state"))
+ .cloned()
+ .unwrap_or_else(|| serde_json::json!({}));
+
+ RuntimeCheckpoint { persisted_state }
+ }
+
+ fn clear_runtime_checkpoint(&mut self) {
+ if let Some(metadata) = self.thread.metadata.as_object_mut() {
+ metadata.remove(RUNTIME_CHECKPOINT_METADATA_KEY);
+ }
+ self.thread.updated_at = chrono::Utc::now();
+ }
+
+ async fn persist_runtime_state(
+ &self,
+ step: Option<&Step>,
+ persisted_event_count: &mut usize,
+ ) -> Result<(), EngineError> {
+ let Some(store) = self.store.as_ref() else {
+ return Ok(());
+ };
+
+ // All three store writes are independent — run them in parallel.
+ let step_fut = async {
+ if let Some(step) = step {
+ store.save_step(step).await
+ } else {
+ Ok(())
+ }
+ };
+
+ let new_event_count = self.thread.events.len();
+ let events_fut = async {
+ if *persisted_event_count < new_event_count {
+ store
+ .append_events(&self.thread.events[*persisted_event_count..])
+ .await
+ } else {
+ Ok(())
+ }
+ };
+
+ let thread_fut = store.save_thread(&self.thread);
+
+ let (step_res, events_res, thread_res) = tokio::join!(step_fut, events_fut, thread_fut);
+ step_res?;
+ events_res?;
+ thread_res?;
+
+ *persisted_event_count = new_event_count;
+ Ok(())
+ }
+
+ /// Run the execution loop to completion.
+ pub async fn run(&mut self) -> Result {
+ let mut persisted_event_count = self.thread.events.len();
+ let checkpoint = self.load_runtime_checkpoint();
+
+ // Transition to Running if this is a fresh start or restart from a resumable state.
+ if self.thread.state != ThreadState::Running {
+ self.thread.transition_to(ThreadState::Running, None)?;
+ }
+
+ // Pre-fetch shared memory docs once — used by both prompt overlay and
+ // orchestrator loading, avoiding a duplicate Store query.
+ let system_docs = if let Some(store) = self.store.as_ref() {
+ match store.list_shared_memory_docs(self.thread.project_id).await {
+ Ok(docs) => docs,
+ Err(e) => {
+ debug!("failed to load shared docs for orchestrator: {e}");
+ Vec::new()
+ }
+ }
+ } else {
+ Vec::new()
+ };
+
+ // Inject CodeAct/RLM system prompt if none exists
+ if !self
+ .thread
+ .messages
+ .iter()
+ .any(|m| m.role == crate::types::message::MessageRole::System)
+ {
+ // Fetch active leases (needed for action list)
+ let active_leases = self.leases.active_for_thread(self.thread.id).await;
+ let actions = match self.effects.available_actions(&active_leases).await {
+ Ok(a) => a,
+ Err(e) => {
+ debug!(thread_id = %self.thread.id, "failed to load actions for system prompt: {e}");
+ Vec::new()
+ }
+ };
+ // Build prompt using pre-fetched docs (no extra Store query)
+ let system_prompt = crate::executor::prompt::build_codeact_system_prompt_with_docs(
+ &actions,
+ &system_docs,
+ self.platform_info.as_ref(),
+ );
+
+ // Skill selection and injection happens in the Python orchestrator
+ // via __list_skills__() host function — not here in Rust.
+
+ self.thread
+ .messages
+ .insert(0, ThreadMessage::system(system_prompt));
+ }
+ self.persist_runtime_state(None, &mut persisted_event_count)
+ .await?;
+
+ // Load versioned Python orchestrator using pre-fetched docs.
+ // Self-modification is disabled by default — only the compiled-in v0
+ // runs unless explicitly opted in via ORCHESTRATOR_SELF_MODIFY=true.
+ let allow_self_modify = std::env::var("ORCHESTRATOR_SELF_MODIFY")
+ .map(|v| v == "true" || v == "1")
+ .unwrap_or(false);
+ let (orchestrator_code, orchestrator_version) =
+ crate::executor::orchestrator::load_orchestrator_from_docs(
+ &system_docs,
+ allow_self_modify,
+ );
+
+ debug!(
+ thread_id = %self.thread.id,
+ orchestrator_version,
+ "running Python orchestrator"
+ );
+
+ // Store version in thread metadata for rollback tracking
+ if let Some(metadata) = self.thread.metadata.as_object_mut() {
+ metadata.insert(
+ "orchestrator_version".into(),
+ serde_json::json!(orchestrator_version),
+ );
+ }
+
+ // Execute the Python orchestrator with host function dispatch
+ let result = crate::executor::orchestrator::execute_orchestrator(
+ &orchestrator_code,
+ &mut self.thread,
+ &self.llm,
+ &self.effects,
+ &self.leases,
+ &self.policy,
+ &mut self.signal_rx,
+ self.event_tx.as_ref(),
+ self.retrieval.as_ref(),
+ self.store.as_ref(),
+ &checkpoint.persisted_state,
+ )
+ .await;
+
+ // Post-cleanup: persist final state, track failures for auto-rollback
+ match result {
+ Ok(orch_result) => {
+ // Reset failure counter on success
+ if let Some(store) = self.store.as_ref() {
+ crate::executor::orchestrator::reset_orchestrator_failures(
+ store,
+ self.thread.project_id,
+ )
+ .await;
+ }
+ let _ = &orch_result.tokens_used;
+
+ self.clear_runtime_checkpoint();
+ self.persist_runtime_state(None, &mut persisted_event_count)
+ .await?;
+ Ok(orch_result.outcome)
+ }
+ Err(e) => {
+ debug!(
+ thread_id = %self.thread.id,
+ error = %e,
+ orchestrator_version,
+ "orchestrator execution failed"
+ );
+
+ // Record failure for auto-rollback tracking
+ if let Some(store) = self.store.as_ref() {
+ crate::executor::orchestrator::record_orchestrator_failure(
+ store,
+ self.thread.project_id,
+ orchestrator_version,
+ )
+ .await;
+
+ // Emit rollback event if this version will be skipped next time
+ // (failure count was just incremented, so check >= threshold - 1)
+ if orchestrator_version > 0 {
+ self.emit_event(EventKind::OrchestratorRollback {
+ from_version: orchestrator_version,
+ to_version: orchestrator_version.saturating_sub(1),
+ reason: format!("execution failed: {e}"),
+ });
+ }
+ }
+
+ // Transition to failed if not already in a terminal state
+ if self.thread.state != ThreadState::Completed
+ && self.thread.state != ThreadState::Failed
+ && self.thread.state != ThreadState::Done
+ {
+ let _ = self.thread.transition_to(
+ ThreadState::Failed,
+ Some(format!("orchestrator error: {e}")),
+ );
+ }
+ self.clear_runtime_checkpoint();
+ self.persist_runtime_state(None, &mut persisted_event_count)
+ .await?;
+ Ok(ThreadOutcome::Failed {
+ error: format!("Orchestrator error: {e}"),
+ })
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ /// Extract a FINAL() answer from the LLM's text response.
+ ///
+ /// Matches `FINAL(...)` anywhere in the text, handling:
+ /// - Single-line: `FINAL("the answer")`
+ /// - Multi-line: `FINAL("""\n...\n""")`
+ /// - With or without quotes
+ fn extract_final_from_text(text: &str) -> Option {
+ let marker = "FINAL(";
+ let start = text.find(marker)?;
+ let content_start = start + marker.len();
+ let remaining = &text[content_start..];
+
+ // Try triple-quoted string first: FINAL("""...""")
+ if remaining.starts_with("\"\"\"") {
+ let inner_start = 3;
+ if let Some(end) = remaining[inner_start..].find("\"\"\"") {
+ let answer = remaining[inner_start..inner_start + end].trim();
+ if !answer.is_empty() {
+ return Some(answer.to_string());
+ }
+ }
+ }
+
+ // Try single/double quoted: FINAL("...") or FINAL('...')
+ if remaining.starts_with('"') || remaining.starts_with('\'') {
+ let quote = remaining.as_bytes()[0] as char;
+ if let Some(end) = remaining[1..].find(quote) {
+ let answer = &remaining[1..1 + end];
+ if !answer.is_empty() {
+ return Some(answer.to_string());
+ }
+ }
+ }
+
+ // Unquoted: FINAL(some content here) — find matching close paren
+ let mut depth = 1;
+ for (i, ch) in remaining.char_indices() {
+ match ch {
+ '(' => depth += 1,
+ ')' => {
+ depth -= 1;
+ if depth == 0 {
+ let answer = remaining[..i].trim(); // safety: i is from char_indices(), always a valid boundary
+ if !answer.is_empty() {
+ return Some(answer.to_string());
+ }
+ return None;
+ }
+ }
+ _ => {}
+ }
+ }
+
+ None
+ }
+ use super::*;
+ use crate::runtime::messaging::ThreadSignal;
+ use crate::traits::effect::ThreadExecutionContext;
+ use crate::traits::llm::{LlmCallConfig, LlmOutput};
+ use crate::types::capability::{ActionDef, CapabilityLease, EffectType, GrantedActions};
+ use crate::types::project::ProjectId;
+ use crate::types::step::LlmResponse;
+ use crate::types::step::{ActionResult, TokenUsage};
+ use crate::types::thread::{ThreadConfig, ThreadType};
+
+ use std::sync::Mutex;
+ use std::time::Duration;
+
+ // ── Mock LLM ────────────────────────────────────────────
+
+ struct MockLlm {
+ responses: Mutex>,
+ }
+
+ impl MockLlm {
+ fn new(responses: Vec) -> Self {
+ Self {
+ responses: Mutex::new(responses),
+ }
+ }
+ }
+
+ #[async_trait::async_trait]
+ impl LlmBackend for MockLlm {
+ async fn complete(
+ &self,
+ _messages: &[ThreadMessage],
+ _actions: &[ActionDef],
+ _config: &LlmCallConfig,
+ ) -> Result {
+ let mut responses = self.responses.lock().unwrap();
+ if responses.is_empty() {
+ Ok(LlmOutput {
+ response: LlmResponse::Text("(no more responses)".into()),
+ usage: TokenUsage::default(),
+ })
+ } else {
+ Ok(responses.remove(0))
+ }
+ }
+
+ fn model_name(&self) -> &str {
+ "mock"
+ }
+ }
+
+ // ── Mock EffectExecutor ─────────────────────────────────
+
+ struct MockEffects {
+ results: Mutex>>,
+ actions: Vec,
+ }
+
+ impl MockEffects {
+ fn new(actions: Vec, results: Vec>) -> Self {
+ Self {
+ results: Mutex::new(results),
+ actions,
+ }
+ }
+ }
+
+ #[async_trait::async_trait]
+ impl EffectExecutor for MockEffects {
+ async fn execute_action(
+ &self,
+ _action_name: &str,
+ _parameters: serde_json::Value,
+ _lease: &CapabilityLease,
+ _context: &ThreadExecutionContext,
+ ) -> Result {
+ let mut results = self.results.lock().unwrap();
+ if results.is_empty() {
+ Ok(ActionResult {
+ call_id: String::new(),
+ action_name: String::new(),
+ output: serde_json::json!({"result": "ok"}),
+ is_error: false,
+ duration: Duration::from_millis(1),
+ })
+ } else {
+ results.remove(0)
+ }
+ }
+
+ async fn available_actions(
+ &self,
+ _leases: &[CapabilityLease],
+ ) -> Result, EngineError> {
+ Ok(self.actions.clone())
+ }
+ }
+
+ // ── Helpers ─────────────────────────────────────────────
+
+ fn text_response(text: &str) -> LlmOutput {
+ LlmOutput {
+ response: LlmResponse::Text(text.into()),
+ usage: TokenUsage {
+ input_tokens: 100,
+ output_tokens: 50,
+ ..Default::default()
+ },
+ }
+ }
+
+ fn action_response(action_name: &str, call_id: &str) -> LlmOutput {
+ LlmOutput {
+ response: LlmResponse::ActionCalls {
+ calls: vec![crate::types::step::ActionCall {
+ id: call_id.into(),
+ action_name: action_name.into(),
+ parameters: serde_json::json!({}),
+ }],
+ content: None,
+ },
+ usage: TokenUsage {
+ input_tokens: 100,
+ output_tokens: 50,
+ ..Default::default()
+ },
+ }
+ }
+
+ fn test_action() -> ActionDef {
+ ActionDef {
+ name: "test_tool".into(),
+ description: "A test tool".into(),
+ parameters_schema: serde_json::json!({"type": "object"}),
+ effects: vec![EffectType::ReadLocal],
+ requires_approval: false,
+ }
+ }
+
+ async fn make_loop(
+ llm_responses: Vec,
+ effect_results: Vec>,
+ config: ThreadConfig,
+ ) -> (ExecutionLoop, crate::runtime::messaging::SignalSender) {
+ let project_id = ProjectId::new();
+ let thread = Thread::new(
+ "test goal",
+ ThreadType::Foreground,
+ project_id,
+ "test-user",
+ config,
+ );
+ let tid = thread.id;
+
+ let llm = Arc::new(MockLlm::new(llm_responses));
+ let effects = Arc::new(MockEffects::new(vec![test_action()], effect_results));
+ let leases = Arc::new(LeaseManager::new());
+ let policy = Arc::new(PolicyEngine::new());
+
+ // Grant a default lease
+ leases
+ .grant(tid, "test_cap", GrantedActions::All, None, None)
+ .await
+ .unwrap();
+
+ let (tx, rx) = crate::runtime::messaging::signal_channel(16);
+
+ let exec = ExecutionLoop::new(thread, llm, effects, leases, policy, rx, "test-user".into());
+ (exec, tx)
+ }
+
+ // ── Tests ───────────────────────────────────────────────
+
+ #[tokio::test]
+ async fn text_response_completes() {
+ let (mut exec, _tx) = make_loop(
+ vec![text_response("Hello!")],
+ vec![],
+ ThreadConfig::default(),
+ )
+ .await;
+
+ let outcome = exec.run().await.unwrap();
+ assert!(matches!(outcome, ThreadOutcome::Completed { response: Some(r) } if r == "Hello!"));
+ assert!(exec.thread.state.is_terminal() || exec.thread.state == ThreadState::Completed);
+ assert_eq!(exec.thread.step_count, 1);
+ assert!(exec.thread.total_tokens_used > 0);
+ }
+
+ #[tokio::test]
+ async fn action_then_text() {
+ let (mut exec, _tx) = make_loop(
+ vec![
+ action_response("test_tool", "call_1"),
+ text_response("Done!"),
+ ],
+ vec![Ok(ActionResult {
+ call_id: "call_1".into(),
+ action_name: "test_tool".into(),
+ output: serde_json::json!({"data": "result"}),
+ is_error: false,
+ duration: Duration::from_millis(5),
+ })],
+ ThreadConfig::default(),
+ )
+ .await;
+
+ let outcome = exec.run().await.unwrap();
+ assert!(matches!(outcome, ThreadOutcome::Completed { response: Some(r) } if r == "Done!"));
+ assert_eq!(exec.thread.step_count, 2);
+ // Orchestrator-driven flow: working messages live in `internal_messages`
+ // (set by `sync_runtime_state` when the orchestrator persists state),
+ // while `thread.messages` only carries the system prompt + final
+ // assistant response. The full conversation transcript (system,
+ // assistant+actions, action_result, assistant) is in internal_messages.
+ assert!(exec.thread.internal_messages.len() >= 3);
+ }
+
+ #[tokio::test]
+ async fn max_iterations_reached() {
+ // LLM always returns actions, so it never exits naturally
+ let many_actions: Vec = (0..5)
+ .map(|i| action_response("test_tool", &format!("call_{i}")))
+ .collect();
+
+ let many_results: Vec> = (0..5)
+ .map(|i| {
+ Ok(ActionResult {
+ call_id: format!("call_{i}"),
+ action_name: "test_tool".into(),
+ output: serde_json::json!({"i": i}),
+ is_error: false,
+ duration: Duration::from_millis(1),
+ })
+ })
+ .collect();
+
+ let config = ThreadConfig {
+ max_iterations: 3,
+ ..ThreadConfig::default()
+ };
+
+ let (mut exec, _tx) = make_loop(many_actions, many_results, config).await;
+
+ let outcome = exec.run().await.unwrap();
+ // The last iteration forces text mode, and MockLlm returns action_response
+ // which gets treated as the 3rd iteration, then on the 3rd iteration force_text
+ // is set. But MockLlm ignores force_text. So we get MaxIterations after 3 iterations.
+ // Actually, max_iterations=3, and force_text is set when iteration >= max-1 = 2,
+ // so iteration 2 (0-indexed) has force_text. The MockLlm still returns action calls,
+ // so we loop 3 times and exit.
+ assert!(matches!(
+ outcome,
+ ThreadOutcome::MaxIterations | ThreadOutcome::Completed { .. }
+ ));
+ assert!(exec.thread.step_count <= 3);
+ }
+
+ #[tokio::test]
+ async fn stop_signal_exits() {
+ // LLM would loop forever, but we send a stop signal
+ let many_actions: Vec