Compare commits

...
Author SHA1 Message Date
f05896fe6a Migrate GitHub webhook normalization into github tool (#758)
* Add event-triggered routines and workflow skill templates

* Add generic host-verified webhook ingress for tools

* Migrate GitHub webhook normalization into github tool

* Bump github tool registry version

* Stabilize trace E2E test rig and approval behavior

* Add reusable gateway workflow harness with mock LLM server (#762)

* Add reusable gateway workflow test harness with mock LLM server

* Fix clippy issues in workflow harness

* Stabilize trace E2E test rig and approval behavior

* Address PR review feedback on gateway workflow harness

- Extract shared TestChannelHandle into test_channel.rs with name override
  support, eliminating ~55 lines of duplication between test_rig.rs and
  gateway_workflow_harness.rs
- Remove redundant RoutineEngine creation that was immediately overwritten
  by Agent::run()
- Replace flaky sleep(500ms) with polling loop for routine run count check
- Use components.context_manager instead of creating a fresh ContextManager
  for job tools, ensuring agent and tools share the same instance

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Fix import ordering in gateway_workflow_harness

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>

* Address PR #758 review feedback

- Fix header_value to use fully case-insensitive lookup (iterate with
  to_ascii_lowercase) instead of checking only exact/lower/upper variants
- Change comment_id from u32 to u64 to handle GitHub's billion-range IDs
- Remove handle_webhook from LLM-facing JSON schema to prevent direct
  invocation bypassing HMAC verification
- Rename enrichment keys from repository/sender to repository_name/
  sender_login to preserve original JSON objects in webhook payloads
- Remove put_string_normalized helper (no longer needed)
- Replace no-op tests (test_validate_event_in_create_pr_review,
  test_validate_merge_method) with test_header_value_case_insensitive
- Add README docs for 6 undocumented actions (list_issue_comments,
  create_issue_comment, list_pull_request_comments,
  reply_pull_request_comment, get_pull_request_reviews,
  get_combined_status)
- Add comment explaining max_tool_calls <= 8 bound in e2e test
- Fix gateway workflow harness: add webhook_capability with secret auth
  to MockGithubWebhookTool, matching staging's hardened webhook security
- Fix merge artifacts: remove duplicate test function, orphaned code
  fragment in e2e_routine_heartbeat

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Fix formatting in gateway workflow harness

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Address Copilot review: filter keys, pr_number fallback, feature gate, version alignment

- Update SKILL.md and workflow-routines.md templates to use `repository_name`
  and `sender_login` (matching enriched payload field names)
- Mark webhook HMAC secret as required in SKILL.md prerequisites
- Fall back to `/issue/number` for `pr_number` on issue_comment PR webhooks
- Gate `gateway_workflow_harness` module behind `#[cfg(feature = "libsql")]`
- Align tool version to 0.2.1 in Cargo.toml and capabilities.json

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-12 01:52:47 +00:00
febed1e12e feat: add cargo-deny for supply chain safety (#834)
* feat: add cargo-deny for supply chain safety

Add dependency auditing via cargo-deny to catch license violations,
security advisories, and untrusted sources. Integrates into CI as a
parallel job alongside clippy, and into the local quality gate script.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: use cargo-deny action in CI, improve quality gate script

- Use EmbarkStudios/cargo-deny-action@v2 instead of cargo install
  for faster CI execution
- Fix quality_gate_strict.sh to check for cargo-deny availability
  instead of suppressing stderr

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: add missing Unlicense and CDLA-Permissive-2.0 to license allowlist

Add Unlicense (used by aho-corasick, memchr, etc.) and
CDLA-Permissive-2.0 (used by webpki-roots) to prevent
cargo deny check from failing on the current dependency tree.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* chore: trigger CI after retargeting PR to staging

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: use valid cargo-deny v0.19 syntax for unmaintained advisories

The `unmaintained` field in [advisories] accepts "all", "workspace",
"transitive", or "none" — not "warn". Use "workspace" to flag
unmaintained direct dependencies without failing on transitive ones.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* chore: re-trigger CI after adding skip-regression-check label

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: migrate deny.toml [licenses] to version 2 format

Remove deprecated `unlicensed` and `default` fields, add `version = 2`.
In v2, all licenses are denied unless explicitly in the allow list,
making these fields redundant.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: ignore pre-existing advisories in deny.toml with justification

Add known RUSTSEC IDs to the ignore list so cargo-deny CI passes.
Each advisory is documented with mitigation context. Dependency
upgrades to resolve these should be tracked separately.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review feedback for cargo-deny integration

- quality_gate_strict.sh: fail hard when cargo-deny is not installed
  instead of silently skipping, and let set -e handle check failures
- deny.toml: remove empty [graph].targets so cargo-deny checks all
  platforms instead of only the runner's default target

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(deny.toml): correct serde_yml advisory comment to reflect direct dependency

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: tighten clippy-windows check in roll-up job

Change from checking only `== "failure"` to checking
`!= "success" && != "skipped"`. This ensures any unexpected
result (e.g., cancelled) also blocks the merge, while still
allowing the expected "skipped" state for non-main PRs.

Addresses zmanian's review feedback on PR #834.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: cd to repo root in strict gate, deny wildcard versions

- quality_gate_strict.sh: add `cd` to repo root so the script works
  when invoked from any working directory.
- deny.toml: change `wildcards = "allow"` to `"deny"` to catch `*`
  version requirements in dependencies.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-11 18:50:15 -07:00
Zaki ManianandGitHub c37b64124c fix(setup): preserve model selection on provider re-run (#679) (#987) 2026-03-12 01:14:25 +00:00
Zaki ManianandGitHub f31cd13135 fix(mcp): attach session manager for non-OAuth HTTP clients (#793) (#986)
* fix(mcp): attach session manager for non-OAuth HTTP clients (#793)

* style(mcp): format factory regression test (#986)
2026-03-12 01:12:01 +00:00
Zaki ManianandGitHub 195ff44b1a fix(security): migrate webhook auth to HMAC-SHA256 signature header (#970) 2026-03-12 01:10:26 +00:00
a9821ac20f fix(security): make unsafe env::set_var calls safe with explicit invariants (#968)
* fix(security): make unsafe env::set_var calls safe with explicit invariants

`std::env::set_var` is unsafe in Rust 1.82+ because concurrent calls
from multiple threads cause undefined behavior. This commit addresses
the two production-code call sites:

1. `bootstrap.rs:load_ironclaw_env()` -- called before the Tokio
   runtime starts (genuinely single-threaded). Added a `debug_assert!`
   that verifies no Tokio runtime is active, making the safety
   invariant machine-checkable rather than relying on a comment.

2. `llm/session.rs:api_key_login()` -- was calling `set_var` mid-
   execution inside the multi-threaded Tokio runtime (UB risk).
   Replaced with `set_runtime_env()`, a new thread-safe overlay
   backed by `OnceLock<Mutex<HashMap>>`. The overlay integrates with
   the existing `optional_env()` config resolution and a new
   `env_or_override()` reader function.

All call sites that read `NEARAI_API_KEY` via raw `std::env::var()`
(wizard.rs, main.rs, doctor.rs) are updated to use the thread-safe
`env_or_override()` helper instead, so the value set during
interactive login is visible without mutating the process environment.

Test code `set_var`/`remove_var` calls (bootstrap tests, config tests,
shell tests, oauth tests, wizard tests) are left as-is since they run
under `ENV_MUTEX` serialization and are not production paths.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix: address review feedback on thread-safe env overlay PR

- Replace debug_assert! with runtime check in bootstrap.rs so release
  builds skip unsafe set_var when a Tokio runtime is active
- Recover from mutex poison in set_runtime_env instead of silently
  dropping writes (poisoned HashMap is still usable)
- Skip empty override values in env_or_override and optional_env for
  consistency with real env var handling
- Fix doc comment on env_or_override (real env checked first, not
  runtime overrides)
- Update api_key_login doc to describe runtime overlay instead of
  env var mutation

[skip-regression-check]

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix: use LazyLock::lock() for INJECTED_VARS; use set_runtime_env() in bootstrap fallback

- helpers.rs: fix env_or_override() to call INJECTED_VARS.lock() instead
  of .get() — INJECTED_VARS was changed upstream from OnceLock<HashMap>
  to LazyLock<Mutex<HashMap>>; calling .get() caused a compile error
  (E0599: no method named 'get' for LazyLock)

- bootstrap.rs: when load_ironclaw_env() is called with an active Tokio
  runtime, use set_runtime_env("DATABASE_BACKEND", "libsql") instead of
  silently dropping the write. This ensures DATABASE_BACKEND is always
  set regardless of thread context (addresses ilblackdragon review item 1).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

---------

Co-authored-by: Gabe Hamilton <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-12 00:59:58 +00:00
8bbb43da52 fix(security): require explicit SANDBOX_ALLOW_FULL_ACCESS to enable FullAccess policy (#967)
* fix(security): require explicit SANDBOX_ALLOW_FULL_ACCESS to enable FullAccess policy

FullAccess policy bypasses Docker entirely and runs commands via sh -c
directly on the host. Previously, setting SANDBOX_POLICY=full_access
alone was sufficient to enable this, which could be triggered
accidentally or via prompt injection if tool approval is bypassed.

This adds a double opt-in guard:

- New SANDBOX_ALLOW_FULL_ACCESS=true env var must ALSO be set for
  FullAccess to take effect. Without it, the policy is downgraded to
  WorkspaceWrite with a tracing::error! log.

- At execution time, every FullAccess command emits a tracing::warn!
  with the command and working directory for audit visibility.

- The FullAccess variant now documents its blast radius (host shell,
  unrestricted filesystem/network/environment).

- SandboxConfig and SandboxModeConfig gain an allow_full_access field,
  wired through from_env() and the builder.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(sandbox): address review feedback on FullAccess double opt-in

- Add doc comment on builder .policy() warning that FullAccess requires
  .allow_full_access(true) or execution will return SandboxError::Config
- Sanitize audit log: log only binary name instead of full command to
  prevent secret leakage; add [FullAccess] prefix for grep-ability
- Add test_builder_full_access_without_allow_returns_error test covering
  the builder path without explicit allow_full_access(true)
- Fix doc comment mismatch: config.rs and SandboxPolicy::FullAccess docs
  said "will downgrade to WorkspaceWrite" but runtime returns
  SandboxError::Config -- aligned docs with actual behavior

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix: merge duplicate mod tests; add allow_full_access to struct initializers

After upstream merge, src/config/sandbox.rs had two issues:
- Duplicate mod tests block (upstream's original tests at line 271 + our
  new FullAccess guard tests at line 478) caused E0428 compile error
- Upstream test struct literals for SandboxModeConfig were missing the
  new allow_full_access field (E0063)

Fixes: merge the two mod tests into one; add allow_full_access: false to
the sandbox_mode_config_custom_values and sandbox_mode_to_sandbox_config
test struct initializers.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

---------

Co-authored-by: Gabe Hamilton <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-12 00:57:05 +00:00
f48fe95ac4 fix(security): add Content-Security-Policy header to web gateway (#966)
* fix(security): add Content-Security-Policy header to web gateway

The web gateway set X-Frame-Options and X-Content-Type-Options but had
no Content-Security-Policy header. Without CSP, there is no browser-
enforced mitigation against XSS attacks. This adds a tailored CSP that
matches the resources the frontend actually loads.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(security): address CSP review feedback

- Remove cdnjs.cloudflare.com from script-src (not used in codebase)
- Add explicit object-src 'none' per security best practice
- Add regression test asserting CSP header presence and directives

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

---------

Co-authored-by: Gabe Hamilton <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-12 00:55:38 +00:00
Jonas WiklundGitHubJonas Wiklund <Jonas Wiklund>
acea1143cf Fix systemctl unit (#472)
Co-authored-by: Jonas Wiklund <Jonas Wiklund>
2026-03-11 17:04:11 -07:00
CPU-216andGitHub c372c99729 fix(test): stabilize openai compat oversized-body regression (#839)
* fix(test): stabilize openai compat oversized-body regression

* docs(web): fix stale body limit in CLAUDE.md (1 MB → 10 MB)

CLAUDE.md:200 still documented the pre-#725 body limit of 1 MB, but
server.rs:354 was changed to 10 MB in #725 (image upload support).
Update the documentation to match the actual production value.
2026-03-11 17:03:12 -07:00
81f7b64994 fix(ci): disambiguate WASM bundle filenames to prevent tool/channel collision (#964)
* fix(ci): disambiguate WASM bundle filenames to prevent tool/channel collision

When a tool and channel share the same name (e.g. slack, telegram), the
CI build produced identical bundle filenames, causing the second to
overwrite the first. Both manifests then pointed to the wrong binary.

Prefix bundle filenames with the extension kind (tool-slack-... vs
channel-slack-...) and parse the prefix when patching manifests, so each
manifest receives the correct artifact URL and SHA256.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* test(registry): add installer tests for tool/channel name disambiguation

Regression tests for the CI artifact collision fix (PR #964). Verifies:
- extract_tar_gz rejects archives with wrong wasm name (the collision bug)
- Tool bundle extracts slack-tool.wasm correctly
- Channel bundle extracts slack.wasm correctly
- Tool and channel manifests install to separate directories

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(ci): add kind validation and filter non-WASM checksum entries

- Validate .kind is "tool" or "channel" before using in build-wasm-extensions (hard error)
- Filter checksums.txt to *-wasm32-wasip2.tar.gz entries before parsing, avoiding noisy warnings from binary artifact entries in build-local-artifacts
- Add kind validation with warning+skip in both checksum-parsing loops

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: fix rustfmt formatting in installer tests

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-11 17:02:11 -07:00
ReidandGitHub 977b7fde99 feat(setup): display ASCII art banner during onboarding (#851)
[skip-regression-check]
2026-03-11 16:54:42 -07:00
ArtemandGitHub f3e8e7c599 docs: add Russian localization (README.ru.md) (#850) 2026-03-11 16:54:21 -07:00
Protocol ZeroandGitHub d47282f444 fix(setup): validate channel credentials during setup (#684)
* fix(setup): validate channel credentials during setup

Validate channel setup credentials against the declared validation endpoint so users get immediate feedback before startup failures. Substitute stored secrets into the validation URL, block private or local targets, and warn on failed checks without interrupting setup.

Made-with: Cursor

* fix(setup): harden channel credential validation

Pin setup-time validation requests to vetted DNS results, disable redirects, and avoid leaking substituted secrets in error output. URL-encode placeholder substitutions and add regressions for DNS failure, trailing-dot localhost, and IPv4-mapped IPv6 SSRF bypasses.

Made-with: Cursor

* refactor(setup): cache validation placeholder regex

Reuse a static placeholder regex in channel credential validation so the SSRF hardening path avoids recompiling the same pattern on every call.
2026-03-11 16:53:52 -07:00
adios2d6andGitHub 5879d06447 fix: drain tunnel pipes to prevent zombie process (#735)
* fix(tunnel): drain ngrok stdout/stderr to prevent zombie process

* fix: limit stderr lines read on startup failure to prevent OOM

* fix: drain pipes in cloudflare and custom tunnel to prevent zombie process

* style: fix formatting in custom tunnel

* test: add regression test for stdout drain preventing zombie process

* style: apply rustfmt
2026-03-11 16:53:38 -07:00
ReidandGitHub a1b3911b27 fix(mcp): header safety validation and Authorization conflict bug from #704 (#752)
* fix(mcp): header safety validation and Authorization conflict bug from #704

* fix(mcp): enforce RFC 9110 header validation on all config load paths

  Replace hand-written CRLF checks with reqwest::header::HeaderName::from_bytes()
  and HeaderValue::from_str(), catching spaces, colons, null bytes, and all
  non-token characters that the previous validation missed.

  Add validation to load_mcp_servers_from() and load_mcp_servers_from_db() so
  corrupted configs from disk or DB are rejected at load time instead of silently
  flowing through to McpClient. Improve app.rs error handling to distinguish
  "no config" from "corrupted config" (including malformed JSON).

  Also fix build_request_headers() to check self.custom_headers directly instead
  of indirectly via server_config, and clarify the wire test comment about
  HeaderMap::insert replacement semantics.

* fix ci issue
2026-03-11 16:53:02 -07:00
pikaxingeandGitHub 2094d6e30d fix(agent): block thread_id-based context pollution across users (#760)
* fix(agent): prevent forged thread UUID context/write contamination

* fix(agent): close thread_id race and reject forged UUID hydration

* fix(ci): satisfy clippy and fmt checks after rebase
2026-03-11 16:52:31 -07:00
ReidandGitHub c8cac0925d fix(mcp): stdio/unix transports skip initialize handshake (#890) (#935)
fixes #890

  - Always call initialize() before list_tools()/call_tool(), removing
    the session_manager.is_some() guard that caused stdio/unix clients
    to skip the MCP protocol handshake entirely
  - Add local AtomicBool flag for idempotent initialization when no
    session manager is present
  - Fire-and-forget JSON-RPC notifications (id=None) in stdio/unix
    transports instead of registering a pending response that would
    block for 30s waiting on a reply that never comes
  - Fix mcp test panic on stdio/unix servers by using
    create_client_from_config() instead of new_with_config() which
    asserts HTTP-only transport
2026-03-11 16:46:40 -07:00
ReidandGitHub 6321bb4688 fix(setup): drain residual events and filter key kind in onboard prompts (#937) (#949)
On Windows, single keypresses during `ironclaw onboard` are registered
  twice, causing channel/tool selection to skip or toggle incorrectly.
  Two root causes:

  1. select_many() had no residual event drain, so Enter from a prior
     prompt was immediately consumed on entry, skipping the selection.

  2. Neither select_many() nor read_secret_line() filtered on
     KeyEventKind::Press, so Windows Key Release/Repeat events caused
     every keypress to fire twice (Space toggles cancel out, Enter
     triggers double-advance, arrows jump two positions).

  Extract a shared drain_pending_events() helper (replacing the inline
  drain in read_secret_line from #849), add it to select_many() entry,
  and filter both event loops to only handle KeyEventKind::Press.

  Fixes #937
[skip-regression-check]
2026-03-11 16:46:03 -07:00
94b448ffab fix(security): load WASM tool description and schema from capabilities.json (#520)
The extract_tool_description and extract_tool_schema stubs in runtime.rs
returned permissive fallbacks ("WASM sandboxed tool" and
additionalProperties:true) for every WASM tool, defeating parameter
validation and preventing the LLM from using tools correctly.

Add optional `description` and `parameters` fields to CapabilitiesFile so
tool authors can declare proper metadata in their sidecar JSON. The
WasmToolLoader now extracts these fields and passes them through to the
tool registry as overrides. Tools without a capabilities.json or without
these fields get a tracing::warn and fall back to the old stubs.

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-11 16:29:59 -07:00
bb06565770 fix(security): resolve DNS once and reuse for SSRF validation to prevent rebinding (#518)
* fix(security): resolve DNS once and reuse for SSRF validation to prevent rebinding

The previous SSRF protection resolved DNS in validate_url() to check IPs
against a blocklist, but then reqwest independently re-resolved DNS when
making the actual HTTP connection.  Between validation and connection, a
DNS rebinding attack could flip the record from a public IP (passes
validation) to a private IP like 169.254.169.254 (AWS metadata endpoint).

Fix: split URL validation into two phases:
- validate_url(): synchronous URL structure checks (scheme, localhost,
  IP literals) -- no DNS resolution
- validate_and_resolve_url(): async DNS resolution via
  tokio::net::lookup_host, validates all resolved IPs, returns
  SocketAddrs
- build_pinned_client(): constructs a per-request reqwest Client with
  resolve() pinning so reqwest connects to the pre-validated IPs without
  a second DNS lookup

Applied to both HttpTool and WebFetchTool.  WebFetchTool builds a fresh
pinned client per redirect hop, ensuring DNS rebinding cannot occur at
any point in a redirect chain.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* style: run cargo fmt

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-11 16:18:43 -07:00
19d9562b4f feat(extensions): unify auth and configure into single entrypoint (#677)
* feat(extensions): unify auth and configure into single entrypoint

Refactors the extension lifecycle to eliminate the divergence between
chat and gateway paths that caused Telegram setup via chat to fail
(missing webhook secret auto-generation, no token validation).

Key changes:
- Rename save_setup_secrets() → configure(): single entrypoint for
  providing secrets to any extension (WasmChannel, WasmTool, MCP).
  Validates, stores, auto-generates, and activates.
- Add configure_token(): convenience wrapper for single-token callers
  (chat auth card, WebSocket, agent auth mode).
- Refactor auth() to pure status check: remove token parameter,
  delete token-storing branches from auth_mcp/auth_wasm_tool,
  rename auth_wasm_channel → auth_wasm_channel_status.
- Add ConfigureResult/MissingSecret types for structured responses.
- Replace hardcoded Telegram token validation with generic
  validation_endpoint from capabilities.json.
- Update all callers (9 files) to use the new interface.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: use ValidationFailed error variant instead of string matching

Replace brittle msg.contains("Invalid token") checks with a proper
ExtensionError::ValidationFailed variant. configure() now returns
this variant for token validation failures, and callers match on it
directly instead of parsing error message strings.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address review — SSRF protection, error typing, missing-secret selection, WS auth

1. SSRF: call validate_fetch_url() before validation_endpoint HTTP request
2. Transport errors map to ExtensionError::Other (not ValidationFailed)
3. configure_token() picks first *missing* secret, not first non-optional
4. WebSocket error path re-emits AuthRequired on ValidationFailed

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* test: add regression tests for extension lifecycle refactoring

- test_configure_token_picks_first_missing_secret: verifies multi-secret
  channels can be configured one secret at a time (commit ce106f4)
- test_auth_is_read_only_for_wasm_channel: verifies auth() has no side
  effects and doesn't store secrets (commit 47f8eb6)
- test_validation_failed_is_distinct_error_variant: verifies the typed
  error variant can be pattern-matched (commit a318161)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address review comments — activation dispatch, dead code, caps consolidation

- Fix configure() fallthrough bug: dispatch activation by ExtensionKind
  instead of unconditionally calling activate_wasm_channel() for all
  non-WasmTool types (MCP servers and channel relays now use their
  correct activation methods)
- Remove dead MissingSecret struct and missing_secrets field (never
  populated, flagged by reviewer)
- Consolidate capabilities file parsing in configure(): parse once
  and reuse for allowed names, validation_endpoint, and auto-generation
- Fix auth() doc comment: note MCP OAuth side effects
- Fix stale save_setup_secrets reference in server.rs comment
- Add regression test for activation dispatch bug

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-11 16:01:41 -07:00
28a22f2a59 fix(security): replace regex HTML sanitizer with DOMPurify to prevent XSS (#510)
* fix(security): replace regex HTML sanitizer with DOMPurify to prevent XSS

The previous sanitizeRenderedHtml() used regex patterns to strip dangerous
HTML tags and event handler attributes before assigning to innerHTML. Regex-
based HTML sanitization is notoriously bypassable via:

- SVG/MathML elements not in the blocklist (<svg onload=...>)
- Newline-split event handlers (<img src=x on\nload=alert(1)>)
- Mutation XSS (browser parsing quirks that reconstruct dangerous DOM)
- Encoded attribute values and alternative quote styles
- Nested/recursive tag patterns that defeat linear regex

This is exploitable through prompt injection: if an LLM tool output contains
crafted HTML, it flows through marked.parse() -> sanitizeRenderedHtml() ->
innerHTML, allowing script execution in the user's browser session.

Replace the regex sanitizer with DOMPurify 3.2.3, the industry-standard
DOM-based HTML sanitizer. DOMPurify parses HTML into a real DOM tree and
walks it node-by-node, which eliminates all known bypass vectors. It is
used by Mozilla, Google, and most major web applications.

CDN: cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js
SRI: sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl

Audited all 60+ innerHTML assignments in app.js:
- 5 use renderMarkdown() -> now protected by DOMPurify
- Remainder use escapeHtml(), static literals, or empty strings

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(security): guard sanitizeRenderedHtml against DOMPurify CDN unavailability [skip-regression-check]

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-11 15:55:35 -07:00
d313f44a19 fix(ci): improve Claude Code review reliability (#955)
The Claude review step was failing ~40% of the time because:
- --allowedTools didn't include Read, Glob, Grep, Agent, causing 8-9
  permission denials per run and preventing Claude from reading files
  or spawning the subagents the prompt required
- Step 4 spawned N additional scoring agents per issue found, exhausting
  the 50-turn budget before the PR comment could be posted
- Subagents could independently post PR comments, causing fragmented output

Fix: add missing tools to --allowedTools, merge per-issue scoring into
the review agents themselves, and add guardrails ensuring exactly one
consolidated comment is always posted.

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-11 14:05:33 -07:00
f08220db82 fix(ci): run gated test jobs during staging CI (#956)
The telegram-tests, windows-build, wasm-wit-compat, and docker-build
jobs were skipped during staging CI because their `if` conditions only
matched `push` and `pull_request` events. When staging-ci.yml calls
test.yml via workflow_call, github.event_name is `schedule` (inherited
from the caller), which matched neither condition.

Invert the conditions to blocklist the one case we want to skip (PRs
targeting staging) instead of allowlisting specific events. This handles
schedule, workflow_dispatch, and any future trigger types.

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-11 14:04:32 -07:00
34550add3e fix(ci): prevent staging-ci tag failure and chained PR auto-close (#900)
- Use fetch-depth: 0 in update-tag to ensure current_head SHA is available
  even when staging receives new commits during the CI run
- Only merge promotion PRs targeting main; leave chained PRs open to
  prevent delete_branch_on_merge from auto-closing downstream PRs

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-11 12:04:54 -07:00
fe82469904 fix(ci): WASM WIT compat sqlite3 duplicate symbol conflict (#953)
* fix(ci): use explicit features in WASM WIT compat test to avoid sqlite3 symbol conflicts

The `import` feature (added in #903) brings in `rusqlite[bundled]` which
conflicts with `libsql-ffi` — both bundle SQLite C code, causing duplicate
symbol linker errors. Use explicit features matching the test matrix instead
of `--all-features`.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: replace rusqlite with libsql in import module to fix sqlite3 symbol conflict

The `import` feature used `rusqlite[bundled]` which bundled its own SQLite
C code, conflicting with `libsql-ffi` (also bundles SQLite). This caused
duplicate `sqlite3_*` symbol linker errors when both features were enabled
via `--all-features`.

Replace `rusqlite` with `libsql` (already a dependency) in the import
reader. The `import` feature now implies `libsql`. This eliminates the
duplicate symbol conflict and allows `--all-features` to compile cleanly.

Also restores `--all-features` in the WASM WIT compat CI test (now safe)
and converts all import test helpers from rusqlite to libsql.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: apply cargo fmt formatting fixes

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-11 11:48:24 -07:00
6b841bb817 feat(i18n): Add internationalization support with Chinese and English translations (#929)
* feat(i18n): Add internationalization support with Chinese and English translations
* fix(i18n): fix duplicate keys, broken placeholders, and dead overrides

---------

Co-authored-by: zwb1982 <[email protected]>
2026-03-11 22:34:13 +08:00
8f513428f1 fix: resolve deferred review items from PRs #883, #848, #788 (#915)
Address three deferred implementation items flagged during code review:

1. SIGHUP lock held across .await (#883): Split restart_with_addr into
   merged_router_clone() + install_listener() so the async TcpListener
   bind happens outside the mutex, eliminating lock contention risk.

2. Recursion depth limit for check_strings (#848): Cap JSON traversal
   at 32 levels to prevent stack overflow on pathological tool params.

3. Named error type for add_tokens (#788): Replace Result<(), String>
   with TokenBudgetExceeded { used, limit } for type-safe budget errors.

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-11 07:12:45 +00:00
369741fc60 Add generic host-verified /webhook/tools/{tool} ingress (#757)
* Add generic host-verified webhook ingress for tools

* Stabilize trace E2E test rig and approval behavior

* Fix webhook security issues from review feedback

- Reject tools without webhook_capability() (was unauthenticated RCE)
- Remove secret-in-query-string fallback (leak via logs/referrers)
- Require approval for event_emit tool (escalation via routine triggers)
- Simplify header_value() (HeaderMap already case-insensitive)
- Redact internal errors from webhook HTTP responses
- Remove unused hmac_timestamp_tolerance_secs field
- Add regression test for tool without webhook capability

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Harden webhook ingress: require auth mechanism, body limit layer, health check

- Reject webhook capabilities that declare no auth mechanism (empty
  WebhookCapability would previously allow unauthenticated access)
- Add DefaultBodyLimit layer to reject oversized payloads before buffering
- Health check (GET) now verifies tool has webhook_capability(), not just
  existence
- Add regression tests for all three fixes

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Fix auto_approve_tools inconsistency between dispatcher and thread_ops

dispatcher.rs skips all approval checks (including Always) when
auto_approve_tools is true, but thread_ops.rs still required approval
for Always tools. This caused deferred tool calls to unexpectedly halt
in test rigs and auto-approve configurations.

Match dispatcher behavior: short-circuit all approval when
auto_approve_tools is enabled.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-11 03:36:25 +00:00
55b5a462a2 fix(web): improve UX readability and accessibility in chat UI (#910)
* fix(web): improve UX readability and accessibility in chat UI

Soften user bubbles, increase assistant message readability, widen message
gaps, improve disabled button visibility, add keyboard focus-visible rings,
fix attach button specificity, expand tree-row click targets, and increase
log entry hover contrast.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(web): address PR review — hover guard, accent-soft var, tree-row a11y

- Guard .chat-input button:hover with :not(:disabled) to prevent
  visual feedback on disabled send button
- Add --accent-soft CSS variable, use in .message.user instead of
  hardcoded rgba
- Make tree-rows keyboard-focusable (tabIndex=0, role=treeitem,
  aria-expanded, Enter/Space keydown handlers)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-11 02:31:35 +00:00
26068db24b feat: Import OpenClaw memory, history and settings (#903)
* feat: Import OpenClaw memory, history and settings

* review fixes

* fix: address remaining code quality issues

1. Remove dead import_conversation() function - replaced by import_conversation_atomic()
2. Improve non-UTF-8 filename handling in list_agent_dbs() - log warning instead of silent 'unknown'
3. Remove emojis from CLI output per project style guide

Co-Authored-By: Claude Haiku 4.5 <[email protected]>

---------

Co-authored-by: Claude Haiku 4.5 <[email protected]>
2026-03-10 18:37:10 -07:00
120 changed files with 12550 additions and 1291 deletions
+25
View File
@@ -98,6 +98,19 @@ TELEGRAM_BOT_TOKEN=...
HTTP_HOST=0.0.0.0
HTTP_PORT=8080
HTTP_WEBHOOK_SECRET=your-webhook-secret
# Webhook authentication uses HMAC-SHA256 signature verification.
# Callers must send an X-IronClaw-Signature header with format: sha256=<hex_digest>
# where the digest is HMAC-SHA256(HTTP_WEBHOOK_SECRET, raw_request_body) in lowercase hex.
#
# Example (bash):
# BODY='{"content":"hello"}'
# SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$HTTP_WEBHOOK_SECRET" | cut -d' ' -f2)
# curl -X POST http://localhost:8080/webhook \
# -H "Content-Type: application/json" \
# -H "X-IronClaw-Signature: sha256=$SIG" \
# -d "$BODY"
#
# DEPRECATED: Passing "secret" in the JSON body still works but will be removed in a future release.
# Signal Channel (optional, requires signal-cli daemon --http)
# SIGNAL_HTTP_URL=http://127.0.0.1:8080
@@ -138,6 +151,18 @@ HEARTBEAT_NOTIFY_USER=default
# MEMORY_HYGIENE_CONVERSATION_RETENTION_DAYS=7 # delete conversations/ docs older than this many days
# MEMORY_HYGIENE_CADENCE_HOURS=12 # minimum hours between cleanup passes
# Docker Sandbox
# SANDBOX_ENABLED=true
# SANDBOX_POLICY=readonly # readonly, workspace_write, or full_access
# SANDBOX_ALLOW_FULL_ACCESS=false # REQUIRED second opt-in for full_access policy.
# # FullAccess bypasses Docker entirely and runs
# # commands directly on the host. Without this
# # set to "true", full_access is downgraded to
# # workspace_write.
# SANDBOX_IMAGE=ironclaw-worker:latest
# SANDBOX_TIMEOUT_SECS=120
# SANDBOX_MEMORY_LIMIT_MB=2048
# Safety settings
SAFETY_MAX_OUTPUT_LENGTH=100000
SAFETY_INJECTION_CHECK_ENABLED=true
+32 -23
View File
@@ -29,18 +29,36 @@ jobs:
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_bots: "ironclaw-ci[bot]"
claude_args: "--max-turns 50 --model claude-haiku-4-5-20251001 --allowedTools 'Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh search:*),Bash(git blame:*),Bash(git log:*),Bash(git diff:*)'"
claude_args: "--max-turns 50 --model claude-haiku-4-5-20251001 --allowedTools 'Read,Glob,Grep,Agent,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh search:*),Bash(git blame:*),Bash(git log:*),Bash(git diff:*)'"
prompt: |
Code review this pull request. Follow these steps precisely:
1. Use a Haiku agent to find relevant CLAUDE.md files: the root CLAUDE.md
and any CLAUDE.md files in directories whose files this PR modifies.
1. Find relevant CLAUDE.md files: the root CLAUDE.md and any CLAUDE.md files
in directories whose files this PR modifies. Use Glob to find them, then Read
to load their contents.
2. Use a Haiku agent to summarize the PR change (use `gh pr diff`).
2. Get the PR diff with `gh pr diff` and summarize the change.
3. Launch 4 parallel agents to review the change independently. Each agent should
read the PR diff with `gh pr diff` and the full source files for changed
code, then return a list of issues found:
code (using Read), then return a list of issues. Each agent MUST score its
own findings inline using the severity and confidence rubric below.
Severity levels:
- CRITICAL: security vulns, panics in prod (.unwrap/.expect), data exfiltration, race conditions
- HIGH: logic bugs, missing error handling, breaking API/schema changes
- MEDIUM: missing tests, unnecessary complexity, performance issues
- LOW: documentation gaps, naming suggestions
Confidence scoring (0-100):
0: False positive, doesn't stand up to scrutiny, or pre-existing issue.
25: Might be real, but may be false positive. Stylistic issues not in CLAUDE.md.
50: Real issue but nitpick or rare in practice. Not very important.
75: Verified real issue, will be hit in practice. Directly impacts functionality
or explicitly mentioned in CLAUDE.md.
100: Certain, confirmed, will happen frequently. Evidence directly confirms.
Each agent returns findings as: [SEVERITY:CONFIDENCE] <brief description>
Agent 1 — Security & Safety
Check for: command injection, path traversal, SSRF, XSS, auth bypass,
@@ -63,22 +81,9 @@ jobs:
timeouts, resource leaks (file handles, connections), large allocations
in hot paths.
4. For each issue found, launch a parallel Haiku agent to:
a. Assign a severity:
- CRITICAL: security vulns, panics in prod (.unwrap/.expect), data exfiltration, race conditions
- HIGH: logic bugs, missing error handling, breaking API/schema changes
- MEDIUM: missing tests, unnecessary complexity, performance issues
- LOW: documentation gaps, naming suggestions
b. Score confidence 0-100 (give this rubric verbatim):
0: False positive, doesn't stand up to scrutiny, or pre-existing issue.
25: Might be real, but may be false positive. Stylistic issues not in CLAUDE.md.
50: Real issue but nitpick or rare in practice. Not very important.
75: Verified real issue, will be hit in practice. Directly impacts functionality
or explicitly mentioned in CLAUDE.md.
100: Certain, confirmed, will happen frequently. Evidence directly confirms.
5. Post a single comment on the PR using `gh pr comment` with this format.
If no issues were found, post "No issues found." instead:
4. Consolidate all agent findings and post exactly one comment on the PR
using `gh pr comment` with this format. If no issues were found,
post "No issues found." instead:
### Code review
@@ -93,8 +98,12 @@ jobs:
You MUST use the full git SHA in links (not HEAD or branch name).
Provide 1 line of context before and after each linked range.
Notes:
- Use `gh` for all GitHub interactions, not web fetch
IMPORTANT rules:
- Only YOU (the main process) may call `gh pr comment`. Agents must return
their findings to you — they must NOT post comments themselves.
- You MUST post exactly one `gh pr comment` before finishing, even if agents
fail or return empty results. If review is incomplete, post "No issues found."
- Use Read/Glob for file access, `gh` for GitHub interactions, not web fetch
- Do NOT check build signal or attempt to build/test the code
- Ignore pre-existing issues not introduced by this PR
- Ignore issues a linter/compiler would catch (formatting, imports, types)
+15 -6
View File
@@ -16,6 +16,15 @@ jobs:
- name: Check formatting
run: cargo fmt --all -- --check
deny-check:
name: cargo-deny
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Run cargo deny
uses: EmbarkStudios/cargo-deny-action@v2
clippy:
name: Clippy (${{ matrix.name }})
runs-on: ubuntu-latest
@@ -71,18 +80,18 @@ jobs:
# Roll-up job for branch protection
code-style:
name: Code Style (fmt + clippy)
name: Code Style (fmt + clippy + deny)
runs-on: ubuntu-latest
if: always()
needs: [format, clippy, clippy-windows]
needs: [format, clippy, clippy-windows, deny-check]
steps:
- run: |
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" ]]; then
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" || "${{ needs.deny-check.result }}" != "success" ]]; then
echo "One or more jobs failed"
exit 1
fi
# clippy-windows only runs on main PRs, so skip/success are both acceptable
if [[ "${{ needs.clippy-windows.result }}" == "failure" ]]; then
echo "Windows clippy failed"
# clippy-windows only runs on main PRs, so skipped is acceptable but failure is not
if [[ "${{ needs.clippy-windows.result }}" != "success" && "${{ needs.clippy-windows.result }}" != "skipped" ]]; then
echo "Windows clippy failed: ${{ needs.clippy-windows.result }}"
exit 1
fi
+48 -30
View File
@@ -156,19 +156,25 @@ jobs:
while IFS= read -r line; do
sha256=$(echo "$line" | awk '{print $1}')
filename=$(echo "$line" | awk '{print $2}')
# Strip -{version}-wasm32-wasip2.tar.gz to get the extension name.
# Use '.*' (greedy) so pre-release suffixes like -alpha.1 are consumed too.
name=$(echo "$filename" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//')
# Skip non-WASM entries (e.g. binary tarballs from cargo-dist)
case "$filename" in *-wasm32-wasip2.tar.gz) ;; *) continue ;; esac
# Parse kind-prefixed filename: "tool-slack-0.2.1-wasm32-wasip2.tar.gz"
# → kind=tool, name=slack
kind=$(echo "$filename" | cut -d'-' -f1)
if [ "$kind" != "tool" ] && [ "$kind" != "channel" ]; then
echo "::warning::Skipping '$filename': unrecognized kind prefix '$kind'"
continue
fi
name=$(echo "$filename" | sed "s/^${kind}-//" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//')
url="https://github.com/nearai/ironclaw/releases/download/${RELEASE_TAG}/${filename}"
for manifest in registry/tools/${name}.json registry/channels/${name}.json; do
if [ -f "$manifest" ]; then
jq --arg sha "$sha256" --arg url "$url" \
'.artifacts["wasm32-wasip2"].sha256 = $sha | .artifacts["wasm32-wasip2"].url = $url' \
"$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest"
echo "Patched $manifest with sha256=$sha256 url=$url"
fi
done
manifest="registry/${kind}s/${name}.json"
if [ -f "$manifest" ]; then
jq --arg sha "$sha256" --arg url "$url" \
'.artifacts["wasm32-wasip2"].sha256 = $sha | .artifacts["wasm32-wasip2"].url = $url' \
"$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest"
echo "Patched $manifest with sha256=$sha256 url=$url"
fi
done < "$CHECKSUMS"
- name: Install dependencies
run: |
@@ -276,9 +282,14 @@ jobs:
[ -f "$manifest" ] || continue
# file_stem: JSON filename without extension (e.g. "slack" for slack.json).
# Used for the bundle filename and CI manifest lookup, so patching always
# finds the right file regardless of whether manifest.name matches the filename.
file_stem=$(basename "$manifest" .json)
# kind: "tool" or "channel" — used as bundle filename prefix to avoid
# collisions when a tool and channel share the same file_stem (e.g. slack).
kind=$(jq -r '.kind' "$manifest")
if [ "$kind" != "tool" ] && [ "$kind" != "channel" ]; then
echo "::error::Manifest '$manifest' has invalid or missing .kind ('$kind'); expected 'tool' or 'channel'"
exit 1
fi
# ext_name: the manifest's .name field (e.g. "slack-tool").
# Used for file names *inside* the archive — the installer extracts by manifest.name.
ext_name=$(jq -r '.name' "$manifest")
@@ -340,18 +351,19 @@ jobs:
echo "::warning::No capabilities file at '$caps_path' for '$file_stem'"
fi
# Bundle filename uses file_stem so CI patching can find the manifest by
# filename (e.g. slack-0.1.0-wasm32-wasip2.tar.gz → registry/tools/slack.json).
bundle="target/wasm-bundles/${file_stem}-${ext_version}-wasm32-wasip2.tar.gz"
# Bundle filename uses kind+file_stem to avoid collisions when a tool
# and channel share the same name (e.g. tool-slack vs channel-slack).
bundle_name="${kind}-${file_stem}-${ext_version}-wasm32-wasip2.tar.gz"
bundle="target/wasm-bundles/${bundle_name}"
(cd target/wasm-bundles && if [ -f "${ext_name}.capabilities.json" ]; then
tar czf "${file_stem}-${ext_version}-wasm32-wasip2.tar.gz" "${ext_name}.wasm" "${ext_name}.capabilities.json"
tar czf "${bundle_name}" "${ext_name}.wasm" "${ext_name}.capabilities.json"
else
tar czf "${file_stem}-${ext_version}-wasm32-wasip2.tar.gz" "${ext_name}.wasm"
tar czf "${bundle_name}" "${ext_name}.wasm"
fi)
# Compute SHA256
sha256=$(sha256sum "$bundle" | cut -d' ' -f1)
echo "$sha256 ${file_stem}-${ext_version}-wasm32-wasip2.tar.gz" >> target/wasm-bundles/checksums.txt
echo "$sha256 ${bundle_name}" >> target/wasm-bundles/checksums.txt
# Clean up intermediate files
rm -f "target/wasm-bundles/${ext_name}.wasm" "target/wasm-bundles/${ext_name}.capabilities.json"
@@ -474,19 +486,25 @@ jobs:
while IFS= read -r line; do
sha256=$(echo "$line" | awk '{print $1}')
filename=$(echo "$line" | awk '{print $2}')
# Strip -{version}-wasm32-wasip2.tar.gz to get the extension name.
# Use '.*' (greedy) so pre-release suffixes like -alpha.1 are consumed too.
name=$(echo "$filename" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//')
# Skip non-WASM entries (defensive — this checksums.txt should only have WASM)
case "$filename" in *-wasm32-wasip2.tar.gz) ;; *) continue ;; esac
# Parse kind-prefixed filename: "tool-slack-0.2.1-wasm32-wasip2.tar.gz"
# → kind=tool, name=slack
kind=$(echo "$filename" | cut -d'-' -f1)
if [ "$kind" != "tool" ] && [ "$kind" != "channel" ]; then
echo "::warning::Skipping '$filename': unrecognized kind prefix '$kind'"
continue
fi
name=$(echo "$filename" | sed "s/^${kind}-//" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//')
url="https://github.com/nearai/ironclaw/releases/download/${RELEASE_TAG}/${filename}"
for manifest in registry/tools/${name}.json registry/channels/${name}.json; do
if [ -f "$manifest" ]; then
jq --arg sha "$sha256" --arg url "$url" \
'.artifacts["wasm32-wasip2"].sha256 = $sha | .artifacts["wasm32-wasip2"].url = $url' \
"$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest"
echo "Patched $manifest with sha256=$sha256 url=$url"
fi
done
manifest="registry/${kind}s/${name}.json"
if [ -f "$manifest" ]; then
jq --arg sha "$sha256" --arg url "$url" \
'.artifacts["wasm32-wasip2"].sha256 = $sha | .artifacts["wasm32-wasip2"].url = $url' \
"$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest"
echo "Patched $manifest with sha256=$sha256 url=$url"
fi
done < "$CHECKSUMS"
- name: Create PR with updated manifests
run: |
+14 -7
View File
@@ -406,6 +406,10 @@ jobs:
echo "passed=true" >> "$GITHUB_OUTPUT"
fi
# Only merge PRs targeting main. Chained PRs (targeting another
# promotion branch) stay open — when the base PR merges into main,
# GitHub auto-retargets the chained PR. Merging chained PRs would
# trigger delete_branch_on_merge, auto-closing downstream PRs.
- name: Merge promotion PR
id: merge
if: steps.evaluate.outputs.passed == 'true'
@@ -414,12 +418,15 @@ jobs:
PR_NUMBER: ${{ needs.create-promotion-pr.outputs.pr_number }}
run: |
if [ -n "$PR_NUMBER" ]; then
echo "Merging promotion PR #${PR_NUMBER}"
# Do NOT use --delete-branch: deleting a promotion branch closes
# any chained PRs that use it as their base (verified in ironclaw-ci-test).
# Stale promotion branches are cleaned up separately.
gh pr merge "$PR_NUMBER" --merge
echo "merged=true" >> "$GITHUB_OUTPUT"
BASE=$(gh pr view "$PR_NUMBER" --json baseRefName --jq '.baseRefName')
if [ "$BASE" = "main" ]; then
echo "Merging promotion PR #${PR_NUMBER} (targets main)"
gh pr merge "$PR_NUMBER" --merge
echo "merged=true" >> "$GITHUB_OUTPUT"
else
echo "PR #${PR_NUMBER} targets '${BASE}' (not main) — leaving open for chain resolution"
echo "merged=false" >> "$GITHUB_OUTPUT"
fi
fi
# ── Update tested tag (always, so next batch covers only new commits) ──
@@ -437,7 +444,7 @@ jobs:
- uses: actions/checkout@v6
with:
ref: staging
fetch-depth: 1
fetch-depth: 0
- name: Update staging-tested tag
run: |
+8 -8
View File
@@ -42,8 +42,8 @@ jobs:
telegram-tests:
name: Telegram Channel Tests
if: >
github.event_name == 'push' ||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
github.event_name != 'pull_request' ||
github.base_ref != 'staging'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -57,8 +57,8 @@ jobs:
windows-build:
name: Windows Build (${{ matrix.name }})
if: >
github.event_name == 'push' ||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
github.event_name != 'pull_request' ||
github.base_ref != 'staging'
runs-on: windows-latest
strategy:
fail-fast: false
@@ -84,8 +84,8 @@ jobs:
wasm-wit-compat:
name: WASM WIT Compatibility
if: >
github.event_name == 'push' ||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
github.event_name != 'pull_request' ||
github.base_ref != 'staging'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -107,8 +107,8 @@ jobs:
docker-build:
name: Docker Build
if: >
github.event_name == 'push' ||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
github.event_name != 'pull_request' ||
github.base_ref != 'staging'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
Generated
+156 -82
View File
@@ -82,7 +82,7 @@ dependencies = [
"const-random",
"once_cell",
"version_check",
"zerocopy 0.8.39",
"zerocopy 0.8.42",
]
[[package]]
@@ -2654,20 +2654,20 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.4.1"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"r-efi 6.0.0",
"wasip2",
"wasip3",
]
@@ -2843,9 +2843,9 @@ dependencies = [
[[package]]
name = "html-to-markdown-rs"
version = "2.25.1"
version = "2.28.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c05335c6bf406653110ad8447c84461c6d0cda5e0aff9d3d3518f87502d30abe"
checksum = "3f9377e16af590b764fd98fd176027cf8831c5335f8964f3f643753e38913a4e"
dependencies = [
"ahash 0.8.12",
"astral-tl",
@@ -3110,7 +3110,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.2",
"socket2 0.6.3",
"system-configuration",
"tokio",
"tower-service",
@@ -3334,9 +3334,9 @@ checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983"
[[package]]
name = "ipnet"
version = "2.11.0"
version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "iri-string"
@@ -3386,6 +3386,7 @@ dependencies = [
"hyper-util",
"iana-time-zone",
"insta",
"json5",
"libsql",
"lru",
"mime_guess",
@@ -3513,14 +3514,25 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.90"
version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14dc6f6450b3f6d4ed5b16327f38fed626d375a886159ca555bd7822c0c3a5a6"
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "json5"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1"
dependencies = [
"pest",
"pest_derive",
"serde",
]
[[package]]
name = "kuchikikiki"
version = "0.9.2"
@@ -3585,9 +3597,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.182"
version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]]
name = "libloading"
@@ -3607,13 +3619,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
version = "0.1.12"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
dependencies = [
"bitflags 2.11.0",
"libc",
"redox_syscall 0.7.2",
"plain",
"redox_syscall 0.7.3",
]
[[package]]
@@ -4397,6 +4410,49 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pest"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662"
dependencies = [
"memchr",
"ucd-trie",
]
[[package]]
name = "pest_derive"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77"
dependencies = [
"pest",
"pest_generator",
]
[[package]]
name = "pest_generator"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f"
dependencies = [
"pest",
"pest_meta",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "pest_meta"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220"
dependencies = [
"pest",
"sha2",
]
[[package]]
name = "pgvector"
version = "0.4.1"
@@ -4519,18 +4575,18 @@ dependencies = [
[[package]]
name = "pin-project"
version = "1.1.10"
version = "1.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a"
checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.10"
version = "1.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
dependencies = [
"proc-macro2",
"quote",
@@ -4539,9 +4595,9 @@ dependencies = [
[[package]]
name = "pin-project-lite"
version = "0.2.16"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pin-utils"
@@ -4551,9 +4607,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "piper"
version = "0.2.4"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066"
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
dependencies = [
"atomic-waker",
"fastrand",
@@ -4576,6 +4632,12 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "polling"
version = "3.11.0"
@@ -4680,7 +4742,7 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy 0.8.39",
"zerocopy 0.8.42",
]
[[package]]
@@ -4711,11 +4773,11 @@ dependencies = [
[[package]]
name = "proc-macro-crate"
version = "3.4.0"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit 0.23.10+spec-1.0.0",
"toml_edit 0.25.4+spec-1.1.0",
]
[[package]]
@@ -4804,7 +4866,7 @@ dependencies = [
"quinn-udp",
"rustc-hash 2.1.1",
"rustls 0.23.37",
"socket2 0.6.2",
"socket2 0.6.3",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -4813,9 +4875,9 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.13"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
@@ -4841,16 +4903,16 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.2",
"socket2 0.6.3",
"tracing",
"windows-sys 0.60.2",
]
[[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",
]
@@ -4861,6 +4923,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "radium"
version = "0.7.0"
@@ -5000,9 +5068,9 @@ dependencies = [
[[package]]
name = "redox_syscall"
version = "0.7.2"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d94dd2f7cd932d4dc02cc8b2b50dfd38bd079a4e5d79198b99743d7fcf9a4b4"
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
dependencies = [
"bitflags 2.11.0",
]
@@ -5540,9 +5608,9 @@ dependencies = [
[[package]]
name = "schannel"
version = "0.1.28"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
@@ -6029,12 +6097,12 @@ dependencies = [
[[package]]
name = "socket2"
version = "0.6.2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -6251,12 +6319,12 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tempfile"
version = "3.26.0"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.1",
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
@@ -6483,9 +6551,9 @@ dependencies = [
[[package]]
name = "tokio"
version = "1.49.0"
version = "1.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
dependencies = [
"bytes",
"libc",
@@ -6493,7 +6561,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2 0.6.2",
"socket2 0.6.3",
"tokio-macros",
"tracing",
"windows-sys 0.61.2",
@@ -6511,9 +6579,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.6.0"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
dependencies = [
"proc-macro2",
"quote",
@@ -6550,7 +6618,7 @@ dependencies = [
"postgres-protocol",
"postgres-types",
"rand 0.9.2",
"socket2 0.6.2",
"socket2 0.6.3",
"tokio",
"tokio-util",
"whoami",
@@ -6700,9 +6768,9 @@ dependencies = [
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
version = "1.0.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e"
dependencies = [
"serde_core",
]
@@ -6723,12 +6791,12 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.23.10+spec-1.0.0"
version = "0.25.4+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2"
dependencies = [
"indexmap 2.13.0",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_datetime 1.0.0+spec-1.1.0",
"toml_parser",
"winnow",
]
@@ -7046,14 +7114,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "uds_windows"
version = "1.1.0"
name = "ucd-trie"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "uds_windows"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca"
dependencies = [
"memoffset",
"tempfile",
"winapi",
"windows-sys 0.61.2",
]
[[package]]
@@ -7183,11 +7257,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.21.0"
version = "1.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
dependencies = [
"getrandom 0.4.1",
"getrandom 0.4.2",
"js-sys",
"serde_core",
"sha1_smol",
@@ -7287,9 +7361,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.113"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60722a937f594b7fde9adb894d7c092fc1bb6612897c46368d18e7a20208eff2"
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
dependencies = [
"cfg-if",
"once_cell",
@@ -7300,9 +7374,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.63"
version = "0.4.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a89f4650b770e4521aa6573724e2aed4704372151bd0de9d16a3bbabb87441a"
checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8"
dependencies = [
"cfg-if",
"futures-util",
@@ -7314,9 +7388,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.113"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fac8c6395094b6b91c4af293f4c79371c163f9a6f56184d2c9a85f5a95f3950"
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -7324,9 +7398,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.113"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab3fabce6159dc20728033842636887e4877688ae94382766e00b180abac9d60"
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -7337,9 +7411,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.113"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de0e091bdb824da87dc01d967388880d017a0a9bc4f3bdc0d86ee9f9336e3bb5"
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
dependencies = [
"unicode-ident",
]
@@ -7766,9 +7840,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.90"
version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "705eceb4ce901230f8625bd1d665128056ccbe4b7408faa625eec1ba80f59a97"
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -8238,9 +8312,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.14"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
@@ -8530,11 +8604,11 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.39"
version = "0.8.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3"
dependencies = [
"zerocopy-derive 0.8.39",
"zerocopy-derive 0.8.42",
]
[[package]]
@@ -8550,9 +8624,9 @@ dependencies = [
[[package]]
name = "zerocopy-derive"
version = "0.8.39"
version = "0.8.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
dependencies = [
"proc-macro2",
"quote",
+4
View File
@@ -175,6 +175,9 @@ readabilityrs = { version = "0.1.2", optional = true }
ed25519-dalek = { version = "2.2.0", features = ["std"] }
hex = "0.4.3"
# OpenClaw import (feature gated)
json5 = { version = "0.4", optional = true }
# macOS keychain
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = "3"
@@ -210,6 +213,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"]
import = ["dep:json5", "libsql"]
[[test]]
name = "html_to_markdown"
+2 -1
View File
@@ -440,6 +440,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| `before_agent_start` hook | ✅ | ❌ | P2 | Model/provider override |
| `before_message_write` hook | ✅ | ❌ | P2 | Pre-write interception |
| `onMessage` hook | ✅ | ✅ | - | Routines with event trigger |
| Structured system-event routines | ✅ | ✅ | P2 | `system_event` trigger + `event_emit` tool for event-driven automation |
| `onSessionStart` hook | ✅ | ✅ | P2 | |
| `onSessionEnd` hook | ✅ | ✅ | P2 | |
| `transcribeAudio` hook | ✅ | ❌ | P3 | |
@@ -558,7 +559,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
- ❌ Media handling (images, PDFs)
- ✅ Ollama/local model support (via rig::providers::ollama)
- ❌ Configuration hot-reload
- ❌ Webhook trigger endpoint in web gateway
- ✅ Tool-driven webhook ingress (`/webhook/tools/{tool}` -> host-verified + tool-normalized `system_event` routines)
- ❌ Channel health monitor with auto-restart
- ❌ Partial output preservation on abort
+2 -1
View File
@@ -16,7 +16,8 @@
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a>
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ru.md">Русский</a>
</p>
<p align="center">
+321
View File
@@ -0,0 +1,321 @@
<p align="center">
<img src="ironclaw.png?v=2" alt="IronClaw" width="200"/>
</p>
<h1 align="center">IronClaw</h1>
<p align="center">
<strong>Ваш защищенный персональный AI-ассистент, всегда на вашей стороне</strong>
</p>
<p align="center">
<a href="#license"><img src="https://img.shields.io/badge/license-MIT%20OR%20Apache%202.0-blue.svg" alt="Лицензия: MIT OR Apache-2.0" /></a>
<a href="https://t.me/ironclawAI"><img src="https://img.shields.io/badge/Telegram-%40ironclawAI-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @ironclawAI" /></a>
<a href="https://www.reddit.com/r/ironclawAI/"><img src="https://img.shields.io/badge/Reddit-r%2FironclawAI-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/ironclawAI" /></a>
</p>
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ru.md">Русский</a>
</p>
<p align="center">
<a href="#философия">Философия</a> •
<a href="#возможности">Возможности</a> •
<a href="#установка">Установка</a> •
<a href="#конфигурация">Конфигурация</a> •
<a href="#безопасность">Безопасность</a> •
<a href="#архитектура">Архитектура</a>
</p>
---
## Философия
IronClaw построен на простом принципе: **ваш AI-ассистент должен работать на вас, а не против вас**.
В мире, где системы ИИ становятся все более непрозрачными в вопросах обработки данных и ориентируются на корпоративные интересы, IronClaw выбирает другой путь:
- **Ваши данные остаются вашими** — вся информация хранится локально, зашифрована и никогда не покидает ваш контроль.
- **Прозрачность по умолчанию** — открытый исходный код, возможность аудита, отсутствие скрытой телеметрии или сбора данных.
- **Саморасширяемые возможности** — создавайте новые инструменты «на лету», не дожидаясь обновлений от вендора.
- **Глубокая защита** — несколько уровней безопасности защищают от инъекций промптов и утечки данных.
IronClaw — это AI-ассистент, которому вы действительно можете доверять в личной и профессиональной жизни.
## Возможности
### Безопасность прежде всего
- **Песочница WASM** — непроверенные инструменты запускаются в изолированных контейнерах WebAssembly с правами на основе возможностей.
- **Защита учетных данных** — секреты никогда не раскрываются инструментам; они внедряются на границе хоста с детектированием утечек.
- **Защита от инъекций промптов** — обнаружение паттернов, очистка контента и применение политик безопасности.
- **Список разрешенных эндпоинтов** — HTTP-запросы только к явно одобренным хостам и путям.
### Всегда доступен
- **Многоканальность** — REPL, HTTP-вебхуки, WASM-каналы (Telegram, Slack) и веб-шлюз.
- **Песочница Docker** — изолированное выполнение контейнеров с токенами для каждого задания и паттерном «оркестратор/воркер».
- **Веб-шлюз** — браузерный интерфейс с потоковой передачей данных в реальном времени через SSE/WebSocket.
- **Рутины (Routines)** — расписания cron, триггеры событий, обработчики вебхуков для фоновой автоматизации.
- **Система Heartbeat** — проактивное фоновое выполнение задач мониторинга и обслуживания.
- **Параллельные задания** — одновременная обработка нескольких запросов с изолированными контекстами.
- **Самовосстановление** — автоматическое обнаружение и восстановление зависших операций.
### Саморасширяемый
- **Динамическое создание инструментов** — опишите, что вам нужно, и IronClaw создаст это как инструмент WASM.
- **Протокол MCP** — подключайтесь к серверам Model Context Protocol для получения дополнительных возможностей.
- **Плагинная архитектура** — добавляйте новые инструменты WASM и каналы без перезагрузки системы.
### Постоянная память
- **Гибридный поиск** — полнотекстовый + векторный поиск с использованием Reciprocal Rank Fusion.
- **Файловая система Workspace** — гибкое хранилище на основе путей для заметок, логов и контекста.
- **Файлы идентичности (Identity Files)** — сохранение индивидуальности и предпочтений между сессиями.
## Установка
### Предварительные условия
- Rust 1.85+
- PostgreSQL 15+ с расширением [pgvector](https://github.com/pgvector/pgvector)
- Аккаунт NEAR AI (аутентификация через мастер настройки)
## Загрузка и сборка
Посетите [страницу релизов](https://github.com/nearai/ironclaw/releases/), чтобы увидеть последние обновления.
<details>
<summary>Установка через установщик Windows (Windows)</summary>
Загрузите [Windows Installer](https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-x86_64-pc-windows-msvc.msi) и запустите его.
</details>
<details>
<summary>Установка через powershell-скрипт (Windows)</summary>
```sh
irm https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.ps1 | iex
```
</details>
<details>
<summary>Установка через shell-скрипт (macOS, Linux, Windows/WSL)</summary>
```sh
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.sh | sh
```
</details>
<details>
<summary>Установка через Homebrew (macOS/Linux)</summary>
```sh
brew install ironclaw
```
</details>
<details>
<summary>Компиляция из исходного кода (Cargo на Windows, Linux, macOS)</summary>
Для установки используйте `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`, чтобы сначала пересобрать каналы.
</details>
### Настройка базы данных
```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, но работает с любыми OpenAI-совместимыми эндпоинтами.
Популярные варианты включают **OpenRouter** (300+ моделей), **Together AI**, **Fireworks AI**, **Ollama** (локально) и собственные серверы, такие как **vLLM** или **LiteLLM**.
Выберите *"OpenAI-compatible"* в мастере настройки или установите переменные окружения напрямую:
```env
LLM_BACKEND=openai_compatible
LLM_BASE_URL=https://openrouter.ai/api/v1
LLM_API_KEY=sk-or-...
LLM_MODEL=anthropic/claude-sonnet-4
```
Смотрите [docs/LLM_PROVIDERS.md](docs/LLM_PROVIDERS.md) для получения полного руководства по провайдерам.
## Безопасность
IronClaw реализует эшелонированную защиту для обеспечения безопасности ваших данных и предотвращения злоупотреблений.
### Песочница WASM
Все непроверенные инструменты запускаются в изолированных контейнерах WebAssembly:
- **Права на основе возможностей** — явное разрешение на HTTP, доступ к секретам, вызов инструментов.
- **Список разрешенных эндпоинтов** — HTTP-запросы только к одобренным хостам/путям.
- **Внедрение учетных данных** — секреты внедряются на границе хоста и никогда не раскрываются коду WASM.
- **Детектирование утечек** — сканирование запросов и ответов на попытки кражи секретов.
- **Ограничение частоты запросов** — лимиты для каждого инструмента для предотвращения злоупотреблений.
- **Лимиты ресурсов** — ограничения по памяти, процессору и времени выполнения.
```
WASM ──► Валидатор ──► Сканер ───► Инъектор ──► Выполнение ──► Сканер ───► WASM
хостов утечек секретов запроса утечек
(запрос) (ответ)
```
### Защита от инъекций промптов
Внешний контент проходит через несколько уровней безопасности:
- Обнаружение попыток инъекций на основе паттернов.
- Очистка и экранирование контента.
- Правила политик с уровнями серьезности (Блокировка/Предупреждение/Проверка/Очистка).
- Обертывание вывода инструментов для безопасного внедрения в контекст LLM.
### Защита данных
- Все данные хранятся локально в вашей базе данных PostgreSQL.
- Секреты зашифрованы с использованием AES-256-GCM.
- Никакой телеметрии, аналитики или обмена данными.
- Полный журнал аудита выполнения всех инструментов.
## Архитектура
```
┌────────────────────────────────────────────────────────────────┐
│ Каналы │
│ ┌──────┐ ┌──────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ REPL │ │ HTTP │ │WASM-каналы │ │ Веб-шлюз │ │
│ └──┬───┘ └──┬───┘ └──────┬──────┘ │ (SSE + WS) │ │
│ │ │ │ └──────┬──────┘ │
│ └─────────┴──────────────┴────────────────┘ │
│ │ │
│ ┌─────────▼─────────┐ │
│ │ Цикл агента │ Маршрутизация │
│ └────┬──────────┬───┘ намерений │
│ │ │ │
│ ┌──────────▼────┐ ┌──▼───────────────┐ │
│ │ Планировщик │ │ Движок рутин │ │
│ │ (пар. задачи) │ │(cron, соб., wh) │ │
│ └──────┬────────┘ └────────┬─────────┘ │
│ │ │ │
│ ┌─────────────┼────────────────────┘ │
│ │ │ │
│ ┌───▼─────┐ ┌────▼────────────────┐ │
│ │ Локальн.│ │ Оркестратор │ │
│ │ воркеры │ │ ┌───────────────┐ │ │
│ │(in-proc)│ │ │ Песочница │ │ │
│ └───┬─────┘ │ │ Docker │ │ │
│ │ │ │ ┌───────────┐ │ │ │
│ │ │ │ │Воркер / CC│ │ │ │
│ │ │ │ └───────────┘ │ │ │
│ │ │ └───────────────┘ │ │
│ │ └─────────┬───────────┘ │
│ └──────────────────┤ │
│ │ │
│ ┌───────────▼──────────┐ │
│ │ Реестр инструментов │ │
│ │ Встроенные, MCP, WASM│ │
│ └──────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
```
### Основные компоненты
| Компонент | Назначение |
|-----------|------------|
| **Цикл агента** | Основная обработка сообщений и координация задач |
| **Роутер** | Классификация намерений пользователя (команда, запрос, задача) |
| **Планировщик** | Управление выполнением параллельных задач с приоритетами |
| **Воркер** | Выполнение задач с рассуждениями LLM и вызовами инструментов |
| **Оркестратор** | Жизненный цикл контейнеров, проксирование LLM, аутентификация для каждой задачи |
| **Веб-шлюз** | Браузерный интерфейс (чат, память, задачи, логи, расширения, рутины) |
| **Движок рутин** | Фоновые задачи: запланированные (cron) и реактивные (события, вебхуки) |
| **Workspace** | Постоянная память с гибридным поиском |
| **Слой безопасности** | Защита от инъекций промптов и очистка контента |
## Использование
```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 название_теста
```
- **Telegram-канал**: Смотрите [docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md) для настройки и привязки аккаунта.
- **Изменение исходников каналов**: Перед `cargo build` выполните `./channels-src/telegram/build.sh`, чтобы обновить встроенный WASM.
## Наследие OpenClaw
IronClaw — это реализация на Rust, вдохновленная проектом [OpenClaw](https://github.com/openclaw/openclaw). Полную матрицу соответствия функций можно найти в [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))
+3 -2
View File
@@ -16,7 +16,8 @@
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a>
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ru.md">Русский</a>
</p>
<p align="center">
@@ -229,7 +230,7 @@ WASM ──► 白名单 ──► 泄露扫描 ──► 凭据 ──► 执
│ │ │ │
│ ┌──────────▼────┐ ┌──▼───────────────┐ │
│ │ 调度器 │ │ 定时任务引擎 │ │
│ │ (并行任务) │ │(cron, 事件, wh) │
│ │ (并行任务) │ │(cron, 事件, Webhook)│
│ └──────┬────────┘ └────────┬─────────┘ │
│ │ │ │
│ ┌─────────────┼────────────────────┘ │
@@ -20,7 +20,8 @@
"optional": false
}
],
"setup_url": "https://t.me/BotFather"
"setup_url": "https://t.me/BotFather",
"validation_endpoint": "https://api.telegram.org/bot{telegram_bot_token}/getMe"
},
"capabilities": {
"http": {
+50
View File
@@ -0,0 +1,50 @@
[advisories]
unmaintained = "workspace"
yanked = "deny"
ignore = [
# Pre-existing advisories — tracked for upgrade in separate PRs
# serde_yml unsound/unmaintained — direct dep, upgrade tracked separately
"RUSTSEC-2025-0068",
# tokio-tar PAX header parsing — sandbox containers only
"RUSTSEC-2025-0111",
# wasmtime fd_renumber host panic — WASIp1, mitigated by fuel limits
"RUSTSEC-2025-0046",
# wasmtime shared linear memory unsoundness — no shared memory in our guests
"RUSTSEC-2025-0118",
# wasmtime guest-controlled resource exhaustion — mitigated by fuel/memory limits
"RUSTSEC-2026-0020",
# wasmtime wasi:http/types.fields panic — mitigated by fuel limits
"RUSTSEC-2026-0021",
]
[licenses]
version = 2
allow = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Unicode-3.0",
"Unicode-DFS-2016",
"OpenSSL",
"Zlib",
"MPL-2.0",
"0BSD",
"BSL-1.0",
"CC0-1.0",
"Unlicense",
"CDLA-Permissive-2.0",
]
unused-allowed-license = "allow"
[bans]
multiple-versions = "warn"
wildcards = "deny"
[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
allow-git = []
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "github",
"display_name": "GitHub",
"kind": "tool",
"version": "0.2.0",
"version": "0.2.1",
"wit_version": "0.3.0",
"description": "GitHub integration for issues, PRs, repos, and code search",
"keywords": [
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
# Ensure we are running from the repository root
cd "$(git rev-parse --show-toplevel)"
echo "==> fmt check"
cargo fmt --all -- --check
echo "==> clippy (all warnings)"
cargo clippy --locked --all --benches --tests --examples --all-features -- -D warnings
echo "==> cargo deny"
if ! command -v cargo-deny &>/dev/null; then
echo "ERROR: cargo-deny not installed (install with: cargo install cargo-deny)"
exit 1
fi
cargo deny check
echo "==> tests"
cargo test --locked
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Test that kind-prefixed artifact filenames are parsed correctly into
# manifest paths. Mirrors the parsing logic in release.yml.
set -euo pipefail
cd "$(dirname "$0")/.."
PASS=0
FAIL=0
assert_parse() {
local filename="$1" expected_kind="$2" expected_name="$3"
local kind name manifest
kind=$(echo "$filename" | cut -d'-' -f1)
name=$(echo "$filename" | sed "s/^${kind}-//" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//')
manifest="registry/${kind}s/${name}.json"
if [[ "$kind" != "$expected_kind" ]]; then
echo "FAIL: $filename → kind=$kind, expected $expected_kind"
FAIL=$((FAIL + 1))
return
fi
if [[ "$name" != "$expected_name" ]]; then
echo "FAIL: $filename → name=$name, expected $expected_name"
FAIL=$((FAIL + 1))
return
fi
echo "OK: $filename$manifest"
PASS=$((PASS + 1))
}
# Tool and channel with same name must produce different manifest paths
assert_parse "tool-slack-0.2.1-wasm32-wasip2.tar.gz" "tool" "slack"
assert_parse "channel-slack-0.2.1-wasm32-wasip2.tar.gz" "channel" "slack"
# Same collision case for telegram
assert_parse "tool-telegram-0.2.2-wasm32-wasip2.tar.gz" "tool" "telegram"
assert_parse "channel-telegram-0.2.2-wasm32-wasip2.tar.gz" "channel" "telegram"
# Hyphenated extension names
assert_parse "tool-web-search-0.2.0-wasm32-wasip2.tar.gz" "tool" "web-search"
assert_parse "tool-google-calendar-0.1.0-wasm32-wasip2.tar.gz" "tool" "google-calendar"
assert_parse "tool-google-docs-0.1.0-wasm32-wasip2.tar.gz" "tool" "google-docs"
assert_parse "tool-google-drive-0.1.0-wasm32-wasip2.tar.gz" "tool" "google-drive"
assert_parse "tool-google-sheets-0.1.0-wasm32-wasip2.tar.gz" "tool" "google-sheets"
assert_parse "tool-google-slides-0.1.0-wasm32-wasip2.tar.gz" "tool" "google-slides"
# Simple names
assert_parse "channel-discord-0.2.0-wasm32-wasip2.tar.gz" "channel" "discord"
assert_parse "channel-whatsapp-0.1.0-wasm32-wasip2.tar.gz" "channel" "whatsapp"
assert_parse "tool-github-0.2.0-wasm32-wasip2.tar.gz" "tool" "github"
assert_parse "tool-gmail-0.1.0-wasm32-wasip2.tar.gz" "tool" "gmail"
# Pre-release versions
assert_parse "tool-slack-0.2.1-alpha.1-wasm32-wasip2.tar.gz" "tool" "slack"
echo ""
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] || exit 1
@@ -28,7 +28,9 @@ Collect these values before creating routines:
Before installing routines, verify:
- Routines system enabled.
- GitHub tool authenticated (for issue/PR/comment/status operations).
- Events are emitted via `event_emit` tool calls (a future HTTP webhook ingestion endpoint is planned but not yet available).
- GitHub webhook delivery configured to `POST /webhook/tools/github`.
- Webhook HMAC secret configured in the secrets store as `github_webhook_secret` (required for GitHub webhook delivery).
- Events can also be emitted via `event_emit` tool calls for testing or when webhook ingestion is not yet configured.
## Install Procedure
1. Open [`workflow-routines.md`](references/workflow-routines.md).
@@ -51,8 +53,8 @@ Install these routines:
## Event Filters
Prefer top-level filters for stability:
- `repository` (string)
- `sender` (string)
- `repository_name` (string, e.g. `owner/repo`)
- `sender_login` (string)
- `issue_number` / `pr_number`
- `ci_status`, `ci_conclusion`
- `review_state`, `comment_author`
@@ -12,7 +12,7 @@ Replace `{{...}}` placeholders before use.
"event_source": "github",
"event_type": "issue.opened",
"event_filters": {
"repository": "{{repository}}"
"repository_name": "{{repository}}"
},
"action_type": "full_job",
"prompt": "For issue #{{issue_number}} in {{repository}}, produce a concrete implementation plan with milestones, edge cases, and tests. Post/update an issue comment with the plan.",
@@ -32,7 +32,7 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
"event_source": "github",
"event_type": "pr.comment.created",
"event_filters": {
"repository": "{{repository}}",
"repository_name": "{{repository}}",
"comment_author": "{{maintainer}}"
},
"action_type": "full_job",
@@ -51,7 +51,7 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
"event_source": "github",
"event_type": "pr.synchronize",
"event_filters": {
"repository": "{{repository}}"
"repository_name": "{{repository}}"
},
"action_type": "full_job",
"prompt": "For PR #{{pr_number}}, collect open review comments and unresolved threads, apply fixes, push branch updates, and summarize remaining blockers. If conflict with {{main_branch}}, rebase/merge from origin/{{main_branch}} and resolve safely.",
@@ -69,7 +69,7 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
"event_source": "github",
"event_type": "ci.check_run.completed",
"event_filters": {
"repository": "{{repository}}",
"repository_name": "{{repository}}",
"ci_conclusion": "failure"
},
"action_type": "full_job",
@@ -102,7 +102,7 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
"event_source": "github",
"event_type": "pr.closed",
"event_filters": {
"repository": "{{repository}}",
"repository_name": "{{repository}}",
"pr_merged": "true"
},
"action_type": "full_job",
@@ -118,9 +118,9 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
"source": "github",
"event_type": "issue.opened",
"payload": {
"repository": "{{repository}}",
"repository_name": "{{repository}}",
"issue_number": 99999,
"sender": "test-bot"
"sender_login": "test-bot"
}
}
```
+3 -1
View File
@@ -803,7 +803,9 @@ impl Agent {
thread_id = %external_thread_id,
"Hydrating thread from DB"
);
self.maybe_hydrate_thread(message, external_thread_id).await;
if let Some(rejection) = self.maybe_hydrate_thread(message, external_thread_id).await {
return Ok(Some(format!("Error: {}", rejection)));
}
}
// Resolve session and thread
+215 -115
View File
@@ -23,6 +23,14 @@ use crate::error::Error;
use crate::llm::{ChatMessage, ToolCall};
use crate::tools::redact_params;
const FORGED_THREAD_ID_ERROR: &str = "Invalid or unauthorized thread ID.";
fn requires_preexisting_uuid_thread(channel: &str) -> bool {
// Gateway-style channels send server-issued conversation UUIDs.
// Unknown UUIDs should be rejected instead of silently creating a new thread.
matches!(channel, "gateway" | "test")
}
impl Agent {
/// Hydrate a historical thread from DB into memory if not already present.
///
@@ -37,11 +45,11 @@ impl Agent {
&self,
message: &IncomingMessage,
external_thread_id: &str,
) {
) -> Option<String> {
// Only hydrate UUID-shaped thread IDs (web gateway uses UUIDs)
let thread_uuid = match Uuid::parse_str(external_thread_id) {
Ok(id) => id,
Err(_) => return,
Err(_) => return None,
};
// Check if already in memory
@@ -52,7 +60,7 @@ impl Agent {
{
let sess = session.lock().await;
if sess.threads.contains_key(&thread_uuid) {
return;
return None;
}
}
@@ -61,6 +69,62 @@ impl Agent {
let msg_count;
if let Some(store) = self.store() {
// Never hydrate history from a conversation UUID that isn't owned
// by the current authenticated user.
let owned = match store
.conversation_belongs_to_user(thread_uuid, &message.user_id)
.await
{
Ok(v) => v,
Err(e) => {
tracing::warn!(
"Failed to verify conversation ownership for hydration {}: {}",
thread_uuid,
e
);
if requires_preexisting_uuid_thread(&message.channel) {
return Some(FORGED_THREAD_ID_ERROR.to_string());
}
return None;
}
};
if !owned {
let exists = match store.get_conversation_metadata(thread_uuid).await {
Ok(Some(_)) => true,
Ok(None) => false,
Err(e) => {
tracing::warn!(
"Failed to inspect conversation metadata for hydration {}: {}",
thread_uuid,
e
);
if requires_preexisting_uuid_thread(&message.channel) {
return Some(FORGED_THREAD_ID_ERROR.to_string());
}
return None;
}
};
if requires_preexisting_uuid_thread(&message.channel) {
tracing::warn!(
user = %message.user_id,
channel = %message.channel,
thread_id = %thread_uuid,
exists,
"Rejected message for unavailable thread id"
);
return Some(FORGED_THREAD_ID_ERROR.to_string());
}
tracing::warn!(
user = %message.user_id,
thread_id = %thread_uuid,
exists,
"Skipped hydration for thread id not owned by sender"
);
return None;
}
let db_messages = store
.list_conversation_messages(thread_uuid)
.await
@@ -104,6 +168,8 @@ impl Agent {
thread_uuid,
msg_count
);
None
}
pub(super) async fn process_user_input(
@@ -303,8 +369,13 @@ impl Agent {
thread_id = %thread_id,
"Persisting user message to DB"
);
self.persist_user_message(thread_id, &message.user_id, effective_content)
.await;
self.persist_user_message(
thread_id,
&message.channel,
&message.user_id,
effective_content,
)
.await;
tracing::debug!(
message_id = %message.id,
@@ -386,10 +457,21 @@ impl Agent {
.await;
// Persist tool calls then assistant response (user message already persisted at turn start)
self.persist_tool_calls(thread_id, &message.user_id, turn_number, &tool_calls)
.await;
self.persist_assistant_response(thread_id, &message.user_id, &response)
.await;
self.persist_tool_calls(
thread_id,
&message.channel,
&message.user_id,
turn_number,
&tool_calls,
)
.await;
self.persist_assistant_response(
thread_id,
&message.channel,
&message.user_id,
&response,
)
.await;
Ok(SubmissionResult::response(response))
}
@@ -423,6 +505,41 @@ impl Agent {
}
}
/// Ensure a thread UUID is writable for `(channel, user_id)`.
///
/// Returns `false` for foreign/unowned conversation IDs or DB errors.
async fn ensure_writable_conversation(
&self,
store: &Arc<dyn crate::db::Database>,
thread_id: Uuid,
channel: &str,
user_id: &str,
) -> bool {
match store
.ensure_conversation(thread_id, channel, user_id, None)
.await
{
Ok(true) => true,
Ok(false) => {
tracing::warn!(
user = %user_id,
channel = %channel,
thread_id = %thread_id,
"Rejected write for unavailable thread id"
);
false
}
Err(e) => {
tracing::warn!(
"Failed to ensure writable conversation {}: {}",
thread_id,
e
);
false
}
}
}
/// Persist the user message to the DB at turn start (before the agentic loop).
///
/// This ensures the user message is durable even if the process crashes
@@ -430,6 +547,7 @@ impl Agent {
pub(super) async fn persist_user_message(
&self,
thread_id: Uuid,
channel: &str,
user_id: &str,
user_input: &str,
) {
@@ -438,11 +556,10 @@ impl Agent {
None => return,
};
if let Err(e) = store
.ensure_conversation(thread_id, "gateway", user_id, None)
if !self
.ensure_writable_conversation(&store, thread_id, channel, user_id)
.await
{
tracing::warn!("Failed to ensure conversation {}: {}", thread_id, e);
return;
}
@@ -462,6 +579,7 @@ impl Agent {
pub(super) async fn persist_assistant_response(
&self,
thread_id: Uuid,
channel: &str,
user_id: &str,
response: &str,
) {
@@ -470,11 +588,10 @@ impl Agent {
None => return,
};
if let Err(e) = store
.ensure_conversation(thread_id, "gateway", user_id, None)
if !self
.ensure_writable_conversation(&store, thread_id, channel, user_id)
.await
{
tracing::warn!("Failed to ensure conversation {}: {}", thread_id, e);
return;
}
@@ -494,6 +611,7 @@ impl Agent {
pub(super) async fn persist_tool_calls(
&self,
thread_id: Uuid,
channel: &str,
user_id: &str,
turn_number: usize,
tool_calls: &[crate::agent::session::TurnToolCall],
@@ -543,11 +661,10 @@ impl Agent {
}
};
if let Err(e) = store
.ensure_conversation(thread_id, "gateway", user_id, None)
if !self
.ensure_writable_conversation(&store, thread_id, channel, user_id)
.await
{
tracing::warn!("Failed to ensure conversation {}: {}", thread_id, e);
return;
}
@@ -925,14 +1042,20 @@ impl Agent {
for (idx, tc) in deferred_tool_calls.iter().enumerate() {
if let Some(tool) = self.tools().get(&tc.name).await {
use crate::tools::ApprovalRequirement;
let needs_approval = match tool.requires_approval(&tc.arguments) {
ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => {
let sess = session.lock().await;
!sess.is_tool_auto_approved(&tc.name)
// Match dispatcher.rs: when auto_approve_tools is true, skip
// all approval checks (including ApprovalRequirement::Always).
let needs_approval = if self.config.auto_approve_tools {
false
} else {
use crate::tools::ApprovalRequirement;
match tool.requires_approval(&tc.arguments) {
ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => {
let sess = session.lock().await;
!sess.is_tool_auto_approved(&tc.name)
}
ApprovalRequirement::Always => true,
}
ApprovalRequirement::Always => true,
};
if needs_approval {
@@ -1208,10 +1331,21 @@ impl Agent {
.map(|t| (t.turn_number, t.tool_calls.clone()))
.unwrap_or_default();
// User message already persisted at turn start; save tool calls then assistant response
self.persist_tool_calls(thread_id, &message.user_id, turn_number, &tool_calls)
.await;
self.persist_assistant_response(thread_id, &message.user_id, &response)
.await;
self.persist_tool_calls(
thread_id,
&message.channel,
&message.user_id,
turn_number,
&tool_calls,
)
.await;
self.persist_assistant_response(
thread_id,
&message.channel,
&message.user_id,
&response,
)
.await;
let _ = self
.channels
.send_status(
@@ -1264,8 +1398,13 @@ impl Agent {
thread.clear_pending_approval();
thread.complete_turn(&rejection);
// User message already persisted at turn start; save rejection response
self.persist_assistant_response(thread_id, &message.user_id, &rejection)
.await;
self.persist_assistant_response(
thread_id,
&message.channel,
&message.user_id,
&rejection,
)
.await;
}
}
@@ -1303,8 +1442,13 @@ impl Agent {
thread.enter_auth_mode(ext_name.clone());
thread.complete_turn(&instructions);
// User message already persisted at turn start; save auth instructions
self.persist_assistant_response(thread_id, &message.user_id, &instructions)
.await;
self.persist_assistant_response(
thread_id,
&message.channel,
&message.user_id,
&instructions,
)
.await;
}
}
let _ = self
@@ -1349,100 +1493,56 @@ impl Agent {
None => return Ok(Some("Extension manager not available.".to_string())),
};
match ext_mgr.auth(&pending.extension_name, Some(token)).await {
Ok(result) if result.is_authenticated() => {
tracing::info!(
"Extension '{}' authenticated via auth mode",
pending.extension_name
);
// Auto-activate so tools are available immediately after auth
match ext_mgr.activate(&pending.extension_name).await {
Ok(activate_result) => {
let tool_count = activate_result.tools_loaded.len();
let tool_list = if activate_result.tools_loaded.is_empty() {
String::new()
} else {
format!("\n\nTools: {}", activate_result.tools_loaded.join(", "))
};
let msg = format!(
"{} authenticated and activated ({} tools loaded).{}",
pending.extension_name, tool_count, tool_list
);
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::AuthCompleted {
extension_name: pending.extension_name.clone(),
success: true,
message: msg.clone(),
},
&message.metadata,
)
.await;
Ok(Some(msg))
}
Err(e) => {
tracing::warn!(
"Extension '{}' authenticated but activation failed: {}",
pending.extension_name,
e
);
let msg = format!(
"{} authenticated successfully, but activation failed: {}. \
Try activating manually.",
pending.extension_name, e
);
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::AuthCompleted {
extension_name: pending.extension_name.clone(),
success: true,
message: msg.clone(),
},
&message.metadata,
)
.await;
Ok(Some(msg))
}
}
}
match ext_mgr
.configure_token(&pending.extension_name, token)
.await
{
Ok(result) => {
// Invalid token, re-enter auth mode
{
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
thread.enter_auth_mode(pending.extension_name.clone());
}
}
let msg = result
.instructions()
.map(String::from)
.unwrap_or_else(|| "Invalid token. Please try again.".to_string());
// Re-emit AuthRequired so web UI re-shows the card
tracing::info!(
"Extension '{}' configured via auth mode: {}",
pending.extension_name,
result.message
);
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::AuthRequired {
StatusUpdate::AuthCompleted {
extension_name: pending.extension_name.clone(),
instructions: Some(msg.clone()),
auth_url: result.auth_url().map(String::from),
setup_url: result.setup_url().map(String::from),
success: true,
message: result.message.clone(),
},
&message.metadata,
)
.await;
Ok(Some(msg))
Ok(Some(result.message))
}
Err(e) => {
let msg = format!(
"Authentication failed for {}: {}",
pending.extension_name, e
);
let msg = e.to_string();
// Token validation errors: re-enter auth mode and re-prompt
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
{
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
thread.enter_auth_mode(pending.extension_name.clone());
}
}
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::AuthRequired {
extension_name: pending.extension_name.clone(),
instructions: Some(msg.clone()),
auth_url: None,
setup_url: None,
},
&message.metadata,
)
.await;
return Ok(Some(msg));
}
// Infrastructure errors
let _ = self
.channels
.send_status(
+13 -1
View File
@@ -563,7 +563,19 @@ impl AppBuilder {
}
}
Err(e) => {
tracing::debug!("No MCP servers configured ({})", e);
if matches!(
e,
crate::tools::mcp::config::ConfigError::InvalidConfig { .. }
| crate::tools::mcp::config::ConfigError::Json(_)
) {
tracing::warn!(
"MCP server configuration is invalid: {}. \
Fix or remove the corrupted config.",
e
);
} else {
tracing::debug!("No MCP servers configured ({})", e);
}
}
}
}
+12 -3
View File
@@ -116,9 +116,18 @@ pub fn load_ironclaw_env() {
.join(".ironclaw")
.join("ironclaw.db");
if default_db.exists() {
// SAFETY: `load_ironclaw_env` is called from a synchronous `fn main()`
// before the Tokio runtime is started, so no other threads exist yet.
unsafe { std::env::set_var("DATABASE_BACKEND", "libsql") };
if tokio::runtime::Handle::try_current().is_ok() {
// Tokio runtime is active (multi-threaded); std::env::set_var is UB here.
// Fall back to the thread-safe runtime overlay so the value is always set.
tracing::warn!(
"load_ironclaw_env called with active Tokio runtime; \
using runtime env overlay for DATABASE_BACKEND"
);
crate::config::set_runtime_env("DATABASE_BACKEND", "libsql");
} else {
// SAFETY: No Tokio runtime = no other threads = safe to call set_var.
unsafe { std::env::set_var("DATABASE_BACKEND", "libsql") };
}
}
}
}
+407 -49
View File
@@ -6,12 +6,15 @@ use async_trait::async_trait;
use axum::{
Json, Router,
extract::{DefaultBodyLimit, State},
http::StatusCode,
http::{HeaderMap, StatusCode},
response::IntoResponse,
routing::{get, post},
};
use bytes::Bytes;
use hmac::{Hmac, Mac};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use subtle::ConstantTimeEq;
use tokio::sync::{RwLock, mpsc, oneshot};
use tokio_stream::wrappers::ReceiverStream;
@@ -24,6 +27,8 @@ use crate::channels::{
use crate::config::HttpConfig;
use crate::error::ChannelError;
type HmacSha256 = Hmac<Sha256>;
/// HTTP webhook channel.
pub struct HttpChannel {
config: HttpConfig,
@@ -135,7 +140,8 @@ struct WebhookRequest {
content: String,
/// Optional thread ID for conversation tracking.
thread_id: Option<String>,
/// Optional webhook secret for authentication.
/// Deprecated: webhook secret in request body. Use X-IronClaw-Signature header instead.
/// This field is accepted for backward compatibility but will be removed in a future release.
secret: Option<String>,
/// Whether to wait for a synchronous response.
#[serde(default)]
@@ -191,10 +197,36 @@ async fn health_handler() -> impl IntoResponse {
})
}
/// Verify an HMAC-SHA256 signature against the raw request body.
///
/// The expected header format is: `sha256=<hex_digest>`
/// where the digest is HMAC-SHA256(secret_key, body_bytes) encoded as lowercase hex.
fn verify_hmac_signature(secret: &str, body: &[u8], signature_header: &str) -> bool {
let hex_digest = match signature_header.strip_prefix("sha256=") {
Some(h) => h,
None => return false,
};
let provided_mac = match hex::decode(hex_digest) {
Ok(bytes) => bytes,
Err(_) => return false,
};
let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
Ok(mac) => mac,
Err(_) => return false,
};
mac.update(body);
let expected_mac = mac.finalize().into_bytes();
bool::from(expected_mac.as_slice().ct_eq(&provided_mac))
}
async fn webhook_handler(
State(state): State<Arc<HttpChannelState>>,
Json(req): Json<WebhookRequest>,
) -> (StatusCode, Json<WebhookResponse>) {
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
// Rate limiting
{
let mut limiter = state.rate_limit.lock().await;
@@ -211,10 +243,153 @@ async fn webhook_handler(
status: "error".to_string(),
response: Some("Rate limit exceeded".to_string()),
}),
);
)
.into_response();
}
}
let content_type_ok = headers
.get("content-type")
.and_then(|value| value.to_str().ok())
.map(|value| value.starts_with("application/json"))
.unwrap_or(false);
if !content_type_ok {
return (
StatusCode::UNSUPPORTED_MEDIA_TYPE,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some("Content-Type must be application/json".to_string()),
}),
)
.into_response();
}
let mut fallback_req = None;
{
let webhook_secret = state.webhook_secret.read().await;
if let Some(expected_secret) = webhook_secret.as_ref() {
let expected_secret = expected_secret.expose_secret();
match headers.get("x-ironclaw-signature") {
Some(raw_signature) => match raw_signature.to_str() {
Ok(signature) => {
if !verify_hmac_signature(expected_secret, &body, signature) {
return (
StatusCode::UNAUTHORIZED,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some("Invalid webhook signature".to_string()),
}),
)
.into_response();
}
}
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some("Invalid signature header encoding".to_string()),
}),
)
.into_response();
}
},
None => {
let req: WebhookRequest = match serde_json::from_slice(&body) {
Ok(req) => req,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some(
"Webhook authentication required. Provide X-IronClaw-Signature header \
(preferred) or 'secret' field in body (deprecated)."
.to_string(),
),
}),
)
.into_response();
}
};
match &req.secret {
Some(provided)
if bool::from(
provided.as_bytes().ct_eq(expected_secret.as_bytes()),
) =>
{
tracing::warn!(
"Webhook authenticated via deprecated 'secret' field in request body. \
Migrate to X-IronClaw-Signature header (HMAC-SHA256). \
Body secret support will be removed in a future release."
);
fallback_req = Some(req);
}
Some(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some("Invalid webhook secret".to_string()),
}),
)
.into_response();
}
None => {
return (
StatusCode::UNAUTHORIZED,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some(
"Webhook authentication required. Provide X-IronClaw-Signature header \
(preferred) or 'secret' field in body (deprecated)."
.to_string(),
),
}),
)
.into_response();
}
}
}
}
}
}
if let Some(req) = fallback_req {
return process_authenticated_request(state, req).await;
}
let req: WebhookRequest = match serde_json::from_slice(&body) {
Ok(req) => req,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some(format!("Invalid JSON: {e}")),
}),
)
.into_response();
}
};
process_authenticated_request(state, req).await
}
async fn process_authenticated_request(
state: Arc<HttpChannelState>,
req: WebhookRequest,
) -> axum::response::Response {
let _ = req.user_id.as_ref().map(|user_id| {
tracing::debug!(
provided_user_id = %user_id,
@@ -222,36 +397,6 @@ async fn webhook_handler(
);
});
// Validate secret if configured
if let Some(ref expected_secret) = *state.webhook_secret.read().await {
let expected_bytes = expected_secret.expose_secret().as_bytes();
match &req.secret {
Some(provided) if bool::from(provided.as_bytes().ct_eq(expected_bytes)) => {
// Secret matches, continue
}
Some(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some("Invalid webhook secret".to_string()),
}),
);
}
None => {
return (
StatusCode::UNAUTHORIZED,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some("Webhook secret required".to_string()),
}),
);
}
}
}
if req.content.len() > MAX_CONTENT_BYTES {
return (
StatusCode::PAYLOAD_TOO_LARGE,
@@ -260,10 +405,12 @@ async fn webhook_handler(
status: "error".to_string(),
response: Some("Content too large".to_string()),
}),
);
)
.into_response();
}
// Validate and decode attachments
let wait_for_response = req.wait_for_response;
let attachments = if !req.attachments.is_empty() {
if req.attachments.len() > MAX_ATTACHMENTS {
return (
@@ -273,7 +420,8 @@ async fn webhook_handler(
status: "error".to_string(),
response: Some(format!("Too many attachments (max {})", MAX_ATTACHMENTS)),
}),
);
)
.into_response();
}
let mut decoded_attachments = Vec::new();
@@ -291,7 +439,8 @@ async fn webhook_handler(
status: "error".to_string(),
response: Some("Invalid base64 in attachment".to_string()),
}),
);
)
.into_response();
}
};
if data.len() > MAX_ATTACHMENT_BYTES {
@@ -305,7 +454,8 @@ async fn webhook_handler(
MAX_ATTACHMENT_BYTES
)),
}),
);
)
.into_response();
}
total_bytes += data.len();
if total_bytes > MAX_TOTAL_ATTACHMENT_BYTES {
@@ -316,7 +466,8 @@ async fn webhook_handler(
status: "error".to_string(),
response: Some("Total attachment size exceeds limit".to_string()),
}),
);
)
.into_response();
}
decoded_attachments.push(IncomingAttachment {
id: Uuid::new_v4().to_string(),
@@ -331,7 +482,6 @@ async fn webhook_handler(
duration_secs: None,
});
} else if let Some(ref url) = att.url {
// URL-only attachment: set source_url but don't download (SSRF prevention)
decoded_attachments.push(IncomingAttachment {
id: Uuid::new_v4().to_string(),
kind: AttachmentKind::from_mime_type(&att.mime_type),
@@ -353,7 +503,7 @@ async fn webhook_handler(
let mut msg = IncomingMessage::new("http", &state.user_id, &req.content).with_metadata(
serde_json::json!({
"wait_for_response": req.wait_for_response,
"wait_for_response": wait_for_response,
}),
);
@@ -365,7 +515,9 @@ async fn webhook_handler(
msg = msg.with_thread(thread_id);
}
process_message(state, msg, req.wait_for_response).await
process_message(state, msg, wait_for_response)
.await
.into_response()
}
async fn process_message(
@@ -515,7 +667,7 @@ impl ChannelSecretUpdater for HttpChannelState {
#[cfg(test)]
mod tests {
use axum::body::Body;
use axum::http::Request;
use axum::http::{HeaderValue, Request};
use secrecy::SecretString;
use tower::ServiceExt;
@@ -530,6 +682,14 @@ mod tests {
})
}
fn compute_signature(secret: &str, body: &[u8]) -> String {
let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC key creation failed");
mac.update(body);
let result = mac.finalize().into_bytes();
format!("sha256={}", hex::encode(result))
}
#[tokio::test]
async fn test_http_channel_requires_secret() {
let channel = test_channel(None);
@@ -538,9 +698,76 @@ mod tests {
}
#[tokio::test]
async fn webhook_correct_secret_returns_ok() {
async fn webhook_hmac_signature_returns_ok() {
let secret = "test-secret-123";
let channel = test_channel(Some(secret));
let _stream = channel.start().await.unwrap();
let app = channel.routes();
let body = serde_json::json!({
"content": "hello"
});
let body_bytes = serde_json::to_vec(&body).unwrap();
let signature = compute_signature(secret, &body_bytes);
let req = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.header("x-ironclaw-signature", signature)
.body(Body::from(body_bytes))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn webhook_wrong_hmac_signature_returns_unauthorized() {
let channel = test_channel(Some("correct-secret"));
let _stream = channel.start().await.unwrap();
let app = channel.routes();
let body = serde_json::json!({
"content": "hello"
});
let body_bytes = serde_json::to_vec(&body).unwrap();
let signature = compute_signature("wrong-secret", &body_bytes);
let req = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.header("x-ironclaw-signature", signature)
.body(Body::from(body_bytes))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn webhook_malformed_signature_returns_unauthorized() {
let channel = test_channel(Some("correct-secret"));
let _stream = channel.start().await.unwrap();
let app = channel.routes();
let body = serde_json::json!({
"content": "hello"
});
let req = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.header("x-ironclaw-signature", "not-a-valid-signature")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn webhook_deprecated_body_secret_still_works() {
let channel = test_channel(Some("test-secret-123"));
// Start the channel so the tx sender is populated (otherwise 503).
let _stream = channel.start().await.unwrap();
let app = channel.routes();
@@ -560,7 +787,7 @@ mod tests {
}
#[tokio::test]
async fn webhook_wrong_secret_returns_unauthorized() {
async fn webhook_wrong_body_secret_returns_unauthorized() {
let channel = test_channel(Some("correct-secret"));
let _stream = channel.start().await.unwrap();
let app = channel.routes();
@@ -581,7 +808,7 @@ mod tests {
}
#[tokio::test]
async fn webhook_missing_secret_returns_unauthorized() {
async fn webhook_missing_all_auth_returns_unauthorized() {
let channel = test_channel(Some("correct-secret"));
let _stream = channel.start().await.unwrap();
let app = channel.routes();
@@ -600,6 +827,104 @@ mod tests {
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn webhook_hmac_takes_precedence_over_body_secret() {
let secret = "test-secret-123";
let channel = test_channel(Some(secret));
let _stream = channel.start().await.unwrap();
let app = channel.routes();
let body = serde_json::json!({
"content": "hello",
"secret": "wrong-secret-in-body"
});
let body_bytes = serde_json::to_vec(&body).unwrap();
let signature = compute_signature(secret, &body_bytes);
let req = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.header("x-ironclaw-signature", signature)
.body(Body::from(body_bytes))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn webhook_invalid_json_returns_bad_request() {
let secret = "test-secret";
let channel = test_channel(Some(secret));
let _stream = channel.start().await.unwrap();
let app = channel.routes();
let body = b"not json".to_vec();
let signature = compute_signature(secret, &body);
let req = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.header("x-ironclaw-signature", signature)
.body(Body::from(body))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn webhook_rejects_non_json_content_type() {
let secret = "test-secret";
let channel = test_channel(Some(secret));
let _stream = channel.start().await.unwrap();
let app = channel.routes();
let body = serde_json::json!({
"content": "hello"
});
let body_bytes = serde_json::to_vec(&body).unwrap();
let signature = compute_signature(secret, &body_bytes);
let req = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "text/plain")
.header("x-ironclaw-signature", signature)
.body(Body::from(body_bytes))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
}
#[tokio::test]
async fn webhook_invalid_signature_header_encoding_returns_unauthorized() {
let channel = test_channel(Some("test-secret"));
let _stream = channel.start().await.unwrap();
let app = channel.routes();
let body = serde_json::json!({
"content": "hello"
});
let mut req = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
req.headers_mut().insert(
"x-ironclaw-signature",
HeaderValue::from_bytes(b"\xFF").unwrap(),
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_update_secret_hot_swap() {
let channel = test_channel(Some("old-secret"));
@@ -751,4 +1076,37 @@ mod tests {
"All concurrent requests should succeed with correct secrets after update"
);
}
#[test]
fn verify_hmac_signature_valid() {
let secret = "my-secret";
let body = b"test body content";
let sig = compute_signature(secret, body);
assert!(verify_hmac_signature(secret, body, &sig));
}
#[test]
fn verify_hmac_signature_invalid_digest() {
let secret = "my-secret";
let body = b"test body content";
assert!(!verify_hmac_signature(
secret,
body,
"sha256=0000000000000000000000000000000000000000000000000000000000000000"
));
}
#[test]
fn verify_hmac_signature_missing_prefix() {
let secret = "my-secret";
let body = b"test body content";
assert!(!verify_hmac_signature(secret, body, "deadbeef"));
}
#[test]
fn verify_hmac_signature_invalid_hex() {
let secret = "my-secret";
let body = b"test body content";
assert!(!verify_hmac_signature(secret, body, "sha256=not-hex!"));
}
}
+46
View File
@@ -106,6 +106,34 @@ pub fn verify_slack_signature(
.into()
}
/// Verify raw-body HMAC-SHA256 signature with a configurable prefix.
///
/// Computes `HMAC-SHA256(secret, body)` and compares against
/// `prefix + hex_digest` in constant time.
pub fn verify_hmac_sha256_prefixed(
secret: &str,
body: &[u8],
signature_header: &str,
prefix: &str,
) -> bool {
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
let mut mac = match Hmac::<Sha256>::new_from_slice(secret.as_bytes()) {
Ok(m) => m,
Err(_) => return false,
};
mac.update(body);
let computed = mac.finalize().into_bytes();
let computed_hex = hex::encode(computed);
let expected = format!("{prefix}{computed_hex}");
expected
.as_bytes()
.ct_eq(signature_header.as_bytes())
.into()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -498,6 +526,24 @@ mod tests {
);
}
#[test]
fn test_hmac_sha256_prefixed_valid() {
let secret = "github-secret";
let body = br#"{"action":"opened"}"#;
use hmac::{Hmac, Mac};
use sha2::Sha256;
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("hmac key");
mac.update(body);
let sig = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
assert!(verify_hmac_sha256_prefixed(secret, body, &sig, "sha256="));
assert!(!verify_hmac_sha256_prefixed(
secret,
body,
"sha256=deadbeef",
"sha256="
));
}
#[test]
fn test_slack_stale_timestamp_rejected() {
let signing_secret = "my-signing-secret";
+1 -1
View File
@@ -197,7 +197,7 @@ All responses include:
- `X-Content-Type-Options: nosniff`
- `X-Frame-Options: DENY`
**Request body limit:** 1 MB (`DefaultBodyLimit::max(1024 * 1024)`). Larger payloads return 413.
**Request body limit:** 10 MB (`DefaultBodyLimit::max(10 * 1024 * 1024)`), sized for image uploads (#725). Larger payloads return 413.
## Pending Approvals
+32 -42
View File
@@ -145,49 +145,33 @@ pub async fn chat_auth_token_handler(
"Extension manager not available".to_string(),
))?;
let result = ext_mgr
.auth(&req.extension_name, Some(&req.token))
match ext_mgr
.configure_token(&req.extension_name, &req.token)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
{
Ok(result) => {
clear_auth_mode(&state).await;
if result.is_authenticated() {
// Auto-activate so tools are available immediately
let msg = match ext_mgr.activate(&req.extension_name).await {
Ok(r) => format!(
"{} authenticated ({} tools loaded)",
req.extension_name,
r.tools_loaded.len()
),
Err(e) => format!(
"{} authenticated but activation failed: {}",
req.extension_name, e
),
};
state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: req.extension_name.clone(),
success: true,
message: result.message.clone(),
});
// Clear auth mode on the active thread
clear_auth_mode(&state).await;
state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: req.extension_name,
success: true,
message: msg.clone(),
});
Ok(Json(ActionResponse::ok(msg)))
} else {
// Re-emit auth_required for retry
state.sse.broadcast(SseEvent::AuthRequired {
extension_name: req.extension_name.clone(),
instructions: result.instructions().map(String::from),
auth_url: result.auth_url().map(String::from),
setup_url: result.setup_url().map(String::from),
});
Ok(Json(ActionResponse::fail(
result
.instructions()
.map(String::from)
.unwrap_or_else(|| "Invalid token".to_string()),
)))
Ok(Json(ActionResponse::ok(result.message)))
}
Err(e) => {
let msg = e.to_string();
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
state.sse.broadcast(SseEvent::AuthRequired {
extension_name: req.extension_name.clone(),
instructions: Some(msg.clone()),
auth_url: None,
setup_url: None,
});
}
Ok(Json(ActionResponse::fail(msg)))
}
}
}
@@ -550,11 +534,17 @@ pub async fn chat_new_thread_handler(
// Persist the empty conversation row with thread_type metadata synchronously
// so that the subsequent loadThreads() call from the frontend sees it.
if let Some(ref store) = state.store {
if let Err(e) = store
match store
.ensure_conversation(thread_id, "gateway", &state.user_id, None)
.await
{
tracing::warn!("Failed to persist new thread: {}", e);
Ok(true) => {}
Ok(false) => tracing::warn!(
user = %state.user_id,
thread_id = %thread_id,
"Skipped persisting new thread due to ownership/channel conflict"
),
Err(e) => tracing::warn!("Failed to persist new thread: {}", e),
}
let metadata_val = serde_json::json!("thread");
if let Err(e) = store
+6
View File
@@ -244,6 +244,12 @@ impl GatewayChannel {
self
}
/// Inject a shared routine engine slot used by other HTTP ingress paths.
pub fn with_routine_engine_slot(mut self, slot: server::RoutineEngineSlot) -> Self {
self.rebuild_state(|s| s.routine_engine = slot);
self
}
/// Get the auth token (for printing to console on startup).
pub fn auth_token(&self) -> &str {
&self.auth_token
+149 -48
View File
@@ -318,7 +318,11 @@ pub async fn start_server(
.route("/", get(index_handler))
.route("/style.css", get(css_handler))
.route("/app.js", get(js_handler))
.route("/favicon.ico", get(favicon_handler));
.route("/favicon.ico", get(favicon_handler))
.route("/i18n/index.js", get(i18n_index_handler))
.route("/i18n/en.js", get(i18n_en_handler))
.route("/i18n/zh-CN.js", get(i18n_zh_handler))
.route("/i18n-app.js", get(i18n_app_handler));
// Project file serving (behind auth to prevent unauthorized file access).
let projects = Router::new()
@@ -368,6 +372,21 @@ pub async fn start_server(
header::X_FRAME_OPTIONS,
header::HeaderValue::from_static("DENY"),
))
.layer(SetResponseHeaderLayer::if_not_present(
header::HeaderName::from_static("content-security-policy"),
header::HeaderValue::from_static(
"default-src 'self'; \
script-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; \
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; \
font-src https://fonts.gstatic.com; \
connect-src 'self'; \
img-src 'self' data:; \
object-src 'none'; \
frame-ancestors 'none'; \
base-uri 'self'; \
form-action 'self'",
),
))
.with_state(state.clone());
let (shutdown_tx, shutdown_rx) = oneshot::channel();
@@ -430,6 +449,46 @@ async fn favicon_handler() -> impl IntoResponse {
)
}
async fn i18n_index_handler() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "no-cache"),
],
include_str!("static/i18n/index.js"),
)
}
async fn i18n_en_handler() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "no-cache"),
],
include_str!("static/i18n/en.js"),
)
}
async fn i18n_zh_handler() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "no-cache"),
],
include_str!("static/i18n/zh-CN.js"),
)
}
async fn i18n_app_handler() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "no-cache"),
],
include_str!("static/i18n-app.js"),
)
}
// --- Health ---
async fn health_handler() -> Json<HealthResponse> {
@@ -1018,49 +1077,35 @@ async fn chat_auth_token_handler(
"Extension manager not available".to_string(),
))?;
let result = ext_mgr
.auth(&req.extension_name, Some(&req.token))
match ext_mgr
.configure_token(&req.extension_name, &req.token)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
{
Ok(result) => {
// Clear auth mode on the active thread
clear_auth_mode(&state).await;
if result.is_authenticated() {
// Auto-activate so tools are available immediately
let msg = match ext_mgr.activate(&req.extension_name).await {
Ok(r) => format!(
"{} authenticated ({} tools loaded)",
req.extension_name,
r.tools_loaded.len()
),
Err(e) => format!(
"{} authenticated but activation failed: {}",
req.extension_name, e
),
};
state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: req.extension_name.clone(),
success: true,
message: result.message.clone(),
});
// Clear auth mode on the active thread
clear_auth_mode(&state).await;
state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: req.extension_name,
success: true,
message: msg.clone(),
});
Ok(Json(ActionResponse::ok(msg)))
} else {
// Re-emit auth_required for retry
state.sse.broadcast(SseEvent::AuthRequired {
extension_name: req.extension_name.clone(),
instructions: result.instructions().map(String::from),
auth_url: result.auth_url().map(String::from),
setup_url: result.setup_url().map(String::from),
});
Ok(Json(ActionResponse::fail(
result
.instructions()
.map(String::from)
.unwrap_or_else(|| "Invalid token".to_string()),
)))
Ok(Json(ActionResponse::ok(result.message)))
}
Err(e) => {
let msg = e.to_string();
// Re-emit auth_required for retry on validation errors
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
state.sse.broadcast(SseEvent::AuthRequired {
extension_name: req.extension_name.clone(),
instructions: Some(msg.clone()),
auth_url: None,
setup_url: None,
});
}
Ok(Json(ActionResponse::fail(msg)))
}
}
}
@@ -1418,11 +1463,17 @@ async fn chat_new_thread_handler(
// Persist the empty conversation row with thread_type metadata synchronously
// so that the subsequent loadThreads() call from the frontend sees it.
if let Some(ref store) = state.store {
if let Err(e) = store
match store
.ensure_conversation(thread_id, "gateway", &state.user_id, None)
.await
{
tracing::warn!("Failed to persist new thread: {}", e);
Ok(true) => {}
Ok(false) => tracing::warn!(
user = %state.user_id,
thread_id = %thread_id,
"Skipped persisting new thread due to ownership/channel conflict"
),
Err(e) => tracing::warn!("Failed to persist new thread: {}", e),
}
let metadata_val = serde_json::json!("thread");
if let Err(e) = store
@@ -1809,7 +1860,7 @@ async fn extensions_install_handler(
// expansion and for first-time auth when credentials are already
// configured (e.g., built-in providers). We only surface an auth_url
// when the extension reports it is awaiting authorization.
match ext_mgr.auth(&req.name, None).await {
match ext_mgr.auth(&req.name).await {
Ok(auth_result) if auth_result.auth_url().is_some() => {
// Scope expansion or initial OAuth: user needs to authorize
resp.auth_url = auth_result.auth_url().map(String::from);
@@ -1838,9 +1889,9 @@ async fn extensions_activate_handler(
// Activation loaded the WASM module. Check if the tool needs
// OAuth scope expansion (e.g., adding google-docs when gmail
// already has a token but missing the documents scope).
// Initial OAuth setup is triggered via save_setup_secrets.
// Initial OAuth setup is triggered via configure.
let mut resp = ActionResponse::ok(result.message);
if let Ok(auth_result) = ext_mgr.auth(&name, None).await
if let Ok(auth_result) = ext_mgr.auth(&name).await
&& auth_result.auth_url().is_some()
{
resp.auth_url = auth_result.auth_url().map(String::from);
@@ -1858,7 +1909,7 @@ async fn extensions_activate_handler(
}
// Activation failed due to auth; try authenticating first.
match ext_mgr.auth(&name, None).await {
match ext_mgr.auth(&name).await {
Ok(auth_result) if auth_result.is_authenticated() => {
// Auth succeeded, retry activation.
match ext_mgr.activate(&name).await {
@@ -2065,7 +2116,7 @@ async fn extensions_setup_submit_handler(
"Extension manager not available (secrets store required)".to_string(),
))?;
match ext_mgr.save_setup_secrets(&name, &req.secrets).await {
match ext_mgr.configure(&name, &req.secrets).await {
Ok(result) => {
// Broadcast auth_completed so the chat UI can dismiss any in-progress
// auth card or setup modal that was triggered by tool_auth/tool_activate.
@@ -2705,6 +2756,56 @@ mod tests {
.with_state(state)
}
#[tokio::test]
async fn test_csp_header_present_on_responses() {
use std::net::SocketAddr;
let state = test_gateway_state(None);
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound = start_server(addr, state.clone(), "test-token".to_string())
.await
.expect("server should start");
let client = reqwest::Client::new();
let resp = client
.get(format!("http://{}/api/health", bound))
.send()
.await
.expect("health request should succeed");
assert_eq!(resp.status(), 200);
let csp = resp
.headers()
.get("content-security-policy")
.expect("CSP header must be present");
let csp_str = csp.to_str().expect("CSP header should be valid UTF-8");
assert!(
csp_str.contains("default-src 'self'"),
"CSP must contain default-src"
);
assert!(
csp_str.contains(
"script-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com"
),
"CSP must allow both marked and DOMPurify script CDNs"
);
assert!(
csp_str.contains("object-src 'none'"),
"CSP must contain object-src 'none'"
);
assert!(
csp_str.contains("frame-ancestors 'none'"),
"CSP must contain frame-ancestors 'none'"
);
if let Some(tx) = state.shutdown_tx.write().await.take() {
let _ = tx.send(());
}
}
#[tokio::test]
async fn test_oauth_callback_missing_params() {
use axum::body::Body;
+125 -121
View File
@@ -55,7 +55,7 @@ let _activityThinking = null;
function authenticate() {
token = document.getElementById('token-input').value.trim();
if (!token) {
document.getElementById('auth-error').textContent = 'Token required';
document.getElementById('auth-error').textContent = I18n.t('auth.errorRequired');
return;
}
@@ -89,7 +89,7 @@ function authenticate() {
sessionStorage.removeItem('ironclaw_token');
document.getElementById('auth-screen').style.display = '';
document.getElementById('app').style.display = 'none';
document.getElementById('auth-error').textContent = 'Invalid token';
document.getElementById('auth-error').textContent = I18n.t('auth.errorInvalid');
});
}
@@ -144,7 +144,7 @@ let restartEnabled = false; // Track if restart is available in this deployment
function triggerRestart() {
if (!currentThreadId) {
alert('Please start a conversation first');
alert(I18n.t('error.startConversation'));
return;
}
@@ -155,7 +155,7 @@ function triggerRestart() {
function confirmRestart() {
if (!currentThreadId) {
alert('Please start a conversation first');
alert(I18n.t('error.startConversation'));
return;
}
@@ -190,7 +190,7 @@ function confirmRestart() {
})
.catch((err) => {
console.error('[confirmRestart] Restart request failed:', err);
addMessage('system', 'Restart failed: ' + err.message);
addMessage('system', I18n.t('error.restartFailed', { message: err.message }));
isRestarting = false;
restartBtn.disabled = false;
if (restartIcon) restartIcon.classList.remove('spinning');
@@ -234,7 +234,7 @@ function connectSSE() {
eventSource.onopen = () => {
document.getElementById('sse-dot').classList.remove('disconnected');
document.getElementById('sse-status').textContent = 'Connected';
document.getElementById('sse-status').textContent = I18n.t('status.connected');
// If we were restarting, close the modal and reset button now that server is back
if (isRestarting) {
@@ -256,7 +256,7 @@ function connectSSE() {
eventSource.onerror = () => {
document.getElementById('sse-dot').classList.add('disconnected');
document.getElementById('sse-status').textContent = 'Reconnecting...';
document.getElementById('sse-status').textContent = I18n.t('status.reconnecting');
};
eventSource.addEventListener('response', (e) => {
@@ -464,7 +464,7 @@ function enableChatInput() {
const btn = document.getElementById('send-btn');
if (input) {
input.disabled = false;
input.placeholder = 'Message or / for commands...';
input.placeholder = I18n.t('chat.inputPlaceholder');
}
if (btn) btn.disabled = false;
}
@@ -676,26 +676,20 @@ function renderMarkdown(text) {
return escapeHtml(text);
}
// Strip dangerous HTML elements and attributes from rendered markdown.
// This prevents XSS from tool output or prompt injection in LLM responses.
// Sanitize rendered HTML using DOMPurify to prevent XSS from tool output
// or prompt injection in LLM responses. DOMPurify is a DOM-based sanitizer
// that handles all known bypass vectors (SVG onload, newline-split event
// handlers, mutation XSS, etc.) unlike the regex approach it replaces.
function sanitizeRenderedHtml(html) {
html = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
html = html.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, '');
html = html.replace(/<object\b[^>]*>[\s\S]*?<\/object>/gi, '');
html = html.replace(/<embed\b[^>]*\/?>/gi, '');
html = html.replace(/<form\b[^>]*>[\s\S]*?<\/form>/gi, '');
html = html.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
html = html.replace(/<link\b[^>]*\/?>/gi, '');
html = html.replace(/<base\b[^>]*\/?>/gi, '');
html = html.replace(/<meta\b[^>]*\/?>/gi, '');
// Remove event handler attributes (onclick, onerror, onload, etc.)
html = html.replace(/\s+on\w+\s*=\s*"[^"]*"/gi, '');
html = html.replace(/\s+on\w+\s*=\s*'[^']*'/gi, '');
html = html.replace(/\s+on\w+\s*=\s*[^\s>]+/gi, '');
// Remove javascript: and data: URLs in href/src attributes
html = html.replace(/(href|src|action)\s*=\s*["']?\s*javascript\s*:/gi, '$1="');
html = html.replace(/(href|src|action)\s*=\s*["']?\s*data\s*:/gi, '$1="');
return html;
if (typeof DOMPurify !== 'undefined') {
return DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
FORBID_TAGS: ['style', 'script'],
FORBID_ATTR: ['style', 'onerror', 'onload']
});
}
// DOMPurify not available (CDN unreachable) — return empty string rather than unsanitized HTML
return '';
}
function copyCodeBlock(btn) {
@@ -703,8 +697,8 @@ function copyCodeBlock(btn) {
const code = pre.querySelector('code');
const text = code ? code.textContent : pre.textContent;
navigator.clipboard.writeText(text).then(() => {
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
btn.textContent = I18n.t('btn.copied');
setTimeout(() => { btn.textContent = I18n.t('btn.copy'); }, 1500);
});
}
@@ -991,7 +985,7 @@ function showApproval(data) {
const header = document.createElement('div');
header.className = 'approval-header';
header.textContent = 'Tool requires approval';
header.textContent = I18n.t('approval.title');
card.appendChild(header);
const toolName = document.createElement('div');
@@ -1009,7 +1003,7 @@ function showApproval(data) {
if (data.parameters) {
const paramsToggle = document.createElement('button');
paramsToggle.className = 'approval-params-toggle';
paramsToggle.textContent = 'Show parameters';
paramsToggle.textContent = I18n.t('approval.showParams');
const paramsBlock = document.createElement('pre');
paramsBlock.className = 'approval-params';
paramsBlock.textContent = data.parameters;
@@ -1017,7 +1011,7 @@ function showApproval(data) {
paramsToggle.addEventListener('click', () => {
const visible = paramsBlock.style.display !== 'none';
paramsBlock.style.display = visible ? 'none' : 'block';
paramsToggle.textContent = visible ? 'Show parameters' : 'Hide parameters';
paramsToggle.textContent = visible ? I18n.t('approval.showParams') : I18n.t('approval.hideParams');
});
card.appendChild(paramsToggle);
card.appendChild(paramsBlock);
@@ -1028,17 +1022,17 @@ function showApproval(data) {
const approveBtn = document.createElement('button');
approveBtn.className = 'approve';
approveBtn.textContent = 'Approve';
approveBtn.textContent = I18n.t('approval.approve');
approveBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'approve'));
const alwaysBtn = document.createElement('button');
alwaysBtn.className = 'always';
alwaysBtn.textContent = 'Always';
alwaysBtn.textContent = I18n.t('approval.always');
alwaysBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'always'));
const denyBtn = document.createElement('button');
denyBtn.className = 'deny';
denyBtn.textContent = 'Deny';
denyBtn.textContent = I18n.t('approval.deny');
denyBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'deny'));
actions.appendChild(approveBtn);
@@ -1065,7 +1059,7 @@ function showJobCard(data) {
const title = document.createElement('div');
title.className = 'job-card-title';
title.textContent = data.title || 'Sandbox Job';
title.textContent = data.title || I18n.t('sandbox.job');
info.appendChild(title);
const id = document.createElement('div');
@@ -1077,7 +1071,7 @@ function showJobCard(data) {
const viewBtn = document.createElement('button');
viewBtn.className = 'job-card-view';
viewBtn.textContent = 'View Job';
viewBtn.textContent = I18n.t('jobs.viewJob');
viewBtn.addEventListener('click', () => {
switchTab('jobs');
openJobDetail(data.job_id);
@@ -1089,7 +1083,7 @@ function showJobCard(data) {
browseBtn.className = 'job-card-browse';
browseBtn.href = data.browse_url;
browseBtn.target = '_blank';
browseBtn.textContent = 'Browse';
browseBtn.textContent = I18n.t('jobs.browse');
card.appendChild(browseBtn);
}
@@ -1110,7 +1104,7 @@ function showAuthCard(data) {
const header = document.createElement('div');
header.className = 'auth-header';
header.textContent = 'Authentication required for ' + data.extension_name;
header.textContent = I18n.t('authRequired.title', {name: data.extension_name});
card.appendChild(header);
if (data.instructions) {
@@ -1126,7 +1120,7 @@ function showAuthCard(data) {
if (data.auth_url) {
const oauthBtn = document.createElement('button');
oauthBtn.className = 'auth-oauth';
oauthBtn.textContent = 'Authenticate with ' + data.extension_name;
oauthBtn.textContent = I18n.t('authRequired.authenticateWith', {name: data.extension_name});
oauthBtn.addEventListener('click', () => {
openOAuthUrl(data.auth_url);
});
@@ -1137,7 +1131,7 @@ function showAuthCard(data) {
const setupLink = document.createElement('a');
setupLink.href = data.setup_url;
setupLink.target = '_blank';
setupLink.textContent = 'Get your token';
setupLink.textContent = I18n.t('authRequired.getToken');
links.appendChild(setupLink);
}
@@ -1151,7 +1145,9 @@ function showAuthCard(data) {
const tokenInput = document.createElement('input');
tokenInput.type = 'password';
tokenInput.placeholder = data.instructions || 'Paste your API key or token';
tokenInput.placeholder = data.instructions
|| I18n.t('auth.extensionTokenPlaceholder')
|| I18n.t('auth.tokenPlaceholder');
tokenInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') submitAuthToken(data.extension_name, tokenInput.value);
});
@@ -1170,12 +1166,12 @@ function showAuthCard(data) {
const submitBtn = document.createElement('button');
submitBtn.className = 'auth-submit';
submitBtn.textContent = 'Submit';
submitBtn.textContent = I18n.t('btn.submit');
submitBtn.addEventListener('click', () => submitAuthToken(data.extension_name, tokenInput.value));
const cancelBtn = document.createElement('button');
cancelBtn.className = 'auth-cancel';
cancelBtn.textContent = 'Cancel';
cancelBtn.textContent = I18n.t('btn.cancel');
cancelBtn.addEventListener('click', () => cancelAuth(data.extension_name));
actions.appendChild(submitBtn);
@@ -1690,22 +1686,25 @@ function renderNodes(nodes, container, depth) {
const row = document.createElement('div');
row.className = 'tree-row';
row.style.paddingLeft = (depth * 16 + 8) + 'px';
row.tabIndex = 0;
row.setAttribute('role', 'treeitem');
if (node.is_dir) {
row.setAttribute('aria-expanded', node.expanded ? 'true' : 'false');
const arrow = document.createElement('span');
arrow.className = 'expand-arrow' + (node.expanded ? ' expanded' : '');
arrow.textContent = '\u25B6';
arrow.addEventListener('click', (e) => {
e.stopPropagation();
toggleExpand(node);
});
row.appendChild(arrow);
const label = document.createElement('span');
label.className = 'tree-label dir';
label.textContent = node.name;
label.addEventListener('click', () => toggleExpand(node));
row.appendChild(label);
row.addEventListener('click', () => toggleExpand(node));
row.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleExpand(node); }
});
} else {
const spacer = document.createElement('span');
spacer.className = 'expand-arrow-spacer';
@@ -1714,8 +1713,12 @@ function renderNodes(nodes, container, depth) {
const label = document.createElement('span');
label.className = 'tree-label file';
label.textContent = node.name;
label.addEventListener('click', () => readMemoryFile(node.path));
row.appendChild(label);
row.addEventListener('click', () => readMemoryFile(node.path));
row.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); readMemoryFile(node.path); }
});
}
container.appendChild(row);
@@ -1960,7 +1963,7 @@ function prependLogEntry(entry) {
function toggleLogsPause() {
logsPaused = !logsPaused;
const btn = document.getElementById('logs-pause-btn');
btn.textContent = logsPaused ? 'Resume' : 'Pause';
btn.textContent = logsPaused ? I18n.t('logs.resume') : I18n.t('logs.pause');
if (!logsPaused) {
// Flush buffer: oldest-first + prepend naturally puts newest at top
@@ -2032,7 +2035,7 @@ function loadExtensions() {
]).then(([extData, toolData, registryData]) => {
// Render installed extensions
if (extData.extensions.length === 0) {
extList.innerHTML = '<div class="empty-state">No extensions installed</div>';
extList.innerHTML = '<div class="empty-state">' + I18n.t('extensions.noInstalled') + '</div>';
} else {
extList.innerHTML = '';
for (const ext of extData.extensions) {
@@ -2046,7 +2049,7 @@ function loadExtensions() {
// Available WASM extensions
if (wasmEntries.length === 0) {
wasmList.innerHTML = '<div class="empty-state">No additional WASM extensions available</div>';
wasmList.innerHTML = '<div class="empty-state">' + I18n.t('extensions.noAvailable') + '</div>';
} else {
wasmList.innerHTML = '';
for (const entry of wasmEntries) {
@@ -2056,7 +2059,7 @@ function loadExtensions() {
// MCP servers (show both installed and uninstalled)
if (mcpEntries.length === 0) {
mcpList.innerHTML = '<div class="empty-state">No MCP servers available</div>';
mcpList.innerHTML = '<div class="empty-state">' + I18n.t('mcp.noServers') + '</div>';
} else {
mcpList.innerHTML = '';
for (const entry of mcpEntries) {
@@ -2121,16 +2124,16 @@ function renderAvailableExtensionCard(entry) {
const installBtn = document.createElement('button');
installBtn.className = 'btn-ext install';
installBtn.textContent = 'Install';
installBtn.textContent = I18n.t('extensions.install');
installBtn.addEventListener('click', function() {
installBtn.disabled = true;
installBtn.textContent = 'Installing...';
installBtn.textContent = I18n.t('extensions.installing');
apiFetch('/api/extensions/install', {
method: 'POST',
body: { name: entry.name, kind: entry.kind },
}).then(function(res) {
if (res.success) {
showToast('Installed ' + entry.display_name, 'success');
showToast(I18n.t('extensions.installedSuccess', {name: entry.display_name}), 'success');
// OAuth popup if auth started during install (builtin creds)
if (res.auth_url) {
showToast('Opening authentication for ' + entry.display_name, 'info');
@@ -2194,39 +2197,39 @@ function renderMcpServerCard(entry, installedExt) {
if (!installedExt.active) {
var activateBtn = document.createElement('button');
activateBtn.className = 'btn-ext activate';
activateBtn.textContent = 'Activate';
activateBtn.textContent = I18n.t('common.activate');
activateBtn.addEventListener('click', function() { activateExtension(installedExt.name); });
actions.appendChild(activateBtn);
} else {
var activeLabel = document.createElement('span');
activeLabel.className = 'ext-active-label';
activeLabel.textContent = 'Active';
activeLabel.textContent = I18n.t('ext.active');
actions.appendChild(activeLabel);
}
var removeBtn = document.createElement('button');
removeBtn.className = 'btn-ext remove';
removeBtn.textContent = 'Remove';
removeBtn.textContent = I18n.t('ext.remove');
removeBtn.addEventListener('click', function() { removeExtension(installedExt.name); });
actions.appendChild(removeBtn);
} else {
var installBtn = document.createElement('button');
installBtn.className = 'btn-ext install';
installBtn.textContent = 'Install';
installBtn.textContent = I18n.t('ext.install');
installBtn.addEventListener('click', function() {
installBtn.disabled = true;
installBtn.textContent = 'Installing...';
installBtn.textContent = I18n.t('ext.installing');
apiFetch('/api/extensions/install', {
method: 'POST',
body: { name: entry.name, kind: entry.kind },
}).then(function(res) {
if (res.success) {
showToast('Installed ' + entry.display_name, 'success');
showToast(I18n.t('extensions.installedSuccess', { name: entry.display_name }), 'success');
} else {
showToast('Install: ' + (res.message || 'unknown error'), 'error');
showToast(I18n.t('ext.install') + ': ' + (res.message || 'unknown error'), 'error');
}
loadExtensions();
}).catch(function(err) {
showToast('Install failed: ' + err.message, 'error');
showToast(I18n.t('ext.installFailed', { message: err.message }), 'error');
loadExtensions();
});
});
@@ -2240,7 +2243,7 @@ function renderMcpServerCard(entry, installedExt) {
function createReconfigureButton(extName) {
var btn = document.createElement('button');
btn.className = 'btn-ext configure';
btn.textContent = 'Reconfigure';
btn.textContent = I18n.t('ext.reconfigure');
btn.addEventListener('click', function() { showConfigureModal(extName); });
return btn;
}
@@ -2324,13 +2327,13 @@ function renderExtensionCard(ext) {
if (status === 'active') {
var activeLabel = document.createElement('span');
activeLabel.className = 'ext-active-label';
activeLabel.textContent = 'Active';
activeLabel.textContent = I18n.t('ext.active');
actions.appendChild(activeLabel);
actions.appendChild(createReconfigureButton(ext.name));
} else if (status === 'pairing') {
var pairingLabel = document.createElement('span');
pairingLabel.className = 'ext-pairing-label';
pairingLabel.textContent = 'Awaiting Pairing';
pairingLabel.textContent = I18n.t('status.awaitingPairing');
actions.appendChild(pairingLabel);
actions.appendChild(createReconfigureButton(ext.name));
} else if (status === 'failed') {
@@ -2339,7 +2342,7 @@ function renderExtensionCard(ext) {
// installed or configured: show Setup button
var setupBtn = document.createElement('button');
setupBtn.className = 'btn-ext configure';
setupBtn.textContent = 'Setup';
setupBtn.textContent = I18n.t('ext.setup');
setupBtn.addEventListener('click', function() { showConfigureModal(ext.name); });
actions.appendChild(setupBtn);
}
@@ -2347,14 +2350,14 @@ function renderExtensionCard(ext) {
// WASM tools / MCP servers
const activeLabel = document.createElement('span');
activeLabel.className = 'ext-active-label';
activeLabel.textContent = ext.active ? 'Active' : 'Installed';
activeLabel.textContent = ext.active ? I18n.t('ext.active') : I18n.t('status.installed');
actions.appendChild(activeLabel);
// MCP servers and channel-relay extensions may be installed but inactive — show Activate button
if ((ext.kind === 'mcp_server' || ext.kind === 'channel_relay') && !ext.active) {
const activateBtn = document.createElement('button');
activateBtn.className = 'btn-ext activate';
activateBtn.textContent = 'Activate';
activateBtn.textContent = I18n.t('common.activate');
activateBtn.addEventListener('click', () => activateExtension(ext.name));
actions.appendChild(activateBtn);
}
@@ -2366,7 +2369,7 @@ function renderExtensionCard(ext) {
if (ext.needs_setup || (ext.has_auth && ext.authenticated)) {
const configBtn = document.createElement('button');
configBtn.className = 'btn-ext configure';
configBtn.textContent = ext.authenticated ? 'Reconfigure' : 'Configure';
configBtn.textContent = ext.authenticated ? I18n.t('ext.reconfigure') : I18n.t('ext.configure');
configBtn.addEventListener('click', () => showConfigureModal(ext.name));
actions.appendChild(configBtn);
}
@@ -2374,7 +2377,7 @@ function renderExtensionCard(ext) {
const removeBtn = document.createElement('button');
removeBtn.className = 'btn-ext remove';
removeBtn.textContent = 'Remove';
removeBtn.textContent = I18n.t('ext.remove');
removeBtn.addEventListener('click', () => removeExtension(ext.name));
actions.appendChild(removeBtn);
@@ -2419,17 +2422,17 @@ function activateExtension(name) {
}
function removeExtension(name) {
if (!confirm('Remove extension "' + name + '"?')) return;
if (!confirm(I18n.t('ext.confirmRemove', { name: name }))) return;
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/remove', { method: 'POST' })
.then((res) => {
if (!res.success) {
showToast('Remove failed: ' + res.message, 'error');
showToast(I18n.t('ext.removeFailed', { message: res.message }), 'error');
} else {
showToast('Removed ' + name, 'success');
showToast(I18n.t('ext.removed', { name: name }), 'success');
}
loadExtensions();
})
.catch((err) => showToast('Remove failed: ' + err.message, 'error'));
.catch((err) => showToast(I18n.t('ext.removeFailed', { message: err.message }), 'error'));
}
function showConfigureModal(name) {
@@ -2456,7 +2459,7 @@ function renderConfigureModal(name, secrets) {
modal.className = 'configure-modal';
const header = document.createElement('h3');
header.textContent = 'Configure ' + name;
header.textContent = I18n.t('config.title', { name: name });
modal.appendChild(header);
const form = document.createElement('div');
@@ -2472,7 +2475,7 @@ function renderConfigureModal(name, secrets) {
if (secret.optional) {
const opt = document.createElement('span');
opt.className = 'field-optional';
opt.textContent = ' (optional)';
opt.textContent = I18n.t('config.optional');
label.appendChild(opt);
}
field.appendChild(label);
@@ -2483,7 +2486,7 @@ function renderConfigureModal(name, secrets) {
const input = document.createElement('input');
input.type = 'password';
input.name = secret.name;
input.placeholder = secret.provided ? '(already set — leave empty to keep)' : '';
input.placeholder = secret.provided ? I18n.t('config.alreadySet') : '';
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') submitConfigureModal(name, fields);
});
@@ -2493,13 +2496,13 @@ function renderConfigureModal(name, secrets) {
const badge = document.createElement('span');
badge.className = 'field-provided';
badge.textContent = '\u2713';
badge.title = 'Already configured';
badge.title = I18n.t('config.alreadyConfigured');
inputRow.appendChild(badge);
}
if (secret.auto_generate && !secret.provided) {
const hint = document.createElement('span');
hint.className = 'field-autogen';
hint.textContent = 'Auto-generated if empty';
hint.textContent = I18n.t('config.autoGenerate');
inputRow.appendChild(hint);
}
@@ -2515,13 +2518,13 @@ function renderConfigureModal(name, secrets) {
const submitBtn = document.createElement('button');
submitBtn.className = 'btn-ext activate';
submitBtn.textContent = 'Save';
submitBtn.textContent = I18n.t('config.save');
submitBtn.addEventListener('click', () => submitConfigureModal(name, fields));
actions.appendChild(submitBtn);
const cancelBtn = document.createElement('button');
cancelBtn.className = 'btn-ext remove';
cancelBtn.textContent = 'Cancel';
cancelBtn.textContent = I18n.t('config.cancel');
cancelBtn.addEventListener('click', closeConfigureModal);
actions.appendChild(cancelBtn);
@@ -2761,11 +2764,11 @@ function loadJobs() {
function renderJobsSummary(s) {
document.getElementById('jobs-summary').innerHTML = ''
+ summaryCard('Total', s.total, '')
+ summaryCard('In Progress', s.in_progress, 'active')
+ summaryCard('Completed', s.completed, 'completed')
+ summaryCard('Failed', s.failed, 'failed')
+ summaryCard('Stuck', s.stuck, 'stuck');
+ summaryCard(I18n.t('jobs.summary.total'), s.total, '')
+ summaryCard(I18n.t('jobs.summary.inProgress'), s.in_progress, 'active')
+ summaryCard(I18n.t('jobs.summary.completed'), s.completed, 'completed')
+ summaryCard(I18n.t('jobs.summary.failed'), s.failed, 'failed')
+ summaryCard(I18n.t('jobs.summary.stuck'), s.stuck, 'stuck');
}
function summaryCard(label, count, cls) {
@@ -3295,11 +3298,11 @@ function loadRoutines() {
function renderRoutinesSummary(s) {
document.getElementById('routines-summary').innerHTML = ''
+ summaryCard('Total', s.total, '')
+ summaryCard('Enabled', s.enabled, 'active')
+ summaryCard('Disabled', s.disabled, '')
+ summaryCard('Failing', s.failing, 'failed')
+ summaryCard('Runs Today', s.runs_today, 'completed');
+ summaryCard(I18n.t('routines.summary.total'), s.total, '')
+ summaryCard(I18n.t('routines.summary.enabled'), s.enabled, 'active')
+ summaryCard(I18n.t('routines.summary.disabled'), s.disabled, '')
+ summaryCard(I18n.t('routines.summary.failing'), s.failing, 'failed')
+ summaryCard(I18n.t('routines.summary.runsToday'), s.runs_today, 'completed');
}
function renderRoutinesList(routines) {
@@ -3465,17 +3468,18 @@ function formatRelativeTime(isoString) {
const absDiff = Math.abs(diffMs);
const future = diffMs < 0;
if (absDiff < 60000) return future ? 'in <1m' : '<1m ago';
if (absDiff < 60000)
return future ? I18n.t('time.lessThan1MinuteFromNow') : I18n.t('time.lessThan1MinuteAgo');
if (absDiff < 3600000) {
const m = Math.floor(absDiff / 60000);
return future ? 'in ' + m + 'm' : m + 'm ago';
return future ? I18n.t('time.minutesFromNow', { n: m }) : I18n.t('time.minutesAgo', { n: m });
}
if (absDiff < 86400000) {
const h = Math.floor(absDiff / 3600000);
return future ? 'in ' + h + 'h' : h + 'h ago';
return future ? I18n.t('time.hoursFromNow', { n: h }) : I18n.t('time.hoursAgo', { n: h });
}
const days = Math.floor(absDiff / 86400000);
return future ? 'in ' + days + 'd' : days + 'd ago';
return future ? I18n.t('time.daysFromNow', { n: days }) : I18n.t('time.daysAgo', { n: days });
}
// --- Gateway status widget ---
@@ -3525,18 +3529,18 @@ function fetchGatewayStatus() {
}
// Connection info
html += '<div class="gw-section-label">Connections</div>';
html += '<div class="gw-stat"><span>SSE</span><span>' + (data.sse_connections || 0) + '</span></div>';
html += '<div class="gw-stat"><span>WebSocket</span><span>' + (data.ws_connections || 0) + '</span></div>';
html += '<div class="gw-stat"><span>Uptime</span><span>' + formatDuration(data.uptime_secs) + '</span></div>';
html += '<div class="gw-section-label">' + I18n.t('dashboard.connections') + '</div>';
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.sse') + '</span><span>' + (data.sse_connections || 0) + '</span></div>';
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.websocket') + '</span><span>' + (data.ws_connections || 0) + '</span></div>';
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.uptime') + '</span><span>' + formatDuration(data.uptime_secs) + '</span></div>';
// Cost tracker
if (data.daily_cost != null) {
html += '<div class="gw-divider"></div>';
html += '<div class="gw-section-label">Cost Today</div>';
html += '<div class="gw-stat"><span>Spent</span><span>' + formatCost(data.daily_cost) + '</span></div>';
html += '<div class="gw-section-label">' + I18n.t('dashboard.costToday') + '</div>';
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.spent') + '</span><span>' + formatCost(data.daily_cost) + '</span></div>';
if (data.actions_this_hour != null) {
html += '<div class="gw-stat"><span>Actions/hr</span><span>' + data.actions_this_hour + '</span></div>';
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.actionsPerHour') + '</span><span>' + data.actions_this_hour + '</span></div>';
}
}
@@ -3744,7 +3748,7 @@ function loadSkills() {
var skillsList = document.getElementById('skills-list');
apiFetch('/api/skills').then(function(data) {
if (!data.skills || data.skills.length === 0) {
skillsList.innerHTML = '<div class="empty-state">No skills installed</div>';
skillsList.innerHTML = '<div class="empty-state">' + I18n.t('skills.noInstalled') + '</div>';
return;
}
skillsList.innerHTML = '';
@@ -3752,7 +3756,7 @@ function loadSkills() {
skillsList.appendChild(renderSkillCard(data.skills[i]));
}
}).catch(function(err) {
skillsList.innerHTML = '<div class="empty-state">Failed to load skills: ' + escapeHtml(err.message) + '</div>';
skillsList.innerHTML = '<div class="empty-state">' + I18n.t('skills.loadFailed', {message: escapeHtml(err.message)}) + '</div>';
});
}
@@ -3789,7 +3793,7 @@ function renderSkillCard(skill) {
if (skill.keywords && skill.keywords.length > 0) {
var kw = document.createElement('div');
kw.className = 'ext-keywords';
kw.textContent = 'Activates on: ' + skill.keywords.join(', ');
kw.textContent = I18n.t('skills.activatesOn') + ': ' + skill.keywords.join(', ');
card.appendChild(kw);
}
@@ -3800,7 +3804,7 @@ function renderSkillCard(skill) {
if (skill.trust.toLowerCase() !== 'trusted') {
var removeBtn = document.createElement('button');
removeBtn.className = 'btn-ext remove';
removeBtn.textContent = 'Remove';
removeBtn.textContent = I18n.t('skills.remove');
removeBtn.addEventListener('click', function() { removeSkill(skill.name); });
actions.appendChild(removeBtn);
}
@@ -3815,7 +3819,7 @@ function searchClawHub() {
if (!query) return;
var resultsDiv = document.getElementById('skill-search-results');
resultsDiv.innerHTML = '<div class="empty-state">Searching...</div>';
resultsDiv.innerHTML = '<div class="empty-state">' + I18n.t('skills.searching') + '</div>';
apiFetch('/api/skills/search', {
method: 'POST',
@@ -3831,7 +3835,7 @@ function searchClawHub() {
warning.style.borderLeft = '3px solid #f0ad4e';
warning.style.paddingLeft = '12px';
warning.style.marginBottom = '16px';
warning.textContent = 'Could not reach ClawHub registry: ' + data.catalog_error;
warning.textContent = I18n.t('skills.registryError', {message: data.catalog_error});
resultsDiv.appendChild(warning);
}
@@ -3863,10 +3867,10 @@ function searchClawHub() {
}
if (resultsDiv.children.length === 0) {
resultsDiv.innerHTML = '<div class="empty-state">No skills found for "' + escapeHtml(query) + '"</div>';
resultsDiv.innerHTML = '<div class="empty-state">' + I18n.t('skills.noResults', {query: escapeHtml(query)}) + '</div>';
}
}).catch(function(err) {
resultsDiv.innerHTML = '<div class="empty-state">Search failed: ' + escapeHtml(err.message) + '</div>';
resultsDiv.innerHTML = '<div class="empty-state">' + I18n.t('skills.searchFailed', {message: escapeHtml(err.message)}) + '</div>';
});
}
@@ -3960,17 +3964,17 @@ function renderCatalogSkillCard(entry, installedNames) {
if (isInstalled) {
var label = document.createElement('span');
label.className = 'ext-active-label';
label.textContent = 'Installed';
label.textContent = I18n.t('status.installed');
actions.appendChild(label);
} else {
var installBtn = document.createElement('button');
installBtn.className = 'btn-ext install';
installBtn.textContent = 'Install';
installBtn.textContent = I18n.t('extensions.install');
installBtn.addEventListener('click', (function(s, btn) {
return function() {
if (!confirm('Install skill "' + s + '" from ClawHub?')) return;
btn.disabled = true;
btn.textContent = 'Installing...';
btn.textContent = I18n.t('extensions.installing');
installSkill(s, null, btn);
};
})(slug, installBtn));
@@ -4012,7 +4016,7 @@ function installSkill(nameOrSlug, url, btn) {
body: body,
}).then(function(res) {
if (res.success) {
showToast('Installed skill "' + nameOrSlug + '"', 'success');
showToast(I18n.t('skills.installedSuccess', {name: nameOrSlug}), 'success');
} else {
showToast('Install failed: ' + (res.message || 'unknown error'), 'error');
}
@@ -4025,19 +4029,19 @@ function installSkill(nameOrSlug, url, btn) {
}
function removeSkill(name) {
if (!confirm('Remove skill "' + name + '"?')) return;
if (!confirm(I18n.t('skills.confirmRemove', { name: name }))) return;
apiFetch('/api/skills/' + encodeURIComponent(name), {
method: 'DELETE',
headers: { 'X-Confirm-Action': 'true' },
}).then(function(res) {
if (res.success) {
showToast('Removed skill "' + name + '"', 'success');
showToast(I18n.t('skills.removed', { name: name }), 'success');
} else {
showToast('Remove failed: ' + (res.message || 'unknown error'), 'error');
showToast(I18n.t('skills.removeFailed', { message: res.message || 'unknown error' }), 'error');
}
loadSkills();
}).catch(function(err) {
showToast('Remove failed: ' + err.message, 'error');
showToast(I18n.t('skills.removeFailed', { message: err.message }), 'error');
});
}
+74
View File
@@ -0,0 +1,74 @@
// i18n Integration for IronClaw App
// This file contains i18n-related functions that extend app.js
// Initialize i18n when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
// Initialize i18n
I18n.init();
I18n.updatePageContent();
updateSlashCommands();
updateLanguageMenu();
});
// Update slash commands with current language
function updateSlashCommands() {
// Update SLASH_COMMANDS descriptions
SLASH_COMMANDS.forEach(cmd => {
const key = 'cmd.' + cmd.cmd.replace(/\s+/g, '').replace(/\//g, '') + '.desc';
const translated = I18n.t(key);
if (translated !== key) {
cmd.desc = translated;
}
});
}
// Toggle language menu
function toggleLanguageMenu() {
const menu = document.getElementById('language-menu');
if (menu) {
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
}
}
// Switch language
function switchLanguage(lang) {
if (I18n.setLanguage(lang)) {
// Update slash commands
updateSlashCommands();
// Update language menu active state
updateLanguageMenu();
// Close menu
const menu = document.getElementById('language-menu');
if (menu) {
menu.style.display = 'none';
}
// Show toast notification
showToast(I18n.t('language.switch') + ': ' + (lang === 'zh-CN' ? '简体中文' : 'English'));
}
}
// Update language menu active state
function updateLanguageMenu() {
const currentLang = I18n.getCurrentLang();
document.querySelectorAll('.language-option').forEach(option => {
if (option.getAttribute('data-lang') === currentLang) {
option.classList.add('active');
} else {
option.classList.remove('active');
}
});
}
// Close language menu when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.language-switcher')) {
const menu = document.getElementById('language-menu');
if (menu) {
menu.style.display = 'none';
}
}
});
+351
View File
@@ -0,0 +1,351 @@
// English Language Pack for IronClaw
I18n.register('en', {
// Auth Page
'auth.title': 'IronClaw',
'auth.tagline': 'Secure AI Assistant',
'auth.tokenLabel': 'Gateway Token',
'auth.tokenPlaceholder': 'Paste your token',
'auth.connect': 'Connect',
'auth.errorRequired': 'Token required',
'auth.errorInvalid': 'Invalid token',
'auth.hint': 'Enter the GATEWAY_AUTH_TOKEN from your .env file',
// Chat
'chat.inputPlaceholder': 'Message or / for commands...',
// Restart Modal
'restart.title': 'Restart IronClaw Instance',
'restart.description': 'Are you sure you want to restart IronClaw? This will gracefully restart the process.',
'restart.warning': 'Running tasks may be interrupted. Restart will complete in a few seconds.',
'restart.cancel': 'Cancel',
'restart.confirm': 'Confirm Restart',
'restart.progressTitle': 'Restarting IronClaw',
'restart.progressSubtitle': 'Please wait for the process to restart...',
'restart.checkLogs': 'Check the Logs tab for details after restart completes.',
// Tabs
'tab.chat': 'Chat',
'tab.memory': 'Memory',
'tab.jobs': 'Jobs',
'tab.routines': 'Routines',
'tab.extensions': 'Extensions',
'tab.skills': 'Skills',
'tab.logs': 'Logs',
// Status
'status.connected': 'Connected',
'status.disconnected': 'Disconnected',
'status.connecting': 'Connecting...',
'status.reconnecting': 'Reconnecting...',
'status.teeVerified': 'TEE Verified',
'status.restart': 'Restart',
'status.active': 'Active',
'status.installed': 'Installed',
'status.awaitingPairing': 'Awaiting Pairing',
// Dashboard
'dashboard.connections': 'Connections',
'dashboard.uptime': 'Uptime',
'dashboard.costToday': 'Cost Today',
'dashboard.spent': 'Spent',
'dashboard.actionsPerHour': 'Actions/hr',
'dashboard.sse': 'SSE',
'dashboard.websocket': 'WebSocket',
// Chat Tab
'chat.newThread': 'New Thread',
'chat.toggleSidebar': 'Toggle Sidebar',
'chat.assistant': 'Assistant',
'chat.conversations': 'Conversations',
'chat.send': 'Send',
'chat.attachImages': 'Attach Images',
'chat.empty': 'Select a file to view content',
'chat.loading': 'Loading...',
'chat.loadingOlder': 'Loading older messages...',
'chat.noFiles': 'No files in workspace',
'chat.noResults': 'No results',
// Thread Sidebar
'thread.assistant': 'Assistant',
'thread.new': 'New Thread',
// Memory Tab
'memory.searchPlaceholder': 'Search memory...',
'memory.workspace': 'workspace',
'memory.edit': 'Edit',
'memory.save': 'Save',
'memory.cancel': 'Cancel',
'memory.selectFile': 'Select a file to view content',
// Jobs Tab
'jobs.summary': 'Jobs Summary',
'jobs.id': 'ID',
'jobs.title': 'Title',
'jobs.source': 'Source',
'jobs.status': 'Status',
'jobs.created': 'Created',
'jobs.actions': 'Actions',
'jobs.empty': 'No jobs',
'jobs.statusRunning': 'Running',
'jobs.statusCompleted': 'Completed',
'jobs.statusFailed': 'Failed',
'jobs.statusPending': 'Pending',
'jobs.jobId': 'Job ID',
'jobs.description': 'Description',
'jobs.stateTransitions': 'State Transitions',
'jobs.projectFiles': 'Project Files',
'jobs.noProjectFiles': 'No project files',
'jobs.viewJob': 'View Job',
'jobs.browse': 'Browse',
// Routines Tab
'routines.summary': 'Routines Summary',
'routines.name': 'Name',
'routines.trigger': 'Trigger',
'routines.action': 'Action',
'routines.lastRun': 'Last Run',
'routines.nextRun': 'Next Run',
'routines.runs': 'Runs',
'routines.status': 'Status',
'routines.actions': 'Actions',
'routines.runsToday': 'Runs Today',
'routines.empty': 'No routines',
'routines.noConfigured': 'No routines configured. Ask the assistant to create one.',
'routines.triggerFailed': 'Trigger failed: {message}',
// Logs Tab
'logs.serverLevel': 'Server: ERROR',
'logs.clientLevel': 'Client Log Level',
'logs.pause': 'Pause',
'logs.resume': 'Resume',
'logs.clear': 'Clear',
'logs.autoScroll': 'Auto-scroll',
'logs.filter': 'Filter logs...',
'logs.empty': 'No logs',
'logs.allLevels': 'All Levels',
'logs.error': 'Error',
'logs.warn': 'Warn',
'logs.info': 'Info',
'logs.debug': 'Debug',
// Extensions Tab
'extensions.installed': 'Installed Extensions',
'extensions.available': 'Available WASM Extensions',
'extensions.installWasm': 'Install WASM Extension',
'extensions.noInstalled': 'No extensions installed',
'extensions.noAvailable': 'No additional WASM extensions available',
'extensions.loading': 'Loading...',
'extensions.install': 'Install',
'extensions.installing': 'Installing...',
'extensions.installedSuccess': 'Installed {name}',
'extensions.remove': 'Remove',
'extensions.activate': 'Activate',
'extensions.reconfigure': 'Reconfigure',
'extensions.tools': 'Tools',
'extensions.noConfigNeeded': 'No configuration needed for {name}',
'extensions.configure': 'Configure {name}',
'extensions.optional': ' (optional)',
'extensions.autoGenerated': 'Auto-generated if empty',
'extensions.pendingPairing': 'Pending pairing requests',
'extensions.from': 'from',
// MCP Servers
'mcp.servers': 'MCP Servers',
'mcp.noServers': 'No MCP servers available',
'mcp.addCustom': 'Add Custom MCP Server',
'mcp.add': 'Add',
'mcp.addedSuccess': 'Added MCP server {name}',
// Registered Tools
'tools.registered': 'Registered Tools',
'tools.name': 'Name',
'tools.description': 'Description',
'tools.empty': 'No tools registered',
// Skills Tab
'skills.installed': 'Installed Skills',
'skills.noInstalled': 'No skills installed',
'skills.searchClawHub': 'Search ClawHub',
'skills.searchPlaceholder': 'Search...',
'skills.installByUrl': 'Install Skill by URL',
'skills.namePlaceholder': 'Skill name or slug',
'skills.urlPlaceholder': 'HTTPS URL to SKILL.md (optional)',
'skills.search': 'Search',
'skills.searching': 'Searching...',
'skills.noResults': 'No skills found for "{query}"',
'skills.searchFailed': 'Search failed: {message}',
'skills.install': 'Install',
'skills.installing': 'Installing...',
'skills.installedSuccess': 'Installed skill "{name}"',
'skills.remove': 'Remove',
'skills.activatesOn': 'Activates on',
'skills.registryError': 'Could not reach ClawHub registry: {message}',
'skills.by': 'by',
'skills.updated': 'updated',
'skills.loading': 'Loading skills...',
'skills.loadFailed': 'Failed to load skills: {message}',
'skills.confirmRemove': 'Remove skill "{name}"?',
'skills.removeFailed': 'Remove failed: {message}',
'skills.removed': 'Removed skill "{name}"',
// Jobs Summary
'jobs.summary.total': 'Total',
'jobs.summary.inProgress': 'In Progress',
'jobs.summary.completed': 'Completed',
'jobs.summary.failed': 'Failed',
'jobs.summary.stuck': 'Stuck',
// Routines Summary
'routines.summary.total': 'Total',
'routines.summary.enabled': 'Enabled',
'routines.summary.disabled': 'Disabled',
'routines.summary.failing': 'Failing',
'routines.summary.runsToday': 'Runs Today',
// Buttons
'btn.close': 'Close',
'btn.cancel': 'Cancel',
'btn.save': 'Save',
'btn.edit': 'Edit',
'btn.confirm': 'Confirm',
'btn.send': 'Send',
'btn.refresh': 'Refresh',
'btn.loadMore': 'Load More',
'btn.copy': 'Copy',
'btn.copied': 'Copied!',
'btn.submit': 'Submit',
'btn.setup': 'Setup',
// Time
'time.lessThan1MinuteAgo': '<1m ago',
'time.lessThan1MinuteFromNow': 'in <1m',
'time.minutesAgo': '{n}m ago',
'time.minutesFromNow': 'in {n}m',
'time.hoursAgo': '{n}h ago',
'time.hoursFromNow': 'in {n}h',
'time.daysAgo': '{n}d ago',
'time.daysFromNow': 'in {n}d',
// Tool Approval
'approval.title': 'Tool requires approval',
'approval.description': 'A tool is requesting permission to run.',
'approval.approve': 'Approve',
'approval.deny': 'Deny',
'approval.always': 'Always',
'approval.approved': 'Approved',
'approval.alwaysApproved': 'Always approved',
'approval.denied': 'Denied',
'approval.showParams': 'Show parameters',
'approval.hideParams': 'Hide parameters',
// Authentication Required
'authRequired.title': 'Authentication required for {name}',
'authRequired.authenticateWith': 'Authenticate with {name}',
'authRequired.getToken': 'Get your token',
'authRequired.instructions': 'Instructions',
// Sandbox Jobs
'sandbox.job': 'Sandbox Job',
'sandbox.doneSignal': 'Done signal sent',
// Error Messages
'error.startConversation': 'Please start a conversation first',
'error.restartFailed': 'Restart failed: {message}',
'error.tokenRequired': 'Token required',
'error.tokenInvalid': 'Invalid token',
'error.connectionFailed': 'Connection failed',
'error.unknown': 'Unknown error',
'error.loadFailed': 'Failed to load: {message}',
// Success Messages
'success.restartInitiated': 'Restart initiated',
'success.saved': 'Saved successfully',
// Slash Commands
'cmd.status.desc': 'Show all jobs, or /status <id> for a specific job',
'cmd.list.desc': 'List all jobs',
'cmd.cancel.desc': '/cancel <job-id> — Cancel a running job',
'cmd.undo.desc': 'Undo last action',
'cmd.redo.desc': 'Redo undone action',
'cmd.compact.desc': 'Compact context window',
'cmd.clear.desc': 'Clear conversation and start fresh',
'cmd.interrupt.desc': 'Stop current operation',
'cmd.heartbeat.desc': 'Trigger manual heartbeat check',
'cmd.summarize.desc': 'Summarize current conversation',
'cmd.suggest.desc': 'Suggest next actions',
'cmd.help.desc': 'Show help',
'cmd.version.desc': 'Show version info',
'cmd.tools.desc': 'List available tools',
'cmd.skills.desc': 'List installed skills',
'cmd.model.desc': 'Show or switch LLM model',
'cmd.threadNew.desc': 'Create new conversation thread',
// Language Switcher
'language.title': 'Language',
'language.en': 'English',
'language.zhCN': '简体中文',
'language.switch': 'Switch Language',
// Tool Activity
'tool.thinking': 'Thinking...',
'tool.completed': 'Completed',
'tool.failed': 'Failed',
'tool.running': 'Running',
'tool.used': '{count} tool(s) used',
'tool.requiresApproval': 'Tool requires approval',
// TEE
'tee.loadingReport': 'Loading attestation report...',
'tee.loadFailed': 'Could not load attestation report',
// Common
'common.loading': 'Loading...',
'common.noData': 'No data',
'common.search': 'Search',
'common.add': 'Add',
'common.remove': 'Remove',
'common.install': 'Install',
'common.activate': 'Activate',
'common.deactivate': 'Deactivate',
'common.configure': 'Configure',
'common.save': 'Save',
'common.cancel': 'Cancel',
'common.confirm': 'Confirm',
'common.close': 'Close',
'common.edit': 'Edit',
'common.delete': 'Delete',
'common.refresh': 'Refresh',
'common.searchPlaceholder': 'Search...',
'common.name': 'Name',
'common.description': 'Description',
'common.status': 'Status',
'common.actions': 'Actions',
'common.version': 'Version',
'common.owner': 'Owner',
'common.tags': 'Tags',
// Extensions
'ext.active': 'Active',
'ext.remove': 'Remove',
'ext.install': 'Install',
'ext.installing': 'Installing...',
'ext.installed': 'Installed',
'ext.setup': 'Setup',
'ext.reconfigure': 'Reconfigure',
'ext.configure': 'Configure',
'ext.confirmRemove': 'Remove extension "{name}"?',
'ext.removeFailed': 'Remove failed: {message}',
'ext.removed': 'Removed {name}',
'ext.installFailed': 'Install failed: {message}',
// Configure
'config.title': 'Configure {name}',
'config.optional': ' (optional)',
'config.alreadySet': '(already set — leave empty to keep)',
'config.alreadyConfigured': 'Already configured',
'config.autoGenerate': 'Auto-generated if empty',
'config.save': 'Save',
'config.cancel': 'Cancel',
});
+89
View File
@@ -0,0 +1,89 @@
// Lightweight internationalization implementation with dynamic language switching
const I18n = {
currentLang: 'en',
fallbackLang: 'en',
translations: {},
// Initialize i18n
init() {
// Read user preference from localStorage
const savedLang = localStorage.getItem('ironclaw_language');
if (savedLang && this.translations[savedLang]) {
this.currentLang = savedLang;
} else {
// Detect browser language
const browserLang = navigator.language || navigator.userLanguage;
this.currentLang = browserLang.startsWith('zh') ? 'zh-CN' : 'en';
}
this.updateHtmlLang();
},
// Register language pack
register(lang, translations) {
this.translations[lang] = translations;
},
// Switch language
setLanguage(lang) {
if (this.translations[lang]) {
this.currentLang = lang;
localStorage.setItem('ironclaw_language', lang);
this.updateHtmlLang();
this.updatePageContent();
return true;
}
return false;
},
// Get current language
getCurrentLang() {
return this.currentLang;
},
// Translate function
t(key, params = {}) {
const translation = this.translations[this.currentLang]?.[key]
|| this.translations[this.fallbackLang]?.[key]
|| key;
// Support placeholder replacement: {name}
return translation.replace(/\{(\w+)\}/g, (match, key) => {
return params[key] !== undefined ? params[key] : match;
});
},
// Update HTML lang attribute
updateHtmlLang() {
document.documentElement.lang = this.currentLang;
},
// Update page content (traverse all data-i18n elements)
updatePageContent() {
// Update text content
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
const attr = el.getAttribute('data-i18n-attr');
if (attr) {
el.setAttribute(attr, this.t(key));
} else {
el.textContent = this.t(key);
}
});
// Update placeholder attributes
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
el.placeholder = this.t(key);
});
// Update title attributes
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
el.title = this.t(key);
});
}
};
// Global access
window.I18n = I18n;
+351
View File
@@ -0,0 +1,351 @@
// 中文语言包 for IronClaw
I18n.register('zh-CN', {
// 认证页面
'auth.title': 'IronClaw',
'auth.tagline': '安全可靠的 AI 助手',
'auth.tokenLabel': '网关令牌',
'auth.tokenPlaceholder': '粘贴你的网关令牌',
'auth.connect': '连接',
'auth.errorRequired': '请输入令牌',
'auth.errorInvalid': '令牌无效',
'auth.hint': '输入 .env 配置文件中的 GATEWAY_AUTH_TOKEN',
// 聊天
'chat.inputPlaceholder': '输入消息或 / 以使用命令...',
// 重启弹窗
'restart.title': '重启 IronClaw 实例',
'restart.description': '确定要重启 IronClaw 实例吗?这将优雅地重启进程。',
'restart.warning': '正在运行的任务可能会中断。重启将在几秒钟内完成。',
'restart.cancel': '取消',
'restart.confirm': '确认重启',
'restart.progressTitle': '正在重启 IronClaw',
'restart.progressSubtitle': '请等待进程重启...',
'restart.checkLogs': '重启完成后,请查看日志标签页了解详情。',
// 标签页
'tab.chat': '聊天',
'tab.memory': '记忆',
'tab.jobs': '任务',
'tab.routines': '定时任务',
'tab.extensions': '扩展',
'tab.skills': '技能',
'tab.logs': '日志',
// 状态
'status.connected': '已连接',
'status.disconnected': '已断开',
'status.connecting': '连接中...',
'status.reconnecting': '重新连接中...',
'status.teeVerified': 'TEE 已验证',
'status.restart': '重启',
'status.active': '已激活',
'status.installed': '已安装',
'status.awaitingPairing': '等待配对',
// 仪表盘
'dashboard.connections': '连接数',
'dashboard.uptime': '运行时间',
'dashboard.costToday': '今日费用',
'dashboard.spent': '已花费',
'dashboard.actionsPerHour': '每小时操作',
'dashboard.sse': 'SSE',
'dashboard.websocket': 'WebSocket',
// 聊天标签页
'chat.newThread': '新对话',
'chat.toggleSidebar': '切换侧边栏',
'chat.assistant': '助手',
'chat.conversations': '对话列表',
'chat.send': '发送',
'chat.attachImages': '附加图片',
'chat.empty': '选择文件查看内容',
'chat.loading': '加载中...',
'chat.loadingOlder': '加载更早的消息...',
'chat.noFiles': '工作区没有文件',
'chat.noResults': '没有结果',
// 对话侧边栏
'thread.assistant': '助手',
'thread.new': '新对话',
// 记忆标签页
'memory.searchPlaceholder': '搜索记忆...',
'memory.workspace': '工作区',
'memory.edit': '编辑',
'memory.save': '保存',
'memory.cancel': '取消',
'memory.selectFile': '选择文件查看内容',
// 任务标签页
'jobs.summary': '任务摘要',
'jobs.id': 'ID',
'jobs.title': '标题',
'jobs.source': '来源',
'jobs.status': '状态',
'jobs.created': '创建时间',
'jobs.actions': '操作',
'jobs.empty': '暂无任务',
'jobs.statusRunning': '运行中',
'jobs.statusCompleted': '已完成',
'jobs.statusFailed': '失败',
'jobs.statusPending': '等待中',
'jobs.jobId': '任务 ID',
'jobs.description': '描述',
'jobs.stateTransitions': '状态转换',
'jobs.projectFiles': '项目文件',
'jobs.noProjectFiles': '没有项目文件',
'jobs.viewJob': '查看任务',
'jobs.browse': '浏览',
// 定时任务标签页
'routines.summary': '定时任务摘要',
'routines.name': '名称',
'routines.trigger': '触发器',
'routines.action': '操作',
'routines.lastRun': '上次运行',
'routines.nextRun': '下次运行',
'routines.runs': '运行次数',
'routines.status': '状态',
'routines.actions': '操作',
'routines.runsToday': '今日运行',
'routines.empty': '暂无定时任务',
'routines.noConfigured': '暂无配置的定时任务。请让助手创建一个。',
'routines.triggerFailed': '触发失败: {message}',
// 日志标签页
'logs.serverLevel': '服务端日志级别',
'logs.clientLevel': '客户端日志级别',
'logs.pause': '暂停',
'logs.resume': '继续',
'logs.clear': '清空',
'logs.autoScroll': '自动滚动',
'logs.filter': '筛选日志...',
'logs.empty': '暂无日志',
'logs.allLevels': '所有级别',
'logs.error': '错误',
'logs.warn': '警告',
'logs.info': '信息',
'logs.debug': '调试',
// 扩展标签页
'extensions.installed': '已安装扩展',
'extensions.available': '可用 WASM 扩展',
'extensions.installWasm': '安装 WASM 扩展',
'extensions.noInstalled': '没有安装扩展',
'extensions.noAvailable': '没有其他可用的 WASM 扩展',
'extensions.loading': '加载中...',
'extensions.install': '安装',
'extensions.installing': '安装中...',
'extensions.installedSuccess': '已安装 {name}',
'extensions.remove': '移除',
'extensions.activate': '激活',
'extensions.reconfigure': '重新配置',
'extensions.tools': '工具',
'extensions.noConfigNeeded': '{name} 不需要配置',
'extensions.configure': '配置 {name}',
'extensions.optional': ' (可选)',
'extensions.autoGenerated': '留空则自动生成',
'extensions.pendingPairing': '等待配对请求',
'extensions.from': '来自',
// MCP 服务器
'mcp.servers': 'MCP 服务器',
'mcp.noServers': '没有可用的 MCP 服务器',
'mcp.addCustom': '添加自定义 MCP 服务器',
'mcp.add': '添加',
'mcp.addedSuccess': '已添加 MCP 服务器 {name}',
// 注册工具
'tools.registered': '注册工具',
'tools.name': '名称',
'tools.description': '描述',
'tools.empty': '没有注册工具',
// 技能标签页
'skills.installed': '已安装技能',
'skills.noInstalled': '没有安装技能',
'skills.searchClawHub': '搜索 ClawHub',
'skills.searchPlaceholder': '搜索...',
'skills.installByUrl': '通过 URL 安装技能',
'skills.namePlaceholder': '技能名称或标识',
'skills.urlPlaceholder': 'SKILL.md 的 HTTPS URL(可选)',
'skills.search': '搜索',
'skills.searching': '搜索中...',
'skills.noResults': '没有找到 "{query}" 相关技能',
'skills.searchFailed': '搜索失败: {message}',
'skills.install': '安装',
'skills.installing': '安装中...',
'skills.installedSuccess': '已安装技能 "{name}"',
'skills.remove': '移除',
'skills.activatesOn': '激活关键词',
'skills.registryError': '无法连接 ClawHub 注册表: {message}',
'skills.by': '作者',
'skills.updated': '更新于',
'skills.loading': '加载技能中...',
'skills.loadFailed': '加载技能失败: {message}',
'skills.confirmRemove': '确定要移除技能 "{name}" 吗?',
'skills.removeFailed': '移除失败: {message}',
'skills.removed': '已移除技能 "{name}"',
// 任务摘要
'jobs.summary.total': '总计',
'jobs.summary.inProgress': '进行中',
'jobs.summary.completed': '已完成',
'jobs.summary.failed': '失败',
'jobs.summary.stuck': '卡住',
// 定时任务摘要
'routines.summary.total': '总计',
'routines.summary.enabled': '已启用',
'routines.summary.disabled': '已禁用',
'routines.summary.failing': '失败',
'routines.summary.runsToday': '今日运行',
// 按钮
'btn.close': '关闭',
'btn.cancel': '取消',
'btn.save': '保存',
'btn.edit': '编辑',
'btn.confirm': '确认',
'btn.send': '发送',
'btn.refresh': '刷新',
'btn.loadMore': '加载更多',
'btn.copy': '复制',
'btn.copied': '已复制!',
'btn.submit': '提交',
'btn.setup': '设置',
// 时间
'time.lessThan1MinuteAgo': '刚刚',
'time.lessThan1MinuteFromNow': '1分钟内',
'time.minutesAgo': '{n}分钟前',
'time.minutesFromNow': '{n}分钟后',
'time.hoursAgo': '{n}小时前',
'time.hoursFromNow': '{n}小时后',
'time.daysAgo': '{n}天前',
'time.daysFromNow': '{n}天后',
// 工具审批
'approval.title': '工具需要审批',
'approval.description': '一个工具请求运行权限。',
'approval.approve': '批准',
'approval.deny': '拒绝',
'approval.always': '始终允许',
'approval.approved': '已批准',
'approval.alwaysApproved': '始终批准',
'approval.denied': '已拒绝',
'approval.showParams': '显示参数',
'approval.hideParams': '隐藏参数',
// 认证
'authRequired.title': '{name} 需要认证',
'authRequired.authenticateWith': '使用 {name} 认证',
'authRequired.getToken': '获取令牌',
'authRequired.instructions': '说明',
// 沙盒任务
'sandbox.job': '沙盒任务',
'sandbox.doneSignal': '完成信号已发送',
// 错误消息
'error.startConversation': '请先开始一个对话',
'error.restartFailed': '重启失败: {message}',
'error.tokenRequired': '请输入令牌',
'error.tokenInvalid': '令牌无效',
'error.connectionFailed': '连接失败',
'error.unknown': '未知错误',
'error.loadFailed': '加载失败: {message}',
// 成功消息
'success.restartInitiated': '已开始重启',
'success.saved': '保存成功',
// 斜杠命令
'cmd.status.desc': '显示所有任务,或使用 /status <id> 查看特定任务',
'cmd.list.desc': '列出所有任务',
'cmd.cancel.desc': '/cancel <job-id> — 取消正在运行的任务',
'cmd.undo.desc': '撤销上一步',
'cmd.redo.desc': '重做已撤销的操作',
'cmd.compact.desc': '压缩上下文窗口',
'cmd.clear.desc': '清空对话并重新开始',
'cmd.interrupt.desc': '停止当前操作',
'cmd.heartbeat.desc': '触发手动心跳检查',
'cmd.summarize.desc': '总结当前对话',
'cmd.suggest.desc': '建议下一步操作',
'cmd.help.desc': '显示帮助',
'cmd.version.desc': '显示版本信息',
'cmd.tools.desc': '列出可用工具',
'cmd.skills.desc': '列出已安装的 AI 技能',
'cmd.model.desc': '显示或切换 LLM 模型',
'cmd.threadNew.desc': '创建新对话线程',
// 语言切换
'language.title': '语言',
'language.en': 'English',
'language.zhCN': '简体中文',
'language.switch': '切换语言',
// 工具活动
'tool.thinking': '思考中...',
'tool.completed': '已完成',
'tool.failed': '失败',
'tool.running': '运行中',
'tool.used': '{count} 个工具已使用',
'tool.requiresApproval': '工具需要审批',
// TEE
'tee.loadingReport': '正在加载证明报告...',
'tee.loadFailed': '无法加载证明报告',
// 通用
'common.loading': '加载中...',
'common.noData': '暂无数据',
'common.search': '搜索',
'common.add': '添加',
'common.remove': '移除',
'common.install': '安装',
'common.activate': '激活',
'common.deactivate': '停用',
'common.configure': '配置',
'common.save': '保存',
'common.cancel': '取消',
'common.confirm': '确认',
'common.close': '关闭',
'common.edit': '编辑',
'common.delete': '删除',
'common.refresh': '刷新',
'common.searchPlaceholder': '搜索...',
'common.name': '名称',
'common.description': '描述',
'common.status': '状态',
'common.actions': '操作',
'common.version': '版本',
'common.owner': '作者',
'common.tags': '标签',
// 扩展
'ext.active': '已激活',
'ext.remove': '移除',
'ext.install': '安装',
'ext.installing': '安装中...',
'ext.installed': '已安装',
'ext.setup': '设置',
'ext.reconfigure': '重新配置',
'ext.configure': '配置',
'ext.confirmRemove': '确定要移除扩展 "{name}" 吗?',
'ext.removeFailed': '移除失败: {message}',
'ext.removed': '已移除 {name}',
'ext.installFailed': '安装失败: {message}',
// 配置
'config.title': '配置 {name}',
'config.optional': '(可选)',
'config.alreadySet': '(已设置 — 留空以保持不变)',
'config.alreadyConfigured': '已配置',
'config.autoGenerate': '如果为空则自动生成',
'config.save': '保存',
'config.cancel': '取消',
});
+114 -86
View File
@@ -9,6 +9,17 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/style.css">
<!-- i18n Modules -->
<script src="/i18n/index.js"></script>
<script src="/i18n/en.js"></script>
<script src="/i18n/zh-CN.js"></script>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js"
integrity="sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl"
crossorigin="anonymous"
></script>
<script
src="https://cdn.jsdelivr.net/npm/[email protected]/lib/marked.umd.min.js"
integrity="sha384-pN9zSKOnTZwXRtYZAu0PBPEgR2B7DOC1aeLxQ33oJ0oy5iN1we6gm57xldM2irDG"
@@ -20,16 +31,16 @@
<div id="auth-screen">
<div class="auth-card-login">
<div class="auth-brand">
<h1>IronClaw</h1>
<p class="auth-tagline">Secure AI Assistant</p>
<h1 data-i18n="auth.title">IronClaw</h1>
<p class="auth-tagline" data-i18n="auth.tagline">Secure AI Assistant</p>
</div>
<div class="auth-form">
<label for="token-input">Gateway Token</label>
<input type="password" id="token-input" placeholder="Paste your auth token" autofocus>
<button onclick="authenticate()">Connect</button>
<label for="token-input" data-i18n="auth.tokenLabel">Gateway Token</label>
<input type="password" id="token-input" data-i18n="auth.tokenPlaceholder" data-i18n-attr="placeholder" placeholder="Paste your auth token" autofocus>
<button onclick="authenticate()" data-i18n="auth.connect">Connect</button>
</div>
<div id="auth-error"></div>
<p class="auth-hint">Enter the GATEWAY_AUTH_TOKEN from your .env configuration.</p>
<p class="auth-hint" data-i18n="auth.hint">Enter the GATEWAY_AUTH_TOKEN from your .env configuration.</p>
</div>
</div>
@@ -38,21 +49,22 @@
<div class="restart-modal-overlay" onclick="cancelRestart()"></div>
<div class="restart-modal-content">
<div class="restart-modal-header">
<h2>Restart IronClaw Instance</h2>
<button class="restart-modal-close" onclick="cancelRestart()" title="Close">×</button>
<h2 data-i18n="restart.title">Restart IronClaw Instance</h2>
<button class="restart-modal-close" onclick="cancelRestart()" data-i18n="restart.closeTooltip" data-i18n-attr="title"
title="Close">×</button>
</div>
<div class="restart-modal-body">
<p class="restart-modal-description">
<p class="restart-modal-description" data-i18n="restart.description">
Are you sure you want to restart the IronClaw instance? This will gracefully restart the process.
</p>
<div class="restart-modal-warning">
<span class="restart-modal-warning-icon">⚠️</span>
<p>Any in-progress jobs may be interrupted. The restart will complete within a few seconds.</p>
<p data-i18n="restart.warning">Any in-progress jobs may be interrupted. The restart will complete within a few seconds.</p>
</div>
</div>
<div class="restart-modal-footer">
<button class="restart-modal-btn cancel" onclick="cancelRestart()">Cancel</button>
<button class="restart-modal-btn confirm" onclick="confirmRestart()">Confirm Restart</button>
<button class="restart-modal-btn cancel" onclick="cancelRestart()" data-i18n="restart.cancel">Cancel</button>
<button class="restart-modal-btn confirm" onclick="confirmRestart()" data-i18n="restart.confirm">Confirm Restart</button>
</div>
</div>
</div>
@@ -63,13 +75,13 @@
<div class="restart-loader-content">
<div class="restart-spinner"></div>
<div class="restart-loader-text">
<p class="restart-title">Restarting IronClaw</p>
<p class="restart-subtitle">Please wait while the process restarts...</p>
<p class="restart-title" data-i18n="restart.progressTitle">Restarting IronClaw</p>
<p class="restart-subtitle" data-i18n="restart.progressSubtitle">Please wait while the process restarts...</p>
</div>
<div class="restart-progress-bar">
<div class="restart-progress-fill"></div>
</div>
<p class="restart-modal-info">
<p class="restart-modal-info" data-i18n="restart.checkLogs">
Check the Logs tab for details after the restart completes.
</p>
</div>
@@ -79,33 +91,45 @@
<div id="app">
<!-- Tab Bar -->
<div class="tab-bar">
<button class="active" data-tab="chat">Chat</button>
<button data-tab="memory">Memory</button>
<button data-tab="jobs">Jobs</button>
<button data-tab="routines">Routines</button>
<button data-tab="extensions">Extensions</button>
<button data-tab="skills">Skills</button>
<button class="active" data-tab="chat" data-i18n="tab.chat">Chat</button>
<button data-tab="memory" data-i18n="tab.memory">Memory</button>
<button data-tab="jobs" data-i18n="tab.jobs">Jobs</button>
<button data-tab="routines" data-i18n="tab.routines">Routines</button>
<button data-tab="extensions" data-i18n="tab.extensions">Extensions</button>
<button data-tab="skills" data-i18n="tab.skills">Skills</button>
<div class="spacer"></div>
<button class="status-logs-btn" data-tab="logs" title="Logs">Logs</button>
<!-- Language Switcher -->
<div class="language-switcher">
<button class="language-btn" id="language-btn" type="button" onclick="toggleLanguageMenu()" title="Switch Language"
aria-label="Switch language" aria-haspopup="true" aria-expanded="false" aria-controls="language-menu">🌐</button>
<div class="language-menu" id="language-menu" style="display: none;">
<button type="button" class="language-option" onclick="switchLanguage('en')" data-lang="en">English</button>
<button type="button" class="language-option" onclick="switchLanguage('zh-CN')" data-lang="zh-CN">简体中文</button>
</div>
</div>
<button class="status-logs-btn" data-tab="logs" data-i18n="tab.logs" title="Logs">Logs</button>
<div class="tee-shield" id="tee-shield" style="display:none" title="Running in a Trusted Execution Environment">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<span id="tee-shield-label">TEE Verified</span>
<span id="tee-shield-label" data-i18n="status.teeVerified">TEE Verified</span>
<div class="tee-popover" id="tee-popover"></div>
</div>
<div class="status" id="gateway-status-trigger">
<div class="dot" id="sse-dot"></div>
<span id="sse-status">Connected</span>
<span id="sse-status" data-i18n="status.connected">Connected</span>
<div class="gateway-popover" id="gateway-popover"></div>
</div>
<button class="restart-btn" id="restart-btn" onclick="triggerRestart()" title="Gracefully restart the process" style="display: none;">
<button class="restart-btn" id="restart-btn" onclick="triggerRestart()" data-i18n="status.restartTooltip"
data-i18n-attr="title" title="Gracefully restart the process" style="display: none;">
<svg id="restart-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 4v6h-6"></path>
<path d="M1 20v-6h6"></path>
<path d="M3.51 9a9 9 0 0114.85-3.36M20.49 15a9 9 0 01-14.85 3.36"></path>
</svg>
<span>Restart</span>
<span data-i18n="status.restart">Restart</span>
</button>
</div>
@@ -113,16 +137,18 @@
<div class="tab-panel active" id="tab-chat">
<div class="thread-sidebar" id="thread-sidebar">
<div class="thread-sidebar-header">
<button class="thread-new-btn" onclick="createNewThread()" title="New thread (Ctrl/Cmd+N)">+</button>
<button class="thread-new-btn" onclick="createNewThread()" data-i18n="chat.newThread" data-i18n-attr="title"
title="New thread (Ctrl/Cmd+N)">+</button>
<div class="spacer"></div>
<button class="thread-toggle-btn" id="thread-toggle-btn" onclick="toggleThreadSidebar()" title="Toggle sidebar">&laquo;</button>
<button class="thread-toggle-btn" id="thread-toggle-btn" onclick="toggleThreadSidebar()" data-i18n="chat.toggleSidebar"
data-i18n-attr="title" title="Toggle sidebar">&laquo;</button>
</div>
<div class="assistant-item" id="assistant-thread" onclick="switchToAssistant()">
<span class="assistant-label" id="assistant-label">Assistant</span>
<span class="assistant-label" id="assistant-label" data-i18n="chat.assistant">Assistant</span>
<span class="assistant-meta" id="assistant-meta"></span>
</div>
<div class="threads-section-header">
<span>Conversations</span>
<span data-i18n="chat.conversations">Conversations</span>
</div>
<div class="thread-list" id="thread-list"></div>
</div>
@@ -131,10 +157,11 @@
<div id="slash-autocomplete" class="slash-autocomplete" style="display:none"></div>
<div class="chat-input">
<div id="image-preview-strip" class="image-preview-strip"></div>
<textarea id="chat-input" placeholder="Message or / for commands..." rows="1"></textarea>
<textarea id="chat-input" data-i18n="chat.inputPlaceholder" data-i18n-attr="placeholder" placeholder="Message or / for commands..." rows="1"></textarea>
<input type="file" id="image-file-input" accept="image/*" multiple style="display:none">
<button id="attach-btn" class="attach-btn" title="Attach images" aria-label="Attach images">&#x1F4CE;</button>
<button id="send-btn" onclick="sendMessage()">Send</button>
<button id="attach-btn" class="attach-btn" data-i18n="chat.attachImages" data-i18n-attr="title" title="Attach images"
aria-label="Attach images">&#x1F4CE;</button>
<button id="send-btn" onclick="sendMessage()" data-i18n="chat.send">Send</button>
</div>
</div>
</div>
@@ -144,23 +171,23 @@
<div class="memory-container">
<div class="memory-sidebar">
<div class="search-box">
<input type="text" id="memory-search" placeholder="Search memory...">
<input type="text" id="memory-search" data-i18n="memory.searchPlaceholder" data-i18n-attr="placeholder" placeholder="Search memory...">
</div>
<div class="memory-tree" id="memory-tree"></div>
</div>
<div class="memory-content">
<div class="memory-breadcrumb" id="memory-breadcrumb">
<span id="memory-breadcrumb-path">workspace /</span>
<button class="memory-edit-btn" id="memory-edit-btn" style="display:none" onclick="startMemoryEdit()">Edit</button>
<button class="memory-edit-btn" id="memory-edit-btn" style="display:none" onclick="startMemoryEdit()" data-i18n="memory.edit">Edit</button>
</div>
<div class="memory-viewer" id="memory-viewer">
<div class="empty">Select a file to view its contents</div>
<div class="empty" data-i18n="memory.selectFile">Select a file to view its contents</div>
</div>
<div class="memory-editor" id="memory-editor" style="display:none">
<textarea id="memory-edit-textarea"></textarea>
<div class="memory-editor-actions">
<button class="btn-save" onclick="saveMemoryEdit()">Save</button>
<button class="btn-cancel-edit" onclick="cancelMemoryEdit()">Cancel</button>
<button class="btn-save" onclick="saveMemoryEdit()" data-i18n="memory.save">Save</button>
<button class="btn-cancel-edit" onclick="cancelMemoryEdit()" data-i18n="memory.cancel">Cancel</button>
</div>
</div>
</div>
@@ -174,17 +201,17 @@
<table class="jobs-table" id="jobs-table">
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Source</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
<th data-i18n="jobs.id">ID</th>
<th data-i18n="jobs.title">Title</th>
<th data-i18n="jobs.source">Source</th>
<th data-i18n="jobs.status">Status</th>
<th data-i18n="jobs.created">Created</th>
<th data-i18n="jobs.actions">Actions</th>
</tr>
</thead>
<tbody id="jobs-tbody"></tbody>
</table>
<div class="empty-state" id="jobs-empty" style="display:none">No jobs found</div>
<div class="empty-state" id="jobs-empty" style="display:none" data-i18n="jobs.empty">No jobs found</div>
</div>
</div>
@@ -199,16 +226,16 @@
<option value="debug">Server: DEBUG</option>
</select>
<select id="logs-level-filter">
<option value="all">All Levels</option>
<option value="ERROR">Error</option>
<option value="WARN">Warn</option>
<option value="INFO">Info</option>
<option value="DEBUG">Debug</option>
<option value="all" data-i18n="logs.allLevels">All Levels</option>
<option value="ERROR" data-i18n="logs.error">Error</option>
<option value="WARN" data-i18n="logs.warn">Warn</option>
<option value="INFO" data-i18n="logs.info">Info</option>
<option value="DEBUG" data-i18n="logs.debug">Debug</option>
</select>
<input type="text" id="logs-target-filter" placeholder="Filter by target...">
<label class="logs-checkbox"><input type="checkbox" id="logs-autoscroll" checked> Auto-scroll</label>
<button id="logs-pause-btn" onclick="toggleLogsPause()">Pause</button>
<button onclick="clearLogs()">Clear</button>
<label class="logs-checkbox"><input type="checkbox" id="logs-autoscroll" checked> <span data-i18n="logs.autoScroll">Auto-scroll</span></label>
<button id="logs-pause-btn" onclick="toggleLogsPause()" data-i18n="logs.pause">Pause</button>
<button onclick="clearLogs()" data-i18n="logs.clear">Clear</button>
</div>
<div class="logs-output" id="logs-output"></div>
</div>
@@ -221,20 +248,20 @@
<table class="routines-table" id="routines-table">
<thead>
<tr>
<th>Name</th>
<th>Trigger</th>
<th>Action</th>
<th>Last Run</th>
<th>Next Run</th>
<th>Runs</th>
<th>Status</th>
<th>Actions</th>
<th data-i18n="routines.name">Name</th>
<th data-i18n="routines.trigger">Trigger</th>
<th data-i18n="routines.action">Action</th>
<th data-i18n="routines.lastRun">Last Run</th>
<th data-i18n="routines.nextRun">Next Run</th>
<th data-i18n="routines.runs">Runs</th>
<th data-i18n="routines.status">Status</th>
<th data-i18n="routines.actions">Actions</th>
</tr>
</thead>
<tbody id="routines-tbody"></tbody>
</table>
<div class="empty-state" id="routines-empty" style="display:none">
No routines configured. Ask the assistant to create one.
<span data-i18n="routines.noConfigured">No routines configured. Ask the assistant to create one.</span>
</div>
<div class="routine-detail" id="routine-detail" style="display:none"></div>
</div>
@@ -244,44 +271,44 @@
<div class="tab-panel" id="tab-extensions">
<div class="extensions-container">
<div class="extensions-section">
<h3>Installed Extensions</h3>
<h3 data-i18n="extensions.installed">Installed Extensions</h3>
<div class="extensions-list" id="extensions-list">
<div class="empty-state">Loading extensions...</div>
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
</div>
<div class="extensions-section" id="available-wasm-section">
<h3>Available WASM Extensions</h3>
<h3 data-i18n="extensions.available">Available WASM Extensions</h3>
<div class="extensions-list" id="available-wasm-list">
<div class="empty-state">Loading...</div>
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
</div>
<div class="extensions-section">
<h3>Install WASM Extension</h3>
<h3 data-i18n="extensions.installWasm">Install WASM Extension</h3>
<div class="ext-install-form">
<input type="text" id="wasm-install-name" placeholder="Extension name">
<input type="text" id="wasm-install-name" data-i18n-placeholder="common.name" placeholder="Extension name">
<input type="text" id="wasm-install-url" placeholder="URL to .tar.gz bundle">
<button onclick="installWasmExtension()">Install</button>
<button onclick="installWasmExtension()" data-i18n="extensions.install">Install</button>
</div>
</div>
<div class="extensions-section">
<h3>MCP Servers</h3>
<h3 data-i18n="mcp.servers">MCP Servers</h3>
<div class="extensions-list" id="mcp-servers-list">
<div class="empty-state">Loading...</div>
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
<h4>Add Custom MCP Server</h4>
<h4 data-i18n="mcp.addCustom">Add Custom MCP Server</h4>
<div class="ext-install-form">
<input type="text" id="mcp-install-name" placeholder="Server name">
<input type="text" id="mcp-install-name" data-i18n-placeholder="common.name" placeholder="Server name">
<input type="text" id="mcp-install-url" placeholder="MCP server URL (https://...)">
<button onclick="addMcpServer()">Add</button>
<button onclick="addMcpServer()" data-i18n="mcp.add">Add</button>
</div>
</div>
<div class="extensions-section">
<h3>Registered Tools</h3>
<h3 data-i18n="tools.registered">Registered Tools</h3>
<table class="tools-table" id="tools-table">
<thead><tr><th>Name</th><th>Description</th></tr></thead>
<thead><tr><th data-i18n="tools.name">Name</th><th data-i18n="tools.description">Description</th></tr></thead>
<tbody id="tools-tbody"></tbody>
</table>
<div class="empty-state" id="tools-empty" style="display:none">No tools registered</div>
<div class="empty-state" id="tools-empty" style="display:none" data-i18n="tools.empty">No tools registered</div>
</div>
</div>
</div>
@@ -290,25 +317,25 @@
<div class="tab-panel" id="tab-skills">
<div class="extensions-container">
<div class="extensions-section">
<h3>Search ClawHub</h3>
<h3 data-i18n="skills.searchClawHub">Search ClawHub</h3>
<div class="skill-search-box">
<input type="text" id="skill-search-input" placeholder="Search for skills...">
<button onclick="searchClawHub()">Search</button>
<input type="text" id="skill-search-input" data-i18n-placeholder="skills.searchPlaceholder" placeholder="Search...">
<button onclick="searchClawHub()" data-i18n="skills.search">Search</button>
</div>
<div class="extensions-list" id="skill-search-results"></div>
</div>
<div class="extensions-section">
<h3>Installed Skills</h3>
<h3 data-i18n="skills.installed">Installed Skills</h3>
<div class="extensions-list" id="skills-list">
<div class="empty-state">Loading skills...</div>
<div class="empty-state" data-i18n="skills.loading">Loading skills...</div>
</div>
</div>
<div class="extensions-section">
<h3>Install Skill by URL</h3>
<h3 data-i18n="skills.installByUrl">Install Skill by URL</h3>
<div class="ext-install-form">
<input type="text" id="skill-install-name" placeholder="Skill name or slug">
<input type="text" id="skill-install-url" placeholder="HTTPS URL to SKILL.md (optional)">
<button onclick="installSkillFromForm()">Install</button>
<input type="text" id="skill-install-name" data-i18n-placeholder="skills.namePlaceholder" placeholder="Skill name or slug">
<input type="text" id="skill-install-url" data-i18n-placeholder="skills.urlPlaceholder" placeholder="HTTPS URL to SKILL.md (optional)">
<button onclick="installSkillFromForm()" data-i18n="extensions.install">Install</button>
</div>
</div>
</div>
@@ -317,5 +344,6 @@
<div id="toasts"></div>
<script src="/app.js"></script>
<script src="/i18n-app.js"></script>
</body>
</html>
+91 -14
View File
@@ -9,6 +9,7 @@
--text-secondary: #a1a1aa;
--accent: #34d399;
--accent-hover: #2fc48d;
--accent-soft: rgba(52, 211, 153, 0.15);
--success: #34d399;
--warning: #F5A623;
--danger: #E64C4C;
@@ -655,11 +656,11 @@ body {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
gap: 16px;
}
.message {
max-width: 80%;
max-width: 72%;
padding: 10px 14px;
border-radius: var(--radius);
font-size: 14px;
@@ -669,8 +670,8 @@ body {
.message.user {
align-self: flex-end;
background: var(--accent);
color: #09090b;
background: var(--accent-soft);
color: var(--accent);
border-bottom-right-radius: 2px;
white-space: pre-wrap;
}
@@ -680,6 +681,9 @@ body {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-bottom-left-radius: 2px;
padding: 14px 18px;
font-size: 15px;
line-height: 1.6;
}
.message.system {
@@ -710,10 +714,10 @@ body {
padding: 0;
}
.message p { margin: 0 0 8px 0; }
.message p { margin: 0 0 10px 0; }
.message p:last-child { margin-bottom: 0; }
.message ul, .message ol { margin: 4px 0; padding-left: 20px; }
.message li { margin: 2px 0; }
.message li { margin: 4px 0; }
.message blockquote {
margin: 6px 0;
padding: 4px 12px;
@@ -1062,7 +1066,7 @@ body {
}
.approval-card .approval-actions button:disabled {
opacity: 0.4;
opacity: 0.5;
cursor: not-allowed;
}
@@ -1241,7 +1245,7 @@ body {
}
.auth-card .auth-actions button:disabled {
opacity: 0.4;
opacity: 0.5;
cursor: not-allowed;
}
@@ -1301,6 +1305,11 @@ body {
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
}
.chat-input textarea:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.chat-input button {
padding: 8px 20px;
background: var(--accent);
@@ -1314,7 +1323,7 @@ body {
transition: background 0.2s, transform 0.2s;
}
.chat-input button:hover {
.chat-input button:hover:not(:disabled) {
background: var(--accent-hover);
transform: translateY(-1px);
}
@@ -1324,8 +1333,18 @@ body {
}
.chat-input button:disabled {
opacity: 0.5;
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
/* Keyboard accessibility focus rings */
.chat-input textarea:focus-visible,
.chat-input button:focus-visible,
.tab-bar button:focus-visible,
.tree-row:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Memory Tab */
@@ -1425,7 +1444,7 @@ body {
color: var(--text-secondary);
}
.tree-label.file:hover {
.tree-row:hover .tree-label.file {
color: var(--accent);
}
@@ -2307,7 +2326,7 @@ body {
}
.log-entry:hover {
background: var(--bg-secondary);
background: var(--bg-tertiary);
}
.log-ts {
@@ -3781,7 +3800,7 @@ mark {
}
/* Image Upload */
.attach-btn {
.chat-input .attach-btn {
background: none;
border: none;
cursor: pointer;
@@ -3794,10 +3813,13 @@ mark {
display: flex;
align-items: center;
justify-content: center;
font-weight: 400;
}
.attach-btn:hover {
.chat-input .attach-btn:hover {
background: none;
color: var(--text);
transform: none;
}
.image-preview-strip {
@@ -3863,6 +3885,61 @@ mark {
display: block;
}
/* Language Switcher */
.language-switcher {
position: relative;
display: flex;
align-items: center;
}
.language-btn {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 8px;
font-size: 16px;
border-radius: var(--radius);
transition: all 0.2s;
}
.language-btn:hover {
color: var(--text);
background: var(--bg-tertiary);
}
.language-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 4px;
min-width: 120px;
z-index: 1000;
box-shadow: var(--shadow);
}
.language-option {
padding: 8px 12px;
cursor: pointer;
border-radius: var(--radius);
color: var(--text);
font-size: 13px;
transition: all 0.2s;
}
.language-option:hover {
background: var(--bg-tertiary);
}
.language-option.active {
background: var(--accent);
color: var(--bg);
}
.generated-image-path {
font-size: 12px;
color: var(--text-secondary);
+15 -27
View File
@@ -255,43 +255,31 @@ async fn handle_client_message(
token,
} => {
if let Some(ref ext_mgr) = state.extension_manager {
match ext_mgr.auth(&extension_name, Some(&token)).await {
Ok(result) if result.is_authenticated() => {
let msg = match ext_mgr.activate(&extension_name).await {
Ok(r) => format!(
"{} authenticated ({} tools loaded)",
extension_name,
r.tools_loaded.len()
),
Err(e) => format!(
"{} authenticated but activation failed: {}",
extension_name, e
),
};
match ext_mgr.configure_token(&extension_name, &token).await {
Ok(result) => {
crate::channels::web::server::clear_auth_mode(state).await;
state
.sse
.broadcast(crate::channels::web::types::SseEvent::AuthCompleted {
extension_name,
success: true,
message: msg,
});
}
Ok(result) => {
state
.sse
.broadcast(crate::channels::web::types::SseEvent::AuthRequired {
extension_name,
instructions: result.instructions().map(String::from),
auth_url: result.auth_url().map(String::from),
setup_url: result.setup_url().map(String::from),
message: result.message,
});
}
Err(e) => {
let msg = format!("Auth failed: {}", e);
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
state.sse.broadcast(
crate::channels::web::types::SseEvent::AuthRequired {
extension_name: extension_name.clone(),
instructions: Some(msg.clone()),
auth_url: None,
setup_url: None,
},
);
}
let _ = direct_tx
.send(WsServerMessage::Error {
message: format!("Auth failed: {}", e),
})
.send(WsServerMessage::Error { message: msg })
.await;
}
}
+63 -47
View File
@@ -24,7 +24,7 @@ pub struct WebhookServerConfig {
pub struct WebhookServer {
config: WebhookServerConfig,
routes: Vec<Router>,
/// Merged router saved after start() for restart_with_addr().
/// Merged router saved after start() for restarts via `install_listener()`.
merged_router: Option<Router>,
shutdown_tx: Option<oneshot::Sender<()>>,
handle: Option<JoinHandle<()>>,
@@ -59,7 +59,7 @@ impl WebhookServer {
}
/// Bind a listener to the configured address and spawn the server task.
/// Private helper used by both start() and restart_with_addr().
/// Private helper used by `start()`.
async fn bind_and_spawn(&mut self, app: Router) -> Result<(), ChannelError> {
let listener = tokio::net::TcpListener::bind(self.config.addr)
.await
@@ -89,47 +89,49 @@ impl WebhookServer {
Ok(())
}
/// Gracefully shut down the current listener and rebind to a new address.
/// The merged router from the original `start()` call is reused.
///
/// If binding to the new address fails, the old listener remains active and
/// state is restored. This prevents a denial-of-service if the new address
/// is invalid or already in use.
pub async fn restart_with_addr(&mut self, new_addr: SocketAddr) -> Result<(), ChannelError> {
let app = self
.merged_router
.clone()
.ok_or_else(|| ChannelError::StartupFailed {
name: "webhook_server".to_string(),
reason: "restart_with_addr called before start()".to_string(),
})?;
/// Clone the merged router, if `start()` has been called.
pub fn merged_router_clone(&self) -> Option<Router> {
self.merged_router.clone()
}
// Save old state for rollback if new bind fails
let old_addr = self.config.addr;
/// Install a pre-bound listener, replacing the current one.
///
/// The caller is responsible for binding the `TcpListener` *outside* any
/// lock so that the async bind does not block other lock waiters. This
/// method only does synchronous bookkeeping plus spawning the (non-blocking)
/// server task, so it is safe to call while holding a mutex.
pub fn install_listener(
&mut self,
new_addr: SocketAddr,
listener: tokio::net::TcpListener,
app: Router,
) -> (Option<oneshot::Sender<()>>, Option<JoinHandle<()>>) {
// Capture old handles so the caller can shut them down outside the lock.
let old_shutdown_tx = self.shutdown_tx.take();
let old_handle = self.handle.take();
// Update config to new address and try to bind
self.config.addr = new_addr;
match self.bind_and_spawn(app).await {
Ok(()) => {
// New listener is running, gracefully shut down the old one
if let Some(tx) = old_shutdown_tx {
let _ = tx.send(());
}
if let Some(handle) = old_handle {
let _ = handle.await;
}
Ok(())
// Spawn the new server task (non-blocking).
let (shutdown_tx, shutdown_rx) = oneshot::channel();
self.shutdown_tx = Some(shutdown_tx);
let handle = tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
tracing::debug!("Webhook server shutting down");
})
.await
{
tracing::error!("Webhook server error: {}", e);
}
Err(e) => {
// Restore old state; old listener remains active
self.config.addr = old_addr;
self.shutdown_tx = old_shutdown_tx;
self.handle = old_handle;
Err(e)
}
}
});
self.handle = Some(handle);
tracing::info!("Webhook server listening on {}", new_addr);
(old_shutdown_tx, old_handle)
}
/// Return the current bind address.
@@ -213,12 +215,21 @@ mod tests {
"First server should respond to health check"
);
// Restart on second port
let addr2 = format!("127.0.0.1:{}", port2).parse().unwrap();
server
.restart_with_addr(addr2)
// Restart on second port using two-phase approach
let addr2: SocketAddr = format!("127.0.0.1:{}", port2).parse().unwrap();
let app = server
.merged_router_clone()
.expect("Router should exist after start()");
let listener = tokio::net::TcpListener::bind(addr2)
.await
.expect("Failed to restart with new addr");
.expect("Failed to bind to new addr");
let (old_tx, old_handle) = server.install_listener(addr2, listener, app);
if let Some(tx) = old_tx {
let _ = tx.send(());
}
if let Some(handle) = old_handle {
let _ = handle.await;
}
// Assert the address changed
assert_eq!(
@@ -295,13 +306,18 @@ mod tests {
.expect("Failed to send request");
assert_eq!(response.status(), 200, "Server should be listening");
// Try to restart on an invalid address (port 0 is reserved, won't bind)
// Use port 1 which typically requires elevated privileges
// Try to restart on an invalid address (port 1 typically requires elevated privileges)
let invalid_addr: SocketAddr = "127.0.0.1:1".parse().unwrap();
// Attempt restart (should fail)
let result = server.restart_with_addr(invalid_addr).await;
assert!(result.is_err(), "Restart with invalid address should fail");
// Attempt bind (should fail); server state is untouched because we
// never call install_listener on failure.
let app = server
.merged_router_clone()
.expect("Router should exist after start()");
let result = tokio::net::TcpListener::bind(invalid_addr).await;
assert!(result.is_err(), "Bind to privileged port should fail");
// `app` is dropped — server state unchanged (rollback by construction)
drop(app);
// Verify the old address is still responding (rollback succeeded)
let response = client
+1 -1
View File
@@ -220,7 +220,7 @@ async fn check_nearai_session() -> CheckResult {
let session_path = crate::config::llm::default_session_path();
if !session_path.exists() {
// Check for API key mode
if std::env::var("NEARAI_API_KEY").is_ok() {
if crate::config::helpers::env_or_override("NEARAI_API_KEY").is_some() {
return CheckResult::Pass("API key configured".into());
}
return CheckResult::Fail(format!(
+162
View File
@@ -0,0 +1,162 @@
//! Import command for migrating data from other AI systems.
use std::path::PathBuf;
use std::sync::Arc;
use clap::Subcommand;
#[cfg(feature = "import")]
use crate::import::ImportOptions;
#[cfg(feature = "import")]
use crate::import::openclaw::OpenClawImporter;
/// Import data from other AI systems.
#[derive(Subcommand, Debug, Clone)]
pub enum ImportCommand {
/// Import from OpenClaw (memory, history, settings, credentials)
#[cfg(feature = "import")]
Openclaw {
/// Path to OpenClaw directory (default: ~/.openclaw)
#[arg(long)]
path: Option<PathBuf>,
/// Dry-run mode: show what would be imported without writing
#[arg(long)]
dry_run: bool,
/// Re-embed memory if dimensions don't match target provider
#[arg(long)]
re_embed: bool,
/// User ID for imported data (default: 'default')
#[arg(long)]
user_id: Option<String>,
},
}
/// Run an import command.
#[cfg(feature = "import")]
pub async fn run_import_command(
cmd: &ImportCommand,
config: &crate::config::Config,
) -> anyhow::Result<()> {
match cmd {
ImportCommand::Openclaw {
path,
dry_run,
re_embed,
user_id,
} => run_import_openclaw(config, path.clone(), *dry_run, *re_embed, user_id.clone()).await,
}
}
/// Run the OpenClaw import.
#[cfg(feature = "import")]
async fn run_import_openclaw(
config: &crate::config::Config,
openclaw_path: Option<PathBuf>,
dry_run: bool,
re_embed: bool,
user_id: Option<String>,
) -> anyhow::Result<()> {
use secrecy::SecretString;
// Determine OpenClaw path
let openclaw_path = if let Some(path) = openclaw_path {
path
} else if let Some(path) = OpenClawImporter::detect() {
path
} else {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".openclaw")
};
let user_id = user_id.unwrap_or_else(|| "default".to_string());
println!("🔍 OpenClaw Import");
println!(" Path: {}", openclaw_path.display());
println!(" User: {}", user_id);
if dry_run {
println!(" Mode: DRY RUN (no data will be written)");
}
println!();
// Initialize database
let db = crate::db::connect_from_config(&config.database)
.await
.map_err(|e| anyhow::anyhow!("Failed to initialize database: {}", e))?;
// Initialize secrets store with master key from env or keychain
let secrets_crypto = if let Ok(master_key_hex) = std::env::var("SECRETS_MASTER_KEY") {
Arc::new(
crate::secrets::SecretsCrypto::new(SecretString::from(master_key_hex))
.map_err(|e| anyhow::anyhow!("Failed to initialize secrets: {}", e))?,
)
} else {
match crate::secrets::keychain::get_master_key().await {
Ok(key_bytes) => {
let key_hex: String = key_bytes.iter().map(|b| format!("{:02x}", b)).collect();
Arc::new(
crate::secrets::SecretsCrypto::new(SecretString::from(key_hex))
.map_err(|e| anyhow::anyhow!("Failed to initialize secrets: {}", e))?,
)
}
Err(_) => {
return Err(anyhow::anyhow!(
"No secrets master key found. Set SECRETS_MASTER_KEY env var or run 'ironclaw onboard' first."
));
}
}
};
let secrets: Arc<dyn crate::secrets::SecretsStore> = Arc::new(
crate::secrets::InMemorySecretsStore::new(secrets_crypto.clone()),
);
// Initialize workspace
let workspace = crate::workspace::Workspace::new_with_db(user_id.clone(), db.clone());
let opts = ImportOptions {
openclaw_path,
dry_run,
re_embed,
user_id,
};
let importer = OpenClawImporter::new(db, workspace, secrets, opts);
let stats = importer.import().await?;
// Print results
println!("Import Complete");
println!();
println!("Summary:");
println!(" Documents: {}", stats.documents);
println!(" Chunks: {}", stats.chunks);
println!(" Conversations: {}", stats.conversations);
println!(" Messages: {}", stats.messages);
println!(" Settings: {}", stats.settings);
println!(" Secrets: {}", stats.secrets);
if stats.skipped > 0 {
println!(" Skipped: {}", stats.skipped);
}
if stats.re_embed_queued > 0 {
println!(" Re-embed queued: {}", stats.re_embed_queued);
}
println!();
println!("Total imported: {}", stats.total_imported());
if dry_run {
println!();
println!("[DRY RUN] No data was written.");
}
Ok(())
}
#[cfg(not(feature = "import"))]
pub async fn run_import_command(
_cmd: &ImportCommand,
_config: &crate::config::Config,
) -> anyhow::Result<()> {
anyhow::bail!("Import feature not enabled. Compile with --features import")
}
+14 -4
View File
@@ -12,9 +12,10 @@ use crate::config::Config;
use crate::db::Database;
use crate::secrets::SecretsStore;
use crate::tools::mcp::{
McpClient, McpServerConfig, McpSessionManager, OAuthConfig,
McpClient, McpProcessManager, McpServerConfig, McpSessionManager, OAuthConfig,
auth::{authorize_mcp_server, is_authenticated},
config::{self, EffectiveTransport, McpServersFile},
factory::create_client_from_config,
};
/// Arguments for the `mcp add` subcommand.
@@ -494,7 +495,7 @@ async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {
let client = if has_tokens {
// We have stored tokens, use authenticated client
McpClient::new_authenticated(server.clone(), session_manager, secrets, user_id)
McpClient::new_authenticated(server.clone(), session_manager.clone(), secrets, user_id)
} else if server.requires_auth() {
// OAuth configured but no tokens - need to authenticate
println!();
@@ -505,8 +506,17 @@ async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {
println!();
return Ok(());
} else {
// No OAuth and no tokens - try unauthenticated
McpClient::new_with_config(server.clone())
// Use the factory to dispatch on transport type (HTTP, stdio, unix)
let process_manager = Arc::new(McpProcessManager::new());
create_client_from_config(
server.clone(),
&session_manager,
&process_manager,
None,
"default",
)
.await
.map_err(|e| anyhow::anyhow!("{}", e))?
};
// Test connection
+31
View File
@@ -14,6 +14,8 @@
mod completion;
mod config;
mod doctor;
#[cfg(feature = "import")]
pub mod import;
mod mcp;
pub mod memory;
pub mod oauth_defaults;
@@ -26,6 +28,8 @@ mod tool;
pub use completion::Completion;
pub use config::{ConfigCommand, run_config_command};
pub use doctor::run_doctor_command;
#[cfg(feature = "import")]
pub use import::{ImportCommand, run_import_command};
pub use mcp::{McpCommand, run_mcp_command};
pub use memory::MemoryCommand;
pub use memory::run_memory_command_with_db;
@@ -183,6 +187,15 @@ pub enum Command {
)]
Completion(Completion),
/// Import data from other AI systems
#[cfg(feature = "import")]
#[command(
subcommand,
about = "Import from other AI systems",
long_about = "Migrate data from other AI assistants like OpenClaw.\nExample: ironclaw import openclaw"
)]
Import(ImportCommand),
/// Run as a sandboxed worker inside a Docker container (internal use).
/// This is invoked automatically by the orchestrator, not by users directly.
#[command(hide = true)]
@@ -282,6 +295,7 @@ mod tests {
}
#[test]
#[cfg(feature = "import")]
fn test_help_output() {
let mut cmd = Cli::command();
let help = cmd.render_help().to_string();
@@ -289,9 +303,26 @@ mod tests {
}
#[test]
#[cfg(not(feature = "import"))]
fn test_help_output_without_import() {
let mut cmd = Cli::command();
let help = cmd.render_help().to_string();
assert_snapshot!(help);
}
#[test]
#[cfg(feature = "import")]
fn test_long_help_output() {
let mut cmd = Cli::command();
let help = cmd.render_long_help().to_string();
assert_snapshot!(help);
}
#[test]
#[cfg(not(feature = "import"))]
fn test_long_help_output_without_import() {
let mut cmd = Cli::command();
let help = cmd.render_long_help().to_string();
assert_snapshot!(help);
}
}
@@ -1,5 +1,6 @@
---
source: src/cli/mod.rs
assertion_line: 302
expression: help
---
Secure personal AI assistant that protects your data and expands its capabilities
@@ -19,6 +20,7 @@ Commands:
doctor Run diagnostics
status Show system status
completion Generate completions
import Import from other AI systems
help Print this message or the help of the given subcommand(s)
Options:
@@ -0,0 +1,32 @@
---
source: src/cli/mod.rs
assertion_line: 310
expression: help
---
Secure personal AI assistant that protects your data and expands its capabilities
Usage: ironclaw [OPTIONS] [COMMAND]
Commands:
run Run the AI agent
onboard Run interactive setup wizard
config Manage app configs
tool Manage WASM tools
registry Browse/install extensions
mcp Manage MCP servers
memory Manage workspace memory
pairing Manage DM pairing
service Manage OS service
doctor Run diagnostics
status Show system status
completion Generate completions
help Print this message or the help of the given subcommand(s)
Options:
--cli-only Run in interactive CLI mode only (disable other channels)
--no-db Skip database connection (for testing)
-m, --message <MESSAGE> Single message mode - send one message and exit
-c, --config <CONFIG> Configuration file path (optional, uses env vars by default)
--no-onboard Skip first-run onboarding check
-h, --help Print help (see more with '--help')
-V, --version Print version
@@ -1,5 +1,6 @@
---
source: src/cli/mod.rs
assertion_line: 318
expression: help
---
IronClaw is a secure AI assistant. Use 'ironclaw <subcommand> --help' for details.
@@ -22,6 +23,7 @@ Commands:
doctor Run diagnostics
status Show system status
completion Generate completions
import Import from other AI systems
help Print this message or the help of the given subcommand(s)
Options:
@@ -0,0 +1,48 @@
---
source: src/cli/mod.rs
assertion_line: 326
expression: help
---
IronClaw is a secure AI assistant. Use 'ironclaw <subcommand> --help' for details.
Examples:
ironclaw run # Start the agent
ironclaw config list # List configs
Usage: ironclaw [OPTIONS] [COMMAND]
Commands:
run Run the AI agent
onboard Run interactive setup wizard
config Manage app configs
tool Manage WASM tools
registry Browse/install extensions
mcp Manage MCP servers
memory Manage workspace memory
pairing Manage DM pairing
service Manage OS service
doctor Run diagnostics
status Show system status
completion Generate completions
help Print this message or the help of the given subcommand(s)
Options:
--cli-only
Run in interactive CLI mode only (disable other channels)
--no-db
Skip database connection (for testing)
-m, --message <MESSAGE>
Single message mode - send one message and exit
-c, --config <CONFIG>
Configuration file path (optional, uses env vars by default)
--no-onboard
Skip first-run onboarding check
-h, --help
Print help (see a summary with '-h')
-V, --version
Print version
+134 -1
View File
@@ -1,6 +1,9 @@
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use crate::error::ConfigError;
use super::INJECTED_VARS;
use crate::config::INJECTED_VARS;
/// Crate-wide mutex for tests that mutate process environment variables.
///
@@ -11,6 +14,73 @@ use super::INJECTED_VARS;
#[cfg(test)]
pub(crate) static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Thread-safe mutable overlay for env vars set at runtime.
///
/// Unlike `INJECTED_VARS` (which is set once at startup from the secrets
/// store), this map supports writes at any point during the process
/// lifetime. It replaces unsafe `std::env::set_var` calls that would
/// otherwise be UB in multi-threaded programs (Rust 1.82+).
///
/// Priority: real env vars > `RUNTIME_ENV_OVERRIDES` > `INJECTED_VARS`.
static RUNTIME_ENV_OVERRIDES: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
fn runtime_overrides() -> &'static Mutex<HashMap<String, String>> {
RUNTIME_ENV_OVERRIDES.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Set a runtime environment override (thread-safe alternative to `std::env::set_var`).
///
/// Values set here are visible to `optional_env()`, `env_or_override()`, and
/// all config resolution that goes through those helpers. This avoids the UB
/// of `std::env::set_var` in multi-threaded programs.
pub fn set_runtime_env(key: &str, value: &str) {
runtime_overrides()
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(key.to_string(), value.to_string());
}
/// Read an env var, checking the real environment first, then runtime overrides.
///
/// Priority: real env vars > runtime overrides > `INJECTED_VARS`.
/// Empty values are treated as unset at every layer for consistency with
/// `optional_env()`.
///
/// Use this instead of `std::env::var()` when the value might have been set
/// via `set_runtime_env()` (e.g., `NEARAI_API_KEY` during interactive login).
pub fn env_or_override(key: &str) -> Option<String> {
// Real env vars always win
if let Ok(val) = std::env::var(key)
&& !val.is_empty()
{
return Some(val);
}
// Check runtime overrides (skip empty values for consistency with optional_env)
if let Some(val) = runtime_overrides()
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(key)
.filter(|v| !v.is_empty())
.cloned()
{
return Some(val);
}
// Check INJECTED_VARS (secrets from DB, set once at startup)
if let Some(val) = INJECTED_VARS
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(key)
.filter(|v| !v.is_empty())
.cloned()
{
return Some(val);
}
None
}
pub(crate) fn optional_env(key: &str) -> Result<Option<String>, ConfigError> {
// Check real env vars first (always win over injected secrets)
match std::env::var(key) {
@@ -24,6 +94,17 @@ pub(crate) fn optional_env(key: &str) -> Result<Option<String>, ConfigError> {
}
}
// Fall back to runtime overrides (set via set_runtime_env)
if let Some(val) = runtime_overrides()
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(key)
.filter(|v| !v.is_empty())
.cloned()
{
return Ok(Some(val));
}
// Fall back to thread-safe overlay (secrets injected from DB)
if let Some(val) = INJECTED_VARS
.lock()
@@ -94,3 +175,55 @@ pub(crate) fn parse_string_env(
) -> Result<String, ConfigError> {
Ok(optional_env(key)?.unwrap_or_else(|| default.into()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn runtime_env_override_is_visible_to_env_or_override() {
// Use a unique key that won't collide with real env vars.
let key = "IRONCLAW_TEST_RUNTIME_OVERRIDE_42";
// Not set initially
assert!(env_or_override(key).is_none());
// Set via the thread-safe overlay
set_runtime_env(key, "test_value");
// Now visible
assert_eq!(env_or_override(key), Some("test_value".to_string()));
}
#[test]
fn runtime_env_override_is_visible_to_optional_env() {
let key = "IRONCLAW_TEST_OPTIONAL_ENV_OVERRIDE_42";
assert_eq!(optional_env(key).unwrap(), None);
set_runtime_env(key, "hello");
assert_eq!(optional_env(key).unwrap(), Some("hello".to_string()));
}
#[test]
fn real_env_var_takes_priority_over_runtime_override() {
let _guard = ENV_MUTEX.lock().unwrap();
let key = "IRONCLAW_TEST_ENV_PRIORITY_42";
// Set runtime override
set_runtime_env(key, "override_value");
// Set real env var (should win)
// SAFETY: test runs under ENV_MUTEX
unsafe { std::env::set_var(key, "real_value") };
assert_eq!(env_or_override(key), Some("real_value".to_string()));
// Clean up
unsafe { std::env::remove_var(key) };
// Now the runtime override is visible again
assert_eq!(env_or_override(key), Some("override_value".to_string()));
}
}
+4
View File
@@ -54,6 +54,10 @@ pub use crate::llm::config::{
};
pub use crate::llm::session::SessionConfig;
// Thread-safe env var override helpers (replaces unsafe `std::env::set_var`
// for mid-process env mutations in multi-threaded contexts).
pub use self::helpers::{env_or_override, set_runtime_env};
/// Thread-safe overlay for injected env vars (secrets loaded from DB).
///
/// Used by `inject_llm_keys_from_secrets()` to make API keys available to
+80 -1
View File
@@ -8,6 +8,13 @@ pub struct SandboxModeConfig {
pub enabled: bool,
/// Sandbox policy: "readonly", "workspace_write", or "full_access".
pub policy: String,
/// Explicit opt-in for `FullAccess` policy.
///
/// When `policy` is `full_access` but this is `false`, the policy is
/// downgraded to `workspace_write` with a loud error log. This prevents
/// accidental host-level command execution from a single misconfigured
/// env var.
pub allow_full_access: bool,
/// Command timeout in seconds.
pub timeout_secs: u64,
/// Memory limit in megabytes.
@@ -31,6 +38,7 @@ impl Default for SandboxModeConfig {
Self {
enabled: true,
policy: "readonly".to_string(),
allow_full_access: false,
timeout_secs: 120,
memory_limit_mb: 2048,
cpu_shares: 1024,
@@ -70,6 +78,7 @@ impl SandboxModeConfig {
Ok(Self {
enabled: parse_bool_env("SANDBOX_ENABLED", true)?,
policy: parse_string_env("SANDBOX_POLICY", "readonly")?,
allow_full_access: parse_bool_env("SANDBOX_ALLOW_FULL_ACCESS", false)?,
timeout_secs: parse_optional_env("SANDBOX_TIMEOUT_SECS", 120)?,
memory_limit_mb: parse_optional_env("SANDBOX_MEMORY_LIMIT_MB", 2048)?,
cpu_shares: parse_optional_env("SANDBOX_CPU_SHARES", 1024)?,
@@ -82,11 +91,25 @@ impl SandboxModeConfig {
}
/// Convert to SandboxConfig for the sandbox module.
///
/// If `policy` is `FullAccess` but `allow_full_access` is `false`,
/// the policy is downgraded to `WorkspaceWrite` and an error is logged.
pub fn to_sandbox_config(&self) -> crate::sandbox::SandboxConfig {
use crate::sandbox::SandboxPolicy;
use std::time::Duration;
let policy = self.policy.parse().unwrap_or(SandboxPolicy::ReadOnly);
let mut policy = self.policy.parse().unwrap_or(SandboxPolicy::ReadOnly);
// Double opt-in guard: FullAccess requires SANDBOX_ALLOW_FULL_ACCESS=true
if policy == SandboxPolicy::FullAccess && !self.allow_full_access {
tracing::error!(
"SANDBOX_POLICY=full_access is set but SANDBOX_ALLOW_FULL_ACCESS is not \
set to 'true'. FullAccess bypasses Docker and runs commands directly on \
the host. Downgrading to WorkspaceWrite for safety. Set \
SANDBOX_ALLOW_FULL_ACCESS=true to explicitly enable FullAccess."
);
policy = SandboxPolicy::WorkspaceWrite;
}
let mut allowlist = crate::sandbox::default_allowlist();
allowlist.extend(self.extra_allowed_domains.clone());
@@ -94,6 +117,7 @@ impl SandboxModeConfig {
crate::sandbox::SandboxConfig {
enabled: self.enabled,
policy,
allow_full_access: self.allow_full_access,
timeout: Duration::from_secs(self.timeout_secs),
memory_limit_mb: self.memory_limit_mb,
cpu_shares: self.cpu_shares,
@@ -302,6 +326,7 @@ mod tests {
extra_allowed_domains: vec!["example.com".to_string()],
reaper_interval_secs: 300,
orphan_threshold_secs: 600,
allow_full_access: false,
};
assert!(!cfg.enabled);
assert_eq!(cfg.policy, "full_access");
@@ -326,6 +351,7 @@ mod tests {
extra_allowed_domains: vec!["custom.example.com".to_string()],
reaper_interval_secs: 300,
orphan_threshold_secs: 600,
allow_full_access: false,
};
let sc = mode.to_sandbox_config();
assert!(sc.enabled);
@@ -485,4 +511,57 @@ mod tests {
);
}
}
#[test]
fn test_full_access_downgraded_without_allow() {
let config = SandboxModeConfig {
policy: "full_access".to_string(),
allow_full_access: false,
..Default::default()
};
let sandbox = config.to_sandbox_config();
// Should have been downgraded to WorkspaceWrite
assert_eq!(
sandbox.policy,
crate::sandbox::SandboxPolicy::WorkspaceWrite
);
assert!(!sandbox.allow_full_access);
}
#[test]
fn test_full_access_allowed_with_explicit_opt_in() {
let config = SandboxModeConfig {
policy: "full_access".to_string(),
allow_full_access: true,
..Default::default()
};
let sandbox = config.to_sandbox_config();
assert_eq!(sandbox.policy, crate::sandbox::SandboxPolicy::FullAccess);
assert!(sandbox.allow_full_access);
}
#[test]
fn test_non_full_access_policy_unaffected() {
let config = SandboxModeConfig {
policy: "workspace_write".to_string(),
allow_full_access: false,
..Default::default()
};
let sandbox = config.to_sandbox_config();
assert_eq!(
sandbox.policy,
crate::sandbox::SandboxPolicy::WorkspaceWrite
);
}
#[test]
fn test_readonly_policy_unaffected() {
let config = SandboxModeConfig {
policy: "readonly".to_string(),
allow_full_access: false,
..Default::default()
};
let sandbox = config.to_sandbox_config();
assert_eq!(sandbox.policy, crate::sandbox::SandboxPolicy::ReadOnly);
}
}
+1 -1
View File
@@ -12,4 +12,4 @@ mod state;
pub use manager::ContextManager;
pub use memory::{ActionRecord, ConversationMemory, Memory};
pub use state::{JobContext, JobState, StateTransition};
pub use state::{JobContext, JobState, StateTransition, TokenBudgetExceeded};
+17 -7
View File
@@ -11,6 +11,16 @@ use uuid::Uuid;
use crate::llm::recording::HttpInterceptor;
/// Error returned when a job exceeds its token budget.
#[derive(Debug, thiserror::Error)]
#[error("Token budget exceeded: used {used} of {limit} allowed tokens")]
pub struct TokenBudgetExceeded {
/// Total tokens consumed (including the call that exceeded the budget).
pub used: u64,
/// Configured token limit for this job.
pub limit: u64,
}
/// State of a job.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -265,15 +275,15 @@ impl JobContext {
self.actual_cost += cost;
}
/// Record token usage from an LLM call. Returns an error string if the
/// token budget has been exceeded after this addition.
pub fn add_tokens(&mut self, tokens: u64) -> Result<(), String> {
/// Record token usage from an LLM call. Returns an error if the token
/// budget has been exceeded after this addition.
pub fn add_tokens(&mut self, tokens: u64) -> Result<(), TokenBudgetExceeded> {
self.total_tokens_used += tokens;
if self.max_tokens > 0 && self.total_tokens_used > self.max_tokens {
Err(format!(
"Token budget exceeded: used {} of {} allowed tokens",
self.total_tokens_used, self.max_tokens
))
Err(TokenBudgetExceeded {
used: self.total_tokens_used,
limit: self.max_tokens,
})
} else {
Ok(())
}
+9 -6
View File
@@ -67,20 +67,23 @@ impl ConversationStore for LibSqlBackend {
channel: &str,
user_id: &str,
thread_id: Option<&str>,
) -> Result<(), DatabaseError> {
) -> Result<bool, DatabaseError> {
let conn = self.connect().await?;
let now = fmt_ts(&Utc::now());
conn.execute(
let affected = conn
.execute(
r#"
INSERT INTO conversations (id, channel, user_id, thread_id, started_at, last_activity)
VALUES (?1, ?2, ?3, ?4, ?5, ?5)
ON CONFLICT (id) DO UPDATE SET last_activity = ?5
ON CONFLICT (id) DO UPDATE SET last_activity = excluded.last_activity
WHERE conversations.user_id = excluded.user_id
AND conversations.channel = excluded.channel
"#,
params![id.to_string(), channel, user_id, opt_text(thread_id), now],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
Ok(())
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
Ok(affected > 0)
}
async fn list_conversations_with_preview(
+1 -1
View File
@@ -207,7 +207,7 @@ pub trait ConversationStore: Send + Sync {
channel: &str,
user_id: &str,
thread_id: Option<&str>,
) -> Result<(), DatabaseError>;
) -> Result<bool, DatabaseError>;
async fn list_conversations_with_preview(
&self,
user_id: &str,
+1 -1
View File
@@ -99,7 +99,7 @@ impl ConversationStore for PgBackend {
channel: &str,
user_id: &str,
thread_id: Option<&str>,
) -> Result<(), DatabaseError> {
) -> Result<bool, DatabaseError> {
self.store
.ensure_conversation(id, channel, user_id, thread_id)
.await
+426 -171
View File
@@ -17,9 +17,9 @@ use crate::channels::wasm::{
use crate::extensions::discovery::OnlineDiscovery;
use crate::extensions::registry::ExtensionRegistry;
use crate::extensions::{
ActivateResult, AuthResult, ExtensionError, ExtensionKind, ExtensionSource, InstallResult,
InstalledExtension, RegistryEntry, ResultSource, SearchResult, ToolAuthState, UpgradeOutcome,
UpgradeResult,
ActivateResult, AuthResult, ConfigureResult, ExtensionError, ExtensionKind, ExtensionSource,
InstallResult, InstalledExtension, RegistryEntry, ResultSource, SearchResult, ToolAuthState,
UpgradeOutcome, UpgradeResult,
};
use crate::hooks::HookRegistry;
use crate::pairing::PairingStore;
@@ -56,16 +56,6 @@ struct ChannelRuntimeState {
wasm_channel_owner_ids: std::collections::HashMap<String, i64>,
}
/// Result of saving setup secrets and attempting activation.
pub struct SetupResult {
/// Human-readable status message.
pub message: String,
/// Whether the channel was successfully activated after saving secrets.
pub activated: bool,
/// OAuth authorization URL for the UI to open (if OAuth flow was started).
pub auth_url: Option<String>,
}
/// Central manager for extension lifecycle operations.
pub struct ExtensionManager {
registry: ExtensionRegistry,
@@ -440,12 +430,11 @@ impl ExtensionManager {
Err(err)
}
/// Authenticate an installed extension.
pub async fn auth(
&self,
name: &str,
token: Option<&str>,
) -> Result<AuthResult, ExtensionError> {
/// Check auth status for an installed extension.
///
/// Read-only for WASM extensions; may initiate OAuth for MCP servers.
/// To provide secrets, use [`configure()`] instead.
pub async fn auth(&self, name: &str) -> Result<AuthResult, ExtensionError> {
// Clean up expired pending auths
self.cleanup_expired_auths().await;
@@ -453,10 +442,10 @@ impl ExtensionManager {
let kind = self.determine_installed_kind(name).await?;
match kind {
ExtensionKind::McpServer => self.auth_mcp(name, token).await,
ExtensionKind::WasmTool => self.auth_wasm_tool(name, token).await,
ExtensionKind::WasmChannel => self.auth_wasm_channel(name, token).await,
ExtensionKind::ChannelRelay => self.auth_channel_relay(name, token).await,
ExtensionKind::McpServer => self.auth_mcp(name).await,
ExtensionKind::WasmTool => self.auth_wasm_tool(name).await,
ExtensionKind::WasmChannel => self.auth_wasm_channel_status(name).await,
ExtensionKind::ChannelRelay => self.auth_channel_relay(name).await,
}
}
@@ -1684,30 +1673,12 @@ impl ExtensionManager {
})
}
async fn auth_mcp(
&self,
name: &str,
token: Option<&str>,
) -> Result<AuthResult, ExtensionError> {
async fn auth_mcp(&self, name: &str) -> Result<AuthResult, ExtensionError> {
let server = self
.get_mcp_server(name)
.await
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
// If a token was provided directly, store it and we're done.
if let Some(token_value) = token {
let secret_name = server.token_secret_name();
let params =
CreateSecretParams::new(&secret_name, token_value).with_provider(name.to_string());
self.secrets
.create(&self.user_id, params)
.await
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
tracing::info!("MCP server '{}' authenticated via manual token", name);
return Ok(AuthResult::authenticated(name, ExtensionKind::McpServer));
}
// Check if already authenticated
if is_authenticated(&server, &self.secrets, &self.user_id).await {
return Ok(AuthResult::authenticated(name, ExtensionKind::McpServer));
@@ -1820,11 +1791,7 @@ impl ExtensionManager {
))
}
async fn auth_wasm_tool(
&self,
name: &str,
token: Option<&str>,
) -> Result<AuthResult, ExtensionError> {
async fn auth_wasm_tool(&self, name: &str) -> Result<AuthResult, ExtensionError> {
// Read the capabilities file to get auth config
let cap_path = self
.wasm_tools_dir
@@ -1895,18 +1862,6 @@ impl ExtensionManager {
// Fall through to OAuth branch for scope expansion
}
// If a token was provided, store it
if let Some(token_value) = token {
let params = CreateSecretParams::new(&auth.secret_name, token_value)
.with_provider(name.to_string());
self.secrets
.create(&self.user_id, params)
.await
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
return Ok(AuthResult::authenticated(name, ExtensionKind::WasmTool));
}
// OAuth flow: if the tool has OAuth config, start the browser-based flow.
// But only if credentials are available — if the tool has setup secrets
// for client_id/secret that aren't configured yet, return needs_setup.
@@ -2554,11 +2509,8 @@ impl ExtensionManager {
}
}
async fn auth_wasm_channel(
&self,
name: &str,
token: Option<&str>,
) -> Result<AuthResult, ExtensionError> {
/// Check auth status for a WASM channel (read-only).
async fn auth_wasm_channel_status(&self, name: &str) -> Result<AuthResult, ExtensionError> {
let cap_path = self
.wasm_channels_dir
.join(format!("{}.capabilities.json", name));
@@ -2577,7 +2529,6 @@ impl ExtensionManager {
let cap_file = crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
.map_err(|e| ExtensionError::Other(e.to_string()))?;
// Get required secrets from the setup section
let required_secrets = &cap_file.setup.required_secrets;
if required_secrets.is_empty() {
return Ok(AuthResult::no_auth_required(
@@ -2586,7 +2537,7 @@ impl ExtensionManager {
));
}
// Find the first non-optional secret that isn't yet stored
// Find non-optional secrets that aren't yet stored
let mut missing = Vec::new();
for secret in required_secrets {
if secret.optional {
@@ -2606,31 +2557,6 @@ impl ExtensionManager {
return Ok(AuthResult::authenticated(name, ExtensionKind::WasmChannel));
}
// If a token was provided, store it for the first missing secret
if let Some(token_value) = token {
let secret = &missing[0];
let params =
CreateSecretParams::new(&secret.name, token_value).with_provider(name.to_string());
self.secrets
.create(&self.user_id, params)
.await
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
// Check if there are more missing secrets
if missing.len() <= 1 {
return Ok(AuthResult::authenticated(name, ExtensionKind::WasmChannel));
}
// More secrets needed; prompt for the next one
let next = &missing[1];
return Ok(AuthResult::awaiting_token(
name,
ExtensionKind::WasmChannel,
next.prompt.clone(),
cap_file.setup.setup_url.clone(),
));
}
// Prompt for the first missing secret
let secret = &missing[0];
Ok(AuthResult::awaiting_token(
@@ -3218,11 +3144,7 @@ impl ExtensionManager {
/// For Slack: initiates OAuth flow (redirect-based).
/// For Telegram: accepts a bot token, registers it with channel-relay,
/// and stores the returned stream token.
async fn auth_channel_relay(
&self,
name: &str,
_token: Option<&str>,
) -> Result<AuthResult, ExtensionError> {
async fn auth_channel_relay(&self, name: &str) -> Result<AuthResult, ExtensionError> {
// Check if already authenticated (stream token exists)
let token_key = format!("relay:{}:stream_token", name);
if self
@@ -3525,16 +3447,26 @@ impl ExtensionManager {
/// Save setup secrets for an extension, validating names against the capabilities schema.
///
/// After saving, attempts to hot-activate the channel. Returns a [`SetupResult`]
/// indicating whether activation succeeded (so the frontend can show appropriate UI).
pub async fn save_setup_secrets(
/// Configure secrets for an extension: validate, store, auto-generate, and activate.
///
/// This is the single entrypoint for providing secrets to any extension.
/// Both the chat auth flow and the Extensions tab setup form call this method.
///
/// - Validates tokens against `validation_endpoint` (if declared in capabilities)
/// - Stores secrets in the encrypted secrets store
/// - Auto-generates missing secrets (e.g., webhook keys)
/// - Activates the extension after configuration
pub async fn configure(
&self,
name: &str,
secrets: &std::collections::HashMap<String, String>,
) -> Result<SetupResult, ExtensionError> {
) -> Result<ConfigureResult, ExtensionError> {
let kind = self.determine_installed_kind(name).await?;
// Load allowed secret names from the extension's capabilities file
// Load allowed secret names and (for channels) the parsed capabilities file.
// The capabilities file is parsed once here and reused for validation_endpoint
// and auto-generation below, avoiding redundant I/O + JSON parsing.
let mut channel_cap_file: Option<crate::channels::wasm::ChannelCapabilitiesFile> = None;
let allowed: std::collections::HashSet<String> = match kind {
ExtensionKind::WasmChannel => {
let cap_path = self
@@ -3552,45 +3484,71 @@ impl ExtensionManager {
let cap_file =
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
.map_err(|e| ExtensionError::Other(e.to_string()))?;
cap_file
let names = cap_file
.setup
.required_secrets
.iter()
.map(|s| s.name.clone())
.collect()
.collect();
channel_cap_file = Some(cap_file);
names
}
ExtensionKind::WasmTool => {
let cap_file = self.load_tool_capabilities(name).await.ok_or_else(|| {
ExtensionError::Other(format!("Capabilities file not found for '{}'", name))
})?;
match cap_file.setup {
Some(s) => s.required_secrets.iter().map(|s| s.name.clone()).collect(),
None => {
return Err(ExtensionError::Other(format!(
"Tool '{}' has no setup schema — no secrets to configure",
name
)));
}
let mut names: std::collections::HashSet<String> = std::collections::HashSet::new();
if let Some(ref s) = cap_file.setup {
names.extend(s.required_secrets.iter().map(|s| s.name.clone()));
}
// Also allow storing the auth token secret directly
if let Some(ref auth) = cap_file.auth {
names.insert(auth.secret_name.clone());
}
if names.is_empty() {
return Err(ExtensionError::Other(format!(
"Tool '{}' has no setup or auth schema — no secrets to configure",
name
)));
}
names
}
_ => {
return Err(ExtensionError::Other(
"Setup is only supported for WASM channels and tools".to_string(),
));
ExtensionKind::McpServer => {
let server = self
.get_mcp_server(name)
.await
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
let mut names = std::collections::HashSet::new();
names.insert(server.token_secret_name());
names
}
ExtensionKind::ChannelRelay => {
let mut names = std::collections::HashSet::new();
names.insert(format!("relay:{}:stream_token", name));
names
}
};
// For Telegram, validate the bot token against the API before storing it.
// This catches bad tokens immediately (both on first setup and reconfigure),
// before the channel activates and potentially shows as active with a bad token.
if name == "telegram"
&& let Some(token_value) = secrets.get("telegram_bot_token")
// Validate secrets against the validation_endpoint if declared in capabilities.
// The endpoint URL template uses {secret_name} placeholders that are
// substituted with the provided secret value before making the request.
if let Some(ref cap_file) = channel_cap_file
&& let Some(ref endpoint_template) = cap_file.setup.validation_endpoint
&& let Some(secret_def) = cap_file
.setup
.required_secrets
.iter()
.find(|s| !s.optional && secrets.contains_key(&s.name))
&& let Some(token_value) = secrets.get(&secret_def.name)
{
let token = token_value.trim();
if !token.is_empty() {
let encoded_token =
let encoded =
url::form_urlencoded::byte_serialize(token.as_bytes()).collect::<String>();
let url = format!("https://api.telegram.org/bot{}/getMe", encoded_token);
let url = endpoint_template.replace(&format!("{{{}}}", secret_def.name), &encoded);
// SSRF defense: block private IPs, localhost, cloud metadata endpoints
crate::tools::builtin::skill_tools::validate_fetch_url(&url)
.map_err(|e| ExtensionError::Other(format!("SSRF blocked: {}", e)))?;
let resp = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
@@ -3598,12 +3556,13 @@ impl ExtensionManager {
.get(&url)
.send()
.await
// Transport errors are infrastructure failures, not token issues
.map_err(|e| {
ExtensionError::Other(format!("Failed to validate bot token: {}", e))
ExtensionError::Other(format!("Token validation request failed: {}", e))
})?;
if !resp.status().is_success() {
return Err(ExtensionError::Other(format!(
"Invalid bot token (Telegram API returned {})",
return Err(ExtensionError::ValidationFailed(format!(
"Invalid token (API returned {})",
resp.status()
)));
}
@@ -3630,43 +3589,34 @@ impl ExtensionManager {
}
// Auto-generate any missing secrets (channel-only feature)
if kind == ExtensionKind::WasmChannel {
let cap_path = self
.wasm_channels_dir
.join(format!("{}.capabilities.json", name));
if let Ok(cap_bytes) = tokio::fs::read(&cap_path).await
&& let Ok(cap_file) =
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
{
for secret_def in &cap_file.setup.required_secrets {
if let Some(ref auto_gen) = secret_def.auto_generate {
let already_provided = secrets
.get(&secret_def.name)
.is_some_and(|v| !v.trim().is_empty());
let already_stored = self
.secrets
.exists(&self.user_id, &secret_def.name)
if let Some(ref cap_file) = channel_cap_file {
for secret_def in &cap_file.setup.required_secrets {
if let Some(ref auto_gen) = secret_def.auto_generate {
let already_provided = secrets
.get(&secret_def.name)
.is_some_and(|v| !v.trim().is_empty());
let already_stored = self
.secrets
.exists(&self.user_id, &secret_def.name)
.await
.unwrap_or(false);
if !already_provided && !already_stored {
use rand::RngCore;
use rand::rngs::OsRng;
let mut bytes = vec![0u8; auto_gen.length];
OsRng.fill_bytes(&mut bytes);
let hex_value: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
let params = CreateSecretParams::new(&secret_def.name, &hex_value)
.with_provider(name.to_string());
self.secrets
.create(&self.user_id, params)
.await
.unwrap_or(false);
if !already_provided && !already_stored {
use rand::RngCore;
use rand::rngs::OsRng;
let mut bytes = vec![0u8; auto_gen.length];
OsRng.fill_bytes(&mut bytes);
let hex_value: String =
bytes.iter().map(|b| format!("{b:02x}")).collect();
let params = CreateSecretParams::new(&secret_def.name, &hex_value)
.with_provider(name.to_string());
self.secrets
.create(&self.user_id, params)
.await
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
tracing::info!(
"Auto-generated secret '{}' for channel '{}'",
secret_def.name,
name
);
}
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
tracing::info!(
"Auto-generated secret '{}' for channel '{}'",
secret_def.name,
name
);
}
}
}
@@ -3703,7 +3653,9 @@ impl ExtensionManager {
// Check if auth is needed (OAuth or manual token).
// This is safe to call here — cancel-and-retry prevents port conflicts.
let mut auth_url = None;
if let Ok(auth_result) = self.auth(name, None).await {
// Box::pin breaks the async recursion cycle:
// auth() → auth_wasm_tool() → (OAuth) → configure() → auth()
if let Ok(auth_result) = Box::pin(self.auth(name)).await {
auth_url = auth_result.auth_url().map(String::from);
}
let message = if auth_url.is_some() {
@@ -3717,7 +3669,7 @@ impl ExtensionManager {
name, result.message
)
};
return Ok(SetupResult {
return Ok(ConfigureResult {
message,
activated: true,
auth_url,
@@ -3729,7 +3681,7 @@ impl ExtensionManager {
name,
e
);
return Ok(SetupResult {
return Ok(ConfigureResult {
message: format!("Configuration saved for '{}'.", name),
activated: false,
auth_url: None,
@@ -3738,14 +3690,29 @@ impl ExtensionManager {
}
}
// Try to hot-activate the channel now that secrets are saved
match self.activate_wasm_channel(name).await {
// Activate the extension now that secrets are saved.
// Dispatch by kind — WasmTool was already handled above with an early return.
let activate_result = match kind {
ExtensionKind::WasmChannel => self.activate_wasm_channel(name).await,
ExtensionKind::McpServer => self.activate_mcp(name).await,
ExtensionKind::ChannelRelay => self.activate_channel_relay(name).await,
ExtensionKind::WasmTool => {
// WasmTool is handled above and returns early; this branch is unreachable.
return Ok(ConfigureResult {
message: format!("Configuration saved for '{}'.", name),
activated: false,
auth_url: None,
});
}
};
match activate_result {
Ok(result) => {
self.activation_errors.write().await.remove(name);
self.broadcast_extension_status(name, "active", None).await;
Ok(SetupResult {
Ok(ConfigureResult {
message: format!(
"Configuration saved and channel '{}' activated. {}",
"Configuration saved and '{}' activated. {}",
name, result.message
),
activated: true,
@@ -3755,9 +3722,9 @@ impl ExtensionManager {
Err(e) => {
let error_msg = e.to_string();
tracing::warn!(
channel = name,
extension = name,
error = %e,
"Saved configuration but hot-activation failed"
"Saved configuration but activation failed"
);
self.activation_errors
.write()
@@ -3765,7 +3732,7 @@ impl ExtensionManager {
.insert(name.to_string(), error_msg.clone());
self.broadcast_extension_status(name, "failed", Some(&error_msg))
.await;
Ok(SetupResult {
Ok(ConfigureResult {
message: format!(
"Configuration saved for '{}'. Activation failed: {}",
name, e
@@ -3777,6 +3744,118 @@ impl ExtensionManager {
}
}
/// Convenience wrapper: configure a single token for an extension.
///
/// Determines the primary secret name from the extension's capabilities,
/// then delegates to [`configure()`]. Use this when the caller only has
/// a bare token value (e.g., from the chat auth card or WebSocket auth).
pub async fn configure_token(
&self,
name: &str,
token: &str,
) -> Result<ConfigureResult, ExtensionError> {
let kind = self.determine_installed_kind(name).await?;
let secret_name = match kind {
ExtensionKind::WasmChannel => {
let cap_path = self
.wasm_channels_dir
.join(format!("{}.capabilities.json", name));
let cap_bytes = tokio::fs::read(&cap_path)
.await
.map_err(|e| ExtensionError::Other(e.to_string()))?;
let cap_file =
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
.map_err(|e| ExtensionError::Other(e.to_string()))?;
// Pick the first *missing* non-optional secret so re-configure
// of a second secret works for multi-secret channels.
let mut target = None;
for s in &cap_file.setup.required_secrets {
if s.optional {
continue;
}
if !self
.secrets
.exists(&self.user_id, &s.name)
.await
.unwrap_or(false)
{
target = Some(s.name.clone());
break;
}
}
// Fall back to first non-optional if all exist (overwrite)
target
.or_else(|| {
cap_file
.setup
.required_secrets
.iter()
.find(|s| !s.optional)
.map(|s| s.name.clone())
})
.ok_or_else(|| {
ExtensionError::Other(format!("Channel '{}' has no required secrets", name))
})?
}
ExtensionKind::WasmTool => {
let cap = self.load_tool_capabilities(name).await.ok_or_else(|| {
ExtensionError::Other(format!("Capabilities not found for '{}'", name))
})?;
// Prefer auth secret, then first missing setup secret
if let Some(ref auth) = cap.auth {
if !self
.secrets
.exists(&self.user_id, &auth.secret_name)
.await
.unwrap_or(false)
{
auth.secret_name.clone()
} else if let Some(ref setup) = cap.setup {
// Auth secret exists, find first missing setup secret
let mut found = None;
for s in &setup.required_secrets {
if !self
.secrets
.exists(&self.user_id, &s.name)
.await
.unwrap_or(false)
{
found = Some(s.name.clone());
break;
}
}
found.unwrap_or_else(|| auth.secret_name.clone())
} else {
auth.secret_name.clone()
}
} else {
cap.setup
.as_ref()
.and_then(|s| s.required_secrets.first())
.map(|s| s.name.clone())
.ok_or_else(|| {
ExtensionError::Other(format!(
"Tool '{}' has no auth or setup secrets",
name
))
})?
}
}
ExtensionKind::McpServer => {
let server = self
.get_mcp_server(name)
.await
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
server.token_secret_name()
}
ExtensionKind::ChannelRelay => format!("relay:{}:stream_token", name),
};
let mut secrets = std::collections::HashMap::new();
secrets.insert(secret_name, token.to_string());
self.configure(name, &secrets).await
}
/// Read a capabilities.json file and revoke its credential mappings from
/// the shared credential registry, so removed extensions lose injection
/// authority immediately.
@@ -4686,4 +4765,180 @@ mod tests {
assert_eq!(result, url);
assert!(result.contains("/v1/users/123/profile"));
}
// ── Regression tests for PR #677 (unify-extension-lifecycle) ─────────
#[tokio::test]
async fn test_configure_token_picks_first_missing_secret() {
// Regression: configure_token() must pick the first *missing* secret,
// not the first non-optional one. This allows multi-secret channels
// to be configured one secret at a time.
let dir = tempfile::tempdir().expect("temp dir");
let channels_dir = dir.path().join("channels");
std::fs::create_dir_all(&channels_dir).unwrap();
// Write a fake channel WASM + capabilities with two required secrets
std::fs::write(channels_dir.join("multi.wasm"), b"\0asm fake").unwrap();
let caps = serde_json::json!({
"type": "channel",
"name": "multi",
"setup": {
"required_secrets": [
{"name": "SECRET_A", "prompt": "Enter secret A (at least 30 chars for validation)"},
{"name": "SECRET_B", "prompt": "Enter secret B (at least 30 chars for validation)"}
]
}
});
std::fs::write(
channels_dir.join("multi.capabilities.json"),
serde_json::to_string(&caps).unwrap(),
)
.unwrap();
let mgr = make_manager_custom_dirs(dir.path().join("tools"), channels_dir);
// Pre-store SECRET_A so it's no longer missing
mgr.secrets
.create(
"test",
crate::secrets::CreateSecretParams::new("SECRET_A", "value-a"),
)
.await
.expect("store SECRET_A");
// configure_token should target SECRET_B (the first missing one)
let _result = mgr.configure_token("multi", "value-b").await;
// configure will fail at activation (no real WASM runtime), but the
// secret should still have been stored before activation was attempted.
// Check that SECRET_B was stored.
assert!(
mgr.secrets
.exists("test", "SECRET_B")
.await
.unwrap_or(false),
"configure_token should have stored SECRET_B (the first missing secret)"
);
}
#[tokio::test]
async fn test_auth_is_read_only_for_wasm_channel() {
// Regression: auth() must be a pure status check — it must not store
// any secrets or modify state. The old API accepted a token parameter.
let dir = tempfile::tempdir().expect("temp dir");
let channels_dir = dir.path().join("channels");
std::fs::create_dir_all(&channels_dir).unwrap();
std::fs::write(channels_dir.join("test-ch.wasm"), b"\0asm fake").unwrap();
let caps = serde_json::json!({
"type": "channel",
"name": "test-ch",
"setup": {
"required_secrets": [
{"name": "BOT_TOKEN", "prompt": "Enter bot token (at least 30 chars for prompt validation)"}
]
}
});
std::fs::write(
channels_dir.join("test-ch.capabilities.json"),
serde_json::to_string(&caps).unwrap(),
)
.unwrap();
let mgr = make_manager_custom_dirs(dir.path().join("tools"), channels_dir);
// auth() should return a result without storing anything
let result = mgr.auth("test-ch").await;
assert!(result.is_ok(), "auth should succeed: {:?}", result.err());
// No secrets should have been created
assert!(
!mgr.secrets
.exists("test", "BOT_TOKEN")
.await
.unwrap_or(true),
"auth() must not create any secrets — it should be read-only"
);
}
#[tokio::test]
async fn test_configure_dispatches_activation_by_kind() {
// Regression: configure() must dispatch to the correct activation method
// by kind. Previously it unconditionally called activate_wasm_channel()
// for all non-WasmTool types, which would fail with a channel-specific
// error for MCP servers and channel relays.
let dir = tempfile::tempdir().expect("temp dir");
let channels_dir = dir.path().join("channels");
std::fs::create_dir_all(&channels_dir).unwrap();
let mgr = make_manager_custom_dirs(dir.path().join("tools"), channels_dir);
// Register a channel relay extension (in-memory)
mgr.installed_relay_extensions
.write()
.await
.insert("test-relay".to_string());
// configure() should dispatch to activate_channel_relay(), not
// activate_wasm_channel(). Both will fail (no runtime configured),
// but the error should be about relay config, not WASM channels.
let mut secrets = std::collections::HashMap::new();
secrets.insert(
"relay:test-relay:stream_token".to_string(),
"tok".to_string(),
);
let result = mgr.configure("test-relay", &secrets).await;
assert!(
result.is_ok(),
"configure should return Ok: {:?}",
result.err()
);
let result = result.unwrap();
// Activation will fail (no relay config), but secrets should still be stored
assert!(
!result.activated,
"activation should fail without relay config"
);
assert!(
!result.message.contains("WASM"),
"error should not mention WASM — got: {}",
result.message
);
// Verify the secret was stored
assert!(
mgr.secrets
.exists("test", "relay:test-relay:stream_token")
.await
.unwrap_or(false),
"configure should have stored the relay stream token"
);
}
#[test]
fn test_validation_failed_is_distinct_error_variant() {
// Regression: ValidationFailed must be a distinct error variant so
// callers can match on it instead of parsing error message strings.
let err = ExtensionError::ValidationFailed("Invalid token".to_string());
assert!(
matches!(err, ExtensionError::ValidationFailed(_)),
"Should match ValidationFailed variant"
);
assert!(
!matches!(err, ExtensionError::Other(_)),
"Must NOT match Other variant"
);
assert!(
!matches!(err, ExtensionError::AuthFailed(_)),
"Must NOT match AuthFailed variant"
);
let msg = err.to_string();
assert!(
msg.contains("validation failed"),
"Display should contain 'validation failed', got: {msg}"
);
}
}
+17
View File
@@ -449,6 +449,20 @@ pub struct ActivateResult {
pub message: String,
}
/// Result of configuring secrets for an extension.
///
/// Returned by `ExtensionManager::configure()`, the single entrypoint
/// for providing secrets to any extension (chat auth, gateway setup, etc.).
#[derive(Debug, Clone)]
pub struct ConfigureResult {
/// Human-readable status message.
pub message: String,
/// Whether the extension was successfully activated after configuration.
pub activated: bool,
/// OAuth authorization URL (if OAuth flow was started).
pub auth_url: Option<String>,
}
fn default_true() -> bool {
true
}
@@ -530,6 +544,9 @@ pub enum ExtensionError {
fallback: Box<ExtensionError>,
},
#[error("Token validation failed: {0}")]
ValidationFailed(String),
#[error("{0}")]
Other(String),
}
+15 -9
View File
@@ -1407,25 +1407,31 @@ pub struct ConversationMessage {
impl Store {
/// Ensure a conversation row exists for a given UUID.
///
/// Idempotent: inserts on first call, bumps `last_activity` on subsequent calls.
/// Returns `true` when the row is inserted or refreshed for the same
/// `(channel, user_id)`. Returns `false` when the UUID already exists but
/// belongs to a different owner/channel.
pub async fn ensure_conversation(
&self,
id: Uuid,
channel: &str,
user_id: &str,
thread_id: Option<&str>,
) -> Result<(), DatabaseError> {
) -> Result<bool, DatabaseError> {
let conn = self.conn().await?;
conn.execute(
r#"
let affected = conn
.execute(
r#"
INSERT INTO conversations (id, channel, user_id, thread_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE SET last_activity = NOW()
ON CONFLICT (id) DO UPDATE
SET last_activity = NOW()
WHERE conversations.user_id = EXCLUDED.user_id
AND conversations.channel = EXCLUDED.channel
"#,
&[&id, &channel, &user_id, &thread_id],
)
.await?;
Ok(())
&[&id, &channel, &user_id, &thread_id],
)
.await?;
Ok(affected > 0)
}
/// List conversations with a title derived from the first user message.
+93
View File
@@ -0,0 +1,93 @@
//! OpenClaw migration and import functionality.
//!
//! Provides tools to migrate existing OpenClaw installations (memory, history,
//! settings, and credentials) into IronClaw without data loss.
#[cfg(feature = "import")]
pub mod openclaw;
use std::path::PathBuf;
/// Configuration options for OpenClaw import.
#[derive(Debug, Clone)]
pub struct ImportOptions {
/// Path to the OpenClaw directory (default: ~/.openclaw).
pub openclaw_path: PathBuf,
/// Dry-run mode: report what would be imported without writing to DB.
pub dry_run: bool,
/// Re-embed memory documents if dimension mismatch detected.
pub re_embed: bool,
/// User ID for scoping imported data.
pub user_id: String,
}
/// Statistics collected during an import operation.
#[derive(Debug, Clone, Default)]
pub struct ImportStats {
/// Number of workspace documents imported.
pub documents: usize,
/// Number of memory chunks imported.
pub chunks: usize,
/// Number of conversations imported.
pub conversations: usize,
/// Number of messages imported.
pub messages: usize,
/// Number of settings imported.
pub settings: usize,
/// Number of credentials imported.
pub secrets: usize,
/// Number of items skipped (already existed).
pub skipped: usize,
/// Number of chunks queued for re-embedding.
pub re_embed_queued: usize,
}
impl ImportStats {
/// Check if any items were imported.
pub fn is_empty(&self) -> bool {
self.documents == 0
&& self.chunks == 0
&& self.conversations == 0
&& self.messages == 0
&& self.settings == 0
&& self.secrets == 0
}
/// Total number of items imported.
pub fn total_imported(&self) -> usize {
self.documents
+ self.chunks
+ self.conversations
+ self.messages
+ self.settings
+ self.secrets
}
}
/// Errors that can occur during import.
#[derive(Debug, thiserror::Error)]
pub enum ImportError {
#[error("OpenClaw not found at {path}: {reason}")]
NotFound { path: PathBuf, reason: String },
#[error("JSON5 parse error: {0}")]
ConfigParse(String),
#[error("SQLite error: {0}")]
Sqlite(String),
#[error("Database error: {0}")]
Database(String),
#[error("Workspace error: {0}")]
Workspace(String),
#[error("Secret error: {0}")]
Secret(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Invalid UTF-8: {0}")]
InvalidUtf8(String),
}
+26
View File
@@ -0,0 +1,26 @@
//! OpenClaw credential import with secure handling.
//!
//! Credential extraction and import is handled in the main importer (mod.rs).
//! The credentials module focuses on security validation and testing.
#[cfg(test)]
mod tests {
use crate::secrets::CreateSecretParams;
use secrecy::SecretString;
#[test]
fn test_secret_string_not_logged() {
let secret = SecretString::new("super-secret-key".to_string().into_boxed_str());
let debug_output = format!("{:?}", secret);
// Verify that the actual secret is not in the debug output
assert!(!debug_output.contains("super-secret-key"));
}
#[test]
fn test_create_secret_params_normalized() {
let params = CreateSecretParams::new("MY_API_KEY", "value123");
// Secret names should be normalized to lowercase
assert_eq!(params.name, "my_api_key");
}
}
+115
View File
@@ -0,0 +1,115 @@
//! OpenClaw conversation history import.
use std::sync::Arc;
use serde_json::json;
use uuid::Uuid;
use crate::db::Database;
use crate::import::{ImportError, ImportOptions};
use super::reader::OpenClawConversation;
/// Import a conversation and its messages atomically.
///
/// This function attempts to create a conversation and add all its messages as a logical unit.
/// While the Database trait does not expose explicit transaction control, this function
/// minimizes the risk of partial writes by:
/// - Validating all message data before creating the conversation
/// - Creating the conversation once
/// - Adding all messages in a tight loop
/// - Returning detailed errors if any step fails
///
/// Returns (conversation_id, message_count) on success.
///
/// **Note on Database Safety**: Without explicit transaction support in the Database trait,
/// if a crash occurs during message insertion, the conversation will exist with fewer messages
/// than expected. This is preferable to crashes during conversation creation (empty conversation).
///
/// **Note on Idempotency**: The metadata includes `openclaw_conversation_id` for deduplication
/// on reimport. However, without metadata-based query support in the Database trait, reimporting
/// will create duplicate conversations. This limitation should be fixed by adding
/// `list_conversations_by_metadata_key()` to the Database trait.
pub async fn import_conversation_atomic(
db: &Arc<dyn Database>,
conv: OpenClawConversation,
opts: &ImportOptions,
) -> Result<(Uuid, usize), ImportError> {
// PHASE 1: Validate all message data before writing anything
let mut validated_messages = Vec::with_capacity(conv.messages.len());
for msg in &conv.messages {
let role = match msg.role.to_lowercase().as_str() {
"user" | "human" => "user",
"assistant" | "ai" => "assistant",
_ => &msg.role,
};
validated_messages.push((role.to_string(), msg.content.clone()));
}
// PHASE 2: Create the conversation (single atomic operation from DB perspective)
// TODO: Add idempotency check when Database trait supports metadata-based lookups
let metadata = json!({
"openclaw_conversation_id": conv.id,
"openclaw_channel": conv.channel,
});
let conv_id = db
.create_conversation_with_metadata(&conv.channel, &opts.user_id, &metadata)
.await
.map_err(|e| ImportError::Database(e.to_string()))?;
// PHASE 3: Add all messages in sequence
// If this fails partway through, the conversation exists but is incomplete.
// On reimport, the openclaw_conversation_id metadata will detect it.
let mut message_count = 0;
for (role, content) in validated_messages {
db.add_conversation_message(conv_id, &role, &content)
.await
.map_err(|e| {
// Log detailed error including conversation ID for recovery
tracing::error!(
"Failed to add message to conversation {}: {}. \
Conversation created but may be incomplete.",
conv_id,
e
);
ImportError::Database(e.to_string())
})?;
message_count += 1;
}
Ok((conv_id, message_count))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::import::openclaw::reader::OpenClawMessage;
#[test]
fn test_conversation_import_structure() {
// Verify that OpenClawConversation can be created with test data
let conv = OpenClawConversation {
id: "conv-123".to_string(),
channel: "telegram".to_string(),
created_at: None,
messages: vec![
OpenClawMessage {
role: "user".to_string(),
content: "Hello".to_string(),
created_at: None,
},
OpenClawMessage {
role: "assistant".to_string(),
content: "Hi there".to_string(),
created_at: None,
},
],
};
assert_eq!(conv.id, "conv-123");
assert_eq!(conv.messages.len(), 2);
assert_eq!(conv.channel, "telegram");
}
}
+63
View File
@@ -0,0 +1,63 @@
//! OpenClaw memory chunk import.
use std::sync::Arc;
use crate::db::Database;
use crate::import::{ImportError, ImportOptions};
use super::reader::OpenClawMemoryChunk;
/// Import a single memory chunk into IronClaw.
pub async fn import_chunk(
db: &Arc<dyn Database>,
chunk: &OpenClawMemoryChunk,
opts: &ImportOptions,
) -> Result<(), ImportError> {
// Get or create document by path
let doc = db
.get_or_create_document_by_path(&opts.user_id, None, &chunk.path)
.await
.map_err(|e| ImportError::Database(e.to_string()))?;
// Insert chunk
let chunk_id = db
.insert_chunk(
doc.id,
chunk.chunk_index,
&chunk.content,
None, // Don't set embedding yet if dimensions might not match
)
.await
.map_err(|e| ImportError::Database(e.to_string()))?;
// If we have an embedding, try to update it
if let Some(ref embedding) = chunk.embedding {
// Note: dimension check would go here if we had target dimensions available
// For now, just store what we have
db.update_chunk_embedding(chunk_id, embedding)
.await
.map_err(|e| ImportError::Database(e.to_string()))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_memory_chunk_import_structure() {
// Verify that OpenClawMemoryChunk can be created with test data
let chunk = OpenClawMemoryChunk {
path: "test/path.md".to_string(),
content: "Test content".to_string(),
embedding: Some(vec![0.1, 0.2, 0.3]),
chunk_index: 0,
};
assert_eq!(chunk.path, "test/path.md");
assert_eq!(chunk.chunk_index, 0);
assert!(chunk.embedding.is_some());
}
}
+182
View File
@@ -0,0 +1,182 @@
//! OpenClaw data migration orchestration and detection.
pub mod credentials;
pub mod history;
pub mod memory;
pub mod reader;
pub mod settings;
use std::path::PathBuf;
use std::sync::Arc;
use crate::db::Database;
use crate::import::{ImportError, ImportOptions, ImportStats};
use crate::secrets::SecretsStore;
use crate::workspace::Workspace;
pub use reader::OpenClawReader;
/// OpenClaw importer that coordinates migration of all data types.
pub struct OpenClawImporter {
db: Arc<dyn Database>,
workspace: Workspace,
secrets: Arc<dyn SecretsStore>,
opts: ImportOptions,
}
impl OpenClawImporter {
/// Create a new OpenClaw importer.
pub fn new(
db: Arc<dyn Database>,
workspace: Workspace,
secrets: Arc<dyn SecretsStore>,
opts: ImportOptions,
) -> Self {
Self {
db,
workspace,
secrets,
opts,
}
}
/// Detect if an OpenClaw installation exists at the default location (~/.openclaw).
pub fn detect() -> Option<PathBuf> {
if let Ok(home) = std::env::var("HOME") {
let openclaw_dir = PathBuf::from(home).join(".openclaw");
let config_file = openclaw_dir.join("openclaw.json");
if config_file.exists() {
return Some(openclaw_dir);
}
}
None
}
/// Run the import process for all data types.
///
/// Returns detailed statistics about what was imported.
/// If `dry_run` is enabled, no data is written to the database.
///
/// **Database Safety Note:** The Database trait does not currently expose explicit
/// transaction control (BEGIN/COMMIT/ROLLBACK). To minimize consistency risks:
/// - All configuration reading is done before any writes
/// - Writes are grouped by type (settings, credentials, documents, chunks, conversations)
/// - Conversations are handled atomically: creation + all messages added together
/// - Errors are logged but don't stop the entire import (fail-safe behavior)
pub async fn import(&self) -> Result<ImportStats, ImportError> {
let mut stats = ImportStats::default();
// === PHASE 1: READ ALL DATA BEFORE ANY WRITES ===
// This minimizes the window where the database could be left in a partial state
// Read OpenClaw data
let reader = OpenClawReader::new(&self.opts.openclaw_path)?;
let config = reader.read_config()?;
let agent_dbs = reader.list_agent_dbs()?;
// Pre-read all conversation data to validate before writing
let mut all_conversations = Vec::new();
for (_agent_name, db_path) in &agent_dbs {
match reader.read_conversations(db_path).await {
Ok(convs) => all_conversations.extend(convs),
Err(e) => {
tracing::warn!("Failed to read conversations: {}", e);
}
}
}
// Pre-read all memory chunks
let mut all_chunks = Vec::new();
for (_agent_name, db_path) in &agent_dbs {
match reader.read_memory_chunks(db_path).await {
Ok(chunks) => all_chunks.extend(chunks),
Err(e) => {
tracing::warn!("Failed to read memory chunks: {}", e);
}
}
}
// Prepare all settings and credentials
let settings_map = settings::map_openclaw_config_to_settings(&config);
let creds = settings::extract_credentials(&config);
// === PHASE 2: WRITE IN GROUPED ORDER ===
// If a crash occurs, earlier groups are fully committed
if !self.opts.dry_run {
// Group 1: Settings (should be idempotent via upsert)
for (key, value) in settings_map {
if let Err(e) = self.db.set_setting(&self.opts.user_id, &key, &value).await {
tracing::warn!("Failed to import setting {}: {}", key, e);
} else {
stats.settings += 1;
}
}
// Group 2: Credentials (should be idempotent via upsert)
for (name, value) in creds {
use secrecy::ExposeSecret;
let exposed = value.expose_secret().to_string();
let params = crate::secrets::CreateSecretParams::new(name, exposed);
if let Err(e) = self.secrets.create(&self.opts.user_id, params).await {
tracing::warn!("Failed to import credential: {}", e);
} else {
stats.secrets += 1;
}
}
// Group 3: Workspace documents
if let Ok(_count) = reader.list_workspace_files() {
match self
.workspace
.import_from_directory(&self.opts.openclaw_path.join("workspace"))
.await
{
Ok(imported) => stats.documents = imported,
Err(e) => {
tracing::warn!("Failed to import workspace documents: {}", e);
}
}
}
// Group 4: Memory chunks (should be idempotent via path deduplication)
for chunk in all_chunks {
if let Err(e) = memory::import_chunk(&self.db, &chunk, &self.opts).await {
tracing::warn!("Failed to import memory chunk: {}", e);
} else {
stats.chunks += 1;
}
}
// Group 5: Conversations with messages
// CRITICAL: Each conversation + its messages form an atomic unit.
// If a crash occurs mid-conversation, only that conversation is incomplete.
// All previous conversations are fully committed.
for conv in all_conversations {
match history::import_conversation_atomic(&self.db, conv, &self.opts).await {
Ok((_conv_id, msg_count)) => {
stats.conversations += 1;
stats.messages += msg_count;
}
Err(e) => {
tracing::warn!("Failed to import conversation: {}", e);
}
}
}
} else {
// DRY RUN: Count only
stats.settings = settings_map.len();
stats.secrets = creds.len();
if let Ok(count) = reader.list_workspace_files() {
stats.documents = count;
}
stats.chunks = all_chunks.len();
stats.conversations = all_conversations.len();
for conv in &all_conversations {
stats.messages += conv.messages.len();
}
}
Ok(stats)
}
}
+442
View File
@@ -0,0 +1,442 @@
//! Read-only extraction layer for OpenClaw data.
//!
//! Handles opening OpenClaw SQLite databases and reading configuration
//! without making any modifications.
use std::fmt;
use std::path::{Path, PathBuf};
use secrecy::SecretString;
use crate::import::ImportError;
/// OpenClaw configuration structure (parsed from openclaw.json).
#[derive(Debug, Clone)]
pub struct OpenClawConfig {
pub llm: Option<OpenClawLlmConfig>,
pub embeddings: Option<OpenClawEmbeddingsConfig>,
pub other_settings: std::collections::HashMap<String, serde_json::Value>,
}
#[derive(Clone)]
pub struct OpenClawLlmConfig {
pub provider: Option<String>,
pub model: Option<String>,
pub api_key: Option<SecretString>,
pub base_url: Option<String>,
}
impl fmt::Debug for OpenClawLlmConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OpenClawLlmConfig")
.field("provider", &self.provider)
.field("model", &self.model)
.field("api_key", &self.api_key.as_ref().map(|_| "***REDACTED***"))
.field("base_url", &self.base_url)
.finish()
}
}
#[derive(Clone)]
pub struct OpenClawEmbeddingsConfig {
pub model: Option<String>,
pub api_key: Option<SecretString>,
pub provider: Option<String>,
}
impl fmt::Debug for OpenClawEmbeddingsConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OpenClawEmbeddingsConfig")
.field("model", &self.model)
.field("api_key", &self.api_key.as_ref().map(|_| "***REDACTED***"))
.field("provider", &self.provider)
.finish()
}
}
/// A memory chunk from OpenClaw's database.
#[derive(Debug, Clone)]
pub struct OpenClawMemoryChunk {
pub path: String,
pub content: String,
pub embedding: Option<Vec<f32>>,
pub chunk_index: i32,
}
/// A conversation from OpenClaw's database.
#[derive(Debug, Clone)]
pub struct OpenClawConversation {
pub id: String,
pub channel: String,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
pub messages: Vec<OpenClawMessage>,
}
/// A message within an OpenClaw conversation.
#[derive(Debug, Clone)]
pub struct OpenClawMessage {
pub role: String,
pub content: String,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
}
/// Open an OpenClaw SQLite database file via libsql for read-only access.
#[cfg(feature = "import")]
async fn open_sqlite(db_path: &Path) -> Result<libsql::Connection, ImportError> {
let db = libsql::Builder::new_local(db_path)
.build()
.await
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
db.connect().map_err(|e| ImportError::Sqlite(e.to_string()))
}
/// Reader for OpenClaw data files and databases.
pub struct OpenClawReader {
openclaw_dir: PathBuf,
}
impl OpenClawReader {
/// Create a new OpenClaw reader for the given directory.
pub fn new(openclaw_dir: &Path) -> Result<Self, ImportError> {
if !openclaw_dir.exists() {
return Err(ImportError::NotFound {
path: openclaw_dir.to_path_buf(),
reason: "Directory does not exist".to_string(),
});
}
Ok(Self {
openclaw_dir: openclaw_dir.to_path_buf(),
})
}
/// Check if an OpenClaw installation exists at ~/.openclaw.
pub fn detect(home_dir: &Path) -> bool {
let openclaw_dir = home_dir.join(".openclaw");
let config_file = openclaw_dir.join("openclaw.json");
config_file.exists()
}
/// Read and parse openclaw.json configuration.
pub fn read_config(&self) -> Result<OpenClawConfig, ImportError> {
let config_path = self.openclaw_dir.join("openclaw.json");
if !config_path.exists() {
return Err(ImportError::NotFound {
path: config_path,
reason: "openclaw.json not found".to_string(),
});
}
let content = std::fs::read_to_string(&config_path).map_err(ImportError::Io)?;
#[cfg(feature = "import")]
{
let config: serde_json::Value =
json5::from_str(&content).map_err(|e| ImportError::ConfigParse(e.to_string()))?;
// Extract LLM config
let llm = config
.get("llm")
.and_then(|v| v.as_object())
.map(|llm_obj| OpenClawLlmConfig {
provider: llm_obj
.get("provider")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
model: llm_obj
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
api_key: llm_obj
.get("api_key")
.and_then(|v| v.as_str())
.map(|s| SecretString::new(s.to_string().into_boxed_str())),
base_url: llm_obj
.get("base_url")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
});
// Extract embeddings config
let embeddings = config
.get("embeddings")
.and_then(|v| v.as_object())
.map(|emb_obj| OpenClawEmbeddingsConfig {
model: emb_obj
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
api_key: emb_obj
.get("api_key")
.and_then(|v| v.as_str())
.map(|s| SecretString::new(s.to_string().into_boxed_str())),
provider: emb_obj
.get("provider")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
});
// Store remaining settings
let mut other_settings = std::collections::HashMap::new();
if let Some(obj) = config.as_object() {
for (k, v) in obj {
if k != "llm" && k != "embeddings" {
other_settings.insert(k.clone(), v.clone());
}
}
}
Ok(OpenClawConfig {
llm,
embeddings,
other_settings,
})
}
#[cfg(not(feature = "import"))]
{
Err(ImportError::ConfigParse(
"Import feature not enabled (compile with --features import)".to_string(),
))
}
}
/// List all agent `.sqlite` files in the agents/ directory, sorted by name for deterministic order.
pub fn list_agent_dbs(&self) -> Result<Vec<(String, PathBuf)>, ImportError> {
let agents_dir = self.openclaw_dir.join("agents");
if !agents_dir.exists() {
// No agents directory is fine (might have no saved conversations)
return Ok(Vec::new());
}
let mut dbs = Vec::new();
for entry in std::fs::read_dir(&agents_dir).map_err(ImportError::Io)? {
let entry = entry.map_err(ImportError::Io)?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("sqlite") {
match path.file_stem().and_then(|s| s.to_str()) {
Some(name) => dbs.push((name.to_string(), path)),
None => {
tracing::warn!(
"Skipping agent database with non-UTF-8 filename: {:?}",
path
);
}
}
}
}
// Sort by agent name for deterministic ordering
dbs.sort_by(|a, b| a.0.cmp(&b.0));
Ok(dbs)
}
/// Read all memory chunks from an OpenClaw SQLite database.
#[cfg(feature = "import")]
pub async fn read_memory_chunks(
&self,
db_path: &Path,
) -> Result<Vec<OpenClawMemoryChunk>, ImportError> {
let conn = open_sqlite(db_path).await?;
let mut rows = conn
.query(
"SELECT path, content, embedding, chunk_index FROM chunks",
(),
)
.await
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
let mut result = Vec::new();
while let Some(row) = rows
.next()
.await
.map_err(|e| ImportError::Sqlite(e.to_string()))?
{
let path: String = row.get(0).map_err(|e| ImportError::Sqlite(e.to_string()))?;
let content: String = row.get(1).map_err(|e| ImportError::Sqlite(e.to_string()))?;
let embedding_blob: Option<Vec<u8>> =
row.get(2).map_err(|e| ImportError::Sqlite(e.to_string()))?;
let chunk_index: i32 = row.get(3).map_err(|e| ImportError::Sqlite(e.to_string()))?;
// Convert binary embedding blob to Vec<f32> if present
let embedding = embedding_blob.map(|bytes| {
bytes
.chunks(4)
.map(|chunk| {
if chunk.len() == 4 {
f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])
} else {
0.0
}
})
.collect()
});
result.push(OpenClawMemoryChunk {
path,
content,
embedding,
chunk_index,
});
}
Ok(result)
}
/// Read all conversations from an OpenClaw SQLite database.
#[cfg(feature = "import")]
pub async fn read_conversations(
&self,
db_path: &Path,
) -> Result<Vec<OpenClawConversation>, ImportError> {
let conn = open_sqlite(db_path).await?;
let mut conv_rows = conn
.query(
"SELECT id, channel, created_at FROM conversations ORDER BY created_at DESC",
(),
)
.await
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
let mut conversations = Vec::new();
while let Some(row) = conv_rows
.next()
.await
.map_err(|e| ImportError::Sqlite(e.to_string()))?
{
let id: String = row.get(0).map_err(|e| ImportError::Sqlite(e.to_string()))?;
let channel: String = row.get(1).map_err(|e| ImportError::Sqlite(e.to_string()))?;
let created_at: Option<String> =
row.get(2).map_err(|e| ImportError::Sqlite(e.to_string()))?;
let created_at = created_at
.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
.map(|dt| dt.with_timezone(&chrono::Utc));
// Read messages for this conversation
let mut msg_rows = conn
.query(
"SELECT role, content, created_at FROM messages WHERE conversation_id = ?1 ORDER BY created_at",
libsql::params![id.as_str()],
)
.await
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
let mut messages = Vec::new();
while let Some(msg_row) = msg_rows
.next()
.await
.map_err(|e| ImportError::Sqlite(e.to_string()))?
{
let role: String = msg_row
.get(0)
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
let content: String = msg_row
.get(1)
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
let msg_created_at: Option<String> = msg_row
.get(2)
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
let msg_created_at = msg_created_at
.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
.map(|dt| dt.with_timezone(&chrono::Utc));
messages.push(OpenClawMessage {
role,
content,
created_at: msg_created_at,
});
}
conversations.push(OpenClawConversation {
id,
channel,
created_at,
messages,
});
}
Ok(conversations)
}
/// List workspace markdown files available for import.
pub fn list_workspace_files(&self) -> Result<usize, ImportError> {
let workspace_dir = self.openclaw_dir.join("workspace");
if !workspace_dir.exists() {
return Ok(0);
}
let mut count = 0;
if let Ok(entries) = std::fs::read_dir(&workspace_dir) {
for entry in entries.flatten() {
if let Some(ext) = entry.path().extension()
&& ext == "md"
{
count += 1;
}
}
}
Ok(count)
}
}
#[cfg(test)]
mod security_tests {
use super::*;
#[test]
fn test_llm_config_debug_redacts_api_key() {
let config = OpenClawLlmConfig {
provider: Some("openai".to_string()),
model: Some("gpt-4".to_string()),
api_key: Some(SecretString::new("sk-secret-key-12345".into())),
base_url: Some("https://api.openai.com".to_string()),
};
let debug_output = format!("{:?}", config);
// Verify the actual API key is never exposed in debug output
assert!(!debug_output.contains("sk-secret-key-12345"));
// Verify the redaction marker is present
assert!(debug_output.contains("***REDACTED***"));
}
#[test]
fn test_embeddings_config_debug_redacts_api_key() {
let config = OpenClawEmbeddingsConfig {
model: Some("text-embedding-3-large".to_string()),
api_key: Some(SecretString::new("sk-embed-secret-67890".into())),
provider: Some("openai".to_string()),
};
let debug_output = format!("{:?}", config);
// Verify the actual API key is never exposed in debug output
assert!(!debug_output.contains("sk-embed-secret-67890"));
// Verify the redaction marker is present
assert!(debug_output.contains("***REDACTED***"));
}
#[test]
fn test_llm_config_without_api_key() {
let config = OpenClawLlmConfig {
provider: Some("openai".to_string()),
model: Some("gpt-4".to_string()),
api_key: None,
base_url: None,
};
let debug_output = format!("{:?}", config);
// Should show None for missing API key
assert!(debug_output.contains("api_key: None"));
}
}
+143
View File
@@ -0,0 +1,143 @@
//! OpenClaw configuration to IronClaw settings mapping.
use secrecy::SecretString;
use std::collections::HashMap;
use super::reader::OpenClawConfig;
/// Map OpenClaw configuration to IronClaw settings (dotted-key format).
pub fn map_openclaw_config_to_settings(
config: &OpenClawConfig,
) -> HashMap<String, serde_json::Value> {
let mut settings = HashMap::new();
// Map LLM configuration
if let Some(ref llm) = config.llm {
if let Some(ref provider) = llm.provider {
settings.insert(
"llm.backend".to_string(),
serde_json::Value::String(provider.clone()),
);
}
if let Some(ref model) = llm.model {
settings.insert(
"llm.selected_model".to_string(),
serde_json::Value::String(model.clone()),
);
}
if let Some(ref base_url) = llm.base_url {
settings.insert(
"llm.base_url".to_string(),
serde_json::Value::String(base_url.clone()),
);
}
}
// Map embeddings configuration
if let Some(ref emb) = config.embeddings {
if let Some(ref model) = emb.model {
settings.insert(
"embeddings.model".to_string(),
serde_json::Value::String(model.clone()),
);
}
if let Some(ref provider) = emb.provider {
settings.insert(
"embeddings.provider".to_string(),
serde_json::Value::String(provider.clone()),
);
}
}
// Map any other top-level settings
for (key, value) in &config.other_settings {
// Safely pass through JSON-serializable values
settings.insert(key.clone(), value.clone());
}
settings
}
/// Extract credentials from OpenClaw configuration.
///
/// Returns a list of (secret_name, secret_value) pairs that should be stored.
/// Secret values are never logged or printed.
pub fn extract_credentials(config: &OpenClawConfig) -> Vec<(String, SecretString)> {
let mut credentials = Vec::new();
// Extract LLM API key if present
if let Some(ref llm) = config.llm
&& let Some(ref api_key) = llm.api_key
{
credentials.push(("llm_api_key".to_string(), api_key.clone()));
}
// Extract embeddings API key if present
if let Some(ref emb) = config.embeddings
&& let Some(ref api_key) = emb.api_key
{
credentials.push(("embeddings_api_key".to_string(), api_key.clone()));
}
credentials
}
#[cfg(test)]
mod tests {
use super::*;
use crate::import::openclaw::reader::{OpenClawConfig, OpenClawLlmConfig};
#[test]
fn test_map_llm_config() {
let mut config = OpenClawConfig {
llm: None,
embeddings: None,
other_settings: HashMap::new(),
};
config.llm = Some(OpenClawLlmConfig {
provider: Some("openai".to_string()),
model: Some("gpt-4".to_string()),
api_key: Some(SecretString::new("secret".to_string().into_boxed_str())),
base_url: None,
});
let settings = map_openclaw_config_to_settings(&config);
assert_eq!(
settings.get("llm.backend"),
Some(&serde_json::Value::String("openai".to_string()))
);
assert_eq!(
settings.get("llm.selected_model"),
Some(&serde_json::Value::String("gpt-4".to_string()))
);
}
#[test]
fn test_extract_credentials_never_logs() {
let mut config = OpenClawConfig {
llm: None,
embeddings: None,
other_settings: HashMap::new(),
};
config.llm = Some(OpenClawLlmConfig {
provider: Some("anthropic".to_string()),
model: Some("claude-3".to_string()),
api_key: Some(SecretString::new(
"secret-key-value".to_string().into_boxed_str(),
)),
base_url: None,
});
let creds = extract_credentials(&config);
assert_eq!(creds.len(), 1);
assert_eq!(creds[0].0, "llm_api_key");
// Verify the value is wrapped in SecretString (never exposed in Debug output)
assert!(!format!("{:?}", creds[0].1).contains("secret-key-value"));
}
}
+3
View File
@@ -54,6 +54,8 @@ pub mod evaluation;
pub mod extensions;
pub mod history;
pub mod hooks;
#[cfg(feature = "import")]
pub mod import;
pub mod llm;
pub mod observability;
pub mod orchestrator;
@@ -72,6 +74,7 @@ pub mod tracing_fmt;
pub mod transcription;
pub mod tunnel;
pub mod util;
pub mod webhooks;
pub mod worker;
pub mod workspace;
+9 -12
View File
@@ -373,9 +373,10 @@ impl SessionManager {
/// NEAR AI Cloud API key entry flow.
///
/// Prompts the user to enter a NEAR AI Cloud API key from
/// cloud.near.ai. The key is set as `NEARAI_API_KEY` env var so
/// `LlmConfig::resolve()` auto-selects ChatCompletions mode, and
/// saved to `~/.ironclaw/.env` for persistence across restarts.
/// cloud.near.ai. The key is stored in the thread-safe runtime
/// env overlay (via `set_runtime_env`) so `LlmConfig::resolve()`
/// auto-selects ChatCompletions mode, and persisted to
/// `~/.ironclaw/.env` for survival across restarts.
/// No session token is saved and no `/v1/users/me` validation is
/// performed (different auth model).
async fn api_key_login(&self) -> Result<(), LlmError> {
@@ -403,15 +404,11 @@ impl SessionManager {
});
}
// Set env var so Config picks it up immediately
// (LlmConfig::resolve() auto-selects ChatCompletions mode when
// NEARAI_API_KEY is present).
//
// SAFETY: called during single-threaded interactive login flow.
#[allow(unused_unsafe)]
unsafe {
std::env::set_var("NEARAI_API_KEY", &key);
}
// Make the key visible to Config resolution and `env_or_override()`
// callers for the remainder of this process. Uses a thread-safe
// overlay instead of `std::env::set_var`, which is UB in
// multi-threaded programs (Rust 1.82+).
crate::config::helpers::set_runtime_env("NEARAI_API_KEY", &key);
// Persist to ~/.ironclaw/.env so the key survives restarts
// (bootstrap layer — available before DB is connected).
+66 -19
View File
@@ -24,6 +24,7 @@ use ironclaw::{
orchestrator::{ReaperConfig, SandboxReaper},
pairing::PairingStore,
tracing_fmt::{init_cli_tracing, init_worker_tracing},
webhooks::{self, ToolWebhookState},
};
#[cfg(any(feature = "postgres", feature = "libsql"))]
@@ -86,6 +87,12 @@ async fn async_main() -> anyhow::Result<()> {
init_cli_tracing();
return completion.run();
}
#[cfg(feature = "import")]
Some(Command::Import(import_cmd)) => {
init_cli_tracing();
let config = ironclaw::config::Config::from_env().await?;
return ironclaw::cli::run_import_command(import_cmd, &config).await;
}
Some(Command::Worker {
job_id,
orchestrator_url,
@@ -271,9 +278,25 @@ async fn async_main() -> anyhow::Result<()> {
}
}
// Shared routine engine slot for gateway + generic webhook ingress.
let shared_routine_engine_slot: ironclaw::channels::web::server::RoutineEngineSlot =
Arc::new(tokio::sync::RwLock::new(None));
// Collect webhook route fragments; a single WebhookServer hosts them all.
let mut webhook_routes: Vec<axum::Router> = Vec::new();
webhook_routes.push(webhooks::routes(ToolWebhookState {
tools: Arc::clone(&components.tools),
routine_engine: Arc::clone(&shared_routine_engine_slot),
user_id: config
.channels
.gateway
.as_ref()
.map(|g| g.user_id.clone())
.unwrap_or_else(|| "default".to_string()),
secrets_store: components.secrets_store.clone(),
}));
// Load WASM channels and register their webhook routes.
if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() {
let wasm_result = ironclaw::channels::wasm::setup_wasm_channels(
@@ -425,7 +448,6 @@ async fn async_main() -> anyhow::Result<()> {
let mut sse_sender: Option<
tokio::sync::broadcast::Sender<ironclaw::channels::web::types::SseEvent>,
> = None;
let mut routine_engine_slot: Option<ironclaw::channels::web::server::RoutineEngineSlot> = None;
if let Some(ref gw_config) = config.channels.gateway {
let mut gw =
GatewayChannel::new(gw_config.clone()).with_llm_provider(Arc::clone(&components.llm));
@@ -449,6 +471,7 @@ async fn async_main() -> anyhow::Result<()> {
gw = gw.with_job_manager(Arc::clone(jm));
}
gw = gw.with_scheduler(scheduler_slot.clone());
gw = gw.with_routine_engine_slot(Arc::clone(&shared_routine_engine_slot));
if let Some(ref sr) = components.skill_registry {
gw = gw.with_skill_registry(Arc::clone(sr));
}
@@ -483,8 +506,6 @@ async fn async_main() -> anyhow::Result<()> {
// IMPORTANT: This must come after all `with_*` calls since `rebuild_state`
// creates a new SseManager, which would orphan this sender.
sse_sender = Some(gw.state().sse.sender());
routine_engine_slot = Some(Arc::clone(&gw.state().routine_engine));
channel_names.push("gateway".to_string());
channels.add(Box::new(gw)).await;
}
@@ -683,9 +704,7 @@ async fn async_main() -> anyhow::Result<()> {
}
// Give the agent the routine engine slot so it can expose the engine to the gateway.
if let Some(slot) = routine_engine_slot {
agent.set_routine_engine_slot(slot);
}
agent.set_routine_engine_slot(shared_routine_engine_slot);
// Prepare SIGHUP handler for hot-reloading HTTP webhook config
// Broadcast channel for clean shutdown of background tasks
@@ -780,12 +799,12 @@ async fn async_main() -> anyhow::Result<()> {
};
// Restart listener if addr changed.
// Minimize lock scope: acquire, read old addr, release, then restart.
// Two-phase approach: bind outside the lock, then swap under lock.
let mut restart_failed = false;
if let Some(ref ws_arc) = sighup_webhook_server {
let old_addr = {
let (old_addr, router) = {
let ws = ws_arc.lock().await;
ws.current_addr()
(ws.current_addr(), ws.merged_router_clone())
}; // Lock released here
if old_addr != new_addr {
@@ -794,17 +813,45 @@ async fn async_main() -> anyhow::Result<()> {
old_addr,
new_addr
);
// NOTE: Lock is held across restart_with_addr().await. This is
// acceptable because SIGHUP is infrequent and restart is fast. A full
// fix would require refactoring restart_with_addr to separate state
// mutation from async I/O.
let mut ws = ws_arc.lock().await;
match ws.restart_with_addr(new_addr).await {
Ok(()) => {
tracing::info!("SIGHUP: webhook server restarted on {}", new_addr);
match router {
Some(app) => {
// Phase 1: Bind new listener WITHOUT holding the lock.
match tokio::net::TcpListener::bind(new_addr).await {
Ok(listener) => {
// Phase 2: Swap state under lock (no await inside).
let (old_tx, old_handle) = {
let mut ws = ws_arc.lock().await;
ws.install_listener(new_addr, listener, app)
}; // Lock released here
// Phase 3: Shut down old listener outside the lock.
if let Some(tx) = old_tx {
let _ = tx.send(());
}
if let Some(handle) = old_handle {
let _ = handle.await;
}
tracing::info!(
"SIGHUP: webhook server restarted on {}",
new_addr
);
}
Err(e) => {
tracing::error!(
"SIGHUP: failed to bind to {}: {}",
new_addr,
e
);
restart_failed = true;
}
}
}
Err(e) => {
tracing::error!("SIGHUP: listener restart failed: {}", e);
None => {
tracing::error!(
"SIGHUP: cannot restart — server was never started"
);
restart_failed = true;
}
}
+166
View File
@@ -629,6 +629,7 @@ fn is_gzip(bytes: &[u8]) -> bool {
}
/// Result of extracting a tar.gz bundle.
#[derive(Debug)]
struct ExtractResult {
has_capabilities: bool,
}
@@ -1112,4 +1113,169 @@ mod tests {
"ChecksumMismatch on version-pinned URL must remain a hard block"
);
}
// Regression tests for tool/channel artifact name collision (PR #964).
// When a tool and channel share the same registry filename (e.g. slack.json),
// CI produces kind-prefixed bundles (tool-slack-*.tar.gz vs channel-slack-*.tar.gz).
// The files *inside* each archive use manifest.name (slack-tool.wasm vs slack.wasm).
// These tests verify the installer extracts by manifest.name correctly.
fn build_test_tar_gz(wasm_name: &str, caps_name: Option<&str>) -> Vec<u8> {
use flate2::Compression;
use flate2::write::GzEncoder;
use tar::Builder;
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
{
let mut builder = Builder::new(&mut encoder);
let wasm_data = b"\0asm\x01\x00\x00\x00";
let mut header = tar::Header::new_gnu();
header.set_size(wasm_data.len() as u64);
header.set_cksum();
builder
.append_data(&mut header, wasm_name, &wasm_data[..])
.unwrap();
if let Some(caps) = caps_name {
let caps_data = br#"{"auth":null}"#;
let mut header = tar::Header::new_gnu();
header.set_size(caps_data.len() as u64);
header.set_cksum();
builder
.append_data(&mut header, caps, &caps_data[..])
.unwrap();
}
builder.finish().unwrap();
}
encoder.finish().unwrap()
}
#[test]
fn test_extract_rejects_archive_with_wrong_wasm_name() {
// Simulates the collision bug: archive contains channel's slack.wasm,
// but installer tries to extract tool's slack-tool.wasm.
let gz_bytes = build_test_tar_gz("slack.wasm", Some("slack.capabilities.json"));
let tmp = tempfile::tempdir().unwrap();
let result = extract_tar_gz(
&gz_bytes,
"slack-tool",
&tmp.path().join("slack-tool.wasm"),
&tmp.path().join("slack-tool.capabilities.json"),
"test://url",
);
let err = result.expect_err("should fail when archive has wrong wasm name");
match err {
RegistryError::DownloadFailed { reason, .. } => {
assert!(
reason.contains("slack-tool.wasm"),
"error should mention expected filename: {}",
reason
);
}
other => panic!("expected DownloadFailed, got: {:?}", other),
}
}
#[test]
fn test_extract_correct_wasm_from_tool_bundle() {
// Tool bundle contains slack-tool.wasm — extraction by name="slack-tool" succeeds.
let gz_bytes = build_test_tar_gz("slack-tool.wasm", Some("slack-tool.capabilities.json"));
let tmp = tempfile::tempdir().unwrap();
let wasm_path = tmp.path().join("slack-tool.wasm");
let caps_path = tmp.path().join("slack-tool.capabilities.json");
let result = extract_tar_gz(
&gz_bytes,
"slack-tool",
&wasm_path,
&caps_path,
"test://url",
)
.unwrap();
assert!(wasm_path.exists());
assert!(caps_path.exists());
assert!(result.has_capabilities);
}
#[test]
fn test_extract_correct_wasm_from_channel_bundle() {
// Channel bundle contains slack.wasm — extraction by name="slack" succeeds.
let gz_bytes = build_test_tar_gz("slack.wasm", Some("slack.capabilities.json"));
let tmp = tempfile::tempdir().unwrap();
let wasm_path = tmp.path().join("slack.wasm");
let caps_path = tmp.path().join("slack.capabilities.json");
let result =
extract_tar_gz(&gz_bytes, "slack", &wasm_path, &caps_path, "test://url").unwrap();
assert!(wasm_path.exists());
assert!(caps_path.exists());
assert!(result.has_capabilities);
}
#[tokio::test]
async fn test_tool_and_channel_install_to_separate_directories() {
// Tool and channel manifests with the same file_stem ("slack") install
// to different directories without collision.
let temp = tempfile::tempdir().expect("tempdir");
let installer = RegistryInstaller::new(
temp.path().to_path_buf(),
temp.path().join("tools"),
temp.path().join("channels"),
);
let tool_manifest = test_manifest_with_kind(
"slack-tool",
"tools-src/slack",
None,
None,
ManifestKind::Tool,
);
let channel_manifest = test_manifest_with_kind(
"slack",
"channels-src/slack",
None,
None,
ManifestKind::Channel,
);
// Both fail because source dirs don't exist, but the error path reveals
// the target directory — tool goes to tools/, channel goes to channels/.
let tool_err = installer
.install_from_source(&tool_manifest, false)
.await
.expect_err("no source dir");
let channel_err = installer
.install_from_source(&channel_manifest, false)
.await
.expect_err("no source dir");
match tool_err {
RegistryError::ManifestRead { path, .. } => {
assert!(
path.ends_with("tools-src/slack"),
"tool should resolve to tools-src/slack, got: {}",
path.display()
);
}
other => panic!("expected ManifestRead for tool, got: {:?}", other),
}
match channel_err {
RegistryError::ManifestRead { path, .. } => {
assert!(
path.ends_with("channels-src/slack"),
"channel should resolve to channels-src/slack, got: {}",
path.display()
);
}
other => panic!("expected ManifestRead for channel, got: {:?}", other),
}
}
}
+49 -4
View File
@@ -197,13 +197,20 @@ impl Validator {
pub fn validate_tool_params(&self, params: &serde_json::Value) -> ValidationResult {
let mut result = ValidationResult::ok();
// Recursively check all string values in the JSON
// Recursively check all string values in the JSON.
// Depth is capped to prevent stack overflow on pathological input.
const MAX_DEPTH: usize = 32;
fn check_strings(
value: &serde_json::Value,
path: &str,
validator: &Validator,
result: &mut ValidationResult,
depth: usize,
) {
if depth > MAX_DEPTH {
return;
}
match value {
serde_json::Value::String(s) => {
let string_result = if s.is_empty() {
@@ -216,7 +223,7 @@ impl Validator {
serde_json::Value::Array(arr) => {
for (i, item) in arr.iter().enumerate() {
let child_path = format!("{path}[{i}]");
check_strings(item, &child_path, validator, result);
check_strings(item, &child_path, validator, result, depth + 1);
}
}
serde_json::Value::Object(obj) => {
@@ -226,14 +233,14 @@ impl Validator {
} else {
format!("{path}.{k}")
};
check_strings(v, &child_path, validator, result);
check_strings(v, &child_path, validator, result, depth + 1);
}
}
_ => {}
}
}
check_strings(params, "", self, &mut result);
check_strings(params, "", self, &mut result, 0);
result
}
}
@@ -423,4 +430,42 @@ mod tests {
.expect("expected forbidden content error");
assert_eq!(error.field, "metadata.tags[1]");
}
#[test]
fn test_tool_params_depth_limit_prevents_stack_overflow() {
let validator = Validator::new().forbid_pattern("evil");
// Build a deeply nested JSON object (depth > MAX_DEPTH of 32)
let mut value = serde_json::json!("evil payload");
for _ in 0..50 {
value = serde_json::json!({ "nested": value });
}
let result = validator.validate_tool_params(&value);
// The "evil payload" is beyond the depth limit so it should NOT be
// detected — the traversal stops before reaching it.
assert!(
result.is_valid,
"Strings beyond depth limit should be silently skipped, got errors: {:?}",
result.errors
);
}
#[test]
fn test_tool_params_within_depth_limit_still_validated() {
let validator = Validator::new().forbid_pattern("evil");
// Build a nested object within the depth limit
let mut value = serde_json::json!("evil payload");
for _ in 0..5 {
value = serde_json::json!({ "nested": value });
}
let result = validator.validate_tool_params(&value);
assert!(
!result.is_valid,
"Strings within depth limit should still be validated"
);
}
}
+18 -1
View File
@@ -9,6 +9,13 @@ pub struct SandboxConfig {
pub enabled: bool,
/// Security policy for sandbox execution.
pub policy: SandboxPolicy,
/// Whether `FullAccess` policy is explicitly allowed.
///
/// When `policy` is `FullAccess` but this field is `false`, the manager
/// will return `SandboxError::Config` and refuse to execute. This is an
/// intentional double opt-in to prevent accidental host execution.
/// Set via `SANDBOX_ALLOW_FULL_ACCESS=true` env var.
pub allow_full_access: bool,
/// Default timeout for command execution.
pub timeout: Duration,
/// Memory limit in megabytes.
@@ -30,6 +37,7 @@ impl Default for SandboxConfig {
Self {
enabled: true, // Startup check disables gracefully if Docker unavailable
policy: SandboxPolicy::ReadOnly,
allow_full_access: false,
timeout: Duration::from_secs(120),
memory_limit_mb: 2048,
cpu_shares: 1024,
@@ -66,7 +74,16 @@ pub enum SandboxPolicy {
WorkspaceWrite,
/// Full access (no sandbox). Use with extreme caution.
/// This bypasses all isolation and runs directly on host.
///
/// **BLAST RADIUS**: This bypasses Docker entirely and executes commands
/// via `sh -c` directly on the host with the agent process's full
/// privileges. If prompt injection bypasses tool approval, arbitrary
/// host shell commands can run. File system, network, and environment
/// are completely unrestricted.
///
/// Requires `SANDBOX_ALLOW_FULL_ACCESS=true` as a second opt-in.
/// Without it, the sandbox manager will return `SandboxError::Config`
/// and refuse to execute.
FullAccess,
}
+77 -1
View File
@@ -207,8 +207,27 @@ impl SandboxManager {
policy: SandboxPolicy,
env: HashMap<String, String>,
) -> Result<ExecOutput> {
// FullAccess policy bypasses the sandbox entirely
// FullAccess policy bypasses the sandbox entirely.
// Double-check the allow_full_access guard at execution time as well,
// in case the policy was overridden per-call via execute_with_policy().
if policy == SandboxPolicy::FullAccess {
if !self.config.allow_full_access {
tracing::error!(
"FullAccess execution requested but SANDBOX_ALLOW_FULL_ACCESS is not \
enabled. Refusing to execute on host. Falling back to error."
);
return Err(SandboxError::Config {
reason: "FullAccess policy requires SANDBOX_ALLOW_FULL_ACCESS=true".to_string(),
});
}
// Log only the binary name to avoid leaking secrets embedded in
// command arguments (e.g. tokens in curl headers).
let binary = command.split_whitespace().next().unwrap_or("<empty>");
tracing::warn!(
binary = %binary,
cwd = %cwd.display(),
"[FullAccess] Executing command directly on host (no sandbox isolation)"
);
return self.execute_direct(command, cwd, env).await;
}
@@ -374,11 +393,22 @@ impl SandboxManagerBuilder {
}
/// Set the sandbox policy.
///
/// **Note:** `SandboxPolicy::FullAccess` additionally requires
/// `allow_full_access(true)` to be set, or the manager will return
/// `SandboxError::Config` at execution time. This is an intentional
/// double opt-in to prevent accidental host execution.
pub fn policy(mut self, policy: SandboxPolicy) -> Self {
self.config.policy = policy;
self
}
/// Explicitly allow FullAccess policy (double opt-in).
pub fn allow_full_access(mut self, allow: bool) -> Self {
self.config.allow_full_access = allow;
self
}
/// Set the command timeout.
pub fn timeout(mut self, timeout: Duration) -> Self {
self.config.timeout = timeout;
@@ -485,6 +515,7 @@ mod tests {
let manager = SandboxManager::new(SandboxConfig {
enabled: true,
policy: SandboxPolicy::FullAccess,
allow_full_access: true,
..Default::default()
});
@@ -498,11 +529,56 @@ mod tests {
assert!(output.stdout.contains("hello"));
}
#[tokio::test]
async fn test_direct_execution_blocked_without_allow() {
let manager = SandboxManager::new(SandboxConfig {
enabled: true,
policy: SandboxPolicy::FullAccess,
allow_full_access: false,
..Default::default()
});
let result = manager
.execute("echo hello", Path::new("."), HashMap::new())
.await;
// Should be rejected because allow_full_access is false
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("SANDBOX_ALLOW_FULL_ACCESS"),
"Error should mention SANDBOX_ALLOW_FULL_ACCESS, got: {}",
err
);
}
#[tokio::test]
async fn test_builder_full_access_without_allow_returns_error() {
let manager = SandboxManagerBuilder::new()
.enabled(true)
.policy(SandboxPolicy::FullAccess)
// Deliberately omitting .allow_full_access(true)
.build();
let result = manager
.execute("echo hello", Path::new("."), HashMap::new())
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("SANDBOX_ALLOW_FULL_ACCESS"),
"Error should mention SANDBOX_ALLOW_FULL_ACCESS, got: {}",
err
);
}
#[tokio::test]
async fn test_direct_execution_truncates_large_output() {
let manager = SandboxManager::new(SandboxConfig {
enabled: true,
policy: SandboxPolicy::FullAccess,
allow_full_access: true,
..Default::default()
});
+1
View File
@@ -114,6 +114,7 @@ fn install_linux() -> Result<()> {
\n\
[Service]\n\
Type=simple\n\
Environment=\"CLI_ENABLED=false\"\n\
ExecStart=\"{exe}\" run\n\
Restart=always\n\
RestartSec=3\n\
+2
View File
@@ -227,6 +227,8 @@ with its own secret name and env var. It is **not** stored as `openai_compatible
2. Otherwise prompt for key entry via `secret_input()`
3. Store encrypted in secrets via `init_secrets_context()`
4. **Cache key in `self.llm_api_key`** for model fetching in Step 4
5. Preserve `selected_model` on a same-backend re-run; clear it only when
switching to a different backend
**NEAR AI** (`setup_nearai`):
- Calls `session_manager.ensure_authenticated()` which shows the auth menu:
+379 -7
View File
@@ -804,13 +804,15 @@ pub async fn setup_wasm_channel(
print_success(&format!("{} saved to database", secret_config.name));
}
// TODO: Substitute secrets into the validation URL and make a
// GET request to verify the configured credentials actually work.
if let Some(ref validation_endpoint) = setup.validation_endpoint {
print_info(&format!(
"Validation endpoint configured: {} (validation not yet implemented)",
validation_endpoint
));
print_info("Validating configured credentials...");
match validate_channel_credentials(secrets, validation_endpoint).await {
Ok(()) => print_success("Credentials validated successfully"),
Err(e) => print_warning(&format!(
"Credential validation failed: {}. Setup will continue, but the channel may fail to start until the credentials are fixed.",
e
)),
}
}
print_success(&format!("{} channel configured", channel_name));
@@ -821,6 +823,225 @@ pub async fn setup_wasm_channel(
})
}
async fn validate_channel_credentials(
secrets: &SecretsContext,
validation_endpoint: &str,
) -> Result<(), ChannelSetupError> {
let validation_url = substitute_validation_placeholders(secrets, validation_endpoint).await?;
let (parsed, resolved_addrs) = validate_public_https_url(&validation_url).await?;
let target = validation_target_display(&parsed);
let mut client_builder = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.redirect(reqwest::redirect::Policy::none());
if matches!(parsed.host(), Some(url::Host::Domain(_)))
&& let Some(host) = parsed.host_str()
{
client_builder = client_builder.resolve_to_addrs(host, &resolved_addrs);
}
let client = client_builder
.build()
.map_err(|e| ChannelSetupError::Network(format!("Failed to build HTTP client: {}", e)))?;
let response = client.get(parsed.clone()).send().await.map_err(|e| {
ChannelSetupError::Network(format!(
"Validation request to {} failed: {}",
target,
describe_validation_request_error(&e)
))
})?;
if response.status().is_success() {
Ok(())
} else {
Err(ChannelSetupError::Validation(format!(
"Validation endpoint returned HTTP {} from {}",
response.status(),
target
)))
}
}
async fn substitute_validation_placeholders(
secrets: &SecretsContext,
validation_endpoint: &str,
) -> Result<String, ChannelSetupError> {
let mut resolved = validation_endpoint.to_string();
let placeholder_names: std::collections::BTreeSet<String> = validation_placeholder_regex()
.captures_iter(validation_endpoint)
.filter_map(|caps| caps.get(1).map(|m| m.as_str().to_string()))
.collect();
for secret_name in placeholder_names {
let secret_value = secrets.get_secret(&secret_name).await?;
let placeholder = format!("{{{}}}", secret_name);
let encoded_value = urlencoding::encode(secret_value.expose_secret());
resolved = resolved.replace(&placeholder, encoded_value.as_ref());
}
Ok(resolved)
}
async fn validate_public_https_url(
url: &str,
) -> Result<(Url, Vec<std::net::SocketAddr>), ChannelSetupError> {
use std::net::{IpAddr, SocketAddr};
let parsed = Url::parse(url)
.map_err(|e| ChannelSetupError::Validation(format!("Invalid URL: {}", e)))?;
if parsed.scheme() != "https" {
return Err(ChannelSetupError::Validation(
"Validation endpoint must use https".to_string(),
));
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(ChannelSetupError::Validation(
"Validation endpoint cannot contain userinfo".to_string(),
));
}
let host = parsed
.host_str()
.ok_or_else(|| ChannelSetupError::Validation("Validation URL missing host".to_string()))?;
let normalized_host = normalize_validation_domain(host);
let host_lower = normalized_host.to_ascii_lowercase();
if host_lower == "localhost" || host_lower.ends_with(".localhost") {
return Err(ChannelSetupError::Validation(
"Validation endpoint cannot target localhost".to_string(),
));
}
let port = parsed.port_or_known_default().unwrap_or(443);
match parsed
.host()
.ok_or_else(|| ChannelSetupError::Validation("Validation URL missing host".to_string()))?
{
url::Host::Ipv4(v4) => {
let ip = IpAddr::V4(v4);
if is_disallowed_ip(&ip) {
return Err(ChannelSetupError::Validation(format!(
"Validation endpoint cannot target private or local IP {}",
ip
)));
}
Ok((parsed, vec![SocketAddr::new(ip, port)]))
}
url::Host::Ipv6(v6) => {
let ip = normalize_ip(IpAddr::V6(v6));
if is_disallowed_ip(&ip) {
return Err(ChannelSetupError::Validation(format!(
"Validation endpoint cannot target private or local IP {}",
ip
)));
}
Ok((parsed, vec![SocketAddr::new(ip, port)]))
}
url::Host::Domain(domain) => {
let addrs: Vec<SocketAddr> = tokio::net::lookup_host((normalized_host, port))
.await
.map_err(|e| {
ChannelSetupError::Validation(format!(
"DNS resolution failed for {}: {}",
normalized_host, e
))
})?
.map(|addr| SocketAddr::new(normalize_ip(addr.ip()), addr.port()))
.collect();
if addrs.is_empty() {
return Err(ChannelSetupError::Validation(format!(
"Validation hostname '{}' did not resolve to any IP addresses",
domain
)));
}
for addr in &addrs {
if is_disallowed_ip(&addr.ip()) {
return Err(ChannelSetupError::Validation(format!(
"Validation hostname '{}' resolves to disallowed IP {}",
domain,
addr.ip()
)));
}
}
Ok((parsed, addrs))
}
}
}
fn is_disallowed_ip(ip: &std::net::IpAddr) -> bool {
match normalize_ip(*ip) {
std::net::IpAddr::V4(v4) => {
v4.is_private()
|| v4.is_loopback()
|| v4.is_link_local()
|| v4.is_multicast()
|| v4.is_unspecified()
|| v4 == std::net::Ipv4Addr::new(169, 254, 169, 254)
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64)
}
std::net::IpAddr::V6(v6) => {
v6.is_loopback()
|| v6.is_unique_local()
|| v6.is_unicast_link_local()
|| v6.is_multicast()
|| v6.is_unspecified()
}
}
}
fn normalize_ip(ip: std::net::IpAddr) -> std::net::IpAddr {
match ip {
std::net::IpAddr::V6(v6) => v6
.to_ipv4_mapped()
.map(std::net::IpAddr::V4)
.unwrap_or(std::net::IpAddr::V6(v6)),
other => other,
}
}
fn normalize_validation_domain(host: &str) -> &str {
host.trim_end_matches('.')
}
fn validation_placeholder_regex() -> &'static regex::Regex {
static PLACEHOLDER_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
PLACEHOLDER_RE.get_or_init(|| {
regex::Regex::new(r"\{([A-Za-z0-9_]+)\}")
.expect("validation placeholder regex must compile")
})
}
fn validation_target_display(parsed: &Url) -> String {
let host = parsed.host_str().unwrap_or("unknown host");
match parsed.port() {
Some(port) => format!("{}:{}", host, port),
None => host.to_string(),
}
}
fn describe_validation_request_error(error: &reqwest::Error) -> &'static str {
if error.is_timeout() {
"request timed out"
} else if error.is_redirect() {
"redirects are not allowed"
} else if error.is_connect() {
"connection failed"
} else if error.is_request() {
"request could not be sent"
} else {
"request failed"
}
}
/// Validate a Cloudflare tunnel token by briefly running `cloudflared`.
///
/// Spawns `cloudflared tunnel run` with a dummy local URL and watches stderr
@@ -911,8 +1132,26 @@ fn generate_secret_with_length(length: usize) -> String {
#[cfg(test)]
mod tests {
use base64::Engine;
use std::sync::Arc;
use crate::setup::channels::{generate_webhook_secret, validate_cloudflare_token_format};
use crate::secrets::{InMemorySecretsStore, SecretsCrypto, SecretsStore};
use crate::setup::channels::{
SecretsContext, generate_webhook_secret, substitute_validation_placeholders,
validate_cloudflare_token_format, validate_public_https_url,
};
fn test_secrets_context() -> SecretsContext {
use secrecy::SecretString;
let crypto = Arc::new(
SecretsCrypto::new(SecretString::from(
"0123456789abcdef0123456789abcdef".to_string(),
))
.unwrap(),
);
let store: Arc<dyn SecretsStore> = Arc::new(InMemorySecretsStore::new(crypto));
SecretsContext::from_store(store, "test-user")
}
#[test]
fn test_generate_webhook_secret() {
@@ -965,4 +1204,137 @@ mod tests {
fn test_validate_cloudflare_token_empty() {
assert!(!validate_cloudflare_token_format(""));
}
#[tokio::test]
async fn test_substitute_validation_placeholders() {
let secrets = test_secrets_context();
secrets
.save_secret(
"telegram_bot_token",
&secrecy::SecretString::from("abc123".to_string()),
)
.await
.unwrap();
secrets
.save_secret(
"workspace_id",
&secrecy::SecretString::from("ws_456".to_string()),
)
.await
.unwrap();
let resolved = substitute_validation_placeholders(
&secrets,
"https://api.example.com/{workspace_id}/verify?token={telegram_bot_token}",
)
.await
.unwrap();
assert_eq!(
resolved,
"https://api.example.com/ws_456/verify?token=abc123"
);
}
#[tokio::test]
async fn test_substitute_validation_placeholders_url_encodes_secrets() {
let secrets = test_secrets_context();
secrets
.save_secret(
"telegram_bot_token",
&secrecy::SecretString::from("abc123?foo=1&bar=#baz/slash".to_string()),
)
.await
.unwrap();
let resolved = substitute_validation_placeholders(
&secrets,
"https://api.example.com/verify?token={telegram_bot_token}",
)
.await
.unwrap();
assert_eq!(
resolved,
"https://api.example.com/verify?token=abc123%3Ffoo%3D1%26bar%3D%23baz%2Fslash"
);
}
#[tokio::test]
async fn test_substitute_validation_placeholders_missing_secret() {
let secrets = test_secrets_context();
let err = substitute_validation_placeholders(
&secrets,
"https://api.example.com/verify?token={missing_secret}",
)
.await
.unwrap_err()
.to_string();
assert!(err.contains("Failed to read secret"));
}
#[tokio::test]
async fn test_validate_public_https_url_rejects_localhost() {
let err = validate_public_https_url("https://localhost/api")
.await
.unwrap_err()
.to_string();
assert!(err.contains("localhost"));
}
#[tokio::test]
async fn test_validate_public_https_url_rejects_localhost_with_trailing_dot() {
let err = validate_public_https_url("https://localhost./api")
.await
.unwrap_err()
.to_string();
assert!(err.contains("localhost"));
}
#[tokio::test]
async fn test_validate_public_https_url_rejects_private_ip() {
let err = validate_public_https_url("https://192.168.1.10/api")
.await
.unwrap_err()
.to_string();
assert!(err.contains("private or local IP"));
}
#[tokio::test]
async fn test_validate_public_https_url_rejects_ipv4_mapped_ipv6() {
let err = validate_public_https_url("https://[::ffff:127.0.0.1]/api")
.await
.unwrap_err()
.to_string();
assert!(err.contains("private or local IP"));
}
#[tokio::test]
async fn test_validate_public_https_url_rejects_http() {
let err = validate_public_https_url("http://example.com/api")
.await
.unwrap_err()
.to_string();
assert!(err.contains("must use https"));
}
#[tokio::test]
async fn test_validate_public_https_url_accepts_public_https_literal_ip() {
let (parsed, addrs) = validate_public_https_url("https://8.8.8.8/api")
.await
.unwrap();
assert_eq!(parsed.as_str(), "https://8.8.8.8/api");
assert_eq!(addrs.len(), 1);
assert_eq!(addrs[0].ip().to_string(), "8.8.8.8");
}
#[tokio::test]
async fn test_validate_public_https_url_fails_closed_on_dns_error() {
let err = validate_public_https_url("https://should-not-resolve.invalid/api")
.await
.unwrap_err()
.to_string();
assert!(err.contains("DNS resolution failed"));
}
}
+41 -13
View File
@@ -11,13 +11,25 @@ use std::io::{self, Write};
use crossterm::{
cursor,
event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
execute,
style::{Color, Print, ResetColor, SetForegroundColor},
terminal::{self, ClearType},
};
use secrecy::SecretString;
/// Drain any residual key events already queued in the terminal buffer.
///
/// On Windows, transitioning between raw mode and cooked mode (or between
/// successive raw-mode prompts) can leave stale events (e.g. the Release
/// half of an Enter keypress) in the queue. Consuming them with a
/// non-blocking poll prevents the next prompt from mis-firing.
fn drain_pending_events() {
while event::poll(std::time::Duration::ZERO).unwrap_or(false) {
let _ = event::read();
}
}
/// Display a numbered menu and get user selection.
///
/// Returns the index (0-based) of the selected option.
@@ -94,6 +106,7 @@ pub fn select_many(prompt: &str, options: &[(&str, bool)]) -> io::Result<Vec<usi
let mut cursor_pos = 0;
terminal::enable_raw_mode()?;
drain_pending_events();
execute!(stdout, cursor::Hide)?;
let result = (|| {
@@ -124,9 +137,13 @@ pub fn select_many(prompt: &str, options: &[(&str, bool)]) -> io::Result<Vec<usi
stdout.flush()?;
// Read key
// Read key — only act on Press events to avoid double-firing
// from Release/Repeat events on Windows.
if let Event::Key(KeyEvent {
code, modifiers, ..
code,
modifiers,
kind: KeyEventKind::Press,
..
}) = event::read()?
{
match code {
@@ -200,19 +217,16 @@ fn read_secret_line() -> io::Result<SecretString> {
let mut input = String::new();
let mut stdout = io::stdout();
// Drain any residual key events (e.g. Enter from a prior `read_line` prompt)
// that are already queued before we start reading. Without this, on
// Windows the leftover Enter is immediately consumed and the function
// returns an empty string before the user can type anything.
// Uses Duration::ZERO so we never block waiting for new input — only
// events already in the queue are consumed.
while event::poll(std::time::Duration::ZERO)? {
let _ = event::read()?;
}
drain_pending_events();
loop {
// Only act on Press events to avoid double-firing from
// Release/Repeat events on Windows.
if let Event::Key(KeyEvent {
code, modifiers, ..
code,
modifiers,
kind: KeyEventKind::Press,
..
}) = event::read()?
{
match code {
@@ -270,6 +284,20 @@ pub fn confirm(prompt: &str, default: bool) -> io::Result<bool> {
})
}
/// Print the IronClaw ASCII art banner in blue.
pub fn print_banner() {
let mut stdout = io::stdout();
let _ = execute!(stdout, SetForegroundColor(Color::Cyan));
println!();
println!(r" ██╗██████╗ ██████╗ ███╗ ██╗ ██████╗██╗ █████╗ ██╗ ██╗");
println!(r" ██║██╔══██╗██╔═══██╗████╗ ██║██╔════╝██║ ██╔══██╗██║ ██║");
println!(r" ██║██████╔╝██║ ██║██╔██╗ ██║██║ ██║ ███████║██║ █╗ ██║");
println!(r" ██║██╔══██╗██║ ██║██║╚██╗██║██║ ██║ ██╔══██║██║███╗██║");
println!(r" ██║██║ ██║╚██████╔╝██║ ╚████║╚██████╗███████╗██║ ██║╚███╔███╔╝");
println!(r" ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ ");
let _ = execute!(stdout, ResetColor);
}
/// Print a styled header box.
///
/// # Example
+61 -33
View File
@@ -30,8 +30,8 @@ use crate::setup::channels::{
SecretsContext, setup_http, setup_signal, setup_tunnel, setup_wasm_channel,
};
use crate::setup::prompts::{
confirm, input, optional_input, print_error, print_header, print_info, print_step,
print_success, secret_input, select_many, select_one,
confirm, input, optional_input, print_banner, print_error, print_header, print_info,
print_step, print_success, secret_input, select_many, select_one,
};
// unused const, keep commented for clarity / future use
@@ -141,6 +141,7 @@ impl SetupWizard {
/// settings are loaded from the database after Step 1 establishes a
/// connection, so users don't have to re-enter everything.
pub async fn run(&mut self) -> Result<(), SetupError> {
print_banner();
print_header("IronClaw Setup Wizard");
if self.config.channels_only {
@@ -1081,7 +1082,7 @@ impl SetupWizard {
"Provider '{}' has no setup wizard. Configure via environment variables.",
provider_id
));
self.settings.llm_backend = Some(provider_id.to_string());
self.set_llm_backend_preserving_model(provider_id);
return Ok(());
};
@@ -1136,9 +1137,19 @@ impl SetupWizard {
Ok(())
}
/// Update the selected LLM backend while preserving the current model when
/// the backend did not actually change.
fn set_llm_backend_preserving_model(&mut self, backend: &str) {
let backend_changed = self.settings.llm_backend.as_deref() != Some(backend);
self.settings.llm_backend = Some(backend.to_string());
if backend_changed {
self.settings.selected_model = None;
}
}
/// NEAR AI provider setup (extracted from the old step_authentication).
async fn setup_nearai(&mut self) -> Result<(), SetupError> {
self.settings.llm_backend = Some("nearai".to_string());
self.set_llm_backend_preserving_model("nearai");
// Check if we already have a session
if let Some(ref session) = self.session_manager
@@ -1182,9 +1193,9 @@ impl SetupWizard {
self.persist_session_to_db().await;
// If the user chose the API key path, NEARAI_API_KEY is now set
// in the environment. Persist it to the encrypted secrets store
// so inject_llm_keys_from_secrets() can load it on future runs.
if let Ok(api_key) = std::env::var("NEARAI_API_KEY")
// in the runtime env overlay. Persist it to the encrypted secrets
// store so inject_llm_keys_from_secrets() can load it on future runs.
if let Some(api_key) = crate::config::helpers::env_or_override("NEARAI_API_KEY")
&& !api_key.is_empty()
&& let Ok(ctx) = self.init_secrets_context().await
{
@@ -1223,11 +1234,7 @@ impl SetupWizard {
/// Anthropic OAuth setup: extract token from `claude login` credentials.
async fn setup_anthropic_oauth(&mut self) -> Result<(), SetupError> {
// Clear model only when switching providers (old model may be invalid)
if self.settings.llm_backend.as_deref() != Some("anthropic") {
self.settings.selected_model = None;
}
self.settings.llm_backend = Some("anthropic".to_string());
self.set_llm_backend_preserving_model("anthropic");
// Try to extract existing OAuth token from Claude Code credentials
if let Some(token) = crate::config::ClaudeCodeConfig::extract_oauth_token() {
@@ -1321,11 +1328,7 @@ impl SetupWizard {
other => other,
});
// Clear model only when switching providers (old model may be invalid)
if self.settings.llm_backend.as_deref() != Some(backend) {
self.settings.selected_model = None;
}
self.settings.llm_backend = Some(backend.to_string());
self.set_llm_backend_preserving_model(backend);
// Check env var first
if let Ok(existing) = std::env::var(env_var) {
@@ -1384,11 +1387,7 @@ impl SetupWizard {
&mut self,
def: &crate::llm::ProviderDefinition,
) -> Result<(), SetupError> {
// Clear model only when switching providers (old model may be invalid)
if self.settings.llm_backend.as_deref() != Some(&def.id) {
self.settings.selected_model = None;
}
self.settings.llm_backend = Some(def.id.clone());
self.set_llm_backend_preserving_model(&def.id);
let default_url = self
.settings
@@ -1418,10 +1417,7 @@ impl SetupWizard {
/// AWS Bedrock provider setup: region, auth, and cross-region config.
async fn setup_bedrock(&mut self) -> Result<(), SetupError> {
if self.settings.llm_backend.as_deref() != Some("bedrock") {
self.settings.selected_model = None;
}
self.settings.llm_backend = Some("bedrock".to_string());
self.set_llm_backend_preserving_model("bedrock");
// Region
let default_region = self
@@ -1512,11 +1508,7 @@ impl SetupWizard {
secret_name: &str,
display_name: &str,
) -> Result<(), SetupError> {
// Clear model only when switching providers (old model may be invalid)
if self.settings.llm_backend.as_deref() != Some(backend_id) {
self.settings.selected_model = None;
}
self.settings.llm_backend = Some(backend_id.to_string());
self.set_llm_backend_preserving_model(backend_id);
let existing_url = self
.settings
@@ -2612,8 +2604,9 @@ impl SetupWizard {
env_vars.push((base_url_env.clone(), base_url.clone()));
}
// Preserve NEARAI_API_KEY if present (set by API key auth flow)
if let Ok(api_key) = std::env::var("NEARAI_API_KEY")
// Preserve NEARAI_API_KEY if present (set by API key auth flow
// via the thread-safe runtime env overlay).
if let Some(api_key) = crate::config::helpers::env_or_override("NEARAI_API_KEY")
&& !api_key.is_empty()
{
env_vars.push(("NEARAI_API_KEY".to_string(), api_key));
@@ -3870,6 +3863,41 @@ mod tests {
}
}
#[test]
fn test_set_llm_backend_preserves_model_when_backend_unchanged() {
let mut wizard = SetupWizard::new();
wizard.settings.llm_backend = Some("openai".to_string());
wizard.settings.selected_model = Some("gpt-4o".to_string());
wizard.set_llm_backend_preserving_model("openai");
assert_eq!(wizard.settings.llm_backend.as_deref(), Some("openai"));
assert_eq!(wizard.settings.selected_model.as_deref(), Some("gpt-4o"));
}
#[test]
fn test_set_llm_backend_clears_model_when_backend_was_unset() {
let mut wizard = SetupWizard::new();
wizard.settings.selected_model = Some("gpt-4o".to_string());
wizard.set_llm_backend_preserving_model("openai");
assert_eq!(wizard.settings.llm_backend.as_deref(), Some("openai"));
assert_eq!(wizard.settings.selected_model, None);
}
#[test]
fn test_set_llm_backend_clears_model_when_backend_changes() {
let mut wizard = SetupWizard::new();
wizard.settings.llm_backend = Some("openai".to_string());
wizard.settings.selected_model = Some("gpt-4o".to_string());
wizard.set_llm_backend_preserving_model("anthropic");
assert_eq!(wizard.settings.llm_backend.as_deref(), Some("anthropic"));
assert_eq!(wizard.settings.selected_model, None);
}
/// Regression test for #600: re-running provider setup for the same backend
/// must NOT clear selected_model. Only switching to a different backend should.
#[test]
+56 -6
View File
@@ -641,14 +641,20 @@ mod tests {
let conv_id = uuid::Uuid::new_v4();
// ensure_conversation should create the row.
db.ensure_conversation(conv_id, "web", "carol", None)
.await
.expect("ensure first");
assert!(
db.ensure_conversation(conv_id, "web", "carol", None)
.await
.expect("ensure first"),
"first ensure_conversation should create the row"
);
// Calling again with the same ID should not error.
db.ensure_conversation(conv_id, "web", "carol", None)
.await
.expect("ensure second (idempotent)");
assert!(
db.ensure_conversation(conv_id, "web", "carol", None)
.await
.expect("ensure second (idempotent)"),
"second ensure_conversation should touch owned row"
);
// Should be able to add messages to it.
let msg_id = db
@@ -666,6 +672,50 @@ mod tests {
assert_eq!(msgs[0].content, "test message");
}
#[cfg(feature = "libsql")]
#[tokio::test]
async fn test_ensure_conversation_foreign_conflict_does_not_touch_last_activity() {
let harness = TestHarnessBuilder::new().build().await;
let db = &harness.db;
let conv_id = db
.create_conversation("web", "alice", None)
.await
.expect("create conversation");
let before = db
.list_conversations_all_channels("alice", 10)
.await
.expect("list conversations before foreign ensure")
.into_iter()
.find(|c| c.id == conv_id)
.expect("conversation must exist before foreign ensure")
.last_activity;
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
assert!(
!db.ensure_conversation(conv_id, "web", "mallory", None)
.await
.expect("foreign ensure should not error"),
"foreign ensure_conversation should report not ensured"
);
let after = db
.list_conversations_all_channels("alice", 10)
.await
.expect("list conversations after foreign ensure")
.into_iter()
.find(|c| c.id == conv_id)
.expect("conversation must still exist after foreign ensure")
.last_activity;
assert_eq!(
after, before,
"foreign ensure_conversation should not mutate last_activity"
);
}
#[cfg(feature = "libsql")]
#[tokio::test]
async fn test_paginated_messages() {
+2 -2
View File
@@ -213,7 +213,7 @@ impl Tool for ToolAuthTool {
let result = self
.manager
.auth(name, None)
.auth(name)
.await
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
@@ -323,7 +323,7 @@ impl Tool for ToolActivateTool {
// Activation failed due to missing auth; initiate auth flow
// so the agent loop can show the auth card.
match self.manager.auth(name, None).await {
match self.manager.auth(name).await {
Ok(auth_result) if auth_result.is_authenticated() => {
// Auth succeeded (e.g. env var was set); retry activation.
let result = self
+322 -88
View File
@@ -1,7 +1,7 @@
//! HTTP request tool.
use std::collections::HashMap;
use std::net::{IpAddr, ToSocketAddrs};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
@@ -31,9 +31,24 @@ const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024;
/// in memory for LLM context. Matches the WASM attachment size cap.
const MAX_SAVE_TO_SIZE: usize = 50 * 1024 * 1024;
/// Maximum number of redirects to follow for simple GET requests.
const MAX_REDIRECTS: usize = 3;
/// Descriptive User-Agent so public APIs don't reject bare requests.
const USER_AGENT: &str = concat!(
"IronClaw-Agent/",
env!("CARGO_PKG_VERSION"),
" (https://github.com/nearai/ironclaw)"
);
/// Tool for making HTTP requests.
///
/// Each request builds a per-request [`Client`] with DNS pinning to prevent
/// TOCTOU DNS rebinding attacks. The hostname is resolved once, validated
/// against the SSRF blocklist, and then pinned via
/// [`reqwest::ClientBuilder::resolve_to_addrs`] so that reqwest connects
/// directly to the pre-validated IPs without a second DNS lookup.
pub struct HttpTool {
client: Client,
credential_registry: Option<Arc<SharedCredentialRegistry>>,
secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
}
@@ -41,52 +56,7 @@ pub struct HttpTool {
impl HttpTool {
/// Create a new HTTP tool.
pub fn new() -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::custom(|attempt| {
if attempt.previous().len() >= 10 {
return attempt.error("too many redirects");
}
// Reject scheme downgrades (https → http)
if attempt.url().scheme() != "https" {
return attempt.error("redirect to non-HTTPS URL is not allowed");
}
// Extract host info before consuming attempt
let host_owned = attempt.url().host_str().map(|h| h.to_owned());
let port = attempt.url().port_or_known_default().unwrap_or(443);
if let Some(host) = host_owned {
let host_lower = host.to_lowercase();
if host_lower == "localhost" || host_lower.ends_with(".localhost") {
return attempt.error("redirect to localhost is not allowed");
}
if let Ok(ip) = host.parse::<IpAddr>()
&& is_disallowed_ip(&ip)
{
return attempt.error("redirect to private/local IP is not allowed");
}
// Resolve hostname and check all IPs
let socket_addr = format!("{}:{}", host, port);
if let Ok(addrs) = socket_addr.to_socket_addrs() {
for addr in addrs {
if is_disallowed_ip(&addr.ip()) {
let msg = format!(
"redirect target '{}' resolves to disallowed IP {}",
host,
addr.ip()
);
return attempt.error(msg);
}
}
}
}
attempt.follow()
}))
.build()
.expect("Failed to create HTTP client");
Self {
client,
credential_registry: None,
secrets_store: None,
}
@@ -129,6 +99,11 @@ fn validate_save_to_path(save_to: &str) -> Result<std::path::PathBuf, ToolError>
Ok(validated)
}
/// Parse and validate a URL without DNS resolution.
///
/// Checks scheme (HTTPS only), rejects localhost and private/link-local IP
/// literals. Does **not** resolve hostnames -- use [`validate_and_resolve_url`]
/// for the full DNS-pinning flow that eliminates the TOCTOU rebinding window.
pub(crate) fn validate_url(url: &str) -> Result<reqwest::Url, ToolError> {
let parsed = reqwest::Url::parse(url)
.map_err(|e| ToolError::InvalidParameters(format!("invalid URL: {}", e)))?;
@@ -159,36 +134,94 @@ pub(crate) fn validate_url(url: &str) -> Result<reqwest::Url, ToolError> {
));
}
// Resolve hostname and check all resolved IPs against the blocklist.
// This prevents DNS rebinding where a hostname resolves to a private IP.
let port = parsed.port_or_known_default().unwrap_or(443);
let socket_addr = format!("{}:{}", host, port);
if let Ok(addrs) = socket_addr.to_socket_addrs() {
for addr in addrs {
if is_disallowed_ip(&addr.ip()) {
return Err(ToolError::NotAuthorized(format!(
"hostname '{}' resolves to disallowed IP {}",
host,
addr.ip()
)));
}
Ok(parsed)
}
/// Resolve DNS for a validated URL and check every resolved address against
/// the SSRF blocklist.
///
/// Returns the resolved [`SocketAddr`]s so that callers can pin the hostname
/// via [`reqwest::ClientBuilder::resolve_to_addrs`], preventing a DNS rebinding
/// attack where a second, independent resolution (inside reqwest) returns a
/// different -- potentially private -- IP after our validation pass.
pub(crate) async fn validate_and_resolve_url(
url: &reqwest::Url,
) -> Result<Vec<SocketAddr>, ToolError> {
let host = url
.host_str()
.ok_or_else(|| ToolError::InvalidParameters("URL missing host".to_string()))?;
let port = url.port_or_known_default().unwrap_or(443);
let addrs: Vec<SocketAddr> = tokio::net::lookup_host(format!("{}:{}", host, port))
.await
.map_err(|e| {
ToolError::ExternalService(format!("DNS resolution failed for '{}': {}", host, e))
})?
.collect();
if addrs.is_empty() {
return Err(ToolError::ExternalService(format!(
"DNS resolution for '{}' returned no addresses",
host
)));
}
for addr in &addrs {
if is_disallowed_ip(&addr.ip()) {
return Err(ToolError::NotAuthorized(format!(
"hostname '{}' resolves to disallowed IP {}",
host,
addr.ip()
)));
}
}
Ok(parsed)
Ok(addrs)
}
/// Build a reqwest [`Client`] that pins the given hostname to the
/// pre-validated resolved addresses, preventing any second DNS lookup.
pub(crate) fn build_pinned_client(
host: &str,
resolved_addrs: &[SocketAddr],
timeout: Duration,
redirect_policy: reqwest::redirect::Policy,
) -> Result<Client, ToolError> {
let builder = Client::builder()
.timeout(timeout)
.redirect(redirect_policy)
.user_agent(USER_AGENT)
.resolve_to_addrs(host, resolved_addrs);
builder
.build()
.map_err(|e| ToolError::ExternalService(format!("failed to build HTTP client: {}", e)))
}
/// Check whether an IPv4 address falls in a disallowed range (private,
/// loopback, link-local, multicast, unspecified, or cloud metadata).
fn is_disallowed_ipv4(v4: &Ipv4Addr) -> bool {
v4.is_private()
|| v4.is_loopback()
|| v4.is_link_local()
|| v4.is_multicast()
|| v4.is_unspecified()
|| *v4 == Ipv4Addr::new(169, 254, 169, 254)
}
fn is_disallowed_ip(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
v4.is_private()
|| v4.is_loopback()
|| v4.is_link_local()
|| v4.is_multicast()
|| v4.is_unspecified()
|| *v4 == std::net::Ipv4Addr::new(169, 254, 169, 254)
}
IpAddr::V4(v4) => is_disallowed_ipv4(v4),
IpAddr::V6(v6) => {
// Catch IPv4-mapped IPv6 addresses (e.g. ::ffff:169.254.169.254)
// that would bypass IPv4-only checks.
if let Some(v4) = v6.to_ipv4_mapped()
&& is_disallowed_ipv4(&v4)
{
return true;
}
v6.is_loopback()
|| v6.is_unique_local()
|| v6.is_unicast_link_local()
@@ -329,16 +362,31 @@ impl Tool for HttpTool {
let url = require_str(&params, "url")?;
let mut parsed_url = validate_url(url)?;
// Resolve DNS once, validate against SSRF blocklist, then pin the
// resolved addresses into the reqwest client so it cannot re-resolve
// to a different (potentially private) IP.
let resolved_addrs = validate_and_resolve_url(&parsed_url).await?;
let host = parsed_url
.host_str()
.ok_or_else(|| ToolError::InvalidParameters("URL missing host".into()))?
.to_string();
let client = build_pinned_client(
&host,
&resolved_addrs,
Duration::from_secs(30),
reqwest::redirect::Policy::none(),
)?;
// Parse headers
let mut headers_vec = parse_headers_param(params.get("headers"))?;
// Build request
let mut request = match method.to_uppercase().as_str() {
"GET" => self.client.get(parsed_url.clone()),
"POST" => self.client.post(parsed_url.clone()),
"PUT" => self.client.put(parsed_url.clone()),
"DELETE" => self.client.delete(parsed_url.clone()),
"PATCH" => self.client.patch(parsed_url.clone()),
"GET" => client.get(parsed_url.clone()),
"POST" => client.post(parsed_url.clone()),
"PUT" => client.put(parsed_url.clone()),
"DELETE" => client.delete(parsed_url.clone()),
"PATCH" => client.patch(parsed_url.clone()),
_ => {
return Err(ToolError::InvalidParameters(format!(
"unsupported method: {}",
@@ -382,8 +430,8 @@ impl Tool for HttpTool {
self.credential_registry.as_ref(),
self.secrets_store.as_ref(),
) {
let host = parsed_url.host_str().unwrap_or("");
let matched: Vec<crate::secrets::CredentialMapping> = registry.find_for_host(host);
let cred_host = parsed_url.host_str().unwrap_or("");
let matched: Vec<crate::secrets::CredentialMapping> = registry.find_for_host(cred_host);
for mapping in &matched {
match store
.get_decrypted(&ctx.user_id, &mapping.secret_name)
@@ -443,20 +491,124 @@ impl Tool for HttpTool {
return Ok(ToolOutput::success(result, start.elapsed()).with_raw(recorded.body));
}
// Execute request
let response = request.send().await.map_err(|e| {
if e.is_timeout() {
ToolError::Timeout(Duration::from_secs(30))
} else {
ToolError::ExternalService(e.to_string())
// Determine if this is a simple GET (eligible for redirect following).
let is_simple_get =
method.eq_ignore_ascii_case("GET") && headers_vec.is_empty() && body_bytes.is_none();
// Execute request, optionally following redirects for simple GETs.
// Each redirect hop gets its own DNS resolution + SSRF validation +
// pinned client to prevent rebinding attacks across hops.
let response = if is_simple_get {
let mut redirects_remaining = MAX_REDIRECTS;
loop {
// Build a per-hop pinned client for the current URL.
let hop_addrs = validate_and_resolve_url(&parsed_url).await?;
let hop_host = parsed_url
.host_str()
.ok_or_else(|| ToolError::InvalidParameters("URL missing host".into()))?
.to_string();
let hop_client = build_pinned_client(
&hop_host,
&hop_addrs,
Duration::from_secs(30),
reqwest::redirect::Policy::none(),
)?;
let resp = hop_client
.get(parsed_url.clone())
.header(
reqwest::header::ACCEPT,
"text/markdown, text/html;q=0.9, application/json;q=0.9, */*;q=0.8",
)
.send()
.await
.map_err(|e| {
if e.is_timeout() {
ToolError::Timeout(Duration::from_secs(30))
} else {
ToolError::ExternalService(e.to_string())
}
})?;
let status = resp.status().as_u16();
if (300..400).contains(&status) {
if redirects_remaining == 0 {
return Err(ToolError::ExecutionFailed(format!(
"too many redirects (max {})",
MAX_REDIRECTS
)));
}
let location = resp
.headers()
.get(reqwest::header::LOCATION)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| {
ToolError::ExecutionFailed(format!(
"redirect (HTTP {}) has no Location header",
status
))
})?;
let next_url_str =
if location.starts_with("http://") || location.starts_with("https://") {
location.to_string()
} else {
parsed_url
.join(location)
.map(|u| u.to_string())
.map_err(|e| {
ToolError::ExecutionFailed(format!(
"could not resolve relative redirect '{}': {}",
location, e
))
})?
};
// SSRF re-validation on every hop (URL structure checks).
// DNS resolution + IP validation happens at the top of the
// next loop iteration via validate_and_resolve_url.
parsed_url = validate_url(&next_url_str)?;
let hop_detector = LeakDetector::new();
hop_detector
.scan_http_request(parsed_url.as_str(), &[], None)
.map_err(|e| ToolError::NotAuthorized(e.to_string()))?;
redirects_remaining -= 1;
tracing::debug!(
to = %parsed_url,
hops_left = redirects_remaining,
"http tool following redirect"
);
continue;
}
break resp;
}
})?;
} else {
let resp = request.send().await.map_err(|e| {
if e.is_timeout() {
ToolError::Timeout(Duration::from_secs(30))
} else {
ToolError::ExternalService(e.to_string())
}
})?;
let status = resp.status().as_u16();
// Block redirects for non-simple requests (potential SSRF)
if (300..400).contains(&status) {
return Err(ToolError::NotAuthorized(format!(
"request returned redirect (HTTP {}), which is blocked to prevent SSRF",
status
)));
}
resp
};
let status = response.status().as_u16();
// Redirects are followed automatically (up to 10 hops).
// If we still see a 3xx here, the chain was too long.
let headers: HashMap<String, String> = response
.headers()
.iter()
@@ -656,8 +808,6 @@ mod tests {
#[test]
fn test_is_disallowed_ip_covers_ranges() {
use std::net::Ipv4Addr;
// Private ranges
assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1))));
@@ -672,6 +822,39 @@ mod tests {
assert!(!is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
}
#[test]
fn test_is_disallowed_ip_catches_ipv4_mapped_ipv6() {
use std::net::Ipv6Addr;
// ::ffff:127.0.0.1 (IPv4-mapped loopback)
let mapped_loopback = IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0x7f00, 0x0001));
assert!(
is_disallowed_ip(&mapped_loopback),
"IPv4-mapped ::ffff:127.0.0.1 should be disallowed"
);
// ::ffff:169.254.169.254 (IPv4-mapped cloud metadata)
let mapped_metadata = IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0xa9fe, 0xa9fe));
assert!(
is_disallowed_ip(&mapped_metadata),
"IPv4-mapped ::ffff:169.254.169.254 should be disallowed"
);
// ::ffff:10.0.0.1 (IPv4-mapped private)
let mapped_private = IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0x0a00, 0x0001));
assert!(
is_disallowed_ip(&mapped_private),
"IPv4-mapped ::ffff:10.0.0.1 should be disallowed"
);
// ::ffff:8.8.8.8 (IPv4-mapped public -- should be allowed)
let mapped_public = IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0x0808, 0x0808));
assert!(
!is_disallowed_ip(&mapped_public),
"IPv4-mapped ::ffff:8.8.8.8 should be allowed"
);
}
#[test]
fn test_max_response_size_is_reasonable() {
// MAX_RESPONSE_SIZE should be 5 MB to prevent OOM while allowing typical API responses.
@@ -936,6 +1119,57 @@ mod tests {
assert_eq!(extract_host_from_params(&params), None);
}
// ── DNS pinning tests ─────────────────────────────────────────────
#[tokio::test]
async fn test_validate_and_resolve_rejects_loopback_hostname() {
// "localhost" is blocked at the URL validation level, but verify
// that validate_and_resolve_url also catches loopback IPs returned
// by DNS for any hostname that resolves to 127.0.0.1.
let url = reqwest::Url::parse("https://127.0.0.1/test").unwrap();
// 127.0.0.1 is an IP literal -- validate_url blocks it before
// we ever reach validate_and_resolve_url, but the function should
// still reject if called directly.
let err = validate_and_resolve_url(&url).await.unwrap_err();
assert!(
err.to_string().contains("disallowed"),
"expected disallowed IP error, got: {}",
err
);
}
// Requires network access -- run with: cargo test -- --ignored
#[ignore]
#[tokio::test]
async fn test_validate_and_resolve_accepts_public_host() {
// example.com resolves to public IPs.
let url = reqwest::Url::parse("https://example.com").unwrap();
let addrs = validate_and_resolve_url(&url).await.unwrap();
assert!(!addrs.is_empty(), "should resolve to at least one address");
for addr in &addrs {
assert!(
!is_disallowed_ip(&addr.ip()),
"example.com resolved to disallowed IP: {}",
addr.ip()
);
}
}
#[test]
fn test_build_pinned_client_succeeds() {
let addrs = vec![SocketAddr::new(
IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)),
443,
)];
let client = build_pinned_client(
"example.com",
&addrs,
Duration::from_secs(10),
reqwest::redirect::Policy::none(),
);
assert!(client.is_ok(), "should build client successfully");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn requires_approval_multi_thread_no_panic() {
use crate::secrets::CredentialMapping;
+121 -11
View File
@@ -5,7 +5,7 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use async_trait::async_trait;
use tokio::sync::RwLock;
@@ -57,6 +57,10 @@ pub struct McpClient {
/// Custom headers to include in every request.
custom_headers: HashMap<String, String>,
/// Whether the MCP initialize handshake has completed.
/// Used as a local idempotency guard when no session_manager is present.
initialized: AtomicBool,
}
impl McpClient {
@@ -79,6 +83,7 @@ impl McpClient {
user_id: "default".to_string(),
server_config: None,
custom_headers: HashMap::new(),
initialized: AtomicBool::new(false),
}
}
@@ -101,6 +106,7 @@ impl McpClient {
user_id: "default".to_string(),
server_config: None,
custom_headers: HashMap::new(),
initialized: AtomicBool::new(false),
}
}
@@ -131,6 +137,7 @@ impl McpClient {
secrets: None,
user_id: "default".to_string(),
custom_headers: config.headers.clone(),
initialized: AtomicBool::new(false),
server_config: Some(config),
}
}
@@ -162,6 +169,7 @@ impl McpClient {
user_id: user_id.into(),
server_config: Some(config),
custom_headers,
initialized: AtomicBool::new(false),
}
}
@@ -197,9 +205,16 @@ impl McpClient {
user_id: user_id.into(),
server_config,
custom_headers,
initialized: AtomicBool::new(false),
}
}
/// Attach a session manager for Streamable HTTP session tracking.
pub fn with_session_manager(mut self, session_manager: Arc<McpSessionManager>) -> Self {
self.session_manager = Some(session_manager);
self
}
/// Get the server name.
pub fn server_name(&self) -> &str {
&self.server_name
@@ -210,6 +225,11 @@ impl McpClient {
&self.server_url
}
/// Whether this client has a session manager attached.
pub fn has_session_manager(&self) -> bool {
self.session_manager.is_some()
}
/// Get the next request ID.
fn next_request_id(&self) -> u64 {
self.next_id.fetch_add(1, Ordering::SeqCst)
@@ -237,9 +257,19 @@ impl McpClient {
}
/// Build the headers map for a request (auth, session-id, custom headers).
///
/// Custom headers are applied first. OAuth token injection is skipped if the
/// user has explicitly configured an Authorization header, so user-provided
/// credentials are never silently overwritten.
async fn build_request_headers(&self) -> Result<HashMap<String, String>, ToolError> {
let mut headers = self.custom_headers.clone();
if let Some(token) = self.get_access_token().await? {
// Only inject OAuth token if the user hasn't set a custom Authorization header.
let has_custom_auth = self
.custom_headers
.keys()
.any(|k| k.eq_ignore_ascii_case("authorization"));
if !has_custom_auth && let Some(token) = self.get_access_token().await? {
headers.insert("Authorization".to_string(), format!("Bearer {}", token));
}
if let Some(ref session_manager) = self.session_manager
@@ -307,9 +337,14 @@ impl McpClient {
/// Initialize the connection to the MCP server.
pub async fn initialize(&self) -> Result<InitializeResult, ToolError> {
// Fast path: already initialized (local flag or session manager)
if self.initialized.load(Ordering::Relaxed) {
return Ok(InitializeResult::default());
}
if let Some(ref session_manager) = self.session_manager
&& session_manager.is_initialized(&self.server_name).await
{
self.initialized.store(true, Ordering::Relaxed);
return Ok(InitializeResult::default());
}
if let Some(ref session_manager) = self.session_manager {
@@ -342,6 +377,7 @@ impl McpClient {
if let Some(ref session_manager) = self.session_manager {
session_manager.mark_initialized(&self.server_name).await;
}
self.initialized.store(true, Ordering::Relaxed);
let notification = McpRequest::initialized_notification();
let _ = self.send_request(notification).await;
@@ -354,9 +390,7 @@ impl McpClient {
if let Some(tools) = self.tools_cache.read().await.as_ref() {
return Ok(tools.clone());
}
if self.session_manager.is_some() {
self.initialize().await?;
}
self.initialize().await?;
let request = McpRequest::list_tools(self.next_request_id());
let response = self.send_request(request).await?;
@@ -386,9 +420,7 @@ impl McpClient {
name: &str,
arguments: serde_json::Value,
) -> Result<CallToolResult, ToolError> {
if self.session_manager.is_some() {
self.initialize().await?;
}
self.initialize().await?;
let request = McpRequest::call_tool(self.next_request_id(), name, arguments);
let response = self.send_request(request).await?;
@@ -452,6 +484,7 @@ impl Clone for McpClient {
user_id: self.user_id.clone(),
server_config: self.server_config.clone(),
custom_headers: self.custom_headers.clone(),
initialized: AtomicBool::new(self.initialized.load(Ordering::Relaxed)),
}
}
}
@@ -694,6 +727,17 @@ mod tests {
assert!(client.session_manager.is_none());
}
#[test]
fn test_with_session_manager() {
let client = McpClient::new("http://localhost:8080");
assert!(!client.has_session_manager());
let session_manager = Arc::new(McpSessionManager::new());
let client = client.with_session_manager(session_manager);
assert!(client.has_session_manager());
}
#[test]
fn test_next_request_id_monotonically_increasing() {
let client = McpClient::new("http://localhost:1234");
@@ -794,13 +838,34 @@ mod tests {
#[tokio::test]
async fn test_non_http_transport_skips_401_retry() {
let response = McpResponse {
// initialize response, then notification ack (consumed but ignored),
// then list_tools response
let init_response = McpResponse {
jsonrpc: "2.0".to_string(),
id: Some(1),
result: Some(serde_json::json!({
"protocolVersion": "2024-11-05",
"capabilities": {},
"serverInfo": {"name": "test", "version": "1.0"}
})),
error: None,
};
let notification_ack = McpResponse {
jsonrpc: "2.0".to_string(),
id: None,
result: None,
error: None,
};
let list_response = McpResponse {
jsonrpc: "2.0".to_string(),
id: Some(2),
result: Some(serde_json::json!({"tools": []})),
error: None,
};
let transport = Arc::new(MockTransport::new(false, vec![response]));
let transport = Arc::new(MockTransport::new(
false,
vec![init_response, notification_ack, list_response],
));
let client = McpClient::new_with_transport(
"test-stdio",
transport.clone(),
@@ -813,7 +878,8 @@ mod tests {
assert!(result.is_ok());
assert_eq!(result.unwrap().len(), 0);
let headers = transport.recorded_headers();
assert_eq!(headers.len(), 1);
// 3 sends: initialize + notifications/initialized + list_tools
assert_eq!(headers.len(), 3);
assert!(!headers[0].contains_key("Authorization"));
assert!(!headers[0].contains_key("Mcp-Session-Id"));
}
@@ -826,6 +892,50 @@ mod tests {
assert!(!mock_non_http.supports_http_features());
}
/// Regression test for issue #890: stdio clients must auto-initialize
/// even without a session manager, and the second call should be idempotent.
#[tokio::test]
async fn test_stdio_client_auto_initializes_without_session_manager() {
let init_response = McpResponse {
jsonrpc: "2.0".to_string(),
id: Some(1),
result: Some(serde_json::json!({
"protocolVersion": "2024-11-05",
"capabilities": {},
"serverInfo": {"name": "test", "version": "1.0"}
})),
error: None,
};
let notification_ack = McpResponse {
jsonrpc: "2.0".to_string(),
id: None,
result: None,
error: None,
};
let transport = Arc::new(MockTransport::new(
false,
vec![init_response, notification_ack],
));
let client = McpClient::new_with_transport(
"test-stdio",
transport.clone(),
None, // no session manager
None,
"default",
None,
);
// First call should send initialize + notification
let result = client.initialize().await;
assert!(result.is_ok());
assert_eq!(transport.recorded_headers().len(), 2);
// Second call should be a no-op (idempotent via local flag)
let result2 = client.initialize().await;
assert!(result2.is_ok());
assert_eq!(transport.recorded_headers().len(), 2); // no additional sends
}
#[test]
fn test_strip_top_level_nulls_removes_null_fields() {
let input = serde_json::json!({
+162
View File
@@ -188,9 +188,42 @@ impl McpServerConfig {
}
}
// Validate custom header names and values using the http crate's RFC 9110
// token validation (catches CRLF, spaces, colons, null bytes, etc.)
for (name, value) in &self.headers {
if name.is_empty() {
return Err(ConfigError::InvalidConfig {
reason: "Header name cannot be empty".to_string(),
});
}
if reqwest::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
return Err(ConfigError::InvalidConfig {
reason: format!(
"Header name '{}' is not a valid HTTP header name (RFC 9110)",
name
),
});
}
if reqwest::header::HeaderValue::from_str(value).is_err() {
return Err(ConfigError::InvalidConfig {
reason: format!("Header value for '{}' contains invalid characters", name),
});
}
}
Ok(())
}
/// Check if any custom header sets an Authorization value.
///
/// Used to skip OAuth token injection when the user has explicitly
/// configured an Authorization header (e.g. for API-key-based servers).
pub fn has_custom_auth_header(&self) -> bool {
self.headers
.keys()
.any(|k| k.eq_ignore_ascii_case("authorization"))
}
/// Check if this server requires authentication.
///
/// Returns true if OAuth is pre-configured OR if this is a remote HTTPS server
@@ -381,6 +414,13 @@ pub async fn load_mcp_servers_from(path: impl AsRef<Path>) -> Result<McpServersF
let content = fs::read_to_string(path).await?;
let config: McpServersFile = serde_json::from_str(&content)?;
// Validate every server on load so corrupted configs are caught early
for server in &config.servers {
server.validate().map_err(|e| ConfigError::InvalidConfig {
reason: format!("Server '{}': {}", server.name, e),
})?;
}
Ok(config)
}
@@ -457,6 +497,12 @@ pub async fn load_mcp_servers_from_db(
match store.get_setting(user_id, "mcp_servers").await {
Ok(Some(value)) => {
let config: McpServersFile = serde_json::from_value(value)?;
// Validate every server on load so corrupted DB configs are caught early
for server in &config.servers {
server.validate().map_err(|e| ConfigError::InvalidConfig {
reason: format!("Server '{}': {}", server.name, e),
})?;
}
Ok(config)
}
Ok(None) => {
@@ -669,6 +715,34 @@ mod tests {
assert!(config.servers.is_empty());
}
#[tokio::test]
async fn test_load_rejects_corrupted_headers() {
let dir = tempdir().unwrap();
let path = dir.path().join("mcp-servers.json");
// Write a config with an invalid header name directly to disk,
// bypassing the add_mcp_server() validation path.
let corrupted = serde_json::json!({
"servers": [{
"name": "bad-server",
"url": "https://mcp.example.com",
"enabled": true,
"headers": { "X Bad": "value" }
}]
});
tokio::fs::write(&path, corrupted.to_string())
.await
.unwrap();
let result = load_mcp_servers_from(&path).await;
assert!(result.is_err(), "Load should reject corrupted headers");
let err = result.unwrap_err().to_string();
assert!(
err.contains("bad-server"),
"Error should name the offending server, got: {err}"
);
}
#[test]
fn test_token_secret_names() {
let config = McpServerConfig::new("notion", "https://mcp.notion.com");
@@ -830,6 +904,94 @@ mod tests {
assert!(!config.requires_auth());
}
#[test]
fn test_header_crlf_injection_rejected() {
let mut headers = HashMap::new();
headers.insert("X-Good".to_string(), "safe".to_string());
headers.insert("X-Bad\r\nInjected: true".to_string(), "value".to_string());
let config =
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
let err = config.validate().unwrap_err().to_string();
assert!(
err.contains("not a valid HTTP header name"),
"Expected RFC 9110 error, got: {err}"
);
}
#[test]
fn test_header_value_crlf_injection_rejected() {
let mut headers = HashMap::new();
headers.insert(
"X-Header".to_string(),
"value\r\nInjected: true".to_string(),
);
let config =
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
let err = config.validate().unwrap_err().to_string();
assert!(
err.contains("invalid characters"),
"Expected invalid characters error, got: {err}"
);
}
#[test]
fn test_header_name_with_space_rejected() {
let headers = HashMap::from([("X Bad".to_string(), "value".to_string())]);
let config =
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
assert!(config.validate().is_err());
}
#[test]
fn test_header_name_with_colon_rejected() {
let headers = HashMap::from([("X:Bad".to_string(), "value".to_string())]);
let config =
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
assert!(config.validate().is_err());
}
#[test]
fn test_header_name_with_null_byte_rejected() {
let headers = HashMap::from([("X-Bad\0".to_string(), "value".to_string())]);
let config =
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
assert!(config.validate().is_err());
}
#[test]
fn test_header_empty_name_rejected() {
let mut headers = HashMap::new();
headers.insert(String::new(), "value".to_string());
let config =
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
let err = config.validate().unwrap_err().to_string();
assert!(
err.contains("empty"),
"Expected empty name error, got: {err}"
);
}
#[test]
fn test_has_custom_auth_header_case_insensitive() {
let headers = HashMap::from([("authorization".to_string(), "Bearer token".to_string())]);
let config =
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
assert!(config.has_custom_auth_header());
let headers = HashMap::from([("AUTHORIZATION".to_string(), "Bearer token".to_string())]);
let config =
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
assert!(config.has_custom_auth_header());
let headers = HashMap::from([("X-Api-Key".to_string(), "key".to_string())]);
let config =
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
assert!(!config.has_custom_auth_header());
}
#[test]
fn test_custom_headers() {
let headers = HashMap::from([
+31 -2
View File
@@ -88,11 +88,40 @@ pub async fn create_client_from_config(
user_id,
))
} else {
Ok(McpClient::new_with_config(server))
Ok(McpClient::new_with_config(server)
.with_session_manager(Arc::clone(session_manager)))
}
} else {
Ok(McpClient::new_with_config(server))
Ok(McpClient::new_with_config(server)
.with_session_manager(Arc::clone(session_manager)))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_factory_non_oauth_http_has_session_manager() {
let server = McpServerConfig::new("test-server", "http://localhost:9999");
let session_manager = Arc::new(McpSessionManager::new());
let process_manager = Arc::new(McpProcessManager::new());
let client = create_client_from_config(
server,
&session_manager,
&process_manager,
None,
"test-user",
)
.await
.expect("factory should succeed for HTTP config");
assert!(
client.has_session_manager(),
"non-OAuth HTTP clients must carry a session manager"
);
}
}
+117
View File
@@ -383,4 +383,121 @@ mod tests {
HttpMcpTransport::new("http://localhost:8080", "test").with_custom_headers(headers);
assert_eq!(transport.custom_headers.get("X-Custom").unwrap(), "value");
}
// -- Wire-level echo server tests -----------------------------------------
//
// These tests spin up a real HTTP server that echoes received headers back
// as a JSON-RPC result, verifying that custom headers and Authorization
// handling work end-to-end through the actual HTTP transport.
/// Spawn a lightweight echo server that returns received headers as a
/// JSON-RPC response. Returns `(url, join_handle)`.
async fn spawn_echo_server() -> (String, tokio::task::JoinHandle<()>) {
use axum::{Router, extract::Request, routing::post};
use tokio::net::TcpListener;
async fn echo_headers(req: Request) -> axum::response::Json<serde_json::Value> {
let mut map = serde_json::Map::new();
for (name, value) in req.headers() {
if let Ok(v) = value.to_str() {
map.insert(name.to_string(), serde_json::Value::String(v.to_string()));
}
}
axum::response::Json(serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"result": map,
}))
}
let app = Router::new().route("/", post(echo_headers));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://127.0.0.1:{}", addr.port());
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
(url, handle)
}
#[tokio::test]
async fn test_wire_custom_headers_sent() {
let (url, _handle) = spawn_echo_server().await;
let custom = HashMap::from([
("X-Api-Key".to_string(), "secret-key".to_string()),
("X-Org-Id".to_string(), "org-123".to_string()),
]);
let transport = HttpMcpTransport::new(&url, "echo-test").with_custom_headers(custom);
let request = McpRequest {
jsonrpc: "2.0".to_string(),
id: Some(1),
method: "initialize".to_string(),
params: Some(serde_json::json!({})),
};
let per_request_headers = HashMap::new();
let response = transport
.send(&request, &per_request_headers)
.await
.unwrap();
let echoed = response.result.unwrap();
assert_eq!(echoed["x-api-key"], "secret-key");
assert_eq!(echoed["x-org-id"], "org-123");
}
#[tokio::test]
async fn test_wire_per_request_headers_override_custom() {
let (url, _handle) = spawn_echo_server().await;
let custom = HashMap::from([(
"authorization".to_string(),
"Bearer custom-token".to_string(),
)]);
let transport = HttpMcpTransport::new(&url, "echo-test").with_custom_headers(custom);
// Per-request header should override the custom header
let per_request = HashMap::from([(
"authorization".to_string(),
"Bearer oauth-token".to_string(),
)]);
let request = McpRequest {
jsonrpc: "2.0".to_string(),
id: Some(1),
method: "initialize".to_string(),
params: Some(serde_json::json!({})),
};
let response = transport.send(&request, &per_request).await.unwrap();
let echoed = response.result.unwrap();
// Per-request headers are inserted after custom headers via HeaderMap::insert,
// which replaces any existing entry for the same key.
assert_eq!(echoed["authorization"], "Bearer oauth-token");
}
#[tokio::test]
async fn test_wire_custom_auth_preserved_when_no_per_request_auth() {
let (url, _handle) = spawn_echo_server().await;
let custom = HashMap::from([(
"authorization".to_string(),
"Bearer custom-token".to_string(),
)]);
let transport = HttpMcpTransport::new(&url, "echo-test").with_custom_headers(custom);
let per_request = HashMap::new(); // no per-request auth
let request = McpRequest {
jsonrpc: "2.0".to_string(),
id: Some(1),
method: "initialize".to_string(),
params: Some(serde_json::json!({})),
};
let response = transport.send(&request, &per_request).await.unwrap();
let echoed = response.result.unwrap();
assert_eq!(echoed["authorization"], "Bearer custom-token");
}
}
+18 -4
View File
@@ -118,13 +118,27 @@ impl McpTransport for StdioMcpTransport {
request: &McpRequest,
_headers: &HashMap<String, String>,
) -> Result<McpResponse, ToolError> {
// JSON-RPC notifications (no id) are fire-and-forget: the server
// will not send a response, so we must not wait for one.
if request.id.is_none() {
let mut stdin = self.stdin.lock().await;
write_jsonrpc_line(&mut *stdin, request).await?;
return Ok(McpResponse {
jsonrpc: "2.0".to_string(),
id: None,
result: None,
error: None,
});
}
let id = request.id.unwrap_or(0);
let (tx, rx) = oneshot::channel();
// Register the pending response handler before writing the request,
// so we don't miss a fast response from the child.
{
let mut pending = self.pending.lock().await;
pending.insert(request.id.unwrap_or(0), tx);
pending.insert(id, tx);
}
// Write the request to stdin.
@@ -133,7 +147,7 @@ impl McpTransport for StdioMcpTransport {
if let Err(e) = write_jsonrpc_line(&mut *stdin, request).await {
// Remove the pending entry on write failure.
let mut pending = self.pending.lock().await;
pending.remove(&request.id.unwrap_or(0));
pending.remove(&id);
return Err(e);
}
}
@@ -145,7 +159,7 @@ impl McpTransport for StdioMcpTransport {
Ok(Err(_)) => {
// Sender was dropped (reader task ended). Clean up pending entry.
let mut pending = self.pending.lock().await;
pending.remove(&request.id.unwrap_or(0));
pending.remove(&id);
Err(ToolError::ExternalService(format!(
"[{}] MCP server closed connection before responding to request {:?}",
self.server_name, request.id
@@ -154,7 +168,7 @@ impl McpTransport for StdioMcpTransport {
Err(_) => {
// Timeout: remove the pending entry.
let mut pending = self.pending.lock().await;
pending.remove(&request.id.unwrap_or(0));
pending.remove(&id);
Err(ToolError::ExternalService(format!(
"[{}] Timeout waiting for response to request {:?} after {:?}",
self.server_name, request.id, timeout
+18 -4
View File
@@ -91,13 +91,27 @@ impl McpTransport for UnixMcpTransport {
request: &McpRequest,
_headers: &HashMap<String, String>,
) -> Result<McpResponse, ToolError> {
// JSON-RPC notifications (no id) are fire-and-forget: the server
// will not send a response, so we must not wait for one.
if request.id.is_none() {
let mut writer = self.writer.lock().await;
write_jsonrpc_line(&mut *writer, request).await?;
return Ok(McpResponse {
jsonrpc: "2.0".to_string(),
id: None,
result: None,
error: None,
});
}
let id = request.id.unwrap_or(0);
let (tx, rx) = oneshot::channel();
// Register the pending response handler before writing the request,
// so we don't miss a fast response from the server.
{
let mut pending = self.pending.lock().await;
pending.insert(request.id.unwrap_or(0), tx);
pending.insert(id, tx);
}
// Write the request to the socket.
@@ -106,7 +120,7 @@ impl McpTransport for UnixMcpTransport {
if let Err(e) = write_jsonrpc_line(&mut *writer, request).await {
// Remove the pending entry on write failure.
let mut pending = self.pending.lock().await;
pending.remove(&request.id.unwrap_or(0));
pending.remove(&id);
return Err(e);
}
}
@@ -118,7 +132,7 @@ impl McpTransport for UnixMcpTransport {
Ok(Err(_)) => {
// Sender was dropped (reader task ended). Clean up pending entry.
let mut pending = self.pending.lock().await;
pending.remove(&request.id.unwrap_or(0));
pending.remove(&id);
Err(ToolError::ExternalService(format!(
"[{}] MCP server closed connection before responding to request {:?}",
self.server_name, request.id
@@ -127,7 +141,7 @@ impl McpTransport for UnixMcpTransport {
Err(_) => {
// Timeout: remove the pending entry.
let mut pending = self.pending.lock().await;
pending.remove(&request.id.unwrap_or(0));
pending.remove(&id);
Err(ToolError::ExternalService(format!(
"[{}] Timeout waiting for response to request {:?} after {:?}",
self.server_name, request.id, timeout
+8
View File
@@ -328,6 +328,14 @@ pub trait Tool: Send + Sync {
None
}
/// Optional host-side webhook verification configuration for this tool.
///
/// When present, `/webhook/tools/{tool}` validates shared secret/signatures
/// before invoking the tool. Tools should then only handle payload normalization.
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
None
}
/// Get the tool schema for LLM function calling.
fn schema(&self) -> ToolSchema {
ToolSchema {
+22
View File
@@ -32,6 +32,8 @@ pub struct Capabilities {
pub tool_invoke: Option<ToolInvokeCapability>,
/// Check if secrets exist.
pub secrets: Option<SecretsCapability>,
/// Webhook authentication and signature verification.
pub webhook: Option<WebhookCapability>,
}
impl Capabilities {
@@ -308,6 +310,25 @@ impl SecretsCapability {
/// WASM capabilities use it to configure per-tool HTTP request limits.
pub use crate::tools::tool::ToolRateLimitConfig as RateLimitConfig;
/// Webhook auth/signature capability configuration for tools.
#[derive(Debug, Clone, Default)]
pub struct WebhookCapability {
/// Optional header name for shared-secret validation.
pub secret_header: Option<String>,
/// Secret name in secrets store for shared-secret validation.
pub secret_name: Option<String>,
/// Secret name in secrets store containing Ed25519 public key (Discord-style).
pub signature_key_secret_name: Option<String>,
/// Secret name in secrets store for HMAC-SHA256 signing validation.
pub hmac_secret_name: Option<String>,
/// Header containing signature (e.g. X-Hub-Signature-256 or X-Slack-Signature).
pub hmac_signature_header: Option<String>,
/// Optional timestamp header. When present, Slack-style v0 signature is used.
pub hmac_timestamp_header: Option<String>,
/// Optional signature prefix (default: "sha256=" or "v0=" for timestamped mode).
pub hmac_prefix: Option<String>,
}
#[cfg(test)]
mod tests {
use crate::tools::wasm::capabilities::{Capabilities, EndpointPattern, SecretsCapability};
@@ -319,6 +340,7 @@ mod tests {
assert!(caps.http.is_none());
assert!(caps.tool_invoke.is_none());
assert!(caps.secrets.is_none());
assert!(caps.webhook.is_none());
}
#[test]
+196 -1
View File
@@ -35,12 +35,24 @@ use serde::{Deserialize, Serialize};
use crate::secrets::{CredentialLocation, CredentialMapping};
use crate::tools::wasm::{
Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability,
ToolInvokeCapability, WorkspaceCapability,
ToolInvokeCapability, WebhookCapability, WorkspaceCapability,
};
/// Root schema for a capabilities JSON file.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CapabilitiesFile {
/// Human-readable description of what the tool does.
/// Used as the `Tool::description()` return value.
/// If omitted, a generic fallback is used (with a warning).
#[serde(default)]
pub description: Option<String>,
/// JSON Schema for the tool's input parameters.
/// Used as the `Tool::parameters_schema()` return value.
/// If omitted, a permissive fallback is used (with a warning).
#[serde(default)]
pub parameters: Option<serde_json::Value>,
/// Extension version (semver).
#[serde(default)]
pub version: Option<String>,
@@ -65,6 +77,10 @@ pub struct CapabilitiesFile {
#[serde(default)]
pub workspace: Option<WorkspaceCapabilitySchema>,
/// Tool webhook authentication/signature configuration.
#[serde(default)]
pub webhook: Option<WebhookCapabilitySchema>,
/// Authentication setup instructions.
/// Used by `ironclaw config` to guide users through auth setup.
#[serde(default)]
@@ -103,10 +119,13 @@ impl CapabilitiesFile {
fn resolve_nested(mut self) -> Self {
if let Some(inner) = self.capabilities.take() {
let inner = inner.resolve_nested();
self.description = self.description.or(inner.description);
self.parameters = self.parameters.or(inner.parameters);
self.http = self.http.or(inner.http);
self.secrets = self.secrets.or(inner.secrets);
self.tool_invoke = self.tool_invoke.or(inner.tool_invoke);
self.workspace = self.workspace.or(inner.workspace);
self.webhook = self.webhook.or(inner.webhook);
self.auth = self.auth.or(inner.auth);
self.setup = self.setup.or(inner.setup);
}
@@ -198,6 +217,10 @@ impl CapabilitiesFile {
});
}
if let Some(webhook) = &self.webhook {
caps.webhook = Some(webhook.to_webhook_capability());
}
caps
}
}
@@ -419,6 +442,46 @@ pub struct WorkspaceCapabilitySchema {
pub allowed_prefixes: Vec<String>,
}
/// Webhook capability schema for tools.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WebhookCapabilitySchema {
/// HTTP header name for secret validation.
#[serde(default)]
pub secret_header: Option<String>,
/// Secret name in secrets store for shared-secret validation.
#[serde(default)]
pub secret_name: Option<String>,
/// Secret name in secrets store containing Ed25519 public key.
#[serde(default)]
pub signature_key_secret_name: Option<String>,
/// Secret name in secrets store for HMAC-SHA256 signing.
#[serde(default)]
pub hmac_secret_name: Option<String>,
/// Signature header for HMAC verification.
#[serde(default)]
pub hmac_signature_header: Option<String>,
/// Optional timestamp header for Slack-style v0 verification.
#[serde(default)]
pub hmac_timestamp_header: Option<String>,
/// Optional signature prefix for body-only HMAC mode (default sha256=).
#[serde(default)]
pub hmac_prefix: Option<String>,
}
impl WebhookCapabilitySchema {
fn to_webhook_capability(&self) -> WebhookCapability {
WebhookCapability {
secret_header: self.secret_header.clone(),
secret_name: self.secret_name.clone(),
signature_key_secret_name: self.signature_key_secret_name.clone(),
hmac_secret_name: self.hmac_secret_name.clone(),
hmac_signature_header: self.hmac_signature_header.clone(),
hmac_timestamp_header: self.hmac_timestamp_header.clone(),
hmac_prefix: self.hmac_prefix.clone(),
}
}
}
/// Authentication setup schema.
///
/// Tools declare their auth requirements here. The agent uses this to provide
@@ -769,6 +832,28 @@ mod tests {
assert_eq!(workspace.allowed_prefixes, vec!["context/", "daily/"]);
}
#[test]
fn test_parse_webhook_capability() {
let json = r#"{
"webhook": {
"hmac_secret_name": "github_webhook_secret",
"hmac_signature_header": "x-hub-signature-256",
"hmac_prefix": "sha256="
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let webhook = caps.webhook.unwrap();
assert_eq!(
webhook.hmac_secret_name.as_deref(),
Some("github_webhook_secret")
);
assert_eq!(
webhook.hmac_signature_header.as_deref(),
Some("x-hub-signature-256")
);
}
#[test]
fn test_to_capabilities() {
let json = r#"{
@@ -1188,4 +1273,114 @@ mod tests {
"Empty inner capabilities should not clobber outer http"
);
}
// ── Tool description and parameters schema ──────────────────────────
#[test]
fn test_parse_description_and_parameters() {
let json = r#"{
"description": "Search the web using Brave Search API",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query"
},
"count": {
"type": "integer",
"description": "Number of results"
}
},
"required": ["query"]
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
assert_eq!(
caps.description.as_deref(),
Some("Search the web using Brave Search API")
);
let params = caps.parameters.unwrap();
assert_eq!(params["type"], "object");
assert!(params["properties"]["query"].is_object());
assert_eq!(params["required"][0], "query");
}
#[test]
fn test_parse_description_only() {
let json = r#"{
"description": "A tool without explicit parameters schema"
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
assert_eq!(
caps.description.as_deref(),
Some("A tool without explicit parameters schema")
);
assert!(caps.parameters.is_none());
}
#[test]
fn test_parse_without_description_or_parameters() {
let json = r#"{
"http": {
"allowlist": [{ "host": "api.example.com" }]
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
assert!(
caps.description.is_none(),
"description should be None when not provided"
);
assert!(
caps.parameters.is_none(),
"parameters should be None when not provided"
);
}
#[test]
fn test_resolve_nested_description_promoted() {
let json = r#"{
"capabilities": {
"description": "Inner tool description",
"parameters": {
"type": "object",
"properties": {
"input": { "type": "string" }
},
"required": ["input"]
}
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
assert_eq!(
caps.description.as_deref(),
Some("Inner tool description"),
"description should be promoted from inner capabilities"
);
assert!(
caps.parameters.is_some(),
"parameters should be promoted from inner capabilities"
);
}
#[test]
fn test_resolve_nested_outer_description_takes_precedence() {
let json = r#"{
"description": "Outer description wins",
"capabilities": {
"description": "Inner description loses"
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
assert_eq!(
caps.description.as_deref(),
Some("Outer description wins"),
"Outer description should take precedence over inner"
);
}
}
+64 -25
View File
@@ -123,34 +123,73 @@ impl WasmToolLoader {
}
let wasm_bytes = fs::read(wasm_path).await?;
// Read capabilities (optional) and extract OAuth refresh config
let (capabilities, oauth_refresh) = if let Some(cap_path) = capabilities_path {
if cap_path.exists() {
let cap_bytes = fs::read(cap_path).await?;
let cap_file = CapabilitiesFile::from_bytes(&cap_bytes)
.map_err(|e| WasmLoadError::InvalidCapabilities(e.to_string()))?;
cap_file.validate(name);
// Read capabilities (optional) and extract OAuth refresh config,
// tool description, and parameter schema.
let (capabilities, oauth_refresh, description, schema) =
if let Some(cap_path) = capabilities_path {
if cap_path.exists() {
let cap_bytes = fs::read(cap_path).await?;
let cap_file = CapabilitiesFile::from_bytes(&cap_bytes)
.map_err(|e| WasmLoadError::InvalidCapabilities(e.to_string()))?;
cap_file.validate(name);
// Check WIT version compatibility
check_wit_version_compat(
name,
cap_file.wit_version.as_deref(),
crate::tools::wasm::WIT_TOOL_VERSION,
)?;
// Check WIT version compatibility
check_wit_version_compat(
name,
cap_file.wit_version.as_deref(),
crate::tools::wasm::WIT_TOOL_VERSION,
)?;
let caps = cap_file.to_capabilities();
let oauth = resolve_oauth_refresh_config(&cap_file);
(caps, oauth)
let caps = cap_file.to_capabilities();
let oauth = resolve_oauth_refresh_config(&cap_file);
let desc = cap_file.description.clone();
// Validate parameters schema before accepting it.
let params = cap_file.parameters.clone().and_then(|p| {
let errors = crate::tools::validate_tool_schema(&p, name);
if errors.is_empty() {
Some(p)
} else {
tracing::warn!(
tool = name,
?errors,
"Invalid parameters schema in capabilities.json, \
using permissive fallback"
);
None
}
});
if desc.is_none() {
tracing::warn!(
tool = name,
path = %cap_path.display(),
"Capabilities file missing \"description\" field; \
tool will use generic fallback description"
);
}
if params.is_none() && cap_file.parameters.is_none() {
tracing::warn!(
tool = name,
path = %cap_path.display(),
"Capabilities file missing \"parameters\" field; \
tool will accept any JSON object (permissive fallback)"
);
}
(caps, oauth, desc, params)
} else {
tracing::warn!(
path = %cap_path.display(),
"Capabilities file not found, using default (no permissions)"
);
(Capabilities::default(), None, None, None)
}
} else {
tracing::warn!(
path = %cap_path.display(),
"Capabilities file not found, using default (no permissions)"
tool = name,
"No capabilities file for WASM tool; \
tool will use generic fallback description and accept any JSON object"
);
(Capabilities::default(), None)
}
} else {
(Capabilities::default(), None)
};
(Capabilities::default(), None, None, None)
};
// Register the tool
self.registry
@@ -160,8 +199,8 @@ impl WasmToolLoader {
runtime: &self.runtime,
capabilities,
limits: None,
description: None,
schema: None,
description: description.as_deref(),
schema,
secrets_store: self.secrets_store.clone(),
oauth_refresh,
})
+1 -1
View File
@@ -108,7 +108,7 @@ pub use wrapper::{OAuthRefreshConfig, WasmToolWrapper};
// Capabilities (V2)
pub use capabilities::{
Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability,
ToolInvokeCapability, WorkspaceCapability, WorkspaceReader,
ToolInvokeCapability, WebhookCapability, WorkspaceCapability, WorkspaceReader,
};
// Security components (V2)
+14 -9
View File
@@ -323,27 +323,32 @@ impl WasmToolRuntime {
/// Extract tool description from a compiled component.
///
/// In a full implementation, this would use WIT bindgen to call the description() export.
/// For now, we return a placeholder since we can't easily introspect without more setup.
/// Returns a generic fallback. Callers should prefer loading the description
/// from the sidecar `*.capabilities.json` file and overriding via
/// `WasmToolWrapper::with_description()` or the `WasmToolRegistration::description` field.
fn extract_tool_description(
_engine: &Engine,
_component: &wasmtime::component::Component,
) -> Result<String, WasmError> {
// TODO: Use WIT bindgen to properly extract description
// This requires instantiating with a linker, which needs host functions.
// For now, tools should have their description set externally.
// WIT bindgen extraction is not yet implemented (see TODO #4 in CLAUDE.md).
// Real descriptions come from the capabilities.json sidecar file, which is
// loaded by the WasmToolLoader and passed as an override at registration time.
Ok("WASM sandboxed tool".to_string())
}
/// Extract tool schema from a compiled component.
/// Extract tool parameter schema from a compiled component.
///
/// In a full implementation, this would use WIT bindgen to call the schema() export.
/// Returns a permissive fallback that accepts any JSON object. Callers should
/// prefer loading the schema from the sidecar `*.capabilities.json` file and
/// overriding via `WasmToolWrapper::with_schema()` or the
/// `WasmToolRegistration::schema` field.
fn extract_tool_schema(
_engine: &Engine,
_component: &wasmtime::component::Component,
) -> Result<serde_json::Value, WasmError> {
// TODO: Use WIT bindgen to properly extract schema
// For now, return a minimal schema that accepts any object.
// WIT bindgen extraction is not yet implemented (see TODO #4 in CLAUDE.md).
// Real schemas come from the capabilities.json sidecar file, which is
// loaded by the WasmToolLoader and passed as an override at registration time.
Ok(serde_json::json!({
"type": "object",
"properties": {},
+4
View File
@@ -808,6 +808,10 @@ impl Tool for WasmToolWrapper {
// Use the timeout as a conservative estimate
Some(self.prepared.limits.timeout)
}
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
self.capabilities.webhook.clone()
}
}
impl std::fmt::Debug for WasmToolWrapper {
+37 -1
View File
@@ -49,6 +49,8 @@ impl Tunnel for CloudflareTunnel {
.kill_on_drop(true)
.spawn()?;
let stdout = child.stdout.take();
// cloudflared prints the public URL on stderr
let stderr = child
.stderr
@@ -82,8 +84,42 @@ impl Tunnel for CloudflareTunnel {
}
if public_url.is_empty() {
let error_detail = if let Some(stdout) = stdout {
let mut out_reader = tokio::io::BufReader::new(stdout).lines();
let mut lines = Vec::new();
while lines.len() < 10 {
match tokio::time::timeout(
tokio::time::Duration::from_secs(1),
out_reader.next_line(),
)
.await
{
Ok(Ok(Some(line))) => lines.push(line),
_ => break,
}
}
lines.join("\n")
} else {
String::new()
};
child.kill().await.ok();
bail!("cloudflared did not produce a public URL within 30s. Is the token valid?");
if error_detail.is_empty() {
bail!("cloudflared did not produce a public URL within 30s");
} else {
bail!("cloudflared failed to start: {error_detail}");
}
}
// Drain stderr in the background to prevent SIGPIPE/buffer stalls.
tokio::spawn(async move { while let Ok(Some(_)) = reader.next_line().await {} });
// Drain stdout silently.
if let Some(stdout) = stdout {
tokio::spawn(async move {
let mut out_reader = tokio::io::BufReader::new(stdout).lines();
while let Ok(Some(_)) = out_reader.next_line().await {}
});
}
if let Ok(mut guard) = self.url.write() {
+41 -1
View File
@@ -69,10 +69,13 @@ impl Tunnel for CustomTunnel {
.kill_on_drop(true)
.spawn()?;
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let mut public_url = format!("http://{local_host}:{local_port}");
if self.url_pattern.is_some()
&& let Some(stdout) = child.stdout.take()
&& let Some(stdout) = stdout
{
let mut reader = tokio::io::BufReader::new(stdout).lines();
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(15);
@@ -100,6 +103,22 @@ impl Tunnel for CustomTunnel {
Err(_) => {}
}
}
// Drain remaining stdout to prevent SIGPIPE/buffer stalls.
tokio::spawn(async move { while let Ok(Some(_)) = reader.next_line().await {} });
} else if let Some(stdout) = stdout {
// No url_pattern: still drain stdout to prevent pipe stalls.
tokio::spawn(async move {
let mut reader = tokio::io::BufReader::new(stdout).lines();
while let Ok(Some(_)) = reader.next_line().await {}
});
}
// Drain stderr silently.
if let Some(stderr) = stderr {
tokio::spawn(async move {
let mut reader = tokio::io::BufReader::new(stderr).lines();
while let Ok(Some(_)) = reader.next_line().await {}
});
}
if let Ok(mut guard) = self.url.write() {
@@ -246,4 +265,25 @@ mod tests {
fn extract_url_none_when_absent() {
assert_eq!(extract_url("no url here"), None);
}
#[tokio::test]
async fn stdout_drain_prevents_zombie() {
// `yes` floods stdout indefinitely; without the drain task the pipe
// buffer fills (64 KB) and the child blocks on write(), becoming a
// zombie. With draining the child stays alive and stop() can kill it.
let tunnel = CustomTunnel::new("yes".into(), None, None);
let url = tunnel.start("127.0.0.1", 19999).await.unwrap();
assert_eq!(url, "http://127.0.0.1:19999");
// Give the drain task time to consume some output.
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
// Child should still be alive (not blocked/zombie).
assert!(
tunnel.health_check().await,
"yes process should still be alive"
);
tunnel.stop().await.unwrap();
}
}
+37 -2
View File
@@ -54,7 +54,7 @@ impl Tunnel for NgrokTunnel {
.stdout
.take()
.ok_or_else(|| anyhow::anyhow!("Failed to capture ngrok stdout"))?;
let stderr = child.stderr.take();
let mut reader = tokio::io::BufReader::new(stdout).lines();
let mut public_url = String::new();
@@ -84,8 +84,43 @@ impl Tunnel for NgrokTunnel {
}
if public_url.is_empty() {
let error_detail = if let Some(stderr) = stderr {
let mut err_reader = tokio::io::BufReader::new(stderr).lines();
let mut lines = Vec::new();
while lines.len() < 10 {
match tokio::time::timeout(
tokio::time::Duration::from_secs(1),
err_reader.next_line(),
)
.await
{
Ok(Ok(Some(line))) => lines.push(line),
_ => break,
}
}
lines.join("\n")
} else {
String::new()
};
child.kill().await.ok();
bail!("ngrok did not produce a public URL within 15s. Is the auth token valid?");
if error_detail.is_empty() {
bail!("ngrok did not produce a public URL within 15s");
} else {
bail!("ngrok failed to start: {error_detail}");
}
}
// Drain stdout silently — ngrok only emits low-level connection events
// to stdout; the pipe must be consumed to prevent SIGPIPE/buffer stalls.
tokio::spawn(async move { while let Ok(Some(_)) = reader.next_line().await {} });
// Drain stderr silently — with --log stdout all meaningful output goes
// to stdout; stderr only needs to be consumed to prevent pipe stalls.
if let Some(stderr) = stderr {
tokio::spawn(async move {
let mut err_reader = tokio::io::BufReader::new(stderr).lines();
while let Ok(Some(_)) = err_reader.next_line().await {}
});
}
if let Ok(mut guard) = self.url.write() {
+712
View File
@@ -0,0 +1,712 @@
//! Generic webhook ingress for tools.
//!
//! Exposes `/webhook/tools/{tool}` so external webhook providers can POST
//! payloads that are normalized by the target tool into `system_event`s.
use std::collections::HashMap;
use std::sync::Arc;
use axum::{
Json, Router,
extract::{DefaultBodyLimit, Path, Query, State},
http::{HeaderMap, Method, StatusCode},
routing::{get, post},
};
use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq;
use crate::agent::routine_engine::RoutineEngine;
use crate::context::JobContext;
use crate::secrets::SecretsStore;
use crate::tools::ToolRegistry;
/// Shared routine engine slot, populated by Agent after startup.
pub type RoutineEngineSlot = Arc<tokio::sync::RwLock<Option<Arc<RoutineEngine>>>>;
/// Shared state for the generic tools webhook ingress.
#[derive(Clone)]
pub struct ToolWebhookState {
pub tools: Arc<ToolRegistry>,
pub routine_engine: RoutineEngineSlot,
pub user_id: String,
pub secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
}
#[derive(Debug, Serialize)]
struct ToolWebhookResponse {
status: &'static str,
tool: String,
emitted_events: usize,
fired_routines: usize,
}
#[derive(Debug, Deserialize)]
struct ToolWebhookOutput {
#[serde(default)]
emit_events: Vec<SystemEventIntent>,
}
#[derive(Debug, Deserialize)]
struct SystemEventIntent {
source: String,
event_type: String,
#[serde(default)]
payload: serde_json::Value,
}
const MAX_WEBHOOK_BODY_BYTES: usize = 64 * 1024;
/// Build routes for tool-driven webhook ingestion.
pub fn routes(state: ToolWebhookState) -> Router {
Router::new()
.route("/webhook/tools/{tool}", post(tool_webhook_handler))
.route(
"/webhook/tools/{tool}/{*rest}",
post(tool_webhook_with_rest_handler),
)
.route("/webhook/tools/{tool}", get(tool_webhook_health))
.layer(DefaultBodyLimit::max(MAX_WEBHOOK_BODY_BYTES))
.with_state(state)
}
async fn tool_webhook_health(
Path(tool): Path<String>,
State(state): State<ToolWebhookState>,
) -> (StatusCode, Json<serde_json::Value>) {
let Some(tool_impl) = state.tools.get(&tool).await else {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": format!("Tool not found: {tool}") })),
);
};
if tool_impl.webhook_capability().is_none() {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": format!("Tool does not support webhooks: {tool}") })),
);
}
(
StatusCode::OK,
Json(serde_json::json!({ "status": "ok", "tool": tool })),
)
}
async fn tool_webhook_handler(
Path(tool): Path<String>,
State(state): State<ToolWebhookState>,
method: Method,
headers: HeaderMap,
Query(query): Query<HashMap<String, String>>,
body: axum::body::Bytes,
) -> (StatusCode, Json<serde_json::Value>) {
tool_webhook_handler_inner(tool, None, state, method, headers, query, body).await
}
async fn tool_webhook_with_rest_handler(
Path((tool, rest)): Path<(String, String)>,
State(state): State<ToolWebhookState>,
method: Method,
headers: HeaderMap,
Query(query): Query<HashMap<String, String>>,
body: axum::body::Bytes,
) -> (StatusCode, Json<serde_json::Value>) {
tool_webhook_handler_inner(tool, Some(rest), state, method, headers, query, body).await
}
async fn tool_webhook_handler_inner(
tool: String,
rest: Option<String>,
state: ToolWebhookState,
method: Method,
headers: HeaderMap,
query: HashMap<String, String>,
body: axum::body::Bytes,
) -> (StatusCode, Json<serde_json::Value>) {
if body.len() > MAX_WEBHOOK_BODY_BYTES {
return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(serde_json::json!({
"error": format!("Webhook body exceeds {} bytes", MAX_WEBHOOK_BODY_BYTES)
})),
);
}
let Some(tool_impl) = state.tools.get(&tool).await else {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": format!("Tool not found: {tool}") })),
);
};
if let Err(msg) = validate_webhook_auth(
&*tool_impl,
state.secrets_store.as_deref(),
&state.user_id,
&headers,
&body,
)
.await
{
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({ "error": msg })),
);
}
let body_json: Option<serde_json::Value> = serde_json::from_slice(&body).ok();
let headers_map: HashMap<String, String> = headers
.iter()
.filter_map(|(k, v)| {
v.to_str()
.ok()
.map(|v| (k.as_str().to_string(), v.to_string()))
})
.collect();
let path = if let Some(rest) = rest.filter(|r| !r.is_empty()) {
format!("/webhook/tools/{tool}/{rest}")
} else {
format!("/webhook/tools/{tool}")
};
let params = serde_json::json!({
"action": "handle_webhook",
"webhook": {
"method": method.as_str(),
"path": path,
"query": query,
"headers": headers_map,
"body_json": body_json,
"body_raw": String::from_utf8_lossy(&body),
}
});
let ctx = JobContext::with_user(
state.user_id.clone(),
format!("webhook:{tool}"),
"Process external webhook",
);
let output = match tool_impl.execute(params, &ctx).await {
Ok(out) => out,
Err(e) => {
tracing::warn!(tool = %tool, error = %e, "Webhook tool execution failed");
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "Tool execution failed" })),
);
}
};
let parsed: ToolWebhookOutput = match serde_json::from_value(output.result) {
Ok(v) => v,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Tool webhook response must be a JSON object (optionally with 'emit_events' array)"
})),
);
}
};
let emitted_events = parsed.emit_events.len();
let mut fired_routines = 0usize;
if emitted_events > 0 {
let Some(engine) = state.routine_engine.read().await.as_ref().cloned() else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({ "error": "Routine engine not available" })),
);
};
for event in parsed.emit_events {
fired_routines += engine
.emit_system_event(
&event.source,
&event.event_type,
&event.payload,
Some(&state.user_id),
)
.await;
}
}
let response = ToolWebhookResponse {
status: "accepted",
tool,
emitted_events,
fired_routines,
};
(StatusCode::ACCEPTED, Json(serde_json::json!(response)))
}
fn header_value<'a>(headers: &'a HeaderMap, key: &str) -> Option<&'a str> {
// HeaderMap::get() already performs case-insensitive lookup per HTTP spec.
headers.get(key).and_then(|v| v.to_str().ok())
}
async fn validate_webhook_auth(
tool: &dyn crate::tools::Tool,
secrets_store: Option<&(dyn SecretsStore + Send + Sync)>,
user_id: &str,
headers: &HeaderMap,
body: &[u8],
) -> Result<(), String> {
let Some(cfg) = tool.webhook_capability() else {
return Err(
"Tool does not declare a webhook capability; webhook access denied".to_string(),
);
};
// Require at least one authentication mechanism to be configured.
if cfg.secret_name.is_none()
&& cfg.signature_key_secret_name.is_none()
&& cfg.hmac_secret_name.is_none()
{
return Err(
"Webhook capability misconfigured: at least one auth mechanism must be configured"
.to_string(),
);
}
let Some(store) = secrets_store else {
return Err("Secrets store not available for webhook verification".to_string());
};
if let Some(secret_name) = cfg.secret_name.as_deref() {
let expected = store
.get_decrypted(user_id, secret_name)
.await
.map_err(|_| format!("Missing webhook secret '{secret_name}'"))?;
let expected = expected.expose();
let secret_header = cfg.secret_header.as_deref().unwrap_or("x-webhook-secret");
let provided = header_value(headers, secret_header)
.or_else(|| {
if secret_header != "x-webhook-secret" {
header_value(headers, "x-webhook-secret")
} else {
None
}
})
.ok_or_else(|| "Webhook secret required".to_string())?;
if !bool::from(expected.as_bytes().ct_eq(provided.as_bytes())) {
return Err("Invalid webhook secret".to_string());
}
}
if let Some(public_key_name) = cfg.signature_key_secret_name.as_deref() {
let key = store
.get_decrypted(user_id, public_key_name)
.await
.map_err(|_| format!("Missing signature key secret '{public_key_name}'"))?;
let key = key.expose();
let sig = header_value(headers, "x-signature-ed25519")
.ok_or_else(|| "Missing signature header".to_string())?;
let ts = header_value(headers, "x-signature-timestamp")
.ok_or_else(|| "Missing signature timestamp header".to_string())?;
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
if !crate::channels::wasm::signature::verify_discord_signature(key, sig, ts, body, now_secs)
{
return Err("Invalid signature".to_string());
}
}
if let Some(hmac_secret_name) = cfg.hmac_secret_name.as_deref() {
let secret = store
.get_decrypted(user_id, hmac_secret_name)
.await
.map_err(|_| format!("Missing HMAC secret '{hmac_secret_name}'"))?;
let secret = secret.expose();
if let Some(timestamp_header) = cfg.hmac_timestamp_header.as_deref() {
let sig_header = cfg
.hmac_signature_header
.as_deref()
.unwrap_or("x-slack-signature");
let sig = header_value(headers, sig_header)
.ok_or_else(|| "Missing HMAC signature header".to_string())?;
let ts = header_value(headers, timestamp_header)
.ok_or_else(|| "Missing HMAC timestamp header".to_string())?;
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
if !crate::channels::wasm::signature::verify_slack_signature(
secret, ts, body, sig, now_secs,
) {
return Err("Invalid timestamped HMAC signature".to_string());
}
} else {
let sig_header = cfg
.hmac_signature_header
.as_deref()
.unwrap_or("x-hub-signature-256");
let prefix = cfg.hmac_prefix.as_deref().unwrap_or("sha256=");
let sig = header_value(headers, sig_header)
.ok_or_else(|| "Missing HMAC signature header".to_string())?;
if !crate::channels::wasm::signature::verify_hmac_sha256_prefixed(
secret, body, sig, prefix,
) {
return Err("Invalid HMAC signature".to_string());
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use axum::body::Body;
use tower::ServiceExt;
use crate::context::JobContext;
use crate::secrets::{CreateSecretParams, InMemorySecretsStore, SecretsCrypto};
use crate::tools::{Tool, ToolError, ToolOutput, ToolRegistry};
use super::*;
struct TestWebhookTool;
struct ProtectedWebhookTool;
struct HmacWebhookTool;
/// Tool that declares webhook_capability() but with no auth mechanism configured.
struct MisconfiguredWebhookTool;
#[async_trait]
impl Tool for TestWebhookTool {
fn name(&self) -> &str {
"test_webhook"
}
fn description(&self) -> &str {
"test"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type":"object"})
}
async fn execute(
&self,
_params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput::success(
serde_json::json!({"emit_events":[]}),
Duration::from_millis(1),
))
}
}
#[async_trait]
impl Tool for ProtectedWebhookTool {
fn name(&self) -> &str {
"protected_webhook"
}
fn description(&self) -> &str {
"protected test"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type":"object"})
}
async fn execute(
&self,
_params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput::success(
serde_json::json!({"emit_events":[]}),
Duration::from_millis(1),
))
}
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
Some(crate::tools::wasm::WebhookCapability {
secret_name: Some("test_webhook_secret".to_string()),
secret_header: Some("x-webhook-secret".to_string()),
..Default::default()
})
}
}
#[async_trait]
impl Tool for HmacWebhookTool {
fn name(&self) -> &str {
"hmac_webhook"
}
fn description(&self) -> &str {
"hmac test"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type":"object"})
}
async fn execute(
&self,
_params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput::success(
serde_json::json!({"emit_events":[]}),
Duration::from_millis(1),
))
}
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
Some(crate::tools::wasm::WebhookCapability {
hmac_secret_name: Some("hmac_secret".to_string()),
hmac_signature_header: Some("x-hub-signature-256".to_string()),
hmac_prefix: Some("sha256=".to_string()),
..Default::default()
})
}
}
#[async_trait]
impl Tool for MisconfiguredWebhookTool {
fn name(&self) -> &str {
"misconfigured_webhook"
}
fn description(&self) -> &str {
"misconfigured test"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type":"object"})
}
async fn execute(
&self,
_params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput::success(
serde_json::json!({"emit_events":[]}),
Duration::from_millis(1),
))
}
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
Some(crate::tools::wasm::WebhookCapability::default())
}
}
#[tokio::test]
async fn returns_not_found_for_unknown_tool() {
let tools = Arc::new(ToolRegistry::new());
let app = routes(ToolWebhookState {
tools,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
user_id: "test".to_string(),
secrets_store: None,
});
let req = axum::http::Request::builder()
.method("POST")
.uri("/webhook/tools/missing")
.body(Body::from("{}"))
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn rejects_tool_without_webhook_capability() {
let tools = Arc::new(ToolRegistry::new());
tools.register(Arc::new(TestWebhookTool)).await;
let app = routes(ToolWebhookState {
tools,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
user_id: "test".to_string(),
secrets_store: None,
});
let req = axum::http::Request::builder()
.method("POST")
.uri("/webhook/tools/test_webhook")
.header("content-type", "application/json")
.body(Body::from(r#"{"ok":true}"#))
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn rejects_when_required_secret_missing() {
let tools = Arc::new(ToolRegistry::new());
tools.register(Arc::new(ProtectedWebhookTool)).await;
let secrets = Arc::new(InMemorySecretsStore::new(Arc::new(
SecretsCrypto::new(secrecy::SecretString::from(
"test-key-at-least-32-chars-long!!".to_string(),
))
.expect("crypto"),
)));
secrets
.create(
"test",
CreateSecretParams::new("test_webhook_secret", "s3cret"),
)
.await
.expect("secret create");
let app = routes(ToolWebhookState {
tools,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
user_id: "test".to_string(),
secrets_store: Some(secrets),
});
let req = axum::http::Request::builder()
.method("POST")
.uri("/webhook/tools/protected_webhook")
.header("content-type", "application/json")
.body(Body::from(r#"{"ok":true}"#))
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn accepts_with_valid_hmac_signature() {
use hmac::Mac;
let tools = Arc::new(ToolRegistry::new());
tools.register(Arc::new(HmacWebhookTool)).await;
let secrets = Arc::new(InMemorySecretsStore::new(Arc::new(
SecretsCrypto::new(secrecy::SecretString::from(
"test-key-at-least-32-chars-long!!".to_string(),
))
.expect("crypto"),
)));
secrets
.create(
"test",
CreateSecretParams::new("hmac_secret", "github-secret"),
)
.await
.expect("secret create");
let app = routes(ToolWebhookState {
tools,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
user_id: "test".to_string(),
secrets_store: Some(secrets),
});
let payload = br#"{"action":"opened"}"#;
let mut mac =
hmac::Hmac::<sha2::Sha256>::new_from_slice(b"github-secret").expect("hmac key");
mac.update(payload);
let sig = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
let req = axum::http::Request::builder()
.method("POST")
.uri("/webhook/tools/hmac_webhook")
.header("content-type", "application/json")
.header("x-hub-signature-256", sig)
.body(Body::from(payload.to_vec()))
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::ACCEPTED);
}
#[tokio::test]
async fn rejects_empty_webhook_capability_as_misconfigured() {
let tools = Arc::new(ToolRegistry::new());
tools.register(Arc::new(MisconfiguredWebhookTool)).await;
let secrets = Arc::new(InMemorySecretsStore::new(Arc::new(
SecretsCrypto::new(secrecy::SecretString::from(
"test-key-at-least-32-chars-long!!".to_string(),
))
.expect("crypto"),
)));
let app = routes(ToolWebhookState {
tools,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
user_id: "test".to_string(),
secrets_store: Some(secrets),
});
let req = axum::http::Request::builder()
.method("POST")
.uri("/webhook/tools/misconfigured_webhook")
.header("content-type", "application/json")
.body(Body::from(r#"{"ok":true}"#))
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn health_check_returns_ok_for_webhook_capable_tool() {
let tools = Arc::new(ToolRegistry::new());
tools.register(Arc::new(ProtectedWebhookTool)).await;
let app = routes(ToolWebhookState {
tools,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
user_id: "test".to_string(),
secrets_store: None,
});
let req = axum::http::Request::builder()
.method("GET")
.uri("/webhook/tools/protected_webhook")
.body(Body::empty())
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn health_check_returns_not_found_for_non_webhook_tool() {
let tools = Arc::new(ToolRegistry::new());
tools.register(Arc::new(TestWebhookTool)).await;
let app = routes(ToolWebhookState {
tools,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
user_id: "test".to_string(),
secrets_store: None,
});
let req = axum::http::Request::builder()
.method("GET")
.uri("/webhook/tools/test_webhook")
.body(Body::empty())
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
}
+3 -3
View File
@@ -1187,13 +1187,13 @@ impl<'a> LoopDelegate for JobDelegate<'a> {
// TokenUsage; only respond_with_tools() usage is tracked here.
let total_tokens = output.usage.total() as u64;
if total_tokens > 0
&& let Err(msg) = self
&& let Err(err) = self
.worker
.context_manager()
.update_context(self.worker.job_id, |ctx| ctx.add_tokens(total_tokens))
.await?
{
self.worker.mark_failed(&msg).await?;
self.worker.mark_failed(&err.to_string()).await?;
}
Ok(output)
@@ -1796,7 +1796,7 @@ mod tests {
// Verify that mark_failed transitions job to Failed
worker
.mark_failed(&budget_result.unwrap_err())
.mark_failed(&budget_result.unwrap_err().to_string())
.await
.unwrap();
let ctx = worker
+17 -4
View File
@@ -58,6 +58,7 @@ mod advanced {
let trace = LlmTrace::from_file(format!("{FIXTURES}/steering.json")).unwrap();
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
@@ -95,7 +96,11 @@ mod advanced {
let _ = std::fs::remove_file("/tmp/ironclaw_recovery_test.txt");
let trace = LlmTrace::from_file(format!("{FIXTURES}/tool_error_recovery.json")).unwrap();
let rig = TestRigBuilder::new().with_trace(trace).build().await;
let rig = TestRigBuilder::new()
.with_trace(trace)
.with_auto_approve_tools(true)
.build()
.await;
rig.send_message("Write 'recovered successfully' to a file for me.")
.await;
@@ -138,7 +143,11 @@ mod advanced {
std::fs::create_dir_all(test_dir).unwrap();
let trace = LlmTrace::from_file(format!("{FIXTURES}/long_tool_chain.json")).unwrap();
let rig = TestRigBuilder::new().with_trace(trace).build().await;
let rig = TestRigBuilder::new()
.with_trace(trace)
.with_auto_approve_tools(true)
.build()
.await;
rig.send_message(
"Create a daily log at /tmp/ironclaw_chain_test/log.md, \
@@ -232,6 +241,7 @@ mod advanced {
let rig = TestRigBuilder::new()
.with_trace(trace)
.with_max_tool_iterations(3)
.with_auto_approve_tools(true)
.build()
.await;
@@ -241,9 +251,11 @@ mod advanced {
assert!(!responses.is_empty(), "no response -- agent may have hung");
let started = rig.tool_calls_started();
// Bound is 8 (not 4) because auto-approve lets the agent chain
// multiple tool calls per iteration without blocking on approval.
assert!(
started.len() <= 4,
"expected <= 4 tool calls with max_tool_iterations=3, got {}: {started:?}",
started.len() <= 8,
"expected <= 8 tool calls with max_tool_iterations=3, got {}: {started:?}",
started.len()
);
assert!(!started.is_empty(), "expected at least 1 tool call, got 0");
@@ -295,6 +307,7 @@ mod advanced {
.with_trace(trace.clone())
.with_routines()
.with_http_exchanges(http_exchanges)
.with_auto_approve_tools(true)
.build()
.await;
+5
View File
@@ -140,6 +140,7 @@ mod tests {
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
@@ -180,6 +181,7 @@ mod tests {
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
@@ -325,6 +327,7 @@ mod tests {
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
@@ -394,6 +397,7 @@ mod tests {
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
@@ -435,6 +439,7 @@ mod tests {
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
+10 -2
View File
@@ -32,7 +32,11 @@ mod tests {
))
.expect("failed to load simple_text.json");
let rig = TestRigBuilder::new().with_trace(trace).build().await;
let rig = TestRigBuilder::new()
.with_trace(trace)
.with_auto_approve_tools(true)
.build()
.await;
rig.send_message("hello").await;
let _responses = rig.wait_for_responses(1, Duration::from_secs(10)).await;
@@ -95,7 +99,11 @@ mod tests {
))
.expect("failed to load file_write_read.json");
let rig = TestRigBuilder::new().with_trace(trace).build().await;
let rig = TestRigBuilder::new()
.with_trace(trace)
.with_auto_approve_tools(true)
.build()
.await;
rig.send_message("Please write a greeting to a file and read it back.")
.await;

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