Compare commits

..
Author SHA1 Message Date
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
7e8c0fbed6 chore: release v0.18.0 (#885)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-11 17:19:51 +00:00
Henry ParkandGitHub 6aae1f8a9e Merge pull request #907 from nearai/staging-promote/b0214fef-22930316561
chore: promote staging to main (2026-03-11 00:16 UTC)
2026-03-11 10:09:44 -07:00
Henry ParkandGitHub 7a9396f081 Merge pull request #904 from nearai/staging-promote/3a841b30-22928320566
chore: promote staging to main (2026-03-10 23:06 UTC)
2026-03-11 09:57:35 -07:00
Henry ParkandClaude Opus 4.6 6116c885e3 merge: resolve main into staging-promote (ChannelSecretUpdater import)
Keep ChannelSecretUpdater as a local import inside #[cfg(unix)] block
to avoid unused-import warnings on non-unix targets.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-10 22:27:16 -07:00
+7 a677b20701 chore: promote staging to main (2026-03-10 15:19 UTC) (#865)
* fix: Channel HTTP: server doesn't start after config change (no hot-r… (#779)

* fix: Channel HTTP: server doesn't start after config change (no hot-reload)

* review fixes

* review fixes

* fix linter

* fix code style

* fix: prevent session lock contention blocking message processing (#783)

* fix: prevent session lock contention blocking message processing

## Problem
After container restart, POST /api/chat/send returns 202 ACCEPTED but messages
don't appear in conversation_messages and agent never responds. Messages get
stuck in "stale state" after restart.

Root cause: Session lock was held for entire duration of chat_threads_handler
and chat_history_handler, including during slow database queries. This blocked
the agent loop from acquiring the session lock to process incoming messages,
causing them to hang indefinitely.

## Solution
1. **Release session lock early in chat_threads_handler**: Only acquire lock
   when reading active_thread at response time, not during DB queries for
   thread list. DB operations no longer block message processing.

2. **Release session lock early in chat_history_handler**: Only acquire lock
   when accessing in-memory thread state, not during paginated DB queries or
   thread ownership checks. DB operations no longer block message processing.

3. **Add comprehensive logging**: Track message flow from receipt through
   session resolution, thread hydration, and state transitions. Helps diagnose
   future issues:
   - Message queued to agent loop (chat_send_handler)
   - Processing message from channel (handle_message)
   - Hydrating thread from DB (maybe_hydrate_thread)
   - Resolving session and thread (resolve_thread)
   - Checking thread state (process_user_input)
   - Persisting user message (persist_user_message)

## Impact
- Message processing no longer blocks on session lock contention
- API response times for thread list/history queries unaffected (DB queries
  still happen, but lock is not held)
- Better diagnostics for future debugging

## Testing
- All 2756 tests pass
- Code compiles with zero clippy warnings
- No changes to user-facing API or behavior, only lock timing

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

* security: redact PII from info-level logs

Downgrade user_id and channel logging to debug level to prevent exposing
Personally Identifiable Information (PII) in production logs.

The user_id field can contain sensitive information such as phone numbers
(e.g., for Signal messages). Logging PII in cleartext at the info level
creates a security and privacy risk, as these logs may be stored in
persistent storage, indexed by log management systems, or accessible to
unauthorized personnel.

Changes:
- Info level: logs only message_id (UUID) for tracking
- Debug level: logs user_id, channel, thread_id for troubleshooting

This maintains debugging capability for developers while protecting user
privacy in production logs.

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

---------

Co-authored-by: Claude Haiku 4.5 <[email protected]>

* chore: sync main into staging (#855)

* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)

GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

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

* fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794)

- Remove continue-on-error from staging-ci.yml app token steps (secrets are configured)
- Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml
  already runs tests before promoting, promotion PR gets full CI on main)
- Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs

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

* fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802)

- Remove branches:[main] filter from code_style.yml so it runs on all PRs
- Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs)
- Update rollup job to allow skipped clippy-windows
- Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs)

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

* feat: persist user_id in save_job and expose job_id on routine runs (#709)

* feat: persist worker events to DB and fix activity tab rendering

In-process Worker (used by Scheduler::dispatch_job) now persists events
via save_job_event at key execution points: plan creation, LLM
responses, tool_use, tool_result, and job completion/failure/stuck.
Event data shapes match the container worker format so the gateway
activity tab renders them correctly.

Frontend: tool_result errors now show a red X icon with danger styling
instead of a silent empty output. The result event falls back to the
error field when message is absent.

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

* feat: wire RoutineEngine into gateway for direct manual trigger firing

Replace the message-channel hack in routines_trigger_handler with a
direct call to RoutineEngine::fire_manual(), ensuring FullJob routines
dispatch correctly when triggered from the web UI. Inject the engine
into GatewayState from Agent::run after construction.

Also persists user_id in save_job for both PG and libSQL backends,
removes the source='sandbox' filter so all jobs are visible, and
exposes job_id on RoutineRunInfo for the frontend job link.

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

* fix: remove stale gateway_state argument from Agent::new test call sites

The gateway_state parameter was removed from Agent::new during rebase
(replaced by post-construction set_routine_engine_slot), but three test
call sites still passed the extra None argument.

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

* fix: address PR review — restore sandbox source filter, remove blank lines

- Revert removal of `source = 'sandbox'` filter in all SandboxStore
  queries (8 sites across PG and libSQL). Sandbox-specific APIs should
  stay scoped to sandbox jobs; unified job listing for the Jobs tab
  should use a separate query path.
- Remove extra blank lines in agent_loop.rs and worker.rs that caused
  formatting CI failure.

[skip-regression-check]

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

* fix: address review — regenerate Cargo.lock, add user_id regression test

- Regenerate Cargo.lock from main's lockfile to eliminate dependency
  version downgrades (anyhow, syn, etc.) that were churn from rebase.
- Add regression test verifying user_id round-trips through save_job
  and get_job in the libSQL backend.

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

* style: remove trailing blank line in libsql jobs.rs

[skip-regression-check]

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

* test: add Postgres-side regression test for user_id persistence in save_job

Mirrors the existing libSQL test (test_save_job_persists_user_id) for the
Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore]
since it requires a running PostgreSQL instance (integration tier).

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

---------

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

* feat(llm): per-provider unsupported parameter filtering (#749, #728) (#809)

Add declarative `unsupported_params` field to provider definitions in
providers.json. Parameters listed are stripped from requests before
sending, preventing 400 errors from providers that reject them (e.g.
gpt-5 family and kimi-k2.5 rejecting custom temperature values).

- Add `unsupported_params` to ProviderDefinition and RegistryProviderConfig
- Propagate from registry through config resolution
- Generic strip helpers handle temperature, max_tokens, stop_sequences
- Apply filtering in RigAdapter and AnthropicOAuthProvider
- Mark openai and tinfoil providers as unsupporting temperature
- Update openai default model to gpt-5-mini

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

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Co-authored-by: Illia Polosukhin <[email protected]>

* fix: Chat input is hidden in mobile browser mode (#877)

* fix: stop XML-escaping tool output content (#598) (#874)

* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)

GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

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

* fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794)

- Remove continue-on-error from staging-ci.yml app token steps (secrets are configured)
- Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml
  already runs tests before promoting, promotion PR gets full CI on main)
- Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs

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

* fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802)

- Remove branches:[main] filter from code_style.yml so it runs on all PRs
- Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs)
- Update rollup job to allow skipped clippy-windows
- Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs)

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

* feat: persist user_id in save_job and expose job_id on routine runs (#709)

* feat: persist worker events to DB and fix activity tab rendering

In-process Worker (used by Scheduler::dispatch_job) now persists events
via save_job_event at key execution points: plan creation, LLM
responses, tool_use, tool_result, and job completion/failure/stuck.
Event data shapes match the container worker format so the gateway
activity tab renders them correctly.

Frontend: tool_result errors now show a red X icon with danger styling
instead of a silent empty output. The result event falls back to the
error field when message is absent.

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

* feat: wire RoutineEngine into gateway for direct manual trigger firing

Replace the message-channel hack in routines_trigger_handler with a
direct call to RoutineEngine::fire_manual(), ensuring FullJob routines
dispatch correctly when triggered from the web UI. Inject the engine
into GatewayState from Agent::run after construction.

Also persists user_id in save_job for both PG and libSQL backends,
removes the source='sandbox' filter so all jobs are visible, and
exposes job_id on RoutineRunInfo for the frontend job link.

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

* fix: remove stale gateway_state argument from Agent::new test call sites

The gateway_state parameter was removed from Agent::new during rebase
(replaced by post-construction set_routine_engine_slot), but three test
call sites still passed the extra None argument.

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

* fix: address PR review — restore sandbox source filter, remove blank lines

- Revert removal of `source = 'sandbox'` filter in all SandboxStore
  queries (8 sites across PG and libSQL). Sandbox-specific APIs should
  stay scoped to sandbox jobs; unified job listing for the Jobs tab
  should use a separate query path.
- Remove extra blank lines in agent_loop.rs and worker.rs that caused
  formatting CI failure.

[skip-regression-check]

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

* fix: address review — regenerate Cargo.lock, add user_id regression test

- Regenerate Cargo.lock from main's lockfile to eliminate dependency
  version downgrades (anyhow, syn, etc.) that were churn from rebase.
- Add regression test verifying user_id round-trips through save_job
  and get_job in the libSQL backend.

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

* style: remove trailing blank line in libsql jobs.rs

[skip-regression-check]

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

* test: add Postgres-side regression test for user_id persistence in save_job

Mirrors the existing libSQL test (test_save_job_persists_user_id) for the
Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore]
since it requires a running PostgreSQL instance (integration tier).

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

---------

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

* feat(llm): per-provider unsupported parameter filtering (#749, #728) (#809)

Add declarative `unsupported_params` field to provider definitions in
providers.json. Parameters listed are stripped from requests before
sending, preventing 400 errors from providers that reject them (e.g.
gpt-5 family and kimi-k2.5 rejecting custom temperature values).

- Add `unsupported_params` to ProviderDefinition and RegistryProviderConfig
- Propagate from registry through config resolution
- Generic strip helpers handle temperature, max_tokens, stop_sequences
- Apply filtering in RigAdapter and AnthropicOAuthProvider
- Mark openai and tinfoil providers as unsupporting temperature
- Update openai default model to gpt-5-mini

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

* fix: stop XML-escaping tool output content in wrap_for_llm (#598)

Remove content escaping that corrupted JSON in tool output. The
<tool_output> structural boundary is preserved but content now passes
through raw, fixing downstream parse failures.

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

---------

Co-authored-by: Henry Park <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>

* fix(safety): allow empty string tool params (#848)

* fix(safety): allow empty string tool params

* fix(safety): preserve heuristic checks and add path context to tool validation

This follow-up refactor addresses PR review feedback by restoring
heuristic checks (whitespace ratio, character repetition) for tool
parameter validation and improving error reporting.

Changes:
- Restored heuristic warnings in validate_non_empty_input so they apply
  to both user input and tool parameters (when non-empty).
- Refactored check_strings to recursively build and pass JSON paths
  (e.g., "metadata.tags[1]").
- Updated validation errors to use the specific JSON path as the field
  name instead of the generic "input".
- Added regression tests for whitespace/repetition warnings and JSON
  path reporting in tool parameters.

This ensures the safety layer remains semantically neutral about empty
strings (fixing the memory_tree path: "" issue) while maintaining
rigorous protection and providing better developer ergonomics.

* style: run cargo fmt

* perf: optimize release and dist build profiles (#843)

* perf: optimize release and dist build profiles

Add [profile.release] with strip=true and panic="abort" for smaller,
faster release binaries. Upgrade [profile.dist] from lto="thin" to
lto="fat" with codegen-units=1 for maximum optimization in CI releases.

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

* fix: remove panic=abort from release profile

Reviewers (zmanian, Copilot, Gemini) correctly flagged that panic=abort
in the release profile would kill the entire process on any tokio task
panic, breaking fault isolation for the long-running server. Removed
from release profile entirely.

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

---------

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

* feat: add PR template with risk assessment (#837)

* feat: add PR template with risk assessment and review tracks

Add a pull request template that includes summary, change type,
validation checklist, security/database impact sections, blast radius,
and rollback plan. Update CONTRIBUTING.md with review track definitions
(A/B/C) based on change risk level.

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

* fix: expand CONTRIBUTING.md with setup, workflow, and guidelines

Add getting started, development workflow, code style summary,
database change guidance, and dependency management sections.

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

---------

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

* feat: add fuzzing targets for untrusted input parsers (#835)

* feat: add fuzzing targets for untrusted input parsers

Add cargo-fuzz infrastructure with 5 fuzz targets exercising
security-critical code paths:

- fuzz_safety_sanitizer: Aho-Corasick + regex injection detection
- fuzz_safety_validator: Input validation (length, encoding, patterns)
- fuzz_leak_detector: Secret leak scanning (API keys, tokens)
- fuzz_tool_params: Tool parameter JSON validation
- fuzz_config_env: TOML/JSON config parsing

Each target exercises real IronClaw business logic with invariant
assertions. Includes corpus directories and setup documentation.

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

* fix: improve fuzz targets to exercise real IronClaw code paths

- fuzz_config_env: exercise SafetyLayer end-to-end (sanitize, validate,
  policy check) instead of generic TOML/JSON parsing
- fuzz_tool_params: add validate_tool_schema coverage alongside
  validate_tool_params
- Add "fuzz" to workspace exclude in root Cargo.toml
- Update README descriptions to match actual target behavior

[skip-regression-check]

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

* fix: replace redundant detect() call with meaningful invariant assertion

Replace the double sanitize()+detect() call with an assertion that
critical severity warnings always trigger content modification.

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

* fix: rewrite fuzz_config_env to exercise IronClaw safety code directly

Replace SafetyLayer wrapper usage with direct Sanitizer, Validator, and
LeakDetector instantiation and invocation. Adds meaningful consistency
assertions (non-empty output, valid-means-no-errors, scan/clean agreement).
Removes the config construction that was only exercising struct instantiation.

[skip-regression-check]

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

---------

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

* fix(wasm): run leak scan before credential injection in tools wrapper (#791)

* fix(wasm): run leak scan before credential injection in tools wrapper

The tools WASM wrapper runs the LeakDetector on HTTP request headers
AFTER inject_host_credentials() has already substituted real secrets
(e.g., xoxb- Slack bot tokens). This causes the leak detector to
flag the tool's own legitimate outbound API calls as secret exfiltration.

Move the scan to run on raw_headers before any credential injection,
matching the fix already applied to the channels wrapper in #421.

Fixes the same class of bug as #421 (which only fixed channels/wasm/wrapper.rs).

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

* perf: inline leak scan to avoid Vec allocation on every HTTP request

Address review feedback: instead of cloning all header keys/values into
a Vec to pass to scan_http_request(), iterate over raw_headers directly
using scan_and_clean(). This also provides more specific error messages
(URL vs header vs body).

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

* style: fix cargo fmt formatting in leak scan loop

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

---------

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

* fix(setup): drain residual terminal events before secret input (#747) (#849)

* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)

GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

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

* fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794)

- Remove continue-on-error from staging-ci.yml app token steps (secrets are configured)
- Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml
  already runs tests before promoting, promotion PR gets full CI on main)
- Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs

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

* fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802)

- Remove branches:[main] filter from code_style.yml so it runs on all PRs
- Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs)
- Update rollup job to allow skipped clippy-windows
- Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs)

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

* feat: persist user_id in save_job and expose job_id on routine runs (#709)

* feat: persist worker events to DB and fix activity tab rendering

In-process Worker (used by Scheduler::dispatch_job) now persists events
via save_job_event at key execution points: plan creation, LLM
responses, tool_use, tool_result, and job completion/failure/stuck.
Event data shapes match the container worker format so the gateway
activity tab renders them correctly.

Frontend: tool_result errors now show a red X icon with danger styling
instead of a silent empty output. The result event falls back to the
error field when message is absent.

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

* feat: wire RoutineEngine into gateway for direct manual trigger firing

Replace the message-channel hack in routines_trigger_handler with a
direct call to RoutineEngine::fire_manual(), ensuring FullJob routines
dispatch correctly when triggered from the web UI. Inject the engine
into GatewayState from Agent::run after construction.

Also persists user_id in save_job for both PG and libSQL backends,
removes the source='sandbox' filter so all jobs are visible, and
exposes job_id on RoutineRunInfo for the frontend job link.

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

* fix: remove stale gateway_state argument from Agent::new test call sites

The gateway_state parameter was removed from Agent::new during rebase
(replaced by post-construction set_routine_engine_slot), but three test
call sites still passed the extra None argument.

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

* fix: address PR review — restore sandbox source filter, remove blank lines

- Revert removal of `source = 'sandbox'` filter in all SandboxStore
  queries (8 sites across PG and libSQL). Sandbox-specific APIs should
  stay scoped to sandbox jobs; unified job listing for the Jobs tab
  should use a separate query path.
- Remove extra blank lines in agent_loop.rs and worker.rs that caused
  formatting CI failure.

[skip-regression-check]

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

* fix: address review — regenerate Cargo.lock, add user_id regression test

- Regenerate Cargo.lock from main's lockfile to eliminate dependency
  version downgrades (anyhow, syn, etc.) that were churn from rebase.
- Add regression test verifying user_id round-trips through save_job
  and get_job in the libSQL backend.

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

* style: remove trailing blank line in libsql jobs.rs

[skip-regression-check]

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

* test: add Postgres-side regression test for user_id persistence in save_job

Mirrors the existing libSQL test (test_save_job_persists_user_id) for the
Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore]
since it requires a running PostgreSQL instance (integration tier).

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

---------

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

* feat(llm): per-provider unsupported parameter filtering (#749, #728) (#809)

Add declarative `unsupported_params` field to provider definitions in
providers.json. Parameters listed are stripped from requests before
sending, preventing 400 errors from providers that reject them (e.g.
gpt-5 family and kimi-k2.5 rejecting custom temperature values).

- Add `unsupported_params` to ProviderDefinition and RegistryProviderConfig
- Propagate from registry through config resolution
- Generic strip helpers handle temperature, max_tokens, stop_sequences
- Apply filtering in RigAdapter and AnthropicOAuthProvider
- Mark openai and tinfoil providers as unsupporting temperature
- Update openai default model to gpt-5-mini

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

* fix: skip the regression check
[skip-regression-check]

---------

Co-authored-by: Henry Park <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Co-authored-by: Illia Polosukhin <[email protected]>

* feat(agent): add context size logging before LLM prompt (#810)

* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)

GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

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

* fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794)

- Remove continue-on-error from staging-ci.yml app token steps (secrets are configured)
- Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml
  already runs tests before promoting, promotion PR gets full CI on main)
- Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs

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

* fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802)

- Remove branches:[main] filter from code_style.yml so it runs on all PRs
- Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs)
- Update rollup job to allow skipped clippy-windows
- Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs)

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

* feat: persist user_id in save_job and expose job_id on routine runs (#709)

* feat: persist worker events to DB and fix activity tab rendering

In-process Worker (used by Scheduler::dispatch_job) now persists events
via save_job_event at key execution points: plan creation, LLM
responses, tool_use, tool_result, and job completion/failure/stuck.
Event data shapes match the container worker format so the gateway
activity tab renders them correctly.

Frontend: tool_result errors now show a red X icon with danger styling
instead of a silent empty output. The result event falls back to the
error field when message is absent.

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

* feat: wire RoutineEngine into gateway for direct manual trigger firing

Replace the message-channel hack in routines_trigger_handler with a
direct call to RoutineEngine::fire_manual(), ensuring FullJob routines
dispatch correctly when triggered from the web UI. Inject the engine
into GatewayState from Agent::run after construction.

Also persists user_id in save_job for both PG and libSQL backends,
removes the source='sandbox' filter so all jobs are visible, and
exposes job_id on RoutineRunInfo for the frontend job link.

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

* fix: remove stale gateway_state argument from Agent::new test call sites

The gateway_state parameter was removed from Agent::new during rebase
(replaced by post-construction set_routine_engine_slot), but three test
call sites still passed the extra None argument.

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

* fix: address PR review — restore sandbox source filter, remove blank lines

- Revert removal of `source = 'sandbox'` filter in all SandboxStore
  queries (8 sites across PG and libSQL). Sandbox-specific APIs should
  stay scoped to sandbox jobs; unified job listing for the Jobs tab
  should use a separate query path.
- Remove extra blank lines in agent_loop.rs and worker.rs that caused
  formatting CI failure.

[skip-regression-check]

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

* fix: address review — regenerate Cargo.lock, add user_id regression test

- Regenerate Cargo.lock from main's lockfile to eliminate dependency
  version downgrades (anyhow, syn, etc.) that were churn from rebase.
- Add regression test verifying user_id round-trips through save_job
  and get_job in the libSQL backend.

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

* style: remove trailing blank line in libsql jobs.rs

[skip-regression-check]

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

* test: add Postgres-side regression test for user_id persistence in save_job

Mirrors the existing libSQL test (test_save_job_persists_user_id) for the
Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore]
since it requires a running PostgreSQL instance (integration tier).

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

---------

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

* feat(agent): add context size logging before LLM prompt

---------

Co-authored-by: Henry Park <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Co-authored-by: Illia Polosukhin <[email protected]>

* fix: preserve text before tool-call XML in forced-text responses (#852)

* fix: preserve text before tool-call XML in forced-text responses (#789)

Local models (Qwen3, DeepSeek, GLM) emit <tool_call> XML even when no
tools are available (force_text mode). The existing strip_xml_tag()
discards everything from an unclosed opening tag onward, producing an
empty string that triggers the "I'm not sure how to respond" fallback.

Add truncate_at_tool_tags() — a code-region-aware pre-processing step
that truncates at the first tool-call XML tag BEFORE clean_response()
runs, preserving all useful text before the tag. Protect all 7
clean_response() call sites. Case-insensitive matching handles models
that emit <TOOL_CALL> or <Tool_Call> variants.

Secondary fix: add has_native_thinking() model detection to skip
<think>/<final> system prompt injection for models with built-in
reasoning (Qwen3, QwQ, DeepSeek-R1, GLM-Z1, etc.), preventing
thinking-only responses that clean to empty.

Wire with_model_name(active_model_name()) at all 9 production sites
that construct Reasoning, so the runtime model name (not static config)
drives system prompt generation.

126 new/updated tests covering truncation edge cases, code-block
awareness, Unicode, case-insensitivity, StubLlm integration for
complete/plan/evaluate_success/respond_with_tools paths, model
detection, and conditional system prompt generation.

Closes #789

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

* fix: address Copilot review — unclosed-only truncation, ASCII case folding

- truncate_at_tool_tags() now only truncates at UNCLOSED tool tags;
  properly closed tags (e.g. <tool_call>...</tool_call>) are left intact
  for clean_response() to strip normally, preserving any text after them
- Switch from to_lowercase() to to_ascii_lowercase() to prevent byte
  offset misalignment with non-ASCII characters whose lowercase form
  has different byte length (e.g. Kelvin sign U+212A)
- Add closing_tag_for() helper to derive closing tags from open patterns
- Fix doc comment: "fenced markdown code blocks or inline code spans"
  (not "indented", which find_code_regions() doesn't detect)
- Add regression tests: closed vs unclosed for each tag variant,
  Unicode + case-insensitive offset safety, and mixed closed/unclosed

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

* fix: minor review items — consistent ascii_lowercase, closing_tag_for tests

- Switch has_native_thinking() from to_lowercase() to to_ascii_lowercase()
  for consistency with truncate_at_tool_tags() approach
- Add unit tests for closing_tag_for(): standard tags, space-suffixed
  patterns, pipe-delimited tags, and exhaustive coverage of all
  TOOL_TAG_PATTERNS entries
- Add test for mixed closed+unclosed tags of different types

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

---------

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

* Feat/docker shell edition (#804)

* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)

GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

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

* fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794)

- Remove continue-on-error from staging-ci.yml app token steps (secrets are configured)
- Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml
  already runs tests before promoting, promotion PR gets full CI on main)
- Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs

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

---------

Co-authored-by: Henry Park <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>

* fix(mcp): strip top-level null params before forwarding to MCP servers (#795)

* feat(llm): per-provider unsupported parameter filtering (#749, #728) (#809)

Add declarative `unsupported_params` field to provider definitions in
providers.json. Parameters listed are stripped from requests before
sending, preventing 400 errors from providers that reject them (e.g.
gpt-5 family and kimi-k2.5 rejecting custom temperature values).

- Add `unsupported_params` to ProviderDefinition and RegistryProviderConfig
- Propagate from registry through config resolution
- Generic strip helpers handle temperature, max_tokens, stop_sequences
- Apply filtering in RigAdapter and AnthropicOAuthProvider
- Mark openai and tinfoil providers as unsupporting temperature
- Update openai default model to gpt-5-mini

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

* fix(mcp): strip top-level null params before forwarding to MCP servers

LLMs frequently emit `"field": null` for optional parameters in tool
calls. Many MCP servers reject explicit nulls for fields that should
simply be absent — e.g. Notion returns 400 for `"sort": null` in a
search call, expecting the field to be omitted entirely.

Strip top-level null keys from the params object before calling
`call_tool()`. Only top-level keys are stripped; nested nulls are
preserved since they may be semantically meaningful.

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

---------

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

* Add event-triggered routines and workflow skill templates (#756)

* Add event-triggered routines and workflow skill templates

* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)

GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

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

* fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794)

- Remove continue-on-error from staging-ci.yml app token steps (secrets are configured)
- Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml
  already runs tests before promoting, promotion PR gets full CI on main)
- Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs

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

* fix: address PR review feedback for event_emit security and quality

Security fixes:
- Require approval (UnlessAutoApproved) for event_emit, matching routine_fire
- Enable sanitization on event_emit payload (external JSON reaches LLM)
- Remove user_id parameter from event_emit to prevent IDOR — always use ctx.user_id

Correctness fixes:
- Rename source → event_source in event_emit for consistency with routine_create
- Use json_value_as_filter_string for filter parsing (handles numbers/booleans)
- Case-insensitive matching for event source and event_type
- Add debug logging for missing filter keys in payload
- Fix skill_install_routine_webhook_sim test missing .with_skills()
- Fix schema_validator test for event_emit payload properties

Code quality:
- Move EventEmitTool struct/impl after RoutineHistoryTool (fix split layout)
- Deduplicate routine_to_info into RoutineInfo::from_routine in types.rs
- Add test section headers in e2e_routine_heartbeat.rs
- Clarify event_emit description to specify system_event routines only

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

* fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802)

- Remove branches:[main] filter from code_style.yml so it runs on all PRs
- Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs)
- Update rollup job to allow skipped clippy-windows
- Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs)

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

* feat: persist user_id in save_job and expose job_id on routine runs (#709)

* feat: persist worker events to DB and fix activity tab rendering

In-process Worker (used by Scheduler::dispatch_job) now persists events
via save_job_event at key execution points: plan creation, LLM
responses, tool_use, tool_result, and job completion/failure/stuck.
Event data shapes match the container worker format so the gateway
activity tab renders them correctly.

Frontend: tool_result errors now show a red X icon with danger styling
instead of a silent empty output. The result event falls back to the
error field when message is absent.

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

* feat: wire RoutineEngine into gateway for direct manual trigger firing

Replace the message-channel hack in routines_trigger_handler with a
direct call to RoutineEngine::fire_manual(), ensuring FullJob routines
dispatch correctly when triggered from the web UI. Inject the engine
into GatewayState from Agent::run after construction.

Also persists user_id in save_job for both PG and libSQL backends,
removes the source='sandbox' filter so all jobs are visible, and
exposes job_id on RoutineRunInfo for the frontend job link.

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

* fix: remove stale gateway_state argument from Agent::new test call sites

The gateway_state parameter was removed from Agent::new during rebase
(replaced by post-construction set_routine_engine_slot), but three test
call sites still passed the extra None argument.

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

* fix: address PR review — restore sandbox source filter, remove blank lines

- Revert removal of `source = 'sandbox'` filter in all SandboxStore
  queries (8 sites across PG and libSQL). Sandbox-specific APIs should
  stay scoped to sandbox jobs; unified job listing for the Jobs tab
  should use a separate query path.
- Remove extra blank lines in agent_loop.rs and worker.rs that caused
  formatting CI failure.

[skip-regression-check]

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

* fix: address review — regenerate Cargo.lock, add user_id regression test

- Regenerate Cargo.lock from main's lockfile to eliminate dependency
  version downgrades (anyhow, syn, etc.) that were churn from rebase.
- Add regression test verifying user_id round-trips through save_job
  and get_job in the libSQL backend.

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

* style: remove trailing blank line in libsql jobs.rs

[skip-regression-check]

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

* test: add Postgres-side regression test for user_id persistence in save_job

Mirrors the existing libSQL test (test_save_job_persists_user_id) for the
Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore]
since it requires a running PostgreSQL instance (integration tier).

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

---------

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

* fix: make routine_system_event_emit test create routine before emitting

- Add routine_create step to trace fixture so event_emit has a matching
  routine to fire
- Assert fired_routines > 0, not just key presence (Copilot review)
- Add .with_auto_approve_tools(true) since event_emit now requires approval

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

* fix: renumber test headers after system_event test insertion

Test 4 was duplicated (routine_cooldown and heartbeat_findings).
Renumber heartbeat_findings to Test 5 and heartbeat_empty_skip to Test 6.

[skip-regression-check]

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

* fix: merge staging and add missing RoutineEngine args in test

RoutineEngine::new on staging requires `tools` and `safety` params.
Update system_event_trigger_matches_and_filters test to pass them.

[skip-regression-check]

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

* fix: address new Copilot review comments

- Add .with_auto_approve_tools(true) to skill_install_routine_webhook_sim
  test so event_emit doesn't block on approval
- Fix module-level doc comment for event_emit to specify system_event trigger

[skip-regression-check]

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

* fix: deduplicate json_value_as_string helper

Remove private `json_value_as_string` from routine_engine.rs and use
the identical public `json_value_as_filter_string` from routine.rs,
eliminating divergence risk. (Copilot review)

[skip-regression-check]

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

---------

Co-authored-by: Henry Park <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>

* fix: enable WASM credential injection in No-DB environments (#845)

* fix(wasm): enable credential injection in no-DB environments via env var fallback

When a secrets store is unavailable (e.g. no-DB mode), WASM channel
credentials were silently not injected, causing channels to start without
credentials. Fix by:

- Changing `inject_channel_credentials_from_secrets` to accept
  `Option<&dyn SecretsStore>` — secrets store is tried first when present
- Adding env var fallback (`inject_env_credentials`) for credentials not
  covered by the secrets store
- Enforcing a channel-name prefix security check on env var names to
  prevent WASM channels from reading unrelated host credentials
  (e.g. `AWS_SECRET_ACCESS_KEY`)
- Extracting pure `resolve_env_credentials` helper for testability
- Adding case-insensitive prefix matching for secrets store lookup

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

* fix(wasm): inject credentials at startup when no secrets store (setup.rs path)

The startup path (setup_wasm_channels -> register_channel) was guarded by
`if let Some(secrets) = secrets_store`, so in No-DB mode credentials were
never injected and the channel started without them.

Fix by:
- Changing inject_channel_credentials to accept Option<&dyn SecretsStore>
- Always calling it (removing the if-let guard) — env var fallback runs
  even when secrets_store is None
- Adding channel-name prefix security check to the env var fallback path
  (e.g. TELEGRAM_ for channel "telegram"), consistent with manager.rs

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

* fix(test): correct misleading comment on ICTEST1_UNRELATED_OTHER placeholder

* fix(wasm): guard against empty channel name in credential injection

An empty channel_name would produce prefix "_", allowing any env var
starting with "_" to pass the security check and be injected. Add an
early-return guard in resolve_env_credentials, inject_env_credentials,
and inject_channel_credentials. Add a test to cover this path.

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

---------

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

* fix: promote to main (#878)

* fix: replace unsafe env::set_var with thread-safe inject_single_var in SIGHUP handler

Fixes race condition where SIGHUP handler modifies global environment variables
while other threads may be reading them via Config::from_env().

Changes:
- Replace unsafe { std::env::set_var() } with ironclaw::config::inject_single_var()
- Uses INJECTED_VARS mutex instead of unsafe global state modification
- All reads via optional_env() check the thread-safe overlay first
- Prevents data races between SIGHUP reload and concurrent config reads

Verification:
- All 2,787 tests pass
- Zero clippy warnings
- Code compiles successfully

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

* fix: spawn webhook restart as background task to avoid blocking I/O across lock

Prevents holding Mutex lock during async I/O operations (TcpListener::bind,
task shutdown). The SIGHUP handler no longer blocks webhook processing during
listener restart.

Changes:
- Read old_addr and drop lock immediately
- Spawn restart_with_addr() as background task via tokio::spawn
- Lock is only held during the actual restart operation, not the signal handler

Benefits:
- SIGHUP handler returns immediately without blocking
- Webhook requests not delayed by listener restart I/O
- Lock contention significantly reduced

Verification:
- All 2,787 tests pass
- Zero clippy warnings
- Code compiles successfully

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

* fix: add graceful shutdown mechanism for SIGHUP handler background task

Prevents unbounded loop without cancellation token. The SIGHUP handler now
listens for a shutdown signal and exits cleanly during graceful termination.

Changes:
- Create broadcast channel for shutdown signaling
- SIGHUP handler uses tokio::select! to wait for shutdown or SIGHUP
- Send shutdown signal to all background tasks after agent.run() completes
- Ensures clean task lifecycle and no orphaned background tasks

Benefits:
- Proper task cancellation during graceful shutdown
- Follows Tokio best practices for background task management
- No background tasks orphaned when runtime shuts down

Verification:
- All 2,787 tests pass
- Zero clippy warnings
- Code compiles successfully

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

* refactor: replace stringly-typed parameter filtering with typed enum and single helper

Fixes DRY violation where unsupported parameter filtering was duplicated across
rig_adapter.rs and anthropic_oauth.rs using string contains checks.

Changes:
- Add UnsupportedParam typed enum in provider.rs (Temperature, MaxTokens, StopSequences)
- Create strip_unsupported_completion_params() helper function
- Create strip_unsupported_tool_params() helper function
- Update rig_adapter.rs to use shared helpers
- Update anthropic_oauth.rs to use shared helpers
- Replace 60+ lines of duplicate stringly-typed logic

Benefits:
- Type safety: parameter names checked at compile time
- Single source of truth: adding a new param updates one place
- Reduced maintenance burden: no duplicate logic to keep in sync
- Better code clarity: named enum variant is self-documenting

Verification:
- All 2,787 tests pass
- Zero clippy warnings
- Code compiles successfully

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

* docs: clarify intentional parameter asymmetry between completion and tool requests

Add documentation explaining why strip_unsupported_tool_params does not handle
StopSequences: the field doesn't exist in ToolCompletionRequest.

Changes:
- Add clarifying comments to strip_unsupported_tool_params()
- Explain why StopSequences is only in CompletionRequest
- Note that ToolCompletionRequest only supports Temperature and MaxTokens
- Inline comment confirms no action needed for StopSequences

This addresses the appearance of incomplete implementation without changing logic,
as the asymmetry is intentional and correct (ToolCompletionRequest lacks the field).

Verification:
- All 2,787 tests pass
- Zero clippy warnings
- Code compiles successfully

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

* perf: isolate webhook_secret to reduce lock contention on hot path

Move webhook_secret from shared HttpChannelState RwLock into its own Arc<RwLock<>>.
This eliminates contention between secret validation and other state operations.

Changes:
- Change webhook_secret field type from RwLock<Option<SecretString>> to Arc<RwLock<Option<SecretString>>>
- Update initialization in HttpChannel::new()
- Update comments to explain isolation rationale

Benefits:
- Reduce lock contention on webhook request hot path (secret validation)
- Rarely-changing field (SIGHUP only) isolated from frequent state accesses
- Other state operations (tx, pending_responses) no longer wait behind secret reads
- Minimal code change: only field declaration and initialization

The Arc wrapper allows cloning the RwLock handle to separate concerns. With this
change, every webhook request acquires its own isolated lock for secret validation,
not the shared HttpChannelState lock. This scales better under high request volume.

Verification:
- All 2,787 tests pass
- Zero clippy warnings
- Code compiles successfully

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

* fix: prevent partial state corruption on SIGHUP restart failure

Ensure atomicity of configuration reload: if webhook listener restart fails,
secret update is skipped to prevent inconsistent state.

Changes:
- Wait for restart_with_addr() to complete (don't spawn background task)
- Track restart result with restart_failed flag
- Only update secret if restart succeeded or wasn't needed
- Ensure listener and secret stay synchronized

Problem addressed:
- Before: restart spawned as background task, secret updated immediately
- If restart failed, secret was changed but listener still on old address
- This left system in inconsistent state (partial corruption)

Solution:
- Make restart blocking (SIGHUP handler can wait, it's not on request hot path)
- Atomically update secret only after successful restart
- Flag prevents race between restart and secret update

Benefits:
- Configuration changes are atomic (both succeed or both fail together)
- No partial state corruption on restart failure
- Failed restarts don't silently leave inconsistent state
- Secret and listener address stay in sync

Verification:
- All 2,787 tests pass
- Zero clippy warnings
- Code compiles successfully

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

* refactor: generalize hot-secret-swapping with ChannelSecretUpdater trait

Decouple SIGHUP handler from HTTP channel internals by introducing a trait
for channels that support zero-downtime secret updates.

Changes:
- Add ChannelSecretUpdater trait in channels/channel.rs
- Implement ChannelSecretUpdater for HttpChannelState
- Export trait from channels module
- Update SIGHUP handler to use trait-based secret updater collection
- Replace explicit HTTP channel knowledge with generic updater loop

Benefits:
- SIGHUP handler no longer depends on HttpChannelState details
- Tight coupling removed: main.rs doesn't need HTTP channel imports
- Extensible: new channels can opt-in by implementing the trait
- Scalable: multiple channels supported without main.rs changes
- Maintainable: adding channels requires only trait implementation, not SIGHUP handler edits

Pattern:
- ChannelSecretUpdater trait defines the interface for all updaters
- Channels that support hot-secret-swapping implement the trait
- SIGHUP handler loops through all registered updaters generically

Verification:
- All 2,787 tests pass
- Zero clippy warnings
- Code compiles successfully

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

* feat: validate parameter names at deserialization time, not just tests

Add custom serde deserializer for unsupported_params that validates parameter
names at runtime when loading providers.json (or user overrides).

Changes:
- Add unsupported_params_de module with custom deserializer
- Only allows: "temperature", "max_tokens", "stop_sequences"
- Invalid parameter names cause immediate deserialization error
- Update ProviderDefinition to use custom deserializer
- Enhanced test with explicit parameter name validation
- Add new test that verifies invalid parameters are rejected

Problem solved:
- Before: Invalid param names (e.g., "temperrature") silently ignored
- Now: Rejected at deserialization time with clear error message
- Prevents runtime failures caused by typos in configuration

Example error:
  unsupported parameter name 'temperrature': must be one of: temperature, max_tokens, stop_sequences

Benefits:
- Fail-fast: errors caught when loading config, not at runtime
- Clear feedback: error message lists valid parameter names
- Type safety: validators run during deserialization
- Configuration errors detected immediately, not silently ignored

Verification:
- All 2,788 tests pass (including new validation test)
- Zero clippy warnings
- Code compiles successfully

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

---------

Co-authored-by: Claude Haiku 4.5 <[email protected]>

* merge: resolve conflicts for PR #800 and #822 into staging (#881)

* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)

GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

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

* fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794)

- Remove continue-on-error from staging-ci.yml app token steps (secrets are configured)
- Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml
  already runs tests before promoting, promotion PR gets full CI on main)
- Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs

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

* fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802)

- Remove branches:[main] filter from code_style.yml so it runs on all PRs
- Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs)
- Update rollup job to allow skipped clippy-windows
- Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs)

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

* feat: persist user_id in save_job and expose job_id on routine runs (#709)

* feat: persist worker events to DB and fix activity tab rendering

In-process Worker (used by Scheduler::dispatch_job) now persists events
via save_job_event at key execution points: plan creation, LLM
responses, tool_use, tool_result, and job completion/failure/stuck.
Event data shapes match the container worker format so the gateway
activity tab renders them correctly.

Frontend: tool_result errors now show a red X icon with danger styling
instead of a silent empty output. The result event falls back to the
error field when message is absent.

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

* feat: wire RoutineEngine into gateway for direct manual trigger firing

Replace the message-channel hack in routines_trigger_handler with a
direct call to RoutineEngine::fire_manual(), ensuring FullJob routines
dispatch correctly when triggered from the web UI. Inject the engine
into GatewayState from Agent::run after construction.

Also persists user_id in save_job for both PG and libSQL backends,
removes the source='sandbox' filter so all jobs are visible, and
exposes job_id on RoutineRunInfo for the frontend job link.

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

* fix: remove stale gateway_state argument from Agent::new test call sites

The gateway_state parameter was removed from Agent::new during rebase
(replaced by post-construction set_routine_engine_slot), but three test
call sites still passed the extra None argument.

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

* fix: address PR review — restore sandbox source filter, remove blank lines

- Revert removal of `source = 'sandbox'` filter in all SandboxStore
  queries (8 sites across PG and libSQL). Sandbox-specific APIs should
  stay scoped to sandbox jobs; unified job listing for the Jobs tab
  should use a separate query path.
- Remove extra blank lines in agent_loop.rs and worker.rs that caused
  formatting CI failure.

[skip-regression-check]

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

* fix: address review — regenerate Cargo.lock, add user_id regression test

- Regenerate Cargo.lock from main's lockfile to eliminate dependency
  version downgrades (anyhow, syn, etc.) that were churn from rebase.
- Add regression test verifying user_id round-trips through save_job
  and get_job in the libSQL backend.

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

* style: remove trailing blank line in libsql jobs.rs

[skip-regression-check]

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

* test: add Postgres-side regression test for user_id persistence in save_job

Mirrors the existing libSQL test (test_save_job_persists_user_id) for the
Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore]
since it requires a running PostgreSQL instance (integration tier).

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

---------

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

* refactor: unify three agentic loops into single AgenticLoop engine (#654)

Replace three independent copy-pasted agentic loops (dispatcher, worker,
container runtime) with a single shared engine in `agentic_loop.rs` that
all consumers customize via the `LoopDelegate` trait.

Phase 1 — Shared engine (`src/agent/agentic_loop.rs`, 205 lines):
  - `run_agentic_loop()` owns the core LLM → tool exec → repeat cycle
  - `LoopDelegate` trait (Send + Sync, &dyn dispatch) with 6 hook points
  - Tool intent nudge logic consolidated (was duplicated in 3 files)
  - Iteration limit + force-text behavior preserved

Phase 2 — Three delegate implementations:
  - `ChatDelegate` (dispatcher.rs): 3-phase approval flow, hooks, cost
    guard, context compaction, skill attenuation, interruption
  - `JobDelegate` (worker/job.rs): planning pre-loop phase, parallel
    JoinSet exec, mark_completed/stuck/failed, SSE streaming, self-repair
  - `ContainerDelegate` (worker/container.rs): sequential tool exec,
    HTTP-proxied LLM, container-safe tools, credential injection

Phase 3 — File moves and cleanup:
  - Delete `src/agent/worker.rs` — job logic moved to `src/worker/job.rs`
  - Rename `src/worker/runtime.rs` → `src/worker/container.rs`
  - Re-export `Worker`/`WorkerDeps` from `crate::worker` in `agent/mod.rs`
  - Update `scheduler.rs` imports to new worker location

Shared helpers (`src/tools/execute.rs`):
  - `execute_tool_with_safety()` replaces 4 copies of validate → timeout
    → execute → serialize
  - `process_tool_result()` replaces 3 copies of sanitize → wrap →
    ChatMessage (also used by thread_ops.rs approval resume paths)

Net result: -2,408 lines, zero duplicated loop logic, single code path
for tool intent nudge and completion detection.

Closes #654

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

* fix: address review feedback from Copilot

1. scheduler.rs: Replace `unwrap_or` fallback with proper error
   propagation when parsing tool output JSON — surfaces bugs instead
   of silently changing the output type.

2. worker/job.rs: Drop MutexGuard before the cancellation `.await` in
   `check_signals()` to avoid holding a lock across an async I/O call
   (prevents `await_holding_lock` lint).

3. worker/job.rs: Restore consecutive rate-limit counter
   (MAX_CONSECUTIVE_RATE_LIMITS = 10) so sustained rate limiting marks
   the job stuck with "Persistent rate limiting" instead of silently
   burning through max_iterations.

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

* fix: incorporate staging changes — token budget tracking + mark_failed

Merge staging's changes into the refactored JobDelegate:
- Add token budget tracking in call_llm (update_context/add_tokens)
- mark_stuck → mark_failed for iteration cap and rate-limit exhaustion
  (aligns with staging's #788 fix)

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

* fix: address zmanian's PR review — eliminate type erasure, clean up

Address all 6 review points from zmanian on PR #800:

1. Replace LoopOutcome::Custom(Box<dyn Any>) with typed
   LoopOutcome::NeedApproval(Box<PendingApproval>) — eliminates
   type erasure and downcast, resolves clippy large_enum_variant.

2. Remove dead max_tool_iterations field from ChatDelegate struct.

3. Add on_tool_intent_nudge() hook to LoopDelegate trait with
   implementations in Job and Container delegates for observability.

4. Fix SSE events in job worker to emit raw sanitized content
   instead of XML-wrapped <tool_output> tags.

5. Remove 4 duplicate completion tests from job.rs that were
   already covered by the shared util module.

6. Avoid logging full tool results — use result_size_bytes in
   debug logs (execute.rs, job.rs).

Also updates path references in CLAUDE.md, COVERAGE_PLAN.md,
and add-sse-event.md command.

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

* feat(doctor): expand diagnostics from 7 to 16 health checks

* test: add unit tests for agentic_loop and execute shared modules

Add 16 tests covering the two new critical shared modules:

agentic_loop.rs (10 tests):
- Text response exits loop immediately
- Tool call → text response continuation
- LoopSignal::Stop exits before LLM call
- LoopSignal::InjectMessage adds user message to context
- Max iterations terminates with LoopOutcome::MaxIterations
- Tool intent nudge fires twice then caps
- before_llm_call early exit bypasses LLM
- truncate_for_preview: short string, long string, multibyte safety

execute.rs (6 tests):
- execute_tool_with_safety success path
- Missing tool returns ToolError::NotFound
- Tool execution failure propagates
- Per-tool timeout enforcement (50ms)
- process_tool_result XML wrapping on success
- process_tool_result error formatting

All 2,777 unit tests pass, 0 clippy warnings.

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

* style: cargo fmt

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

* fix: address code review — 9 issues across agentic loop, job worker, container

CRITICAL fixes:
- Rate-limit exhaustion now returns Err(LlmError::RateLimited) instead of
  Ok(Text("")), stopping the loop immediately with no ghost iteration.
  Below-threshold retries still use Text("") with an explicit empty-string
  guard in handle_text_response to skip injection.
- check_signals drains the entire message channel before returning,
  prioritizing Stop over UserMessage. Previously returned early on first
  UserMessage, silently dropping any queued Stop or additional messages.
- check_signals now detects all non-progressing job states (Cancelled,
  Failed, Stuck, Completed, Submitted, Accepted) instead of only
  Cancelled and Failed.

HIGH fixes:
- Error path in process_tool_result_job applies truncate_for_preview to
  bound error strings in SSE/DB events (was unbounded).
- Document Send+Sync lifetime constraint on LoopDelegate trait.
- Test mock before_llm_call refactored from double-lock to single lock
  acquisition, eliminating deadlock risk on refactor.

MEDIUM fixes:
- CompletionReport includes actual iteration count via shared
  Arc<Mutex<u32>> tracker (was hardcoded 0).
- process_tool_result_job return type changed from Result<bool> to
  Result<()> — the bool was always false (dead API).
- Deduplicate truncate in container.rs; now uses truncate_for_preview
  from agentic_loop.

Verified: 0 clippy warnings, 2781 tests pass, cargo fmt clean.

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

---------

Co-authored-by: Henry Park <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Co-authored-by: Illia Polosukhin <[email protected]>
Co-authored-by: Umesh Kumar Singh <[email protected]>
Co-authored-by: reidliu41 <[email protected]>

* Revert "Feat/docker shell edition" + fix fmt/clippy (#886)

* Revert "Feat/docker shell edition (#804)"

This reverts commit c566faf28f.

* style: fix formatting issues from revert

Run cargo fmt to fix formatting across 7 files after the revert of
the docker shell edition feature.

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

---------

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

* refactor: centralize test credential constants into testing::credentials (#829)

* refactor: centralize test credential constants into testing::credentials

Scattered test credential strings (API keys, OAuth tokens, crypto keys,
Telegram tokens, session tokens) across ~25 files made security auditing
harder and created unnecessary duplication. Centralize all test-only fake
credentials into a new `src/testing/credentials.rs` module with named
constants and a shared `test_secrets_store()` helper.

- Convert `src/testing.rs` to directory module (`src/testing/mod.rs`)
- Add `src/testing/credentials.rs` with ~30 named constants
- Replace hardcoded literals in 24 source files
- Deduplicate `test_store()` helper (was copy-pasted in 3 files)
- Leave leak_detector/shell/signature tests as-is (inline values
  aid readability for pattern detection tests)

[skip-regression-check]

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

* refactor: replace real Telegram bot token with obviously fake test stub

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

* Update src/testing/credentials.rs

Co-authored-by: Copilot <[email protected]>

* Update src/testing/credentials.rs

Co-authored-by: Copilot <[email protected]>

* refactor: address PR review feedback on test credentials

- Fix TEST_CRYPTO_KEY doc comment ("32-byte hex" → "32-character key string")
- Rename confusing "real"/"fake" Anthropic constant names and values
- Change TEST_STRIPE_KEY from "sk-live" to "sk_test_fake123" to avoid scanners
- Use test_secrets_store() helper in orchestrator and http tool tests
- Clarify config_round_trip.rs doc comment about integration test visibility

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-authored-by: Copilot <[email protected]>

* fix(registry): version-pinned WASM artifact URLs + ChecksumMismatch source fallback (#832)

* fix(registry): version-pinned WASM artifact URLs + ChecksumMismatch fallback (#439)

Root cause: all artifact URLs used releases/latest/download/, which is a
moving target. Every release rebuilds all WASM extensions non-deterministically,
so sha256 baked into an older binary diverges from the content at 'latest'.
ChecksumMismatch was also a hard block with no source-build fallback.

Three-layer fix:

1. src/registry/installer.rs — allow source-build fallback for ChecksumMismatch
   on releases/latest URLs (moving-target artifact rotation, not tampering).
   Version-pinned URLs (releases/download/vX.Y.Z/) remain a hard block.
   Adds regression test (test_source_fallback_on_latest_url_mismatch) and
   updates test_should_attempt_source_fallback_policy to cover both URL types.

2. .github/workflows/release.yml — three CI changes:
   - build-wasm-extensions: version-detect, skip-if-unchanged, versioned filenames
     (name-{version}-wasm32-wasip2.tar.gz). Skip rebuild when manifest already has
     a non-null sha256 and the URL embeds the current version — stable checksums
     until source actually changes.
   - build-local-artifacts: patch manifests with version-pinned URL + sha256 (for
     binary embedding via build.rs).
   - update-registry-checksums: same URL patching for the main-branch PR.
   All three sed patterns use '.*' (greedy) to correctly handle pre-release
   version strings like 0.1.0-alpha.1.

3. registry/{tools,channels}/*.json — null out all 14 stale sha256 values.
   Null sha256 -> MissingChecksum -> source-build fallback (works on all binaries).
   Next release CI will populate version-pinned URLs + stable checksums.

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

* style: cargo fmt

* fix(ci): use JSON filename stem for WASM bundle names to fix manifest lookup

Manifests like registry/tools/slack.json have name='slack-tool', causing
the patching step to look for registry/tools/slack-tool.json (missing).

Introduce file_stem (JSON filename without .json) for the bundle filename
and checksums.txt entry, while keeping ext_name (manifest .name) for archive
contents — the installer extracts files by manifest.name so those must still
match. The patching step strips -{version}-wasm32-wasip2.tar.gz from the
filename stem and looks up registry/tools/slack.json correctly.

* fix(registry): tighten fallback URL check + deduplicate tests

Address PR review feedback:

1. Make should_attempt_source_fallback check repo-specific
   (github.com/nearai/ironclaw/releases/latest/) instead of a
   generic substring (/releases/latest/download/).

2. Remove duplicate ChecksumMismatch cases from
   test_should_attempt_source_fallback_policy — that coverage
   lives in the dedicated regression test
   test_source_fallback_on_latest_url_mismatch.

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

---------

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

* fix: agent logging (#888)

* fix: optimize agent logging to reduce DataDog bill

* fix: log permanent repair failures as ERROR not WARN

RepairResult::Failed is permanent failure requiring attention (ERROR level)
not a temporary/retryable condition (WARN level).

[skip-regression-check]

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

* security: remove user message content from trace logs

Never log user message content at any log level (includes TRACE).
Log only safe metadata: content length, message ID, image count.

This prevents accidental exposure of sensitive user data in logs
even at the most verbose logging level.

[skip-regression-check]

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

* security: move LLM response body logging to TRACE level

Response bodies can contain user-generated content, tool outputs, and
leaked secrets. Moving to TRACE (not enabled in production) prevents
exposure in DEBUG logs. Status log remains at DEBUG.

[skip-regression-check]

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

* refactor: simplify URL sanitization using url::Url API

Use set_query, set_fragment, set_username, set_password methods
instead of manual string reconstruction. Cleaner, handles edge cases,
eliminates port branching complexity.

[skip-regression-check]

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

* test: add comprehensive unit tests for sanitize_url_for_logging

Add 9 test cases covering:
- URL with query parameters
- URL with credentials (user:pass@host)
- URL with fragment
- URL with port
- URL with all components combined
- Malformed URL fallback behavior
- Short strings (pass-through)
- Non-URL-like strings
- Path preservation

Tests verify that sanitization correctly removes sensitive components
while preserving safe components like host, port, and path.

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

* fix: libsql per-migration logs should be DEBUG, not TRACE

Individual migration logs are now visible with standard debug logging
(RUST_LOG=ironclaw=debug), improving debuggability when troubleshooting
migration issues. Summary log remains at INFO level.

Fixes behavioral change that made it harder to identify which specific
migration ran or failed without enabling full TRACE logging.

[skip-regression-check]

---------

Co-authored-by: Claude Haiku 4.5 <[email protected]>

* fix: staging CI review issues (batch 1) (#883)

* fix: address staging-ci-review issues (batch 1)

- #811: Fix unreachable error handling in worker — restructure .await?
  to explicit match on nested Result so token budget errors are properly
  logged and marked as failed
- #813: Combine metadata + token budget into single update_context()
  call to prevent concurrent worker observing partial state
- #814: Persist max_tokens and total_tokens_used to both PostgreSQL and
  libSQL backends — add V12 migration, update save_job/get_job
- #815: Cap user-supplied max_tokens at configured max_tokens_per_job
  to prevent budget bypass via metadata injection
- #869: Release locks before async I/O in webhook handler (http.rs) and
  SIGHUP handler (main.rs) to prevent blocking concurrent requests

Fixes: #811, #813, #814, #815, #869

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

* fix: address PR #883 review feedback

- Fix min(user_val, 0) bug: guard for unlimited config (max_tokens_per_job == 0)
- Remove duplicate columns from libSQL base SCHEMA (v12 migration is sole source)
- Use get_i64() helper for consistency in libsql/jobs.rs
- Add regression tests for scheduler token budget capping

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

---------

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

* fix: gate ChannelSecretUpdater import behind #[cfg(unix)] for Windows clippy

The import was unconditional but all usages are inside a #[cfg(unix)]
block, causing unused-import errors on Windows CI.

[skip-regression-check]

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

* style: cargo fmt

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

---------

Co-authored-by: Nick Pismenkov <[email protected]>
Co-authored-by: Claude Haiku 4.5 <[email protected]>
Co-authored-by: Henry Park <[email protected]>
Co-authored-by: Illia Polosukhin <[email protected]>
Co-authored-by: Xing Ji <[email protected]>
Co-authored-by: Nick Stebbings <[email protected]>
Co-authored-by: Reid <[email protected]>
Co-authored-by: Umesh Kumar Singh <[email protected]>
Co-authored-by: 智方云cubecloud-io <[email protected]>
Co-authored-by: lizican <[email protected]>
Co-authored-by: lizican123 <[email protected]>
Co-authored-by: Zaki Manian <[email protected]>
Co-authored-by: reidliu41 <[email protected]>
Co-authored-by: ironclaw-ci[bot] <266877842+ironclaw-ci[bot]@users.noreply.github.com>
Co-authored-by: Copilot <[email protected]>
2026-03-10 22:19:14 -07:00
Henry ParkandGitHub 8c094aec63 Merge pull request #830 from nearai/staging-promote/3a2989d0-22888378864
chore: promote staging to main (2026-03-10 05:21 UTC)
2026-03-10 14:14:14 -07:00
56 changed files with 376 additions and 6283 deletions
+23 -32
View File
@@ -29,36 +29,18 @@ 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 '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:*)'"
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:*)'"
prompt: |
Code review this pull request. Follow these steps precisely:
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.
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.
2. Get the PR diff with `gh pr diff` and summarize the change.
2. Use a Haiku agent to summarize the PR change (use `gh pr diff`).
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 (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>
code, then return a list of issues found:
Agent 1 — Security & Safety
Check for: command injection, path traversal, SSRF, XSS, auth bypass,
@@ -81,9 +63,22 @@ jobs:
timeouts, resource leaks (file handles, connections), large allocations
in hot paths.
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:
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:
### Code review
@@ -98,12 +93,8 @@ 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.
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
Notes:
- Use `gh` for all 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)
+7 -14
View File
@@ -406,10 +406,6 @@ 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'
@@ -418,15 +414,12 @@ jobs:
PR_NUMBER: ${{ needs.create-promotion-pr.outputs.pr_number }}
run: |
if [ -n "$PR_NUMBER" ]; then
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
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"
fi
# ── Update tested tag (always, so next batch covers only new commits) ──
@@ -444,7 +437,7 @@ jobs:
- uses: actions/checkout@v6
with:
ref: staging
fetch-depth: 0
fetch-depth: 1
- 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 != 'pull_request' ||
github.base_ref != 'staging'
github.event_name == 'push' ||
(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 != 'pull_request' ||
github.base_ref != 'staging'
github.event_name == 'push' ||
(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 != 'pull_request' ||
github.base_ref != 'staging'
github.event_name == 'push' ||
(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 != 'pull_request' ||
github.base_ref != 'staging'
github.event_name == 'push' ||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
runs-on: ubuntu-latest
steps:
- name: Checkout repository
+9
View File
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.18.0](https://github.com/nearai/ironclaw/compare/v0.17.0...v0.18.0) - 2026-03-11
### Other
- Merge pull request #907 from nearai/staging-promote/b0214fef-22930316561
- promote staging to main (2026-03-10 15:19 UTC) ([#865](https://github.com/nearai/ironclaw/pull/865))
- Merge pull request #830 from nearai/staging-promote/3a2989d0-22888378864
- update WASM artifact SHA256 checksums [skip ci] ([#876](https://github.com/nearai/ironclaw/pull/876))
## [0.17.0](https://github.com/nearai/ironclaw/compare/v0.16.1...v0.17.0) - 2026-03-10
### Added
Generated
+1 -62
View File
@@ -3350,7 +3350,7 @@ dependencies = [
[[package]]
name = "ironclaw"
version = "0.17.0"
version = "0.18.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -3386,7 +3386,6 @@ dependencies = [
"hyper-util",
"iana-time-zone",
"insta",
"json5",
"libsql",
"lru",
"mime_guess",
@@ -3522,17 +3521,6 @@ dependencies = [
"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"
@@ -4409,49 +4397,6 @@ 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"
@@ -7100,12 +7045,6 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "ucd-trie"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "uds_windows"
version = "1.1.0"
+1 -5
View File
@@ -19,7 +19,7 @@ exclude = [
[package]
name = "ironclaw"
version = "0.17.0"
version = "0.18.0"
edition = "2024"
rust-version = "1.92"
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
@@ -175,9 +175,6 @@ 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"
@@ -213,7 +210,6 @@ 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"
+1 -2
View File
@@ -440,7 +440,6 @@ 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 | |
@@ -559,7 +558,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
- ✅ Tool-driven webhook ingress (`/webhook/tools/{tool}` -> host-verified + tool-normalized `system_event` routines)
- ❌ Webhook trigger endpoint in web gateway
- ❌ Channel health monitor with auto-restart
- ❌ Partial output preservation on abort
+1 -1
View File
@@ -229,7 +229,7 @@ WASM ──► 白名单 ──► 泄露扫描 ──► 凭据 ──► 执
│ │ │ │
│ ┌──────────▼────┐ ┌──▼───────────────┐ │
│ │ 调度器 │ │ 定时任务引擎 │ │
│ │ (并行任务) │ │(cron, 事件, Webhook)│
│ │ (并行任务) │ │(cron, 事件, wh) │
│ └──────┬────────┘ └────────┬─────────┘ │
│ │ │ │
│ ┌─────────────┼────────────────────┘ │
+7 -13
View File
@@ -925,20 +925,14 @@ impl Agent {
for (idx, tc) in deferred_tool_calls.iter().enumerate() {
if let Some(tool) = self.tools().get(&tc.name).await {
// 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,
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)
}
ApprovalRequirement::Always => true,
};
if needs_approval {
-46
View File
@@ -106,34 +106,6 @@ 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::*;
@@ -526,24 +498,6 @@ 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";
-6
View File
@@ -244,12 +244,6 @@ 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
+1 -45
View File
@@ -318,11 +318,7 @@ 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("/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));
.route("/favicon.ico", get(favicon_handler));
// Project file serving (behind auth to prevent unauthorized file access).
let projects = Router::new()
@@ -434,46 +430,6 @@ 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> {
+121 -125
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 = I18n.t('auth.errorRequired');
document.getElementById('auth-error').textContent = 'Token required';
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 = I18n.t('auth.errorInvalid');
document.getElementById('auth-error').textContent = 'Invalid token';
});
}
@@ -144,7 +144,7 @@ let restartEnabled = false; // Track if restart is available in this deployment
function triggerRestart() {
if (!currentThreadId) {
alert(I18n.t('error.startConversation'));
alert('Please start a conversation first');
return;
}
@@ -155,7 +155,7 @@ function triggerRestart() {
function confirmRestart() {
if (!currentThreadId) {
alert(I18n.t('error.startConversation'));
alert('Please start a conversation first');
return;
}
@@ -190,7 +190,7 @@ function confirmRestart() {
})
.catch((err) => {
console.error('[confirmRestart] Restart request failed:', err);
addMessage('system', I18n.t('error.restartFailed', { message: err.message }));
addMessage('system', 'Restart failed: ' + 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 = I18n.t('status.connected');
document.getElementById('sse-status').textContent = '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 = I18n.t('status.reconnecting');
document.getElementById('sse-status').textContent = 'Reconnecting...';
};
eventSource.addEventListener('response', (e) => {
@@ -464,7 +464,7 @@ function enableChatInput() {
const btn = document.getElementById('send-btn');
if (input) {
input.disabled = false;
input.placeholder = I18n.t('chat.inputPlaceholder');
input.placeholder = 'Message or / for commands...';
}
if (btn) btn.disabled = false;
}
@@ -676,20 +676,26 @@ function renderMarkdown(text) {
return escapeHtml(text);
}
// 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.
// Strip dangerous HTML elements and attributes from rendered markdown.
// This prevents XSS from tool output or prompt injection in LLM responses.
function sanitizeRenderedHtml(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 '';
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;
}
function copyCodeBlock(btn) {
@@ -697,8 +703,8 @@ function copyCodeBlock(btn) {
const code = pre.querySelector('code');
const text = code ? code.textContent : pre.textContent;
navigator.clipboard.writeText(text).then(() => {
btn.textContent = I18n.t('btn.copied');
setTimeout(() => { btn.textContent = I18n.t('btn.copy'); }, 1500);
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
});
}
@@ -985,7 +991,7 @@ function showApproval(data) {
const header = document.createElement('div');
header.className = 'approval-header';
header.textContent = I18n.t('approval.title');
header.textContent = 'Tool requires approval';
card.appendChild(header);
const toolName = document.createElement('div');
@@ -1003,7 +1009,7 @@ function showApproval(data) {
if (data.parameters) {
const paramsToggle = document.createElement('button');
paramsToggle.className = 'approval-params-toggle';
paramsToggle.textContent = I18n.t('approval.showParams');
paramsToggle.textContent = 'Show parameters';
const paramsBlock = document.createElement('pre');
paramsBlock.className = 'approval-params';
paramsBlock.textContent = data.parameters;
@@ -1011,7 +1017,7 @@ function showApproval(data) {
paramsToggle.addEventListener('click', () => {
const visible = paramsBlock.style.display !== 'none';
paramsBlock.style.display = visible ? 'none' : 'block';
paramsToggle.textContent = visible ? I18n.t('approval.showParams') : I18n.t('approval.hideParams');
paramsToggle.textContent = visible ? 'Show parameters' : 'Hide parameters';
});
card.appendChild(paramsToggle);
card.appendChild(paramsBlock);
@@ -1022,17 +1028,17 @@ function showApproval(data) {
const approveBtn = document.createElement('button');
approveBtn.className = 'approve';
approveBtn.textContent = I18n.t('approval.approve');
approveBtn.textContent = 'Approve';
approveBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'approve'));
const alwaysBtn = document.createElement('button');
alwaysBtn.className = 'always';
alwaysBtn.textContent = I18n.t('approval.always');
alwaysBtn.textContent = 'Always';
alwaysBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'always'));
const denyBtn = document.createElement('button');
denyBtn.className = 'deny';
denyBtn.textContent = I18n.t('approval.deny');
denyBtn.textContent = 'Deny';
denyBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'deny'));
actions.appendChild(approveBtn);
@@ -1059,7 +1065,7 @@ function showJobCard(data) {
const title = document.createElement('div');
title.className = 'job-card-title';
title.textContent = data.title || I18n.t('sandbox.job');
title.textContent = data.title || 'Sandbox Job';
info.appendChild(title);
const id = document.createElement('div');
@@ -1071,7 +1077,7 @@ function showJobCard(data) {
const viewBtn = document.createElement('button');
viewBtn.className = 'job-card-view';
viewBtn.textContent = I18n.t('jobs.viewJob');
viewBtn.textContent = 'View Job';
viewBtn.addEventListener('click', () => {
switchTab('jobs');
openJobDetail(data.job_id);
@@ -1083,7 +1089,7 @@ function showJobCard(data) {
browseBtn.className = 'job-card-browse';
browseBtn.href = data.browse_url;
browseBtn.target = '_blank';
browseBtn.textContent = I18n.t('jobs.browse');
browseBtn.textContent = 'Browse';
card.appendChild(browseBtn);
}
@@ -1104,7 +1110,7 @@ function showAuthCard(data) {
const header = document.createElement('div');
header.className = 'auth-header';
header.textContent = I18n.t('authRequired.title', {name: data.extension_name});
header.textContent = 'Authentication required for ' + data.extension_name;
card.appendChild(header);
if (data.instructions) {
@@ -1120,7 +1126,7 @@ function showAuthCard(data) {
if (data.auth_url) {
const oauthBtn = document.createElement('button');
oauthBtn.className = 'auth-oauth';
oauthBtn.textContent = I18n.t('authRequired.authenticateWith', {name: data.extension_name});
oauthBtn.textContent = 'Authenticate with ' + data.extension_name;
oauthBtn.addEventListener('click', () => {
openOAuthUrl(data.auth_url);
});
@@ -1131,7 +1137,7 @@ function showAuthCard(data) {
const setupLink = document.createElement('a');
setupLink.href = data.setup_url;
setupLink.target = '_blank';
setupLink.textContent = I18n.t('authRequired.getToken');
setupLink.textContent = 'Get your token';
links.appendChild(setupLink);
}
@@ -1145,9 +1151,7 @@ function showAuthCard(data) {
const tokenInput = document.createElement('input');
tokenInput.type = 'password';
tokenInput.placeholder = data.instructions
|| I18n.t('auth.extensionTokenPlaceholder')
|| I18n.t('auth.tokenPlaceholder');
tokenInput.placeholder = data.instructions || 'Paste your API key or token';
tokenInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') submitAuthToken(data.extension_name, tokenInput.value);
});
@@ -1166,12 +1170,12 @@ function showAuthCard(data) {
const submitBtn = document.createElement('button');
submitBtn.className = 'auth-submit';
submitBtn.textContent = I18n.t('btn.submit');
submitBtn.textContent = 'Submit';
submitBtn.addEventListener('click', () => submitAuthToken(data.extension_name, tokenInput.value));
const cancelBtn = document.createElement('button');
cancelBtn.className = 'auth-cancel';
cancelBtn.textContent = I18n.t('btn.cancel');
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', () => cancelAuth(data.extension_name));
actions.appendChild(submitBtn);
@@ -1686,25 +1690,22 @@ 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';
@@ -1713,12 +1714,8 @@ 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);
@@ -1963,7 +1960,7 @@ function prependLogEntry(entry) {
function toggleLogsPause() {
logsPaused = !logsPaused;
const btn = document.getElementById('logs-pause-btn');
btn.textContent = logsPaused ? I18n.t('logs.resume') : I18n.t('logs.pause');
btn.textContent = logsPaused ? 'Resume' : 'Pause';
if (!logsPaused) {
// Flush buffer: oldest-first + prepend naturally puts newest at top
@@ -2035,7 +2032,7 @@ function loadExtensions() {
]).then(([extData, toolData, registryData]) => {
// Render installed extensions
if (extData.extensions.length === 0) {
extList.innerHTML = '<div class="empty-state">' + I18n.t('extensions.noInstalled') + '</div>';
extList.innerHTML = '<div class="empty-state">No extensions installed</div>';
} else {
extList.innerHTML = '';
for (const ext of extData.extensions) {
@@ -2049,7 +2046,7 @@ function loadExtensions() {
// Available WASM extensions
if (wasmEntries.length === 0) {
wasmList.innerHTML = '<div class="empty-state">' + I18n.t('extensions.noAvailable') + '</div>';
wasmList.innerHTML = '<div class="empty-state">No additional WASM extensions available</div>';
} else {
wasmList.innerHTML = '';
for (const entry of wasmEntries) {
@@ -2059,7 +2056,7 @@ function loadExtensions() {
// MCP servers (show both installed and uninstalled)
if (mcpEntries.length === 0) {
mcpList.innerHTML = '<div class="empty-state">' + I18n.t('mcp.noServers') + '</div>';
mcpList.innerHTML = '<div class="empty-state">No MCP servers available</div>';
} else {
mcpList.innerHTML = '';
for (const entry of mcpEntries) {
@@ -2124,16 +2121,16 @@ function renderAvailableExtensionCard(entry) {
const installBtn = document.createElement('button');
installBtn.className = 'btn-ext install';
installBtn.textContent = I18n.t('extensions.install');
installBtn.textContent = 'Install';
installBtn.addEventListener('click', function() {
installBtn.disabled = true;
installBtn.textContent = I18n.t('extensions.installing');
installBtn.textContent = 'Installing...';
apiFetch('/api/extensions/install', {
method: 'POST',
body: { name: entry.name, kind: entry.kind },
}).then(function(res) {
if (res.success) {
showToast(I18n.t('extensions.installedSuccess', {name: entry.display_name}), 'success');
showToast('Installed ' + 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');
@@ -2197,39 +2194,39 @@ function renderMcpServerCard(entry, installedExt) {
if (!installedExt.active) {
var activateBtn = document.createElement('button');
activateBtn.className = 'btn-ext activate';
activateBtn.textContent = I18n.t('common.activate');
activateBtn.textContent = 'Activate';
activateBtn.addEventListener('click', function() { activateExtension(installedExt.name); });
actions.appendChild(activateBtn);
} else {
var activeLabel = document.createElement('span');
activeLabel.className = 'ext-active-label';
activeLabel.textContent = I18n.t('ext.active');
activeLabel.textContent = 'Active';
actions.appendChild(activeLabel);
}
var removeBtn = document.createElement('button');
removeBtn.className = 'btn-ext remove';
removeBtn.textContent = I18n.t('ext.remove');
removeBtn.textContent = 'Remove';
removeBtn.addEventListener('click', function() { removeExtension(installedExt.name); });
actions.appendChild(removeBtn);
} else {
var installBtn = document.createElement('button');
installBtn.className = 'btn-ext install';
installBtn.textContent = I18n.t('ext.install');
installBtn.textContent = 'Install';
installBtn.addEventListener('click', function() {
installBtn.disabled = true;
installBtn.textContent = I18n.t('ext.installing');
installBtn.textContent = 'Installing...';
apiFetch('/api/extensions/install', {
method: 'POST',
body: { name: entry.name, kind: entry.kind },
}).then(function(res) {
if (res.success) {
showToast(I18n.t('extensions.installedSuccess', { name: entry.display_name }), 'success');
showToast('Installed ' + entry.display_name, 'success');
} else {
showToast(I18n.t('ext.install') + ': ' + (res.message || 'unknown error'), 'error');
showToast('Install: ' + (res.message || 'unknown error'), 'error');
}
loadExtensions();
}).catch(function(err) {
showToast(I18n.t('ext.installFailed', { message: err.message }), 'error');
showToast('Install failed: ' + err.message, 'error');
loadExtensions();
});
});
@@ -2243,7 +2240,7 @@ function renderMcpServerCard(entry, installedExt) {
function createReconfigureButton(extName) {
var btn = document.createElement('button');
btn.className = 'btn-ext configure';
btn.textContent = I18n.t('ext.reconfigure');
btn.textContent = 'Reconfigure';
btn.addEventListener('click', function() { showConfigureModal(extName); });
return btn;
}
@@ -2327,13 +2324,13 @@ function renderExtensionCard(ext) {
if (status === 'active') {
var activeLabel = document.createElement('span');
activeLabel.className = 'ext-active-label';
activeLabel.textContent = I18n.t('ext.active');
activeLabel.textContent = '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 = I18n.t('status.awaitingPairing');
pairingLabel.textContent = 'Awaiting Pairing';
actions.appendChild(pairingLabel);
actions.appendChild(createReconfigureButton(ext.name));
} else if (status === 'failed') {
@@ -2342,7 +2339,7 @@ function renderExtensionCard(ext) {
// installed or configured: show Setup button
var setupBtn = document.createElement('button');
setupBtn.className = 'btn-ext configure';
setupBtn.textContent = I18n.t('ext.setup');
setupBtn.textContent = 'Setup';
setupBtn.addEventListener('click', function() { showConfigureModal(ext.name); });
actions.appendChild(setupBtn);
}
@@ -2350,14 +2347,14 @@ function renderExtensionCard(ext) {
// WASM tools / MCP servers
const activeLabel = document.createElement('span');
activeLabel.className = 'ext-active-label';
activeLabel.textContent = ext.active ? I18n.t('ext.active') : I18n.t('status.installed');
activeLabel.textContent = ext.active ? 'Active' : '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 = I18n.t('common.activate');
activateBtn.textContent = 'Activate';
activateBtn.addEventListener('click', () => activateExtension(ext.name));
actions.appendChild(activateBtn);
}
@@ -2369,7 +2366,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 ? I18n.t('ext.reconfigure') : I18n.t('ext.configure');
configBtn.textContent = ext.authenticated ? 'Reconfigure' : 'Configure';
configBtn.addEventListener('click', () => showConfigureModal(ext.name));
actions.appendChild(configBtn);
}
@@ -2377,7 +2374,7 @@ function renderExtensionCard(ext) {
const removeBtn = document.createElement('button');
removeBtn.className = 'btn-ext remove';
removeBtn.textContent = I18n.t('ext.remove');
removeBtn.textContent = 'Remove';
removeBtn.addEventListener('click', () => removeExtension(ext.name));
actions.appendChild(removeBtn);
@@ -2422,17 +2419,17 @@ function activateExtension(name) {
}
function removeExtension(name) {
if (!confirm(I18n.t('ext.confirmRemove', { name: name }))) return;
if (!confirm('Remove extension "' + name + '"?')) return;
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/remove', { method: 'POST' })
.then((res) => {
if (!res.success) {
showToast(I18n.t('ext.removeFailed', { message: res.message }), 'error');
showToast('Remove failed: ' + res.message, 'error');
} else {
showToast(I18n.t('ext.removed', { name: name }), 'success');
showToast('Removed ' + name, 'success');
}
loadExtensions();
})
.catch((err) => showToast(I18n.t('ext.removeFailed', { message: err.message }), 'error'));
.catch((err) => showToast('Remove failed: ' + err.message, 'error'));
}
function showConfigureModal(name) {
@@ -2459,7 +2456,7 @@ function renderConfigureModal(name, secrets) {
modal.className = 'configure-modal';
const header = document.createElement('h3');
header.textContent = I18n.t('config.title', { name: name });
header.textContent = 'Configure ' + name;
modal.appendChild(header);
const form = document.createElement('div');
@@ -2475,7 +2472,7 @@ function renderConfigureModal(name, secrets) {
if (secret.optional) {
const opt = document.createElement('span');
opt.className = 'field-optional';
opt.textContent = I18n.t('config.optional');
opt.textContent = ' (optional)';
label.appendChild(opt);
}
field.appendChild(label);
@@ -2486,7 +2483,7 @@ function renderConfigureModal(name, secrets) {
const input = document.createElement('input');
input.type = 'password';
input.name = secret.name;
input.placeholder = secret.provided ? I18n.t('config.alreadySet') : '';
input.placeholder = secret.provided ? '(already set — leave empty to keep)' : '';
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') submitConfigureModal(name, fields);
});
@@ -2496,13 +2493,13 @@ function renderConfigureModal(name, secrets) {
const badge = document.createElement('span');
badge.className = 'field-provided';
badge.textContent = '\u2713';
badge.title = I18n.t('config.alreadyConfigured');
badge.title = 'Already configured';
inputRow.appendChild(badge);
}
if (secret.auto_generate && !secret.provided) {
const hint = document.createElement('span');
hint.className = 'field-autogen';
hint.textContent = I18n.t('config.autoGenerate');
hint.textContent = 'Auto-generated if empty';
inputRow.appendChild(hint);
}
@@ -2518,13 +2515,13 @@ function renderConfigureModal(name, secrets) {
const submitBtn = document.createElement('button');
submitBtn.className = 'btn-ext activate';
submitBtn.textContent = I18n.t('config.save');
submitBtn.textContent = 'Save';
submitBtn.addEventListener('click', () => submitConfigureModal(name, fields));
actions.appendChild(submitBtn);
const cancelBtn = document.createElement('button');
cancelBtn.className = 'btn-ext remove';
cancelBtn.textContent = I18n.t('config.cancel');
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', closeConfigureModal);
actions.appendChild(cancelBtn);
@@ -2764,11 +2761,11 @@ function loadJobs() {
function renderJobsSummary(s) {
document.getElementById('jobs-summary').innerHTML = ''
+ 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');
+ 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');
}
function summaryCard(label, count, cls) {
@@ -3298,11 +3295,11 @@ function loadRoutines() {
function renderRoutinesSummary(s) {
document.getElementById('routines-summary').innerHTML = ''
+ 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');
+ 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');
}
function renderRoutinesList(routines) {
@@ -3468,18 +3465,17 @@ function formatRelativeTime(isoString) {
const absDiff = Math.abs(diffMs);
const future = diffMs < 0;
if (absDiff < 60000)
return future ? I18n.t('time.lessThan1MinuteFromNow') : I18n.t('time.lessThan1MinuteAgo');
if (absDiff < 60000) return future ? 'in <1m' : '<1m ago';
if (absDiff < 3600000) {
const m = Math.floor(absDiff / 60000);
return future ? I18n.t('time.minutesFromNow', { n: m }) : I18n.t('time.minutesAgo', { n: m });
return future ? 'in ' + m + 'm' : m + 'm ago';
}
if (absDiff < 86400000) {
const h = Math.floor(absDiff / 3600000);
return future ? I18n.t('time.hoursFromNow', { n: h }) : I18n.t('time.hoursAgo', { n: h });
return future ? 'in ' + h + 'h' : h + 'h ago';
}
const days = Math.floor(absDiff / 86400000);
return future ? I18n.t('time.daysFromNow', { n: days }) : I18n.t('time.daysAgo', { n: days });
return future ? 'in ' + days + 'd' : days + 'd ago';
}
// --- Gateway status widget ---
@@ -3529,18 +3525,18 @@ function fetchGatewayStatus() {
}
// Connection info
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>';
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>';
// Cost tracker
if (data.daily_cost != null) {
html += '<div class="gw-divider"></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>';
html += '<div class="gw-section-label">Cost Today</div>';
html += '<div class="gw-stat"><span>Spent</span><span>' + formatCost(data.daily_cost) + '</span></div>';
if (data.actions_this_hour != null) {
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.actionsPerHour') + '</span><span>' + data.actions_this_hour + '</span></div>';
html += '<div class="gw-stat"><span>Actions/hr</span><span>' + data.actions_this_hour + '</span></div>';
}
}
@@ -3748,7 +3744,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">' + I18n.t('skills.noInstalled') + '</div>';
skillsList.innerHTML = '<div class="empty-state">No skills installed</div>';
return;
}
skillsList.innerHTML = '';
@@ -3756,7 +3752,7 @@ function loadSkills() {
skillsList.appendChild(renderSkillCard(data.skills[i]));
}
}).catch(function(err) {
skillsList.innerHTML = '<div class="empty-state">' + I18n.t('skills.loadFailed', {message: escapeHtml(err.message)}) + '</div>';
skillsList.innerHTML = '<div class="empty-state">Failed to load skills: ' + escapeHtml(err.message) + '</div>';
});
}
@@ -3793,7 +3789,7 @@ function renderSkillCard(skill) {
if (skill.keywords && skill.keywords.length > 0) {
var kw = document.createElement('div');
kw.className = 'ext-keywords';
kw.textContent = I18n.t('skills.activatesOn') + ': ' + skill.keywords.join(', ');
kw.textContent = 'Activates on: ' + skill.keywords.join(', ');
card.appendChild(kw);
}
@@ -3804,7 +3800,7 @@ function renderSkillCard(skill) {
if (skill.trust.toLowerCase() !== 'trusted') {
var removeBtn = document.createElement('button');
removeBtn.className = 'btn-ext remove';
removeBtn.textContent = I18n.t('skills.remove');
removeBtn.textContent = 'Remove';
removeBtn.addEventListener('click', function() { removeSkill(skill.name); });
actions.appendChild(removeBtn);
}
@@ -3819,7 +3815,7 @@ function searchClawHub() {
if (!query) return;
var resultsDiv = document.getElementById('skill-search-results');
resultsDiv.innerHTML = '<div class="empty-state">' + I18n.t('skills.searching') + '</div>';
resultsDiv.innerHTML = '<div class="empty-state">Searching...</div>';
apiFetch('/api/skills/search', {
method: 'POST',
@@ -3835,7 +3831,7 @@ function searchClawHub() {
warning.style.borderLeft = '3px solid #f0ad4e';
warning.style.paddingLeft = '12px';
warning.style.marginBottom = '16px';
warning.textContent = I18n.t('skills.registryError', {message: data.catalog_error});
warning.textContent = 'Could not reach ClawHub registry: ' + data.catalog_error;
resultsDiv.appendChild(warning);
}
@@ -3867,10 +3863,10 @@ function searchClawHub() {
}
if (resultsDiv.children.length === 0) {
resultsDiv.innerHTML = '<div class="empty-state">' + I18n.t('skills.noResults', {query: escapeHtml(query)}) + '</div>';
resultsDiv.innerHTML = '<div class="empty-state">No skills found for "' + escapeHtml(query) + '"</div>';
}
}).catch(function(err) {
resultsDiv.innerHTML = '<div class="empty-state">' + I18n.t('skills.searchFailed', {message: escapeHtml(err.message)}) + '</div>';
resultsDiv.innerHTML = '<div class="empty-state">Search failed: ' + escapeHtml(err.message) + '</div>';
});
}
@@ -3964,17 +3960,17 @@ function renderCatalogSkillCard(entry, installedNames) {
if (isInstalled) {
var label = document.createElement('span');
label.className = 'ext-active-label';
label.textContent = I18n.t('status.installed');
label.textContent = 'Installed';
actions.appendChild(label);
} else {
var installBtn = document.createElement('button');
installBtn.className = 'btn-ext install';
installBtn.textContent = I18n.t('extensions.install');
installBtn.textContent = 'Install';
installBtn.addEventListener('click', (function(s, btn) {
return function() {
if (!confirm('Install skill "' + s + '" from ClawHub?')) return;
btn.disabled = true;
btn.textContent = I18n.t('extensions.installing');
btn.textContent = 'Installing...';
installSkill(s, null, btn);
};
})(slug, installBtn));
@@ -4016,7 +4012,7 @@ function installSkill(nameOrSlug, url, btn) {
body: body,
}).then(function(res) {
if (res.success) {
showToast(I18n.t('skills.installedSuccess', {name: nameOrSlug}), 'success');
showToast('Installed skill "' + nameOrSlug + '"', 'success');
} else {
showToast('Install failed: ' + (res.message || 'unknown error'), 'error');
}
@@ -4029,19 +4025,19 @@ function installSkill(nameOrSlug, url, btn) {
}
function removeSkill(name) {
if (!confirm(I18n.t('skills.confirmRemove', { name: name }))) return;
if (!confirm('Remove skill "' + name + '"?')) return;
apiFetch('/api/skills/' + encodeURIComponent(name), {
method: 'DELETE',
headers: { 'X-Confirm-Action': 'true' },
}).then(function(res) {
if (res.success) {
showToast(I18n.t('skills.removed', { name: name }), 'success');
showToast('Removed skill "' + name + '"', 'success');
} else {
showToast(I18n.t('skills.removeFailed', { message: res.message || 'unknown error' }), 'error');
showToast('Remove failed: ' + (res.message || 'unknown error'), 'error');
}
loadSkills();
}).catch(function(err) {
showToast(I18n.t('skills.removeFailed', { message: err.message }), 'error');
showToast('Remove failed: ' + err.message, 'error');
});
}
-74
View File
@@ -1,74 +0,0 @@
// 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
@@ -1,351 +0,0 @@
// 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
@@ -1,89 +0,0 @@
// 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
@@ -1,351 +0,0 @@
// 中文语言包 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': '取消',
});
+86 -114
View File
@@ -9,17 +9,6 @@
<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"
@@ -31,16 +20,16 @@
<div id="auth-screen">
<div class="auth-card-login">
<div class="auth-brand">
<h1 data-i18n="auth.title">IronClaw</h1>
<p class="auth-tagline" data-i18n="auth.tagline">Secure AI Assistant</p>
<h1>IronClaw</h1>
<p class="auth-tagline">Secure AI Assistant</p>
</div>
<div class="auth-form">
<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>
<label for="token-input">Gateway Token</label>
<input type="password" id="token-input" placeholder="Paste your auth token" autofocus>
<button onclick="authenticate()">Connect</button>
</div>
<div id="auth-error"></div>
<p class="auth-hint" data-i18n="auth.hint">Enter the GATEWAY_AUTH_TOKEN from your .env configuration.</p>
<p class="auth-hint">Enter the GATEWAY_AUTH_TOKEN from your .env configuration.</p>
</div>
</div>
@@ -49,22 +38,21 @@
<div class="restart-modal-overlay" onclick="cancelRestart()"></div>
<div class="restart-modal-content">
<div class="restart-modal-header">
<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>
<h2>Restart IronClaw Instance</h2>
<button class="restart-modal-close" onclick="cancelRestart()" title="Close">×</button>
</div>
<div class="restart-modal-body">
<p class="restart-modal-description" data-i18n="restart.description">
<p class="restart-modal-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 data-i18n="restart.warning">Any in-progress jobs may be interrupted. The restart will complete within a few seconds.</p>
<p>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()" data-i18n="restart.cancel">Cancel</button>
<button class="restart-modal-btn confirm" onclick="confirmRestart()" data-i18n="restart.confirm">Confirm Restart</button>
<button class="restart-modal-btn cancel" onclick="cancelRestart()">Cancel</button>
<button class="restart-modal-btn confirm" onclick="confirmRestart()">Confirm Restart</button>
</div>
</div>
</div>
@@ -75,13 +63,13 @@
<div class="restart-loader-content">
<div class="restart-spinner"></div>
<div class="restart-loader-text">
<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>
<p class="restart-title">Restarting IronClaw</p>
<p class="restart-subtitle">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" data-i18n="restart.checkLogs">
<p class="restart-modal-info">
Check the Logs tab for details after the restart completes.
</p>
</div>
@@ -91,45 +79,33 @@
<div id="app">
<!-- Tab Bar -->
<div class="tab-bar">
<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>
<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>
<div class="spacer"></div>
<!-- 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>
<button class="status-logs-btn" data-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" data-i18n="status.teeVerified">TEE Verified</span>
<span id="tee-shield-label">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" data-i18n="status.connected">Connected</span>
<span id="sse-status">Connected</span>
<div class="gateway-popover" id="gateway-popover"></div>
</div>
<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;">
<button class="restart-btn" id="restart-btn" onclick="triggerRestart()" 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 data-i18n="status.restart">Restart</span>
<span>Restart</span>
</button>
</div>
@@ -137,18 +113,16 @@
<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()" data-i18n="chat.newThread" data-i18n-attr="title"
title="New thread (Ctrl/Cmd+N)">+</button>
<button class="thread-new-btn" onclick="createNewThread()" title="New thread (Ctrl/Cmd+N)">+</button>
<div class="spacer"></div>
<button class="thread-toggle-btn" id="thread-toggle-btn" onclick="toggleThreadSidebar()" data-i18n="chat.toggleSidebar"
data-i18n-attr="title" title="Toggle sidebar">&laquo;</button>
<button class="thread-toggle-btn" id="thread-toggle-btn" onclick="toggleThreadSidebar()" title="Toggle sidebar">&laquo;</button>
</div>
<div class="assistant-item" id="assistant-thread" onclick="switchToAssistant()">
<span class="assistant-label" id="assistant-label" data-i18n="chat.assistant">Assistant</span>
<span class="assistant-label" id="assistant-label">Assistant</span>
<span class="assistant-meta" id="assistant-meta"></span>
</div>
<div class="threads-section-header">
<span data-i18n="chat.conversations">Conversations</span>
<span>Conversations</span>
</div>
<div class="thread-list" id="thread-list"></div>
</div>
@@ -157,11 +131,10 @@
<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" data-i18n="chat.inputPlaceholder" data-i18n-attr="placeholder" placeholder="Message or / for commands..." rows="1"></textarea>
<textarea id="chat-input" 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" 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>
<button id="attach-btn" class="attach-btn" title="Attach images" aria-label="Attach images">&#x1F4CE;</button>
<button id="send-btn" onclick="sendMessage()">Send</button>
</div>
</div>
</div>
@@ -171,23 +144,23 @@
<div class="memory-container">
<div class="memory-sidebar">
<div class="search-box">
<input type="text" id="memory-search" data-i18n="memory.searchPlaceholder" data-i18n-attr="placeholder" placeholder="Search memory...">
<input type="text" id="memory-search" 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()" data-i18n="memory.edit">Edit</button>
<button class="memory-edit-btn" id="memory-edit-btn" style="display:none" onclick="startMemoryEdit()">Edit</button>
</div>
<div class="memory-viewer" id="memory-viewer">
<div class="empty" data-i18n="memory.selectFile">Select a file to view its contents</div>
<div class="empty">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()" data-i18n="memory.save">Save</button>
<button class="btn-cancel-edit" onclick="cancelMemoryEdit()" data-i18n="memory.cancel">Cancel</button>
<button class="btn-save" onclick="saveMemoryEdit()">Save</button>
<button class="btn-cancel-edit" onclick="cancelMemoryEdit()">Cancel</button>
</div>
</div>
</div>
@@ -201,17 +174,17 @@
<table class="jobs-table" id="jobs-table">
<thead>
<tr>
<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>
<th>ID</th>
<th>Title</th>
<th>Source</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="jobs-tbody"></tbody>
</table>
<div class="empty-state" id="jobs-empty" style="display:none" data-i18n="jobs.empty">No jobs found</div>
<div class="empty-state" id="jobs-empty" style="display:none">No jobs found</div>
</div>
</div>
@@ -226,16 +199,16 @@
<option value="debug">Server: DEBUG</option>
</select>
<select id="logs-level-filter">
<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>
<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>
</select>
<input type="text" id="logs-target-filter" placeholder="Filter by target...">
<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>
<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>
</div>
<div class="logs-output" id="logs-output"></div>
</div>
@@ -248,20 +221,20 @@
<table class="routines-table" id="routines-table">
<thead>
<tr>
<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>
<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>
</tr>
</thead>
<tbody id="routines-tbody"></tbody>
</table>
<div class="empty-state" id="routines-empty" style="display:none">
<span data-i18n="routines.noConfigured">No routines configured. Ask the assistant to create one.</span>
No routines configured. Ask the assistant to create one.
</div>
<div class="routine-detail" id="routine-detail" style="display:none"></div>
</div>
@@ -271,44 +244,44 @@
<div class="tab-panel" id="tab-extensions">
<div class="extensions-container">
<div class="extensions-section">
<h3 data-i18n="extensions.installed">Installed Extensions</h3>
<h3>Installed Extensions</h3>
<div class="extensions-list" id="extensions-list">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
<div class="empty-state">Loading extensions...</div>
</div>
</div>
<div class="extensions-section" id="available-wasm-section">
<h3 data-i18n="extensions.available">Available WASM Extensions</h3>
<h3>Available WASM Extensions</h3>
<div class="extensions-list" id="available-wasm-list">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
<div class="empty-state">Loading...</div>
</div>
</div>
<div class="extensions-section">
<h3 data-i18n="extensions.installWasm">Install WASM Extension</h3>
<h3>Install WASM Extension</h3>
<div class="ext-install-form">
<input type="text" id="wasm-install-name" data-i18n-placeholder="common.name" placeholder="Extension name">
<input type="text" id="wasm-install-name" placeholder="Extension name">
<input type="text" id="wasm-install-url" placeholder="URL to .tar.gz bundle">
<button onclick="installWasmExtension()" data-i18n="extensions.install">Install</button>
<button onclick="installWasmExtension()">Install</button>
</div>
</div>
<div class="extensions-section">
<h3 data-i18n="mcp.servers">MCP Servers</h3>
<h3>MCP Servers</h3>
<div class="extensions-list" id="mcp-servers-list">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
<div class="empty-state">Loading...</div>
</div>
<h4 data-i18n="mcp.addCustom">Add Custom MCP Server</h4>
<h4>Add Custom MCP Server</h4>
<div class="ext-install-form">
<input type="text" id="mcp-install-name" data-i18n-placeholder="common.name" placeholder="Server name">
<input type="text" id="mcp-install-name" placeholder="Server name">
<input type="text" id="mcp-install-url" placeholder="MCP server URL (https://...)">
<button onclick="addMcpServer()" data-i18n="mcp.add">Add</button>
<button onclick="addMcpServer()">Add</button>
</div>
</div>
<div class="extensions-section">
<h3 data-i18n="tools.registered">Registered Tools</h3>
<h3>Registered Tools</h3>
<table class="tools-table" id="tools-table">
<thead><tr><th data-i18n="tools.name">Name</th><th data-i18n="tools.description">Description</th></tr></thead>
<thead><tr><th>Name</th><th>Description</th></tr></thead>
<tbody id="tools-tbody"></tbody>
</table>
<div class="empty-state" id="tools-empty" style="display:none" data-i18n="tools.empty">No tools registered</div>
<div class="empty-state" id="tools-empty" style="display:none">No tools registered</div>
</div>
</div>
</div>
@@ -317,25 +290,25 @@
<div class="tab-panel" id="tab-skills">
<div class="extensions-container">
<div class="extensions-section">
<h3 data-i18n="skills.searchClawHub">Search ClawHub</h3>
<h3>Search ClawHub</h3>
<div class="skill-search-box">
<input type="text" id="skill-search-input" data-i18n-placeholder="skills.searchPlaceholder" placeholder="Search...">
<button onclick="searchClawHub()" data-i18n="skills.search">Search</button>
<input type="text" id="skill-search-input" placeholder="Search for skills...">
<button onclick="searchClawHub()">Search</button>
</div>
<div class="extensions-list" id="skill-search-results"></div>
</div>
<div class="extensions-section">
<h3 data-i18n="skills.installed">Installed Skills</h3>
<h3>Installed Skills</h3>
<div class="extensions-list" id="skills-list">
<div class="empty-state" data-i18n="skills.loading">Loading skills...</div>
<div class="empty-state">Loading skills...</div>
</div>
</div>
<div class="extensions-section">
<h3 data-i18n="skills.installByUrl">Install Skill by URL</h3>
<h3>Install Skill by URL</h3>
<div class="ext-install-form">
<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>
<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>
</div>
</div>
</div>
@@ -344,6 +317,5 @@
<div id="toasts"></div>
<script src="/app.js"></script>
<script src="/i18n-app.js"></script>
</body>
</html>
+14 -91
View File
@@ -9,7 +9,6 @@
--text-secondary: #a1a1aa;
--accent: #34d399;
--accent-hover: #2fc48d;
--accent-soft: rgba(52, 211, 153, 0.15);
--success: #34d399;
--warning: #F5A623;
--danger: #E64C4C;
@@ -656,11 +655,11 @@ body {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
gap: 12px;
}
.message {
max-width: 72%;
max-width: 80%;
padding: 10px 14px;
border-radius: var(--radius);
font-size: 14px;
@@ -670,8 +669,8 @@ body {
.message.user {
align-self: flex-end;
background: var(--accent-soft);
color: var(--accent);
background: var(--accent);
color: #09090b;
border-bottom-right-radius: 2px;
white-space: pre-wrap;
}
@@ -681,9 +680,6 @@ 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 {
@@ -714,10 +710,10 @@ body {
padding: 0;
}
.message p { margin: 0 0 10px 0; }
.message p { margin: 0 0 8px 0; }
.message p:last-child { margin-bottom: 0; }
.message ul, .message ol { margin: 4px 0; padding-left: 20px; }
.message li { margin: 4px 0; }
.message li { margin: 2px 0; }
.message blockquote {
margin: 6px 0;
padding: 4px 12px;
@@ -1066,7 +1062,7 @@ body {
}
.approval-card .approval-actions button:disabled {
opacity: 0.5;
opacity: 0.4;
cursor: not-allowed;
}
@@ -1245,7 +1241,7 @@ body {
}
.auth-card .auth-actions button:disabled {
opacity: 0.5;
opacity: 0.4;
cursor: not-allowed;
}
@@ -1305,11 +1301,6 @@ 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);
@@ -1323,7 +1314,7 @@ body {
transition: background 0.2s, transform 0.2s;
}
.chat-input button:hover:not(:disabled) {
.chat-input button:hover {
background: var(--accent-hover);
transform: translateY(-1px);
}
@@ -1333,18 +1324,8 @@ body {
}
.chat-input button:disabled {
opacity: 0.6;
opacity: 0.5;
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 */
@@ -1444,7 +1425,7 @@ body {
color: var(--text-secondary);
}
.tree-row:hover .tree-label.file {
.tree-label.file:hover {
color: var(--accent);
}
@@ -2326,7 +2307,7 @@ body {
}
.log-entry:hover {
background: var(--bg-tertiary);
background: var(--bg-secondary);
}
.log-ts {
@@ -3800,7 +3781,7 @@ mark {
}
/* Image Upload */
.chat-input .attach-btn {
.attach-btn {
background: none;
border: none;
cursor: pointer;
@@ -3813,13 +3794,10 @@ mark {
display: flex;
align-items: center;
justify-content: center;
font-weight: 400;
}
.chat-input .attach-btn:hover {
background: none;
.attach-btn:hover {
color: var(--text);
transform: none;
}
.image-preview-strip {
@@ -3885,61 +3863,6 @@ 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);
+47 -63
View File
@@ -24,7 +24,7 @@ pub struct WebhookServerConfig {
pub struct WebhookServer {
config: WebhookServerConfig,
routes: Vec<Router>,
/// Merged router saved after start() for restarts via `install_listener()`.
/// Merged router saved after start() for restart_with_addr().
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 `start()`.
/// Private helper used by both start() and restart_with_addr().
async fn bind_and_spawn(&mut self, app: Router) -> Result<(), ChannelError> {
let listener = tokio::net::TcpListener::bind(self.config.addr)
.await
@@ -89,49 +89,47 @@ impl WebhookServer {
Ok(())
}
/// Clone the merged router, if `start()` has been called.
pub fn merged_router_clone(&self) -> Option<Router> {
self.merged_router.clone()
}
/// Install a pre-bound listener, replacing the current one.
/// Gracefully shut down the current listener and rebind to a new address.
/// The merged router from the original `start()` call is reused.
///
/// 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.
/// 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(),
})?;
// Save old state for rollback if new bind fails
let old_addr = self.config.addr;
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;
// 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);
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(())
}
});
self.handle = Some(handle);
tracing::info!("Webhook server listening on {}", new_addr);
(old_shutdown_tx, old_handle)
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)
}
}
}
/// Return the current bind address.
@@ -215,21 +213,12 @@ mod tests {
"First server should respond to health check"
);
// 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)
// Restart on second port
let addr2 = format!("127.0.0.1:{}", port2).parse().unwrap();
server
.restart_with_addr(addr2)
.await
.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;
}
.expect("Failed to restart with new addr");
// Assert the address changed
assert_eq!(
@@ -306,18 +295,13 @@ mod tests {
.expect("Failed to send request");
assert_eq!(response.status(), 200, "Server should be listening");
// Try to restart on an invalid address (port 1 typically requires elevated privileges)
// Try to restart on an invalid address (port 0 is reserved, won't bind)
// Use port 1 which typically requires elevated privileges
let invalid_addr: SocketAddr = "127.0.0.1:1".parse().unwrap();
// 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);
// Attempt restart (should fail)
let result = server.restart_with_addr(invalid_addr).await;
assert!(result.is_err(), "Restart with invalid address should fail");
// Verify the old address is still responding (rollback succeeded)
let response = client
-162
View File
@@ -1,162 +0,0 @@
//! 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")
}
-31
View File
@@ -14,8 +14,6 @@
mod completion;
mod config;
mod doctor;
#[cfg(feature = "import")]
pub mod import;
mod mcp;
pub mod memory;
pub mod oauth_defaults;
@@ -28,8 +26,6 @@ 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;
@@ -187,15 +183,6 @@ 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)]
@@ -295,7 +282,6 @@ mod tests {
}
#[test]
#[cfg(feature = "import")]
fn test_help_output() {
let mut cmd = Cli::command();
let help = cmd.render_help().to_string();
@@ -303,26 +289,9 @@ 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,6 +1,5 @@
---
source: src/cli/mod.rs
assertion_line: 302
expression: help
---
Secure personal AI assistant that protects your data and expands its capabilities
@@ -20,7 +19,6 @@ 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:
@@ -1,32 +0,0 @@
---
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,6 +1,5 @@
---
source: src/cli/mod.rs
assertion_line: 318
expression: help
---
IronClaw is a secure AI assistant. Use 'ironclaw <subcommand> --help' for details.
@@ -23,7 +22,6 @@ 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:
@@ -1,48 +0,0 @@
---
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
+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, TokenBudgetExceeded};
pub use state::{JobContext, JobState, StateTransition};
+7 -17
View File
@@ -11,16 +11,6 @@ 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")]
@@ -275,15 +265,15 @@ impl JobContext {
self.actual_cost += cost;
}
/// 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> {
/// 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> {
self.total_tokens_used += tokens;
if self.max_tokens > 0 && self.total_tokens_used > self.max_tokens {
Err(TokenBudgetExceeded {
used: self.total_tokens_used,
limit: self.max_tokens,
})
Err(format!(
"Token budget exceeded: used {} of {} allowed tokens",
self.total_tokens_used, self.max_tokens
))
} else {
Ok(())
}
-93
View File
@@ -1,93 +0,0 @@
//! 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
@@ -1,26 +0,0 @@
//! 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
@@ -1,115 +0,0 @@
//! 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
@@ -1,63 +0,0 @@
//! 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
@@ -1,182 +0,0 @@
//! 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
@@ -1,442 +0,0 @@
//! 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
@@ -1,143 +0,0 @@
//! 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,8 +54,6 @@ 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;
@@ -74,7 +72,6 @@ pub mod tracing_fmt;
pub mod transcription;
pub mod tunnel;
pub mod util;
pub mod webhooks;
pub mod worker;
pub mod workspace;
+22 -68
View File
@@ -9,8 +9,8 @@ use ironclaw::{
agent::{Agent, AgentDeps},
app::{AppBuilder, AppBuilderFlags},
channels::{
ChannelManager, ChannelSecretUpdater, GatewayChannel, HttpChannel, ReplChannel,
SignalChannel, WebhookServer, WebhookServerConfig,
ChannelManager, GatewayChannel, HttpChannel, ReplChannel, SignalChannel, WebhookServer,
WebhookServerConfig,
wasm::{WasmChannelRouter, WasmChannelRuntime},
web::log_layer::LogBroadcaster,
},
@@ -24,7 +24,6 @@ use ironclaw::{
orchestrator::{ReaperConfig, SandboxReaper},
pairing::PairingStore,
tracing_fmt::{init_cli_tracing, init_worker_tracing},
webhooks::{self, ToolWebhookState},
};
#[cfg(any(feature = "postgres", feature = "libsql"))]
@@ -87,12 +86,6 @@ 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,
@@ -278,25 +271,9 @@ 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(
@@ -448,6 +425,7 @@ 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));
@@ -471,7 +449,6 @@ 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));
}
@@ -506,6 +483,8 @@ 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;
}
@@ -704,7 +683,9 @@ async fn async_main() -> anyhow::Result<()> {
}
// Give the agent the routine engine slot so it can expose the engine to the gateway.
agent.set_routine_engine_slot(shared_routine_engine_slot);
if let Some(slot) = routine_engine_slot {
agent.set_routine_engine_slot(slot);
}
// Prepare SIGHUP handler for hot-reloading HTTP webhook config
// Broadcast channel for clean shutdown of background tasks
@@ -712,6 +693,7 @@ async fn async_main() -> anyhow::Result<()> {
#[cfg(unix)]
{
use ironclaw::channels::ChannelSecretUpdater;
// Collect all channels that support secret updates
let mut secret_updaters: Vec<Arc<dyn ChannelSecretUpdater>> = Vec::new();
if let Some(ref state) = http_channel_state {
@@ -799,12 +781,12 @@ async fn async_main() -> anyhow::Result<()> {
};
// Restart listener if addr changed.
// Two-phase approach: bind outside the lock, then swap under lock.
// Minimize lock scope: acquire, read old addr, release, then restart.
let mut restart_failed = false;
if let Some(ref ws_arc) = sighup_webhook_server {
let (old_addr, router) = {
let old_addr = {
let ws = ws_arc.lock().await;
(ws.current_addr(), ws.merged_router_clone())
ws.current_addr()
}; // Lock released here
if old_addr != new_addr {
@@ -813,45 +795,17 @@ async fn async_main() -> anyhow::Result<()> {
old_addr,
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;
}
}
// 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);
}
None => {
tracing::error!(
"SIGHUP: cannot restart — server was never started"
);
Err(e) => {
tracing::error!("SIGHUP: listener restart failed: {}", e);
restart_failed = true;
}
}
+4 -49
View File
@@ -197,20 +197,13 @@ 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.
// Depth is capped to prevent stack overflow on pathological input.
const MAX_DEPTH: usize = 32;
// Recursively check all string values in the JSON
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() {
@@ -223,7 +216,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, depth + 1);
check_strings(item, &child_path, validator, result);
}
}
serde_json::Value::Object(obj) => {
@@ -233,14 +226,14 @@ impl Validator {
} else {
format!("{path}.{k}")
};
check_strings(v, &child_path, validator, result, depth + 1);
check_strings(v, &child_path, validator, result);
}
}
_ => {}
}
}
check_strings(params, "", self, &mut result, 0);
check_strings(params, "", self, &mut result);
result
}
}
@@ -430,42 +423,4 @@ 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"
);
}
}
-8
View File
@@ -328,14 +328,6 @@ 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,8 +32,6 @@ 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 {
@@ -310,25 +308,6 @@ 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};
@@ -340,7 +319,6 @@ mod tests {
assert!(caps.http.is_none());
assert!(caps.tool_invoke.is_none());
assert!(caps.secrets.is_none());
assert!(caps.webhook.is_none());
}
#[test]
+1 -72
View File
@@ -35,7 +35,7 @@ use serde::{Deserialize, Serialize};
use crate::secrets::{CredentialLocation, CredentialMapping};
use crate::tools::wasm::{
Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability,
ToolInvokeCapability, WebhookCapability, WorkspaceCapability,
ToolInvokeCapability, WorkspaceCapability,
};
/// Root schema for a capabilities JSON file.
@@ -65,10 +65,6 @@ 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)]
@@ -111,7 +107,6 @@ impl CapabilitiesFile {
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);
}
@@ -203,10 +198,6 @@ impl CapabilitiesFile {
});
}
if let Some(webhook) = &self.webhook {
caps.webhook = Some(webhook.to_webhook_capability());
}
caps
}
}
@@ -428,46 +419,6 @@ 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
@@ -818,28 +769,6 @@ 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#"{
+1 -1
View File
@@ -108,7 +108,7 @@ pub use wrapper::{OAuthRefreshConfig, WasmToolWrapper};
// Capabilities (V2)
pub use capabilities::{
Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability,
ToolInvokeCapability, WebhookCapability, WorkspaceCapability, WorkspaceReader,
ToolInvokeCapability, WorkspaceCapability, WorkspaceReader,
};
// Security components (V2)
-4
View File
@@ -808,10 +808,6 @@ 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 {
-712
View File
@@ -1,712 +0,0 @@
//! 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(err) = self
&& let Err(msg) = self
.worker
.context_manager()
.update_context(self.worker.job_id, |ctx| ctx.add_tokens(total_tokens))
.await?
{
self.worker.mark_failed(&err.to_string()).await?;
self.worker.mark_failed(&msg).await?;
}
Ok(output)
@@ -1796,7 +1796,7 @@ mod tests {
// Verify that mark_failed transitions job to Failed
worker
.mark_failed(&budget_result.unwrap_err().to_string())
.mark_failed(&budget_result.unwrap_err())
.await
.unwrap();
let ctx = worker
+4 -15
View File
@@ -58,7 +58,6 @@ 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;
@@ -96,11 +95,7 @@ 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)
.with_auto_approve_tools(true)
.build()
.await;
let rig = TestRigBuilder::new().with_trace(trace).build().await;
rig.send_message("Write 'recovered successfully' to a file for me.")
.await;
@@ -143,11 +138,7 @@ 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)
.with_auto_approve_tools(true)
.build()
.await;
let rig = TestRigBuilder::new().with_trace(trace).build().await;
rig.send_message(
"Create a daily log at /tmp/ironclaw_chain_test/log.md, \
@@ -241,7 +232,6 @@ mod advanced {
let rig = TestRigBuilder::new()
.with_trace(trace)
.with_max_tool_iterations(3)
.with_auto_approve_tools(true)
.build()
.await;
@@ -252,8 +242,8 @@ mod advanced {
let started = rig.tool_calls_started();
assert!(
started.len() <= 8,
"expected <= 8 tool calls with max_tool_iterations=3, got {}: {started:?}",
started.len() <= 4,
"expected <= 4 tool calls with max_tool_iterations=3, got {}: {started:?}",
started.len()
);
assert!(!started.is_empty(), "expected at least 1 tool call, got 0");
@@ -305,7 +295,6 @@ mod advanced {
.with_trace(trace.clone())
.with_routines()
.with_http_exchanges(http_exchanges)
.with_auto_approve_tools(true)
.build()
.await;
-5
View File
@@ -140,7 +140,6 @@ mod tests {
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
@@ -181,7 +180,6 @@ mod tests {
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
@@ -327,7 +325,6 @@ mod tests {
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
@@ -397,7 +394,6 @@ mod tests {
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
@@ -439,7 +435,6 @@ mod tests {
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
+2 -10
View File
@@ -32,11 +32,7 @@ mod tests {
))
.expect("failed to load simple_text.json");
let rig = TestRigBuilder::new()
.with_trace(trace)
.with_auto_approve_tools(true)
.build()
.await;
let rig = TestRigBuilder::new().with_trace(trace).build().await;
rig.send_message("hello").await;
let _responses = rig.wait_for_responses(1, Duration::from_secs(10)).await;
@@ -99,11 +95,7 @@ mod tests {
))
.expect("failed to load file_write_read.json");
let rig = TestRigBuilder::new()
.with_trace(trace)
.with_auto_approve_tools(true)
.build()
.await;
let rig = TestRigBuilder::new().with_trace(trace).build().await;
rig.send_message("Please write a greeting to a file and read it back.")
.await;
-69
View File
@@ -1,69 +0,0 @@
//! Integration tests for OpenClaw import functionality.
#![cfg(feature = "import")]
#[cfg(feature = "import")]
mod import_tests {
use ironclaw::import::openclaw::reader::{OpenClawConfig, OpenClawMemoryChunk};
use ironclaw::import::{ImportError, ImportStats};
#[test]
fn test_import_stats_is_empty() {
let stats = ImportStats::default();
assert!(stats.is_empty());
assert_eq!(stats.total_imported(), 0);
}
#[test]
fn test_import_stats_total_imported() {
let stats = ImportStats {
documents: 5,
chunks: 10,
conversations: 2,
messages: 50,
settings: 3,
secrets: 1,
..ImportStats::default()
};
assert!(!stats.is_empty());
assert_eq!(stats.total_imported(), 71);
}
#[test]
fn test_import_error_display() {
let err = ImportError::ConfigParse("test error".to_string());
assert_eq!(err.to_string(), "JSON5 parse error: test error");
let err = ImportError::Database("db error".to_string());
assert_eq!(err.to_string(), "Database error: db error");
}
#[test]
fn test_openclaw_config_construction() {
let config = OpenClawConfig {
llm: None,
embeddings: None,
other_settings: std::collections::HashMap::new(),
};
assert!(config.llm.is_none());
assert!(config.embeddings.is_none());
assert!(config.other_settings.is_empty());
}
#[test]
fn test_memory_chunk_construction() {
let chunk = OpenClawMemoryChunk {
path: "test/doc.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/doc.md");
assert_eq!(chunk.content, "Test content");
assert!(chunk.embedding.is_some());
assert_eq!(chunk.chunk_index, 0);
}
}
-442
View File
@@ -1,442 +0,0 @@
//! Comprehensive end-to-end tests for OpenClaw import with synthetic test data.
#![cfg(feature = "import")]
#[cfg(feature = "import")]
mod comprehensive_import_tests {
use std::path::{Path, PathBuf};
use tempfile::TempDir;
use uuid::Uuid;
use ironclaw::import::openclaw::reader::OpenClawReader;
use ironclaw::import::{ImportError, ImportOptions};
/// Helper to create a minimal synthetic OpenClaw directory structure
fn create_synthetic_openclaw_dir() -> Result<(TempDir, PathBuf), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let openclaw_path = temp_dir.path().to_path_buf();
// Create openclaw.json
let config_content = r#"{
llm: {
provider: "openai",
model: "gpt-4",
api_key: "sk-test-key-123",
base_url: "https://api.openai.com/v1"
},
embeddings: {
model: "text-embedding-3-small",
provider: "openai",
api_key: "sk-test-embed-456"
}
}"#;
std::fs::write(openclaw_path.join("openclaw.json"), config_content)?;
// Create workspace directory with Markdown files
let workspace_dir = openclaw_path.join("workspace");
std::fs::create_dir_all(&workspace_dir)?;
let memory_content =
"# Memory\n\nThis is a test memory document.\n\n## Section 1\nSome content here.";
std::fs::write(workspace_dir.join("MEMORY.md"), memory_content)?;
let readme_content = "# README\n\nTest workspace README with important notes.";
std::fs::write(workspace_dir.join("README.md"), readme_content)?;
Ok((temp_dir, openclaw_path))
}
/// Helper to create a synthetic SQLite database with memory chunks
async fn create_synthetic_memory_db(
agents_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
std::fs::create_dir_all(agents_dir)?;
let db_path = agents_dir.join("test_agent.sqlite");
let db = libsql::Builder::new_local(&db_path).build().await?;
let conn = db.connect()?;
// Create chunks table (simplified schema)
conn.execute(
"CREATE TABLE IF NOT EXISTS chunks (
id TEXT PRIMARY KEY,
path TEXT NOT NULL,
content TEXT NOT NULL,
embedding BLOB,
chunk_index INTEGER NOT NULL
)",
(),
)
.await?;
// Insert test chunks
conn.execute(
"INSERT INTO chunks (id, path, content, embedding, chunk_index)
VALUES (?, ?, ?, ?, ?)",
libsql::params![
Uuid::new_v4().to_string(),
"test/doc.md",
"This is test chunk 1 content.",
libsql::Value::Null,
0i64
],
)
.await?;
conn.execute(
"INSERT INTO chunks (id, path, content, embedding, chunk_index)
VALUES (?, ?, ?, ?, ?)",
libsql::params![
Uuid::new_v4().to_string(),
"test/doc.md",
"This is test chunk 2 content.",
libsql::Value::Null,
1i64
],
)
.await?;
// Create conversation table
conn.execute(
"CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
channel TEXT NOT NULL,
created_at TEXT
)",
(),
)
.await?;
// Create messages table
conn.execute(
"CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT,
FOREIGN KEY(conversation_id) REFERENCES conversations(id)
)",
(),
)
.await?;
// Insert test conversation
let conv_id = Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO conversations (id, channel, created_at) VALUES (?, ?, ?)",
libsql::params![conv_id.clone(), "telegram", "2024-01-15T10:30:00Z"],
)
.await?;
// Insert test messages
conn.execute(
"INSERT INTO messages (id, conversation_id, role, content, created_at)
VALUES (?, ?, ?, ?, ?)",
libsql::params![
Uuid::new_v4().to_string(),
conv_id.clone(),
"user",
"Hello, how are you?",
"2024-01-15T10:30:00Z"
],
)
.await?;
conn.execute(
"INSERT INTO messages (id, conversation_id, role, content, created_at)
VALUES (?, ?, ?, ?, ?)",
libsql::params![
Uuid::new_v4().to_string(),
conv_id.clone(),
"assistant",
"I'm doing well, thank you for asking!",
"2024-01-15T10:31:00Z"
],
)
.await?;
Ok(db_path)
}
#[test]
fn test_openclaw_reader_detects_config() {
let (temp_dir, openclaw_path) =
create_synthetic_openclaw_dir().expect("failed to create test data");
// Verify detection works
assert!(openclaw_path.join("openclaw.json").exists());
// Create reader
let reader = OpenClawReader::new(&openclaw_path).expect("failed to create reader");
let _ = (temp_dir, reader);
}
#[test]
fn test_openclaw_reader_parses_config() {
let (temp_dir, openclaw_path) =
create_synthetic_openclaw_dir().expect("failed to create test data");
let reader = OpenClawReader::new(&openclaw_path).expect("failed to create reader");
let config = reader.read_config().expect("failed to read config");
// Verify LLM config
assert!(config.llm.is_some());
let llm = config.llm.unwrap();
assert_eq!(llm.provider, Some("openai".to_string()));
assert_eq!(llm.model, Some("gpt-4".to_string()));
// API key is wrapped in SecretString, just verify it's present
assert!(llm.api_key.is_some());
// Verify embeddings config
assert!(config.embeddings.is_some());
let emb = config.embeddings.unwrap();
assert_eq!(emb.provider, Some("openai".to_string()));
assert_eq!(emb.model, Some("text-embedding-3-small".to_string()));
// API key is wrapped in SecretString, just verify it's present
assert!(emb.api_key.is_some());
let _ = temp_dir;
}
#[test]
fn test_openclaw_reader_lists_workspace_files() {
let (temp_dir, openclaw_path) =
create_synthetic_openclaw_dir().expect("failed to create test data");
let reader = OpenClawReader::new(&openclaw_path).expect("failed to create reader");
let count = reader
.list_workspace_files()
.expect("failed to list workspace files");
// Should find MEMORY.md and README.md
assert_eq!(count, 2);
let _ = temp_dir;
}
#[tokio::test]
async fn test_openclaw_reader_lists_agent_dbs() {
let (temp_dir, openclaw_path) =
create_synthetic_openclaw_dir().expect("failed to create test data");
let agents_dir = openclaw_path.join("agents");
let _db_path = create_synthetic_memory_db(&agents_dir)
.await
.expect("failed to create test DB");
let reader = OpenClawReader::new(&openclaw_path).expect("failed to create reader");
let dbs = reader.list_agent_dbs().expect("failed to list agent DBs");
// Should find test_agent.sqlite
assert_eq!(dbs.len(), 1);
assert_eq!(dbs[0].0, "test_agent");
let _ = temp_dir;
}
#[tokio::test]
async fn test_openclaw_reader_reads_memory_chunks() {
let (temp_dir, openclaw_path) =
create_synthetic_openclaw_dir().expect("failed to create test data");
let agents_dir = openclaw_path.join("agents");
let db_path = create_synthetic_memory_db(&agents_dir)
.await
.expect("failed to create test DB");
let reader = OpenClawReader::new(&openclaw_path).expect("failed to create reader");
let chunks = reader
.read_memory_chunks(&db_path)
.await
.expect("failed to read memory chunks");
// Should find 2 chunks
assert_eq!(chunks.len(), 2);
// Verify chunk content
assert_eq!(chunks[0].path, "test/doc.md");
assert_eq!(chunks[0].content, "This is test chunk 1 content.");
assert_eq!(chunks[0].chunk_index, 0);
assert!(chunks[0].embedding.is_none());
assert_eq!(chunks[1].path, "test/doc.md");
assert_eq!(chunks[1].content, "This is test chunk 2 content.");
assert_eq!(chunks[1].chunk_index, 1);
let _ = temp_dir;
}
#[tokio::test]
async fn test_openclaw_reader_reads_conversations() {
let (temp_dir, openclaw_path) =
create_synthetic_openclaw_dir().expect("failed to create test data");
let agents_dir = openclaw_path.join("agents");
let db_path = create_synthetic_memory_db(&agents_dir)
.await
.expect("failed to create test DB");
let reader = OpenClawReader::new(&openclaw_path).expect("failed to create reader");
let conversations = reader
.read_conversations(&db_path)
.await
.expect("failed to read conversations");
// Should find 1 conversation
assert_eq!(conversations.len(), 1);
let conv = &conversations[0];
assert_eq!(conv.channel, "telegram");
assert_eq!(conv.messages.len(), 2);
// Verify messages
assert_eq!(conv.messages[0].role, "user");
assert_eq!(conv.messages[0].content, "Hello, how are you?");
assert_eq!(conv.messages[1].role, "assistant");
assert_eq!(
conv.messages[1].content,
"I'm doing well, thank you for asking!"
);
let _ = temp_dir;
}
#[test]
fn test_openclaw_reader_handles_missing_directory() {
let missing_path = PathBuf::from("/nonexistent/openclaw");
let result = OpenClawReader::new(&missing_path);
assert!(result.is_err());
match result {
Err(ImportError::NotFound { .. }) => (), // Expected
_ => panic!("Expected NotFound error"),
}
}
#[test]
fn test_openclaw_reader_handles_missing_config() {
let temp_dir = TempDir::new().expect("failed to create temp dir");
let reader = OpenClawReader::new(temp_dir.path()).expect("failed to create reader");
let result = reader.read_config();
assert!(result.is_err());
}
#[test]
fn test_import_options_construction() {
let opts = ImportOptions {
openclaw_path: PathBuf::from("/test/openclaw"),
dry_run: true,
re_embed: false,
user_id: "test_user".to_string(),
};
assert_eq!(opts.user_id, "test_user");
assert!(opts.dry_run);
assert!(!opts.re_embed);
}
#[test]
fn test_openclaw_reader_empty_agents_directory() {
let (temp_dir, openclaw_path) =
create_synthetic_openclaw_dir().expect("failed to create test data");
// Create empty agents directory
std::fs::create_dir(openclaw_path.join("agents")).expect("failed to create agents dir");
let reader = OpenClawReader::new(&openclaw_path).expect("failed to create reader");
let dbs = reader.list_agent_dbs().expect("failed to list agent DBs");
// Should find no databases
assert_eq!(dbs.len(), 0);
let _ = temp_dir;
}
#[test]
fn test_openclaw_reader_no_workspace_files() {
let temp_dir = TempDir::new().expect("failed to create temp dir");
let openclaw_path = temp_dir.path().to_path_buf();
// Create config
let config_content = r#"{ llm: { provider: "openai" } }"#;
std::fs::write(openclaw_path.join("openclaw.json"), config_content)
.expect("failed to write config");
let reader = OpenClawReader::new(&openclaw_path).expect("failed to create reader");
let count = reader
.list_workspace_files()
.expect("failed to list workspace files");
// Should find no files
assert_eq!(count, 0);
}
#[test]
fn test_openclaw_reader_malformed_json5() {
let temp_dir = TempDir::new().expect("failed to create temp dir");
let openclaw_path = temp_dir.path().to_path_buf();
// Create malformed config
let bad_config = r#"{ llm: { provider: "openai" }"#; // Missing closing brace
std::fs::write(openclaw_path.join("openclaw.json"), bad_config)
.expect("failed to write config");
let reader = OpenClawReader::new(&openclaw_path).expect("failed to create reader");
let result = reader.read_config();
assert!(result.is_err());
}
#[test]
fn test_openclaw_detect_existing() {
let (temp_dir, openclaw_path) =
create_synthetic_openclaw_dir().expect("failed to create test data");
// Verify the openclaw.json config exists (which is what detect() checks for)
assert!(openclaw_path.join("openclaw.json").exists());
let _ = temp_dir;
}
#[test]
fn test_import_stats_aggregation() {
let stats = ironclaw::import::ImportStats {
documents: 5,
chunks: 10,
conversations: 3,
messages: 25,
settings: 2,
secrets: 1,
skipped: 2,
re_embed_queued: 1,
};
assert_eq!(stats.total_imported(), 46); // All except skipped
assert!(!stats.is_empty());
}
#[test]
fn test_import_error_variants() {
let err1 = ImportError::ConfigParse("test".to_string());
assert_eq!(err1.to_string(), "JSON5 parse error: test");
let err2 = ImportError::Database("db failed".to_string());
assert_eq!(err2.to_string(), "Database error: db failed");
let err3 = ImportError::Sqlite("sqlite error".to_string());
assert_eq!(err3.to_string(), "SQLite error: sqlite error");
let err4 = ImportError::Workspace("workspace error".to_string());
assert_eq!(err4.to_string(), "Workspace error: workspace error");
}
}
-490
View File
@@ -1,490 +0,0 @@
//! End-to-end integration tests for OpenClaw importer with actual import execution.
//!
//! These tests verify the complete import pipeline: configuration, settings,
//! credentials, memory chunks, workspace documents, and conversations.
#![cfg(feature = "import")]
#[cfg(feature = "import")]
mod e2e_import_tests {
use std::path::PathBuf;
use tempfile::TempDir;
use uuid::Uuid;
use ironclaw::import::openclaw::reader::OpenClawReader;
use ironclaw::import::openclaw::settings;
use ironclaw::import::{ImportOptions, ImportStats};
/// Helper: Create a synthetic OpenClaw with full structure
async fn setup_full_openclaw_test_env() -> Result<(TempDir, PathBuf), Box<dyn std::error::Error>>
{
let temp_dir = TempDir::new()?;
let openclaw_path = temp_dir.path().to_path_buf();
// 1. Create openclaw.json with all settings
let config_content = r#"{
llm: {
provider: "openai",
model: "gpt-4-turbo",
api_key: "sk-test-key-12345",
base_url: "https://api.openai.com/v1"
},
embeddings: {
model: "text-embedding-3-large",
provider: "openai",
api_key: "sk-embed-key-67890"
},
custom_setting: "custom_value"
}"#;
std::fs::write(openclaw_path.join("openclaw.json"), config_content)?;
// 2. Create workspace with multiple files
let workspace_dir = openclaw_path.join("workspace");
std::fs::create_dir_all(&workspace_dir)?;
std::fs::write(
workspace_dir.join("MEMORY.md"),
"# Memory\n\nStored memories and facts.\n\n- User prefers morning briefings\n- Key project: Alpha",
)?;
std::fs::write(
workspace_dir.join("README.md"),
"# Project README\n\nThis is the main project documentation.\n\n## Goals\n1. Complete migration\n2. Verify data",
)?;
std::fs::write(
workspace_dir.join("AGENTS.md"),
"# Agent Definitions\n\n## Main Agent\n- Role: Assistant\n- Capabilities: Analysis, Planning",
)?;
// 3. Create agents directory with databases
let agents_dir = openclaw_path.join("agents");
std::fs::create_dir_all(&agents_dir)?;
create_full_agent_db(&agents_dir.join("primary_agent.sqlite")).await?;
create_full_agent_db(&agents_dir.join("secondary_agent.sqlite")).await?;
Ok((temp_dir, openclaw_path))
}
/// Helper: Create a full agent SQLite database with chunks and conversations
async fn create_full_agent_db(db_path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
let db = libsql::Builder::new_local(db_path).build().await?;
let conn = db.connect()?;
// Chunks table
conn.execute(
"CREATE TABLE IF NOT EXISTS chunks (
id TEXT PRIMARY KEY,
path TEXT NOT NULL,
content TEXT NOT NULL,
embedding BLOB,
chunk_index INTEGER NOT NULL
)",
(),
)
.await?;
// Insert 5 chunks
for i in 0..5 {
conn.execute(
"INSERT INTO chunks (id, path, content, embedding, chunk_index)
VALUES (?, ?, ?, ?, ?)",
libsql::params![
Uuid::new_v4().to_string(),
format!("notes/section_{}.md", i),
format!("Content for section {}. This is important information.", i),
libsql::Value::Null,
i as i64
],
)
.await?;
}
// Conversations table
conn.execute(
"CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
channel TEXT NOT NULL,
created_at TEXT
)",
(),
)
.await?;
// Messages table
conn.execute(
"CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT,
FOREIGN KEY(conversation_id) REFERENCES conversations(id)
)",
(),
)
.await?;
// Insert 3 conversations with messages
for conv_num in 0..3 {
let conv_id = Uuid::new_v4().to_string();
let channel = match conv_num {
0 => "telegram",
1 => "slack",
_ => "discord",
};
conn.execute(
"INSERT INTO conversations (id, channel, created_at) VALUES (?, ?, ?)",
libsql::params![
conv_id.clone(),
channel,
format!("2024-01-{:02}T10:00:00Z", 10 + conv_num)
],
)
.await?;
// Add 3 messages per conversation
for msg_num in 0..3 {
let role = if msg_num % 2 == 0 {
"user"
} else {
"assistant"
};
conn.execute(
"INSERT INTO messages (id, conversation_id, role, content, created_at)
VALUES (?, ?, ?, ?, ?)",
libsql::params![
Uuid::new_v4().to_string(),
conv_id.clone(),
role,
format!(
"{} message {} from conversation {}",
role, msg_num, conv_num
),
format!("2024-01-{:02}T10:{:02}:00Z", 10 + conv_num, msg_num * 10)
],
)
.await?;
}
}
Ok(())
}
// ────────────────────────────────────────────────────────────────────
// Configuration & Settings Tests
// ────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn test_full_config_extraction() {
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let config = reader.read_config().expect("config read failed");
// Verify LLM config
assert_eq!(
config.llm.as_ref().map(|c| c.provider.clone()),
Some(Some("openai".to_string()))
);
assert_eq!(
config.llm.as_ref().map(|c| c.model.clone()),
Some(Some("gpt-4-turbo".to_string()))
);
// Verify embeddings config
assert_eq!(
config.embeddings.as_ref().map(|c| c.model.clone()),
Some(Some("text-embedding-3-large".to_string()))
);
// Verify custom settings preserved
assert!(config.other_settings.contains_key("custom_setting"));
}
#[tokio::test]
async fn test_settings_mapping_to_ironclaw_format() {
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let config = reader.read_config().expect("config read failed");
let settings_map = settings::map_openclaw_config_to_settings(&config);
// Verify key mappings
assert!(settings_map.contains_key("llm.backend"));
assert!(settings_map.contains_key("llm.selected_model"));
assert!(settings_map.contains_key("embeddings.model"));
assert!(settings_map.contains_key("custom_setting"));
// Verify values
assert_eq!(
settings_map.get("llm.backend").and_then(|v| v.as_str()),
Some("openai")
);
}
// ────────────────────────────────────────────────────────────────────
// Credential Extraction Tests
// ────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn test_credentials_extraction() {
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let config = reader.read_config().expect("config read failed");
let creds = settings::extract_credentials(&config);
// Should extract 2 credentials (llm_api_key + embeddings_api_key)
assert_eq!(creds.len(), 2);
// Verify names (order may vary, so check both are present)
let names: Vec<_> = creds.iter().map(|(name, _)| name).collect();
assert!(names.contains(&&"llm_api_key".to_string()));
assert!(names.contains(&&"embeddings_api_key".to_string()));
// Verify credentials are wrapped in SecretString (not exposed in debug)
for (_name, secret) in creds {
let debug_str = format!("{:?}", secret);
assert!(!debug_str.contains("sk-test-key"));
assert!(!debug_str.contains("sk-embed-key"));
}
}
#[tokio::test]
async fn test_credentials_never_logged() {
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let config = reader.read_config().expect("config read failed");
let creds = settings::extract_credentials(&config);
// Verify actual secrets are not exposed
for (_name, secret) in creds {
let secret_debug = format!("{:?}", secret);
// Should NOT contain the actual API keys
assert!(!secret_debug.contains("sk-test-key-12345"));
assert!(!secret_debug.contains("sk-embed-key-67890"));
}
}
// ────────────────────────────────────────────────────────────────────
// Data Volume Tests
// ────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn test_full_workspace_import_counts() {
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
// Count workspace files
let workspace_count = reader
.list_workspace_files()
.expect("list workspace files failed");
assert_eq!(workspace_count, 3); // MEMORY.md, README.md, AGENTS.md
// Count agent databases
let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed");
assert_eq!(agent_dbs.len(), 2); // primary + secondary
}
#[tokio::test]
async fn test_full_memory_chunks_import() {
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed");
// Each agent should have 5 chunks
for (_name, db_path) in agent_dbs {
let chunks = reader
.read_memory_chunks(&db_path)
.await
.expect("read memory chunks failed");
assert_eq!(chunks.len(), 5);
// Verify chunk structure
for (i, chunk) in chunks.iter().enumerate() {
assert_eq!(chunk.chunk_index, i as i32);
assert!(
chunk
.content
.contains(&format!("Content for section {}", i))
);
}
}
}
#[tokio::test]
async fn test_full_conversations_import() {
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed");
// Each agent should have 3 conversations
for (_name, db_path) in agent_dbs {
let conversations = reader
.read_conversations(&db_path)
.await
.expect("read conversations failed");
assert_eq!(conversations.len(), 3);
// Verify each conversation has messages
for conv in conversations {
assert_eq!(conv.messages.len(), 3); // Each has 3 messages
assert!(!conv.channel.is_empty());
// Verify message roles
let roles: Vec<_> = conv.messages.iter().map(|m| m.role.as_str()).collect();
assert!(roles.contains(&"user"));
assert!(roles.contains(&"assistant"));
}
}
}
// ────────────────────────────────────────────────────────────────────
// Import Stats Verification
// ────────────────────────────────────────────────────────────────────
#[test]
fn test_import_options_validation() {
let opts = ImportOptions {
openclaw_path: PathBuf::from("/test/openclaw"),
dry_run: true,
re_embed: true,
user_id: "test_user".to_string(),
};
assert_eq!(opts.user_id, "test_user");
assert!(opts.dry_run);
assert!(opts.re_embed);
}
#[test]
fn test_import_stats_calculations() {
// Simulating a full import scenario
let stats = ImportStats {
// Workspace: 3 files
documents: 3,
// Memory: 2 agents × 5 chunks each = 10 chunks
chunks: 10,
// Conversations: 2 agents × 3 conversations = 6 conversations
conversations: 6,
// Messages: 2 agents × 3 conversations × 3 messages = 18 messages
messages: 18,
// Settings: LLM config + embeddings + custom = 3
settings: 3,
// Credentials: api_key + embeddings_key = 2
secrets: 2,
..ImportStats::default()
};
let total = stats.total_imported();
assert_eq!(total, 3 + 10 + 6 + 18 + 3 + 2);
assert!(!stats.is_empty());
}
// ────────────────────────────────────────────────────────────────────
// Error Handling Tests
// ────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn test_error_on_corrupt_sqlite() {
let temp_dir = TempDir::new().expect("temp dir creation failed");
let openclaw_path = temp_dir.path().to_path_buf();
// Create agents dir with corrupt SQLite file
let agents_dir = openclaw_path.join("agents");
std::fs::create_dir_all(&agents_dir).expect("agents dir creation failed");
// Write garbage data as "SQLite"
std::fs::write(
agents_dir.join("corrupt.sqlite"),
"this is not a sqlite file",
)
.expect("write failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
// Listing should succeed (file exists)
let dbs = reader.list_agent_dbs().expect("list agent dbs failed");
assert_eq!(dbs.len(), 1);
// But reading should fail
let result = reader.read_memory_chunks(&dbs[0].1).await;
assert!(result.is_err());
}
#[test]
fn test_graceful_handling_missing_agents_directory() {
let temp_dir = TempDir::new().expect("temp dir creation failed");
let openclaw_path = temp_dir.path().to_path_buf();
// Create config but no agents directory
std::fs::write(
openclaw_path.join("openclaw.json"),
r#"{ llm: { provider: "openai" } }"#,
)
.expect("write failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
// Should return empty list, not error
let dbs = reader.list_agent_dbs().expect("list agent dbs failed");
assert_eq!(dbs.len(), 0);
}
// ────────────────────────────────────────────────────────────────────
// Extensibility Tests
// ────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn test_multiple_agents_independent_data() {
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed");
// Verify each agent has independent data
assert_eq!(agent_dbs.len(), 2);
assert_eq!(agent_dbs[0].0, "primary_agent");
assert_eq!(agent_dbs[1].0, "secondary_agent");
// Each should have its own chunks
for (_name, db_path) in &agent_dbs {
let chunks = reader
.read_memory_chunks(db_path)
.await
.expect("read chunks failed");
assert_eq!(chunks.len(), 5);
}
}
#[tokio::test]
async fn test_channel_diversity_in_conversations() {
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed");
// Get conversations from first agent
let conversations = reader
.read_conversations(&agent_dbs[0].1)
.await
.expect("read conversations failed");
// Should have different channels
let channels: std::collections::HashSet<_> =
conversations.iter().map(|c| c.channel.as_str()).collect();
assert!(channels.contains("telegram"));
assert!(channels.contains("slack"));
assert!(channels.contains("discord"));
}
}
-473
View File
@@ -1,473 +0,0 @@
//! Error handling and edge case tests for OpenClaw import.
//!
//! These tests verify proper error handling for:
//! - Missing/corrupt files
//! - Invalid configurations
//! - Database corruption
//! - Permission issues
//! - Edge cases in data
#![cfg(feature = "import")]
#[cfg(feature = "import")]
mod error_handling_tests {
use std::path::PathBuf;
use tempfile::TempDir;
use ironclaw::import::ImportError;
use ironclaw::import::openclaw::reader::OpenClawReader;
// ────────────────────────────────────────────────────────────────────
// Missing Directory Tests
// ────────────────────────────────────────────────────────────────────
#[test]
fn test_error_nonexistent_openclaw_directory() {
let nonexistent = PathBuf::from("/nonexistent/path/openclaw");
let result = OpenClawReader::new(&nonexistent);
assert!(result.is_err());
if let Err(e) = result {
match e {
ImportError::NotFound { .. } => (), // Expected
_ => panic!("Expected NotFound, got: {}", e),
}
}
}
#[test]
fn test_error_empty_openclaw_directory() {
let temp_dir = TempDir::new().expect("temp dir creation failed");
let result = OpenClawReader::new(temp_dir.path());
// Should succeed (directory exists)
assert!(result.is_ok());
let reader = result.unwrap();
let config_result = reader.read_config();
// But reading config should fail
assert!(config_result.is_err());
}
// ────────────────────────────────────────────────────────────────────
// Config File Errors
// ────────────────────────────────────────────────────────────────────
#[test]
fn test_error_missing_openclaw_json() {
let temp_dir = TempDir::new().expect("temp dir creation failed");
let openclaw_path = temp_dir.path().to_path_buf();
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let result = reader.read_config();
assert!(result.is_err());
}
#[test]
fn test_error_invalid_json5_syntax() {
let temp_dir = TempDir::new().expect("temp dir creation failed");
let openclaw_path = temp_dir.path().to_path_buf();
// Invalid JSON5: missing closing brace
let bad_config = r#"{ llm: { provider: "openai" }"#;
std::fs::write(openclaw_path.join("openclaw.json"), bad_config).expect("write failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let result = reader.read_config();
assert!(result.is_err());
}
#[test]
fn test_error_truncated_json5() {
let temp_dir = TempDir::new().expect("temp dir creation failed");
let openclaw_path = temp_dir.path().to_path_buf();
// Truncated JSON5
std::fs::write(openclaw_path.join("openclaw.json"), "{").expect("write failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let result = reader.read_config();
assert!(result.is_err());
}
#[test]
fn test_error_empty_openclaw_json() {
let temp_dir = TempDir::new().expect("temp dir creation failed");
let openclaw_path = temp_dir.path().to_path_buf();
// Empty file
std::fs::write(openclaw_path.join("openclaw.json"), "").expect("write failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let result = reader.read_config();
assert!(result.is_err());
}
// ────────────────────────────────────────────────────────────────────
// SQLite Database Errors
// ────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn test_error_corrupt_sqlite_file() {
let temp_dir = TempDir::new().expect("temp dir creation failed");
let openclaw_path = temp_dir.path().to_path_buf();
let agents_dir = openclaw_path.join("agents");
std::fs::create_dir_all(&agents_dir).expect("mkdir failed");
// Write invalid SQLite data
std::fs::write(
agents_dir.join("bad.sqlite"),
"this is definitely not a sqlite database",
)
.expect("write failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let dbs = reader.list_agent_dbs().expect("list agent dbs failed");
assert_eq!(dbs.len(), 1);
// But reading should fail
let result = reader.read_memory_chunks(&dbs[0].1).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_error_missing_chunks_table() {
let temp_dir = TempDir::new().expect("temp dir creation failed");
let openclaw_path = temp_dir.path().to_path_buf();
let agents_dir = openclaw_path.join("agents");
std::fs::create_dir_all(&agents_dir).expect("mkdir failed");
let db_path = agents_dir.join("no_chunks.sqlite");
// Create valid SQLite but without chunks table
let db = libsql::Builder::new_local(&db_path)
.build()
.await
.expect("db creation failed");
let conn = db.connect().expect("connect failed");
conn.execute(
"CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT)",
(),
)
.await
.expect("create table failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let dbs = reader.list_agent_dbs().expect("list agent dbs failed");
assert_eq!(dbs.len(), 1);
// Should fail: chunks table doesn't exist
let result = reader.read_memory_chunks(&dbs[0].1).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_error_missing_conversations_table() {
let temp_dir = TempDir::new().expect("temp dir creation failed");
let openclaw_path = temp_dir.path().to_path_buf();
let agents_dir = openclaw_path.join("agents");
std::fs::create_dir_all(&agents_dir).expect("mkdir failed");
let db_path = agents_dir.join("no_conversations.sqlite");
let db = libsql::Builder::new_local(&db_path)
.build()
.await
.expect("db creation failed");
let conn = db.connect().expect("connect failed");
// Only create chunks table, not conversations
conn.execute(
"CREATE TABLE chunks (id TEXT, path TEXT, content TEXT, embedding BLOB, chunk_index INTEGER)",
(),
)
.await
.expect("create table failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let dbs = reader.list_agent_dbs().expect("list agent dbs failed");
assert_eq!(dbs.len(), 1);
// Should fail: conversations table doesn't exist
let result = reader.read_conversations(&dbs[0].1).await;
assert!(result.is_err());
}
// ────────────────────────────────────────────────────────────────────
// Edge Cases
// ────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn test_edge_case_empty_chunks_table() {
let temp_dir = TempDir::new().expect("temp dir creation failed");
let openclaw_path = temp_dir.path().to_path_buf();
let agents_dir = openclaw_path.join("agents");
std::fs::create_dir_all(&agents_dir).expect("mkdir failed");
let db_path = agents_dir.join("empty.sqlite");
let db = libsql::Builder::new_local(&db_path)
.build()
.await
.expect("db creation failed");
let conn = db.connect().expect("connect failed");
conn.execute(
"CREATE TABLE chunks (id TEXT, path TEXT, content TEXT, embedding BLOB, chunk_index INTEGER)",
(),
)
.await
.expect("create table failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let dbs = reader.list_agent_dbs().expect("list agent dbs failed");
// Should succeed but return empty list
let chunks = reader
.read_memory_chunks(&dbs[0].1)
.await
.expect("read chunks failed");
assert_eq!(chunks.len(), 0);
}
#[tokio::test]
async fn test_edge_case_empty_conversations_table() {
let temp_dir = TempDir::new().expect("temp dir creation failed");
let openclaw_path = temp_dir.path().to_path_buf();
let agents_dir = openclaw_path.join("agents");
std::fs::create_dir_all(&agents_dir).expect("mkdir failed");
let db_path = agents_dir.join("empty_conv.sqlite");
let db = libsql::Builder::new_local(&db_path)
.build()
.await
.expect("db creation failed");
let conn = db.connect().expect("connect failed");
conn.execute(
"CREATE TABLE conversations (id TEXT, channel TEXT, created_at TEXT)",
(),
)
.await
.expect("create table failed");
conn.execute(
"CREATE TABLE messages (id TEXT, conversation_id TEXT, role TEXT, content TEXT, created_at TEXT)",
(),
)
.await
.expect("create table failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let dbs = reader.list_agent_dbs().expect("list agent dbs failed");
// Should succeed but return empty list
let conversations = reader
.read_conversations(&dbs[0].1)
.await
.expect("read conversations failed");
assert_eq!(conversations.len(), 0);
}
#[tokio::test]
async fn test_edge_case_very_large_content() {
let temp_dir = TempDir::new().expect("temp dir creation failed");
let openclaw_path = temp_dir.path().to_path_buf();
let agents_dir = openclaw_path.join("agents");
std::fs::create_dir_all(&agents_dir).expect("mkdir failed");
let db_path = agents_dir.join("large.sqlite");
let db = libsql::Builder::new_local(&db_path)
.build()
.await
.expect("db creation failed");
let conn = db.connect().expect("connect failed");
conn.execute(
"CREATE TABLE chunks (id TEXT, path TEXT, content TEXT, embedding BLOB, chunk_index INTEGER)",
(),
)
.await
.expect("create table failed");
// Insert very large content (1MB)
let large_content = "x".repeat(1024 * 1024);
conn.execute(
"INSERT INTO chunks VALUES (?, ?, ?, ?, ?)",
libsql::params!["id1", "path", large_content, libsql::Value::Null, 0i64],
)
.await
.expect("insert failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let dbs = reader.list_agent_dbs().expect("list agent dbs failed");
// Should still succeed
let chunks = reader
.read_memory_chunks(&dbs[0].1)
.await
.expect("read chunks failed");
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].content.len(), 1024 * 1024);
}
#[tokio::test]
async fn test_edge_case_special_characters_in_content() {
let temp_dir = TempDir::new().expect("temp dir creation failed");
let openclaw_path = temp_dir.path().to_path_buf();
let agents_dir = openclaw_path.join("agents");
std::fs::create_dir_all(&agents_dir).expect("mkdir failed");
let db_path = agents_dir.join("special.sqlite");
let db = libsql::Builder::new_local(&db_path)
.build()
.await
.expect("db creation failed");
let conn = db.connect().expect("connect failed");
conn.execute(
"CREATE TABLE chunks (id TEXT, path TEXT, content TEXT, embedding BLOB, chunk_index INTEGER)",
(),
)
.await
.expect("create table failed");
// Insert content with special characters
let special_content = "Content with emoji \u{1f680} and UTF-8: \u{4e2d}\u{6587}, \u{0627}\u{0644}\u{0639}\u{0631}\u{0628}\u{064a}\u{0629}, \u{03b5}\u{03bb}\u{03bb}\u{03b7}\u{03bd}\u{03b9}\u{03ba}\u{03ac}";
conn.execute(
"INSERT INTO chunks VALUES (?, ?, ?, ?, ?)",
libsql::params!["id1", "path", special_content, libsql::Value::Null, 0i64],
)
.await
.expect("insert failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let dbs = reader.list_agent_dbs().expect("list agent dbs failed");
// Should handle special characters
let chunks = reader
.read_memory_chunks(&dbs[0].1)
.await
.expect("read chunks failed");
assert_eq!(chunks.len(), 1);
assert!(chunks[0].content.contains("\u{1f680}"));
assert!(chunks[0].content.contains("\u{4e2d}\u{6587}"));
}
#[tokio::test]
async fn test_edge_case_null_values_in_fields() {
let temp_dir = TempDir::new().expect("temp dir creation failed");
let openclaw_path = temp_dir.path().to_path_buf();
let agents_dir = openclaw_path.join("agents");
std::fs::create_dir_all(&agents_dir).expect("mkdir failed");
let db_path = agents_dir.join("nulls.sqlite");
let db = libsql::Builder::new_local(&db_path)
.build()
.await
.expect("db creation failed");
let conn = db.connect().expect("connect failed");
conn.execute(
"CREATE TABLE conversations (id TEXT, channel TEXT, created_at TEXT)",
(),
)
.await
.expect("create table failed");
conn.execute(
"CREATE TABLE messages (id TEXT, conversation_id TEXT, role TEXT, content TEXT, created_at TEXT)",
(),
)
.await
.expect("create table failed");
// Insert conversation with NULL created_at
conn.execute(
"INSERT INTO conversations VALUES (?, ?, ?)",
libsql::params!["conv1", "telegram", libsql::Value::Null],
)
.await
.expect("insert failed");
// Insert message with NULL created_at
conn.execute(
"INSERT INTO messages VALUES (?, ?, ?, ?, ?)",
libsql::params!["msg1", "conv1", "user", "hello", libsql::Value::Null],
)
.await
.expect("insert failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let dbs = reader.list_agent_dbs().expect("list agent dbs failed");
// Should handle NULL timestamps gracefully
let conversations = reader
.read_conversations(&dbs[0].1)
.await
.expect("read conversations failed");
assert_eq!(conversations.len(), 1);
assert!(conversations[0].created_at.is_none());
assert!(conversations[0].messages[0].created_at.is_none());
}
// ────────────────────────────────────────────────────────────────────
// Workspace File Errors
// ────────────────────────────────────────────────────────────────────
#[test]
fn test_error_workspace_not_directory() {
let temp_dir = TempDir::new().expect("temp dir creation failed");
let openclaw_path = temp_dir.path().to_path_buf();
// Create "workspace" as a file, not a directory
std::fs::write(openclaw_path.join("workspace"), "not a directory").expect("write failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
// Should handle gracefully (no files found)
let count = reader
.list_workspace_files()
.expect("list workspace files failed");
assert_eq!(count, 0);
}
#[test]
fn test_edge_case_many_markdown_files() {
let temp_dir = TempDir::new().expect("temp dir creation failed");
let openclaw_path = temp_dir.path().to_path_buf();
let workspace_dir = openclaw_path.join("workspace");
std::fs::create_dir_all(&workspace_dir).expect("mkdir failed");
// Create 100 markdown files
for i in 0..100 {
std::fs::write(workspace_dir.join(format!("doc_{}.md", i)), "content")
.expect("write failed");
}
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let count = reader
.list_workspace_files()
.expect("list workspace files failed");
assert_eq!(count, 100);
}
}
-374
View File
@@ -1,374 +0,0 @@
//! Idempotency and dry-run tests for OpenClaw import.
//!
//! These tests verify that:
//! 1. Running import twice produces the same results (idempotency)
//! 2. Dry-run mode doesn't modify any state
//! 3. Re-running import doesn't create duplicates
#![cfg(feature = "import")]
#[cfg(feature = "import")]
mod idempotency_tests {
use std::path::PathBuf;
use tempfile::TempDir;
use uuid::Uuid;
use ironclaw::import::openclaw::reader::OpenClawReader;
use ironclaw::import::{ImportOptions, ImportStats};
/// Helper: Create minimal test OpenClaw
async fn create_minimal_openclaw() -> Result<(TempDir, PathBuf), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let openclaw_path = temp_dir.path().to_path_buf();
// Config
std::fs::write(
openclaw_path.join("openclaw.json"),
r#"{ llm: { provider: "openai", model: "gpt-4" } }"#,
)?;
// Workspace
let workspace_dir = openclaw_path.join("workspace");
std::fs::create_dir_all(&workspace_dir)?;
std::fs::write(
workspace_dir.join("MEMORY.md"),
"# Memory\nTest memory content",
)?;
// Agent DB
let agents_dir = openclaw_path.join("agents");
std::fs::create_dir_all(&agents_dir)?;
let db_path = agents_dir.join("agent.sqlite");
let db = libsql::Builder::new_local(&db_path).build().await?;
let conn = db.connect()?;
conn.execute(
"CREATE TABLE chunks (
id TEXT PRIMARY KEY,
path TEXT NOT NULL,
content TEXT NOT NULL,
embedding BLOB,
chunk_index INTEGER
)",
(),
)
.await?;
conn.execute(
"INSERT INTO chunks VALUES (?, ?, ?, ?, ?)",
libsql::params![
Uuid::new_v4().to_string(),
"test.md",
"Test content",
libsql::Value::Null,
0i64
],
)
.await?;
conn.execute(
"CREATE TABLE conversations (id TEXT PRIMARY KEY, channel TEXT, created_at TEXT)",
(),
)
.await?;
conn.execute(
"CREATE TABLE messages (
id TEXT PRIMARY KEY,
conversation_id TEXT,
role TEXT,
content TEXT,
created_at TEXT
)",
(),
)
.await?;
Ok((temp_dir, openclaw_path))
}
// ────────────────────────────────────────────────────────────────────
// Idempotency Tests
// ────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn test_reader_idempotent_config_reads() {
let (_temp, openclaw_path) = create_minimal_openclaw().await.expect("setup failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
// Read config twice
let config1 = reader.read_config().expect("first read failed");
let config2 = reader.read_config().expect("second read failed");
// Results should be identical
assert_eq!(
config1.llm.as_ref().map(|c| &c.provider),
config2.llm.as_ref().map(|c| &c.provider)
);
assert_eq!(
config1.llm.as_ref().map(|c| &c.model),
config2.llm.as_ref().map(|c| &c.model)
);
}
#[tokio::test]
async fn test_reader_idempotent_workspace_file_listing() {
let (_temp, openclaw_path) = create_minimal_openclaw().await.expect("setup failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
// List files twice
let count1 = reader.list_workspace_files().expect("first list failed");
let count2 = reader.list_workspace_files().expect("second list failed");
assert_eq!(count1, count2);
assert_eq!(count1, 1); // MEMORY.md
}
#[tokio::test]
async fn test_reader_idempotent_memory_chunk_reads() {
let (_temp, openclaw_path) = create_minimal_openclaw().await.expect("setup failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed");
let db_path = &agent_dbs[0].1;
// Read chunks twice
let chunks1 = reader
.read_memory_chunks(db_path)
.await
.expect("first read failed");
let chunks2 = reader
.read_memory_chunks(db_path)
.await
.expect("second read failed");
// Same number of chunks
assert_eq!(chunks1.len(), chunks2.len());
// Same content
for (c1, c2) in chunks1.iter().zip(chunks2.iter()) {
assert_eq!(c1.path, c2.path);
assert_eq!(c1.content, c2.content);
assert_eq!(c1.chunk_index, c2.chunk_index);
}
}
#[test]
fn test_import_options_are_independent() {
let opts1 = ImportOptions {
openclaw_path: std::path::PathBuf::from("/test1"),
dry_run: true,
re_embed: false,
user_id: "user1".to_string(),
};
let opts2 = ImportOptions {
openclaw_path: std::path::PathBuf::from("/test2"),
dry_run: false,
re_embed: true,
user_id: "user2".to_string(),
};
// Different options should remain independent
assert_ne!(opts1.user_id, opts2.user_id);
assert_ne!(opts1.dry_run, opts2.dry_run);
assert_ne!(opts1.re_embed, opts2.re_embed);
}
// ────────────────────────────────────────────────────────────────────
// Dry-Run Verification Tests
// ────────────────────────────────────────────────────────────────────
#[test]
fn test_dry_run_option_construction() {
let dry_run_opts = ImportOptions {
openclaw_path: std::path::PathBuf::from("/test"),
dry_run: true,
re_embed: false,
user_id: "test".to_string(),
};
let normal_opts = ImportOptions {
openclaw_path: std::path::PathBuf::from("/test"),
dry_run: false,
re_embed: false,
user_id: "test".to_string(),
};
// Verify dry_run flag is set correctly
assert!(dry_run_opts.dry_run);
assert!(!normal_opts.dry_run);
}
#[tokio::test]
async fn test_dry_run_stats_would_be_same() {
// Simulating what import stats would be in dry-run vs real run
let (_temp, openclaw_path) = create_minimal_openclaw().await.expect("setup failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let document_count = reader
.list_workspace_files()
.expect("list workspace files failed");
// Dry-run would count: 1 config, 1 document, 1 chunk, 0 conversations
let dry_run_stats = ImportStats {
settings: 1,
documents: document_count,
chunks: 1,
conversations: 0,
..ImportStats::default()
};
// Real run would have same stats (just written to DB)
let real_run_stats = ImportStats {
settings: 1,
documents: document_count,
chunks: 1,
conversations: 0,
..ImportStats::default()
};
// Stats should match (same data would be imported)
assert_eq!(dry_run_stats.documents, real_run_stats.documents);
assert_eq!(dry_run_stats.chunks, real_run_stats.chunks);
}
// ────────────────────────────────────────────────────────────────────
// Duplicate Prevention Tests
// ────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn test_chunk_deduplication_by_path() {
let (_temp, openclaw_path) = create_minimal_openclaw().await.expect("setup failed");
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed");
let db_path = &agent_dbs[0].1;
let chunks = reader
.read_memory_chunks(db_path)
.await
.expect("read chunks failed");
// All chunks should have unique (path, chunk_index) pairs
let mut seen = std::collections::HashSet::new();
for chunk in chunks {
let key = (chunk.path.clone(), chunk.chunk_index);
assert!(seen.insert(key.clone()), "Duplicate chunk: {:?}", key);
}
}
#[test]
fn test_conversation_deduplication_by_id() {
// This would be verified by metadata.openclaw_conversation_id in real import
let conversation_ids = vec![
"conv_1".to_string(),
"conv_2".to_string(),
"conv_1".to_string(), // Duplicate
];
// In real import, check if already exists
let mut seen = std::collections::HashSet::new();
let mut duplicates = 0;
for id in conversation_ids {
if !seen.insert(id) {
duplicates += 1;
}
}
assert_eq!(duplicates, 1);
}
#[test]
fn test_setting_upsert_semantics() {
// Settings should use upsert (update if exists, insert if not)
let settings_map = vec![
("llm.backend", "openai"),
("llm.backend", "anthropic"), // Same key, different value
("embeddings.model", "text-embedding-3"),
];
// Simulate upsert with HashMap
let mut result = std::collections::HashMap::new();
for (key, value) in settings_map {
result.insert(key, value);
}
// Should have 2 entries, not 3 (last value wins)
assert_eq!(result.len(), 2);
assert_eq!(result.get("llm.backend"), Some(&"anthropic")); // Last value
}
#[test]
fn test_credential_idempotent_storage() {
// Credentials use secrets store's upsert semantics
let credentials = vec![
("api_key_1", "secret1"),
("api_key_2", "secret2"),
("api_key_1", "secret1_updated"), // Same name, updated value
];
// Simulate upsert with HashMap
let mut result = std::collections::HashMap::new();
for (name, value) in credentials {
result.insert(name, value);
}
// Should have 2 entries (same name means upsert)
assert_eq!(result.len(), 2);
assert_eq!(result.get("api_key_1"), Some(&"secret1_updated"));
}
// ────────────────────────────────────────────────────────────────────
// Re-import Scenarios
// ────────────────────────────────────────────────────────────────────
#[test]
fn test_stats_on_second_import_would_be_zero() {
// After first import, second import should find all items already exist
// and report stats.skipped instead of new imports
let _first_import_stats = ImportStats {
documents: 1,
chunks: 1,
conversations: 0,
..ImportStats::default()
};
let second_import_stats = ImportStats {
documents: 0,
chunks: 0,
conversations: 0,
skipped: 2, // 1 doc + 1 chunk already exist
..ImportStats::default()
};
// Second import should report skipped, not imported
assert_eq!(second_import_stats.total_imported(), 0);
assert!(second_import_stats.is_empty());
}
#[test]
fn test_partial_re_import_new_content() {
// If OpenClaw adds new content and import is run again
let first_stats = ImportStats {
chunks: 5,
..ImportStats::default()
};
let second_stats = ImportStats {
chunks: 3, // 3 new chunks added
skipped: 5, // 5 chunks already exist
..ImportStats::default()
};
// Total should reflect new additions
assert_eq!(first_stats.chunks + second_stats.chunks, 8);
assert_eq!(second_stats.total_imported(), 3);
}
}
-559
View File
@@ -1,559 +0,0 @@
//! Integration tests for OpenClaw import with actual database state verification.
//!
//! These tests exercise the full import pipeline with real database writes,
//! verifying that data is correctly stored, idempotent, and that dry-run mode
//! prevents modifications.
#![cfg(feature = "import")]
#[cfg(feature = "import")]
mod import_integration_tests {
use ironclaw::db::Database;
use ironclaw::db::libsql::LibSqlBackend;
use ironclaw::import::ImportStats;
use ironclaw::import::openclaw::reader::OpenClawReader;
use std::path::PathBuf;
use std::sync::Arc;
use tempfile::TempDir;
use uuid::Uuid;
/// Helper: Create a test database and return both the DB and temp dir
async fn create_test_db()
-> Result<(Arc<dyn ironclaw::db::Database>, TempDir), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let db_path = temp_dir.path().join("test.db");
let backend = LibSqlBackend::new_local(&db_path).await?;
backend.run_migrations().await?;
let db: Arc<dyn ironclaw::db::Database> = Arc::new(backend);
Ok((db, temp_dir))
}
/// Helper: Create a test OpenClaw directory with full structure
async fn create_test_openclaw() -> Result<(TempDir, PathBuf), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let openclaw_path = temp_dir.path().to_path_buf();
// Config
let config = r#"{
llm: {
provider: "openai",
model: "gpt-4",
api_key: "sk-test-12345"
},
embeddings: {
model: "text-embedding-3-small",
api_key: "sk-embed-67890"
}
}"#;
std::fs::write(openclaw_path.join("openclaw.json"), config)?;
// Workspace files
let workspace_dir = openclaw_path.join("workspace");
std::fs::create_dir_all(&workspace_dir)?;
std::fs::write(
workspace_dir.join("MEMORY.md"),
"# Memory\n\nTest memory content for integration test.",
)?;
std::fs::write(
workspace_dir.join("NOTES.md"),
"# Notes\n\nAdditional notes content.",
)?;
// Agent databases
let agents_dir = openclaw_path.join("agents");
std::fs::create_dir_all(&agents_dir)?;
create_test_agent_db(&agents_dir.join("agent1.sqlite")).await?;
create_test_agent_db(&agents_dir.join("agent2.sqlite")).await?;
Ok((temp_dir, openclaw_path))
}
/// Helper: Create a test agent SQLite database using libsql
async fn create_test_agent_db(db_path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
let db = libsql::Builder::new_local(db_path).build().await?;
let conn = db.connect()?;
// Chunks table
conn.execute(
"CREATE TABLE chunks (
id TEXT PRIMARY KEY,
path TEXT NOT NULL,
content TEXT NOT NULL,
embedding BLOB,
chunk_index INTEGER NOT NULL
)",
(),
)
.await?;
for i in 0..3 {
conn.execute(
"INSERT INTO chunks (id, path, content, embedding, chunk_index) VALUES (?1, ?2, ?3, ?4, ?5)",
libsql::params![
Uuid::new_v4().to_string(),
format!("doc/section_{}.md", i),
format!("Chunk {} content", i),
libsql::Value::Null,
i as i64
],
)
.await?;
}
// Conversations
conn.execute(
"CREATE TABLE conversations (id TEXT PRIMARY KEY, channel TEXT, created_at TEXT)",
(),
)
.await?;
conn.execute(
"CREATE TABLE messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT
)",
(),
)
.await?;
let conv_id = Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO conversations VALUES (?1, ?2, ?3)",
libsql::params![conv_id.as_str(), "slack", "2024-01-15T10:00:00Z"],
)
.await?;
for j in 0..2 {
conn.execute(
"INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
libsql::params![
Uuid::new_v4().to_string(),
conv_id.as_str(),
if j % 2 == 0 { "user" } else { "assistant" },
format!("Message {}", j),
format!("2024-01-15T10:{:02}:00Z", j)
],
)
.await?;
}
Ok(())
}
// ────────────────────────────────────────────────────────────────────
// Integration Test 1: Full Import with Database Verification
// ────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn test_full_import_with_database_writes() {
let (db, _db_temp) = create_test_db().await.expect("DB creation failed");
let (_openclaw_temp, openclaw_path) = create_test_openclaw()
.await
.expect("OpenClaw creation failed");
// Verify DB starts empty
let before_docs = db
.list_documents("test_user", None)
.await
.expect("list docs failed");
assert_eq!(before_docs.len(), 0);
// Create reader
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
// Read config
let config = reader.read_config().expect("config read failed");
assert!(config.llm.is_some());
// Verify reader can find data
let workspace_count = reader
.list_workspace_files()
.expect("list workspace files failed");
assert_eq!(workspace_count, 2); // MEMORY.md, NOTES.md
let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed");
assert_eq!(agent_dbs.len(), 2); // agent1, agent2
// Read chunks from first agent
let chunks = reader
.read_memory_chunks(&agent_dbs[0].1)
.await
.expect("read chunks failed");
assert_eq!(chunks.len(), 3); // 3 chunks created
// Read conversations from first agent
let conversations = reader
.read_conversations(&agent_dbs[0].1)
.await
.expect("read conversations failed");
assert_eq!(conversations.len(), 1); // 1 conversation created
assert_eq!(conversations[0].messages.len(), 2); // 2 messages
}
// ────────────────────────────────────────────────────────────────────
// Integration Test 2: CLI Import Command End-to-End
// ────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn test_import_command_execution() {
let (_openclaw_temp, openclaw_path) = create_test_openclaw()
.await
.expect("OpenClaw creation failed");
let (_db, _db_temp) = create_test_db().await.expect("DB creation failed");
// Create import options
let opts = ironclaw::import::ImportOptions {
openclaw_path: openclaw_path.clone(),
dry_run: false,
re_embed: false,
user_id: "test_user".to_string(),
};
// Verify options are correctly configured
assert_eq!(opts.user_id, "test_user");
assert!(!opts.dry_run);
assert!(!opts.re_embed);
// Verify the OpenClaw path exists
assert!(openclaw_path.join("openclaw.json").exists());
assert!(openclaw_path.join("workspace").exists());
assert!(openclaw_path.join("agents").exists());
}
// ────────────────────────────────────────────────────────────────────
// Integration Test 3: Dry-Run Prevents Database Writes
// ────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn test_dry_run_prevents_database_writes() {
let (db, _db_temp) = create_test_db().await.expect("DB creation failed");
let (_openclaw_temp, openclaw_path) = create_test_openclaw()
.await
.expect("OpenClaw creation failed");
let user_id = "test_user";
// Count documents before import
let before_import = db
.list_documents(user_id, None)
.await
.expect("list docs before failed");
let before_count = before_import.len();
// Create import options in DRY-RUN mode
let opts = ironclaw::import::ImportOptions {
openclaw_path: openclaw_path.clone(),
dry_run: true, // ← KEY: dry_run is enabled
re_embed: false,
user_id: user_id.to_string(),
};
// Verify dry_run flag is set
assert!(opts.dry_run, "dry_run should be true");
// Count documents after (in dry-run mode, no writes should occur)
let after_import = db
.list_documents(user_id, None)
.await
.expect("list docs after failed");
let after_count = after_import.len();
// Counts should be identical (no writes in dry-run)
assert_eq!(
before_count, after_count,
"Dry-run should not modify database"
);
}
// ────────────────────────────────────────────────────────────────────
// Integration Test 4: Database-Level Idempotency (No Duplicates on Reimport)
// ────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn test_import_idempotency_no_duplicates_on_reimport() {
let (_db, _db_temp) = create_test_db().await.expect("DB creation failed");
let (_openclaw_temp, openclaw_path) = create_test_openclaw()
.await
.expect("OpenClaw creation failed");
// Simulate first import: count what would be imported
let reader1 = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let workspace_count1 = reader1
.list_workspace_files()
.expect("list workspace failed");
let agent_dbs1 = reader1.list_agent_dbs().expect("list agent dbs failed");
let mut total_chunks_first = 0;
let mut total_conversations_first = 0;
for (_, db_path) in &agent_dbs1 {
let chunks = reader1
.read_memory_chunks(db_path)
.await
.expect("read chunks failed");
total_chunks_first += chunks.len();
let conversations = reader1
.read_conversations(db_path)
.await
.expect("read conversations failed");
total_conversations_first += conversations.len();
}
let stats1 = ImportStats {
documents: workspace_count1,
chunks: total_chunks_first,
conversations: total_conversations_first,
..ImportStats::default()
};
// Simulate second import: same data
let reader2 = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let workspace_count2 = reader2
.list_workspace_files()
.expect("list workspace failed");
let agent_dbs2 = reader2.list_agent_dbs().expect("list agent dbs failed");
// Should find the exact same data
assert_eq!(workspace_count1, workspace_count2);
assert_eq!(agent_dbs1.len(), agent_dbs2.len());
// On second import, all items would already exist, so skipped count == first import total
let second_stats = ImportStats {
documents: 0, // Already exist
chunks: 0, // Already exist
conversations: 0, // Already exist
skipped: stats1.total_imported(),
..ImportStats::default()
};
// Verify that total imported in second run would be 0
assert_eq!(second_stats.total_imported(), 0);
assert!(second_stats.is_empty());
assert_eq!(second_stats.skipped, stats1.total_imported());
}
// ────────────────────────────────────────────────────────────────────
// Integration Test 5: Embedding Dimension Mismatch Handling
// ────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn test_embedding_dimension_mismatch_queues_reembedding() {
let (_openclaw_temp, openclaw_path) = create_test_openclaw()
.await
.expect("OpenClaw creation failed");
// Create an agent DB with embeddings (1536-dim)
let agents_dir = openclaw_path.join("agents");
std::fs::create_dir_all(&agents_dir).expect("mkdir failed");
let db_path = agents_dir.join("with_embeddings.sqlite");
{
let db = libsql::Builder::new_local(&db_path)
.build()
.await
.expect("db build failed");
let conn = db.connect().expect("db connect failed");
conn.execute(
"CREATE TABLE chunks (
id TEXT PRIMARY KEY,
path TEXT NOT NULL,
content TEXT NOT NULL,
embedding BLOB,
chunk_index INTEGER NOT NULL
)",
(),
)
.await
.expect("create table failed");
// Create a 1536-dimensional embedding (ada-002 size)
// Each f32 is 4 bytes, so 1536 * 4 = 6144 bytes
let embedding_1536_bytes: Vec<u8> = vec![0.1f32; 1536]
.iter()
.flat_map(|f| f.to_le_bytes().to_vec())
.collect();
conn.execute(
"INSERT INTO chunks (id, path, content, embedding, chunk_index) VALUES (?1, ?2, ?3, ?4, ?5)",
libsql::params![
Uuid::new_v4().to_string(),
"test.md",
"Chunk with embedding",
embedding_1536_bytes,
0i64
],
)
.await
.expect("insert failed");
conn.execute(
"CREATE TABLE conversations (id TEXT PRIMARY KEY, channel TEXT, created_at TEXT)",
(),
)
.await
.expect("create conv table failed");
conn.execute(
"CREATE TABLE messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT
)",
(),
)
.await
.expect("create messages table failed");
}
// Read the chunks back
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let chunks = reader
.read_memory_chunks(&db_path)
.await
.expect("read chunks failed");
assert_eq!(chunks.len(), 1);
let chunk = &chunks[0];
// Verify embedding was read correctly
assert!(chunk.embedding.is_some());
let embedding = chunk.embedding.as_ref().unwrap();
assert_eq!(embedding.len(), 1536);
// Verify all values are approximately 0.1
for (i, val) in embedding.iter().enumerate() {
assert!(
(val - 0.1).abs() < 0.001,
"Embedding value {} should be ~0.1, got {}",
i,
val
);
}
// Simulate dimension mismatch scenario:
let source_dim = embedding.len();
let target_dim = 3072; // text-embedding-3-large
if source_dim != target_dim {
assert!(
source_dim != target_dim,
"Dimension mismatch detected: {} -> {}",
source_dim,
target_dim
);
let mut re_embed_queued = 0;
if source_dim != target_dim {
re_embed_queued += 1;
}
assert_eq!(re_embed_queued, 1);
}
}
// ────────────────────────────────────────────────────────────────────
// Integration Test 6: Embedding Dimension Match (No Re-embedding)
// ────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn test_embedding_same_dimension_no_reembedding() {
let temp_dir = TempDir::new().expect("temp dir failed");
let openclaw_path = temp_dir.path().to_path_buf();
// Create minimal config
std::fs::write(
openclaw_path.join("openclaw.json"),
r#"{ llm: { provider: "openai", model: "gpt-4" } }"#,
)
.expect("write config failed");
// Create agent DB with 1536-dim embeddings
let agents_dir = openclaw_path.join("agents");
std::fs::create_dir_all(&agents_dir).expect("mkdir failed");
let db_path = agents_dir.join("same_dim.sqlite");
{
let db = libsql::Builder::new_local(&db_path)
.build()
.await
.expect("db build failed");
let conn = db.connect().expect("db connect failed");
conn.execute(
"CREATE TABLE chunks (
id TEXT PRIMARY KEY,
path TEXT NOT NULL,
content TEXT NOT NULL,
embedding BLOB,
chunk_index INTEGER NOT NULL
)",
(),
)
.await
.expect("create table failed");
// 1536-dimensional embedding (text-embedding-3-small)
let embedding_bytes: Vec<u8> = vec![0.5f32; 1536]
.iter()
.flat_map(|f| f.to_le_bytes().to_vec())
.collect();
conn.execute(
"INSERT INTO chunks (id, path, content, embedding, chunk_index) VALUES (?1, ?2, ?3, ?4, ?5)",
libsql::params![
Uuid::new_v4().to_string(),
"test.md",
"Chunk",
embedding_bytes,
0i64
],
)
.await
.expect("insert failed");
conn.execute(
"CREATE TABLE conversations (id TEXT PRIMARY KEY, channel TEXT, created_at TEXT)",
(),
)
.await
.expect("create conv table failed");
conn.execute(
"CREATE TABLE messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT
)",
(),
)
.await
.expect("create messages table failed");
}
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
let chunks = reader
.read_memory_chunks(&db_path)
.await
.expect("read chunks failed");
let embedding = chunks[0].embedding.as_ref().unwrap();
let source_dim = embedding.len();
let target_dim = 1536; // Same as source (text-embedding-3-small)
// Dimensions match, so no re-embedding needed
assert_eq!(source_dim, target_dim);
let re_embed_queued = if source_dim != target_dim { 1 } else { 0 };
assert_eq!(re_embed_queued, 0);
}
}
+1 -9
View File
@@ -183,15 +183,7 @@ pub fn verify_expects(
// all_tools_succeeded
if expects.all_tools_succeeded == Some(true) {
let failed: Vec<&str> = completed
.iter()
.filter(|(_, success)| !*success)
.map(|(name, _)| name.as_str())
.collect();
assert!(
failed.is_empty(),
"[{label}] Expected all tools to succeed, failed={failed:?}, completed={completed:?}, results={results:?}"
);
assert_all_tools_succeeded(completed);
}
// max_tool_calls
+3 -44
View File
@@ -312,23 +312,7 @@ impl TestRig {
.collect();
let started = self.tool_calls_started();
let completed = self.tool_calls_completed();
let mut results = self.tool_results();
for status in self.channel.captured_status_events() {
if let ironclaw::channels::StatusUpdate::ToolCompleted {
name,
success: false,
error,
parameters,
} = status
{
let detail = format!(
"error={}; params={}",
error.unwrap_or_else(|| "unknown".to_string()),
parameters.unwrap_or_else(|| "{}".to_string())
);
results.push((name, detail));
}
}
let results = self.tool_results();
verify_expects(
&trace.expects,
&all_response_strings,
@@ -355,23 +339,7 @@ impl TestRig {
let response_strings: Vec<String> = responses.iter().map(|r| r.content.clone()).collect();
let started = self.tool_calls_started();
let completed = self.tool_calls_completed();
let mut results = self.tool_results();
for status in self.channel.captured_status_events() {
if let ironclaw::channels::StatusUpdate::ToolCompleted {
name,
success: false,
error,
parameters,
} = status
{
let detail = format!(
"error={}; params={}",
error.unwrap_or_else(|| "unknown".to_string()),
parameters.unwrap_or_else(|| "{}".to_string())
);
results.push((name, detail));
}
}
let results = self.tool_results();
verify_expects(
&trace.expects,
&response_strings,
@@ -426,7 +394,7 @@ impl TestRigBuilder {
llm: None,
max_tool_iterations: 10,
injection_check: false,
auto_approve_tools: Some(true),
auto_approve_tools: None,
enable_skills: false,
enable_routines: false,
http_exchanges: Vec::new(),
@@ -599,20 +567,11 @@ impl TestRigBuilder {
.await
.expect("AppBuilder::build_all() failed in test rig");
// AppBuilder may re-resolve config from env/TOML and override test defaults.
// Force test-rig agent flags to the requested deterministic values.
components.config.agent.auto_approve_tools = auto_approve_tools.unwrap_or(true);
components.config.agent.allow_local_tools = true;
let scheduler_slot: ironclaw::tools::builtin::SchedulerSlot =
Arc::new(tokio::sync::RwLock::new(None));
// 6. Register job tools, routine tools, and extra tools.
{
// Ensure filesystem/shell dev tools are always available in the
// test rig, even if upstream builder flags/config disable local tools.
components.tools.register_dev_tools();
components.tools.register_job_tools(
Arc::clone(&components.context_manager),
Some(scheduler_slot.clone()),