Compare commits

..
Author SHA1 Message Date
ZakiandClaude Opus 4.6 74cbe5c2ea fix(security): eliminate TOCTOU race in approval request_id check (#1486)
Hold session lock for the entire take-verify sequence so pending approval
cannot be lost if a concurrent operation modifies the thread between
take and restore.

Closes #1486

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-22 23:26:49 -07:00
d9358b0fa9 feat(workspace): multi-scope workspace reads (#1117)
* feat(workspace): multi-scope workspace reads

Adds the ability for a workspace to read from multiple user scopes
while keeping writes isolated to the primary scope. Configuration
via WORKSPACE_READ_SCOPES env var (comma-separated user IDs).

Includes identity file isolation (read_primary), multi-scope search,
list, and read operations, WorkspaceConfig refactor, and comprehensive
integration tests.

* fix: address review feedback for multi-scope workspace reads

- fix(memory): deduplicate timezone parsing for daily_log target
  parse_timezone was called twice when target was "daily_log" without a
  layer — once in path resolution, again in the fallback. Now computed
  once and reused.

- fix(config): add character validation for WORKSPACE_READ_SCOPES and
  layer scopes — both enforce [a-zA-Z0-9_-] to prevent path traversal
  or injection via scope strings used as user_id in SQL queries.

- fix(config): use chars().take(32) instead of byte-index slicing for
  scope length error messages (UTF-8 safety).

- fix(error): remove unused WorkspaceError::NotFound variant

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

* style: downgrade search log to debug, add comments on list iteration

- Downgrade hybrid_search_multi tracing::info! to debug! — fires on
  every multi-scope search with the default backend, too noisy for info
- Add comments explaining why list/list_all iterate per-scope instead
  of using _multi trait methods (identity path filtering needs scope
  attribution that merged results lose)

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

---------

Co-authored-by: [email protected] <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-22 22:56:26 -07:00
Vitali AvagyanandGitHub 8f6999a074 docs: add gitcgr code graph badge (#1563) 2026-03-22 21:03:51 -07:00
Henry ParkandGitHub 4d7501a968 Fix owner-scoped message routing fallbacks (#1574)
* Fix owner-scoped message routing fallbacks

* Address PR feedback on routing regressions

* Address review notes on routing fallbacks
2026-03-22 20:33:52 -07:00
NigeGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
abba083147 docs(feishu): clarify webhook-only event subscription support (#1567)
* docs(feishu): clarify webhook-only event subscription support

* Update channels-src/feishu/feishu.capabilities.json

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-22 18:27:10 -07:00
Niclas Overby ⓃGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>Copilot Autofix powered by AIIllia Polosukhin
7034e910c4 fix: generate Mistral-compatible 9-char alphanumeric tool call IDs (#1242)
* fix: generate Mistral-compatible 9-char alphanumeric tool call IDs

Mistral's API requires tool call IDs to match [a-zA-Z0-9]{9} exactly.
Previously, IDs like 'turn1_0', 'recovered_0', 'call_<uuid>', and
'generated_tool_call_N' were generated, which Mistral rejects with
HTTP 400.

Add generate_tool_call_id() that produces deterministic 9-char base-36
IDs from two seed values, and use it at all tool call ID generation
sites.

Fixes #1241

* Update src/llm/provider.rs

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* fix: address review feedback on Mistral tool-call ID generation

- Remove .unwrap() in generate_tool_call_id (provider.rs) per zero-tolerance policy
- Remove .expect() in normalized_tool_call_id (rig_adapter.rs), use direct array indexing
- Replace magic constant 99 with named RECOVERED_TOOL_CALL_SEED in reasoning.rs
- Add tests for normalized_tool_call_id: passthrough, hashing, empty/whitespace, determinism
- Add comment explaining intentional use of turn_idx vs turn.turn_number in session.rs
- Fix duplicate `mod tests` block in provider.rs (pre-existing compile error)
- Update stale test assertions expecting old `generated_tool_call_` prefix format

[skip-regression-check]

* Apply suggestions from code review

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

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Illia Polosukhin <[email protected]>
2026-03-22 18:07:03 -07:00
NigeGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>[email protected] <[email protected]>Claude Opus 4.6
3e73dbe615 perf(tools): remove unconditional params clone in shared execution (fix #893) (#926)
* perf(tools): remove unconditional params clone in shared execution

* Update src/tools/execute.rs

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* chore(fmt): apply rustfmt in worker container tool execution

* fix(tools): restore owned param call sites

* fix(tools): pass normalized_params to tool.execute() instead of raw params

The ownership refactor accidentally passed the un-coerced `params` to
`tool.execute()` while validation ran against the coerced
`normalized_params`. This meant tools received un-normalized input
(e.g. stringified JSON arrays instead of actual arrays). Since
`normalized_params` is owned and unused after the execute call, passing
it directly achieves the original zero-clone goal without breaking
parameter coercion.

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

* fix(tools): update empty-tool-name test for owned params signature

Adapts the test_execute_empty_tool_name_returns_not_found test (added
on staging) to pass owned Value instead of &Value, matching the new
execute_tool_with_safety signature.

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

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: [email protected] <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-22 17:48:02 -07:00
NigeGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
969b559e2a fix(mcp): handle empty 202 notification acknowledgements (#1539)
* fix(mcp): handle empty 202 notification acknowledgements

* test(mcp): tighten accepted response regression coverage

* Update src/tools/mcp/http_transport.rs

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-22 14:41:54 -07:00
3aa36c8f55 fix(tests): eliminate env mutex poison cascade (#1558)
* fix(tests): eliminate env mutex poison cascade and fix test flakiness

The shared ENV_MUTEX used by ~68 config tests would cascade a single
test panic into failures across every module. Replace all .unwrap() /
.expect() lock acquisitions with a poison-recovering lock_env() helper.
Consolidate rogue module-local ENV_LOCK instances (workspace, orchestrator,
bootstrap) onto the shared global mutex to prevent cross-module races.

Also fixes:
- gateway user_id fallback was hardcoded to "default" instead of owner_id
- test_ironclaw_env_path used LazyLock which is order-dependent

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

* test(helpers): add regression test for lock_env poison recovery

Satisfies the regression-test-check CI gate by adding a test that
intentionally poisons ENV_MUTEX and verifies lock_env() recovers.

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

* fix(ci): detect test changes inside #[cfg(test)] regions

The regression test check relied on git diff -W to expand context to
function boundaries, but git doesn't recognize Rust `mod tests {}` as a
function boundary. Changes to imports, helpers, or lock calls inside
test modules were invisible to the check.

Add a line-level fallback: for each changed .rs file, find where
#[cfg(test)] starts and check if any diff hunk targets a line at or
after that boundary. This catches edits anywhere inside test modules
regardless of git's language awareness.

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

* fix: address PR review feedback

- Clear ENV_MUTEX poison after regression test so it doesn't leave
  global state dirty for subsequent tests.
- Fix CI regression-test-check to match #[cfg(test)] only when followed
  by `mod` (the test module pattern), avoiding false positives from
  standalone #[cfg(test)] items like statics or functions.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-22 14:36:24 -07:00
fbce9a5fe3 refactor(llm): move transcription module into src/llm/ (#1559)
* refactor(llm): move transcription module into src/llm/

Transcription is an LLM capability (Whisper, Chat Completions audio).
Move it from a top-level module into src/llm/transcription/ to reflect
this, and update all references across the codebase.

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

* style: fix rustfmt formatting after module move

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-22 00:25:54 -07:00
NigeGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>[email protected] <[email protected]>Claude Opus 4.6
1a62febe67 perf(agent): avoid preview allocations for non-truncated strings (fix #894) (#924)
* perf(agent): avoid preview allocation on non-truncated strings

* Update src/worker/container.rs

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* chore(ci): annotate test assertions for no-panics gate

* fix: remove unnecessary allocation and consolidate tests

- Remove redundant `.to_string()` on `&String` in container.rs error arm
- Bind `format!()` result to a let in job.rs to avoid Cow borrowing from temporary
- Merge borrowed/owned Cow assertions into existing tests, drop misleading comments

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

* fix: restore separate test functions for CI regression check

Keep dedicated `test_truncate_short_string_borrows` and
`test_truncate_long_string_owns` tests so the PR diff contains
new `#[test]` functions, satisfying the regression test enforcement check.

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

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: [email protected] <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-22 00:04:02 -07:00
a09c023642 feat(ux): complete UX overhaul — design system, onboarding, web polish (#1277)
* feat(ux): complete UX overhaul — design system, boot screen, onboarding, web polish

Shared design system: CSS custom properties for spacing, typography,
transitions, and color tokens used across web UI and boot screen.

Boot screen: compact feature-tags line showing enabled subsystems
(db, tools, routines, heartbeat, skills, sandbox, embeddings) at a
glance. Downgrade startup info logs (libSQL, webhook, workspace seed)
to debug level since the boot screen now covers this.

Onboarding wizard: model picker with live API fetch, provider-aware
auth flow, improved error recovery and progress display.

Web UI: ARIA attributes, welcome card, streaming debounce,
connection status banner, skeleton loaders, send cooldown.

CLI: doctor command enhancements, status command cleanup,
REPL banner consolidation, shared fmt module.

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

* feat(ux): Apple-level design refinements — spring physics, glass morphism, chat polish

Merge staging theme support (dark/light/system toggle) and layer UX
polish on top: spring-physics motion, glass morphism depth, chat
experience improvements, and responsive mobile refinements.

Design system:
- Restore and extend design token system (spacing, typography, timing,
  easing) with legacy aliases for theme compatibility
- Add shadow tiers, accent glow, glass morphism, spring easing tokens
- Tokens defined in both dark (:root) and light ([data-theme="light"])

Micro-interactions (Phase 2):
- Spring-overshoot message entry animation (slideUp)
- Spring-scale button press on all interactive buttons
- Tab crossfade animation, tool card smooth accordion (max-height)
- Modal scale(0.95) + blur(8px) entry, toast spring slide
- Sidebar width crossfade, card hover lift

Visual depth (Phase 3):
- Tab bar glass morphism + surface highlight + sliding indicator
- Active tab accent background pill
- Assistant message accent left border, user message bubble tail
- Floating input area (rounded + shadow + margin)

Chat polish (Phase 4):
- Smooth streaming cursor (cursorPulse), message hover timestamps
- Time separators (Today/Yesterday/date)
- Textarea smooth auto-expand, send button glow

Settings & forms (Phase 5):
- iOS-style toggle switches for boolean settings
- Input focus glow, save feedback spring animation
- Welcome card with gradient background + proper spacing
- Sticky settings group headers with glass backdrop

Accessibility & mobile (Phase 6):
- Animated focus ring, prefers-reduced-motion global kill-switch
- Touch target audit (44px min), mobile bottom-sheet modals
- Mobile bottom tab bar, toast redesign (icon + border + countdown)
- Thread hover translateX, badge in_progress pulse

Bug fixes:
- Gateway/TEE popover z-index (tab-bar z-index: 200, popovers 500)
- Connection lost banner as fixed top bar instead of flex child
- Sidebar collapse keeps toggle + new thread buttons visible
- Downgrade noisy startup logs (db, webhook, vector) to debug
- Remove green dot pulse animation on connected status
- Deduplicate confirm-modal in HTML, add tab-indicator div

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

* feat(web): mobile layout improvements — sidebar toggle, settings drill-down, tab bar polish

- Fix mobile sidebar toggle: use expanded-mobile class instead of collapsed,
  add backdrop overlay, auto-close on thread select, outside-click dismiss
- Settings: replace cramped horizontal tabs with drill-down navigation
  (category list → detail view → back button)
- Bottom tab bar: add glass morphism, hide theme toggle, flip tab indicator
  to top edge
- Keep thread toggle button visible in collapsed 36px sidebar strip

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

* feat(repl): interactive approval selector and transient status lines

- Replace ASCII-art approval box with clean horizontal rule card
- Add inquire-based interactive selector for tool approvals (↑↓ + Enter)
- Selector runs directly from send_status via spawn_blocking, with
  stdin_locked flag to prevent readline from competing for stdin
- Transient thinking/tool-started lines: each replaces the previous,
  all erased before final output (no clutter left in scrollback)
- Esc in selector sends denial so agent never gets stuck

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

* fix: widen TurnCost token fields to u64 and remove unused variable

- Change input_tokens/output_tokens from u32 to u64 in StatusUpdate::TurnCost,
  SseEvent::TurnCost, and the thread_ops emit site to avoid truncation on
  large conversations
- Remove unused _routine_engine_for_loop binding in agent_loop.rs

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

* chore: reduce startup log noise — demote info to debug

Demote routine startup messages (builder, WASM tools, tunnel, WASM
channels) from info to debug so the default log output stays clean.

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

* fix(web): allow CDN scripts in CSP connect-src directive

Add cdn.jsdelivr.net and cdnjs.cloudflare.com to connect-src so the
browser can fetch marked.js and DOMPurify without CSP violations.

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

* style: fix cargo fmt in repl.rs

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

* fix(web): gate turn_cost SSE handler on current thread

Prevents cost badge from attaching to the wrong message when
switching threads or receiving events from background threads.

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

* ci: retrigger CI

* fix: add missing extension_manager to webhook EngineContext

The webhook trigger path added in #736 was missing the
extension_manager field introduced by #1453.

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

* chore: ignore RUSTSEC-2026-0049 rustls-webpki CRL advisory

Low impact — requires compromised CA to exploit. Tracked for
upstream rustls-webpki upgrade.

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

* fix(routines): use fields.join for cron normalization

Use split_whitespace fields instead of re-trimming the original string
to avoid preserving extra internal whitespace in cron expressions.

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

* feat(repl): Apple-style approval card — clean vertical flow

- Drop verbose tool description (the command IS the decision surface)
- Unified vertical pipe layout: ◆ header → │ params → │ selector
- Selector options show keyboard shortcuts inline: Approve (y)
- Compact help message, answered state uses └ to close the flow
- No horizontal rules, no blank-line padding — just breathing room

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

* refactor(repl): replace inquire with crossterm for approval selector

Drop the inquire dependency (which pulled in crossterm 0.25, duplicating
the existing 0.28). The 3-option approval selector is now built directly
with crossterm raw mode — same UX, zero new dependencies.

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

* chore(deps): upgrade crossterm 0.28 → 0.29, eliminate duplication

termimad (via crokey) uses crossterm 0.29. Upgrading our direct
dependency from 0.28 to 0.29 collapses to a single crossterm version
in the dependency tree. Also migrated termimad::crossterm:: references
to the direct crossterm import.

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

* fix: address review comments — box_top off-by-one, smart_truncate overflow, mobile theme toggle

- Fix box_top() fill calculation: was off-by-one, producing boxes 1 char
  too wide (fmt.rs)
- Fix smart_truncate(): account for "..." in the budget so output never
  exceeds max_chars (repl.rs)
- Move theme toggle to settings sidebar on mobile instead of display:none,
  so mobile users can still switch themes (style.css, index.html, app.js)

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

* style: cargo fmt repl.rs

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

* fix: address review — retry duplication, CSP connect-src, deny color

- Remove failed message before retry to prevent duplicate user messages
- Revert connect-src to 'self' — CDN hosts only need script-src
- Use red for Deny confirmation in REPL approval selector

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-21 23:50:49 -07:00
8638895879 feat(gemini_oauth): full Gemini CLI OAuth integration with Cloud Code API (#1356)
* feat: integrate Gemini CLI OAuth with Cloud Code API

- Add gemini_oauth.rs: full OAuth flow with PKCE, token refresh,
  and Cloud Code project discovery (loadCodeAssist + onboardUser)
- Route preview/gemini-3 models through cloudcode-pa.googleapis.com
  with proper project ID injection in request payload
- Trigger OAuth login during onboarding wizard (not first chat message)
- Support manual redirect URL paste as fallback (tokio::select race)
- Parse 429 rate-limit errors with retry_after from Google response
- Add static model list: gemini-1.5/2.0/2.5/3.0/3.1 variants
- Add GeminiOauthConfig with default credentials path (~/.gemini/)

* feat(gemini): implement function calling, generationConfig, and update models

- Implement function calling support (functionDeclarations, functionResponse)
- Add functionCall SSE parsing and empty stream retry support
- Add generationConfig (temperature, maxOutputTokens)
- Add thinkingConfig for Gemini 3 and thinking models
- Add toolConfig (functionCallingConfig.mode)
- Fix .expect() panics with .ok_or_else()
- Restrict oauth credentials file permissions to 0600
- Update docs and FEATURE_PARITY.md
- Update wizard to current Gemini 3.1 and 2.5 models

* fix: address code review issues in gemini-cli OAuth integration

- Add cache_read_input_tokens/cache_creation_input_tokens fields (value 0)
- Implement manual Debug for OAuthCredential to redact tokens
- Fix hardcoded /tmp: use GeminiOauthConfig::default_credentials_path()
- Replace emoji output with plain text markers
- Propagate Client::builder() errors instead of silent fallback
- Use tokio::fs for all file I/O in CredentialManager (was std::fs)
- Use if let Some(ref pid) to avoid consuming credential.project_id
- Extract uses_cloud_code_api() helper; route by major version (gemini-2+)
- Concatenate multiple system messages into systemInstruction
- Include functionCall parts in assistant message conversion
- Add 401 retry loop with allow_retry flag for auth failures
- Remove biased from tokio::select! in OAuth callback handler
- Remove hardcoded context_length 1M; vary by model family
- Change GOOG_API_CLIENT from Node.js spoof to gl-rust/1.0.0
- Implement list_models() with static model list
- Move create_gemini_oauth_provider() before test module (clippy)
- Fix 9 additional clippy warnings (collapsible_if, map_or, needless_borrow)
- Run cargo fmt

* Add dedicated regression tests for Gemini OAuth fixes

* style: fix formatting in Gemini OAuth regression tests

* feat(gemini-oauth): implement code review v3 refinements

- Add force_refresh() for 401 retry (bypass timestamp check)
- Standardize Gemini model list across docs, wizard, and provider
- Restore gemini-3 check for thinkingConfig
- Redact sensitive tokens in GoogleTokenRefreshResponse Debug output
- Use dynamic version for GOOG_API_CLIENT
- Improve model_metadata() context length heuristics
- Use strip_prefix("data:") for safer SSE parsing
- Skip re-auth in wizard if keeping existing provider

* feat(gemini_oauth): full Cloud Code API integration with project discovery

- Register gemini_oauth as a dedicated backend in config/llm.rs (skip
  registry fallback, preserve backend name, suppress unknown-backend warning)
- Fix app.rs credential guard to exclude backends with dedicated configs
  (gemini_oauth, bedrock) from the provider.is_none() check
- Auto-discover Cloud Code project_id via loadCodeAssist when credentials
  lack it (e.g. created by the original Gemini CLI)
- Persist discovered project_id to credentials file for subsequent runs
- Add safety settings (BLOCK_NONE), gated behind GEMINI_SAFETY_BLOCK_NONE env
- Add thinkingConfig: budget-based for Gemini 2.5, level-based for Gemini 3.x
  (without includeThoughts to avoid empty responses from reasoning.rs stripping)
- Add thought signature injection for Gemini 3.x preview APIs
- Add history curation to filter invalid model outputs before re-sending
- Add extended generationConfig env vars (topP, topK, seed, penalties,
  responseMimeType, responseJsonSchema, cachedContent)
- Add custom headers support via GEMINI_CLI_CUSTOM_HEADERS
- Add API key auth mode (GEMINI_API_KEY + GEMINI_API_KEY_AUTH_MECHANISM)
- Add SSE metadata extraction (modelVersion, credits, promptFeedback,
  groundingMetadata, citationMetadata, cachedContentTokenCount)
- Add countTokens API support
- Add new models to wizard (gemini-3.1-pro-preview-customtools,
  gemini-3-pro-preview, gemini-3.1-flash-lite-preview)
- Update docs/LLM_PROVIDERS.md with new models and routing rules
- Rewrite regression tests with comprehensive coverage (23 unit tests pass)

* fix: CI violations — add safety comment on expect, fix fmt

- Add '// safety: hardcoded literal' to regex .expect() to satisfy
  the no-panic-in-prod CI check
- Fix cargo fmt whitespace in collapsible if-let chain

* fix: address PR review feedback from gemini-code-assist

- Fix parse_custom_headers to preserve commas in values by splitting
  only on commas followed by a header-name:colon pattern (manual scan
  instead of simple split(','))
- Use matches! macro for backend exclusion check in app.rs
- Merge SSE metadata extraction into single pass (was iterating twice)
- Replace fragile substring-based context_length with explicit match
  on known Gemini model IDs via gemini_context_length()
- Add missing models to regression test (8 models, not 5)

* fix: address Copilot PR review feedback

- Fix empty text part for assistant messages with tool calls
  (curate_contents could drop entire model turn)
- Propagate cache_read/creation_input_tokens in complete_with_tools
- Log warning on save_credential failure instead of silently ignoring
- Fix doc comment to mention underscore in header name pattern
- Handle gemini-oauth (hyphen variant) in setup wizard display
- Fix docs: thinkingConfig uses thinkingBudget/thinkingLevel, not
  includeThoughts

* fix: add missing allow_always field after staging merge

* fix(gemini_oauth): align header parser doc with implementation [skip-regression-check]

Update parse_custom_headers doc comments to include underscore in the
header-name character class, matching the actual implementation.
Also fix formatting from merge.

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

* fix(gemini_oauth): curate_contents per-part filtering and dead code removal

Fix curate_contents to filter invalid parts individually instead of
dropping entire model turn sequences. Previously a single empty text
part would discard all consecutive model turns including valid
functionCall parts, breaking the tool-call flow.

Also remove unused MID_STREAM_* constants.

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

* style(gemini_oauth): rustfmt formatting [skip-regression-check]

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

* fix(llm): support smart routing cheap model for gemini_oauth backend

Add explicit gemini_oauth handling in create_cheap_provider_for_backend()
to create a GeminiOauthProvider with the cheap model swapped in. Without
this, setting LLM_CHEAP_MODEL with gemini_oauth backend would fail with
a confusing "no registry provider config available" error.

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

* docs: add Gemini OAuth env vars to .env.example [skip-regression-check]

Document GEMINI_MODEL, GEMINI_CREDENTIALS_PATH, GEMINI_API_KEY, and
all extended generation config env vars in the example config file.

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

---------

Co-authored-by: [email protected] <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-21 22:41:44 -07:00
95 changed files with 8906 additions and 1847 deletions
+18 -1
View File
@@ -4,7 +4,7 @@ DATABASE_POOL_SIZE=10
# LLM Provider
# LLM_BACKEND=nearai # default
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, github_copilot, tinfoil, openai_codex
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, github_copilot, tinfoil, openai_codex, gemini_oauth
# LLM_REQUEST_TIMEOUT_SECS=120 # Increase for local LLMs (Ollama, vLLM, LM Studio)
# === Anthropic Direct ===
@@ -110,6 +110,23 @@ NEARAI_AUTH_URL=https://private.near.ai
# OPENAI_CODEX_AUTH_URL=https://auth.openai.com # override (rare)
# OPENAI_CODEX_API_URL=https://chatgpt.com/backend-api/codex # override (rare)
# === Google Gemini (OAuth, Gemini CLI compatible) ===
# LLM_BACKEND=gemini_oauth
# GEMINI_MODEL=gemini-2.5-flash # default
# GEMINI_CREDENTIALS_PATH=~/.gemini/oauth_creds.json # default
# GEMINI_API_KEY=... # optional: use API key instead of OAuth
# GEMINI_API_KEY_AUTH_MECHANISM=query # "query" (default) or "header"
# GEMINI_SAFETY_BLOCK_NONE=true # disable safety filters (default: false)
# GEMINI_CLI_CUSTOM_HEADERS=Key:Value,Key2:Value2
# GEMINI_TOP_P=0.95
# GEMINI_TOP_K=40
# GEMINI_SEED=42
# GEMINI_PRESENCE_PENALTY=0.0
# GEMINI_FREQUENCY_PENALTY=0.0
# GEMINI_RESPONSE_MIME_TYPE=application/json
# GEMINI_RESPONSE_JSON_SCHEMA={"type":"object"}
# GEMINI_CACHED_CONTENT=cachedContents/abc123
# For full provider setup guide see docs/LLM_PROVIDERS.md
# Channel Configuration
@@ -121,6 +121,7 @@ jobs:
fi
# Whole-function context: detect edits inside existing test functions.
# Uses -W (whole function) which works when git recognises function boundaries.
if git diff "${BASE_REF}...${HEAD_REF}" -W -- '*.rs' | awk '
/^@@/ { if (has_test && has_add) { found=1; exit } has_test=0; has_add=0 }
/^ .*#\[test\]/ || /^ .*#\[tokio::test\]/ || /^ .*#\[cfg\(test\)\]/ || /^ .*mod tests/ { has_test=1 }
@@ -132,6 +133,40 @@ jobs:
exit 0
fi
# Line-level check: detect changes inside #[cfg(test)] mod blocks.
# git -W relies on function boundary detection which misses Rust mod blocks,
# so this fallback checks whether changed line numbers fall within test modules.
# We specifically match #[cfg(test)] that is followed by `mod` (same or next
# line) to avoid false positives from standalone #[cfg(test)] items like
# individual statics or functions.
CHANGED_RS=$(echo "$CHANGED_FILES" | grep '\.rs$' || true)
if [ -n "$CHANGED_RS" ]; then
while IFS= read -r rs_file; do
[ -f "$rs_file" ] || continue
# Find the line where #[cfg(test)] precedes a `mod` declaration.
# Handles both `#[cfg(test)] mod tests` (same line) and the two-line form.
TEST_MOD_START=$(awk '
/^[[:space:]]*#\[cfg\(test\)\].*mod / { print NR; exit }
/^[[:space:]]*#\[cfg\(test\)\][[:space:]]*$/ { pending=NR; next }
pending && /^[[:space:]]*mod / { print pending; exit }
{ pending=0 }
' "$rs_file")
[ -n "$TEST_MOD_START" ] || continue
# Get changed line numbers in this file from the diff hunk headers.
# Each @@ line looks like: @@ -old,count +new,count @@
while IFS= read -r hunk_line; do
line_no=$(echo "$hunk_line" | sed -E 's/^@@ -[0-9,]+ \+([0-9]+).*/\1/')
[ -n "$line_no" ] || continue
if [ "$line_no" -ge "$TEST_MOD_START" ]; then
echo "Test changes found: $rs_file has changes at line $line_no inside #[cfg(test)] mod block (starts at line $TEST_MOD_START)."
exit 0
fi
done < <(git diff "${BASE_REF}...${HEAD_REF}" -U0 -- "$rs_file" | grep -E '^@@')
done <<< "$CHANGED_RS"
fi
if grep -qE '^tests/' <<< "$CHANGED_FILES"; then
echo "Test file changes found under tests/."
exit 0
Generated
+10 -26
View File
@@ -1510,7 +1510,7 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "980c2afde4af43d6a05c5be738f9eae595cff86dce1f38f88b95058a98c027f3"
dependencies = [
"crossterm 0.29.0",
"crossterm",
]
[[package]]
@@ -1731,7 +1731,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04a63daf06a168535c74ab97cdba3ed4fa5d4f32cb36e437dcceb83d66854b7c"
dependencies = [
"crokey-proc_macros",
"crossterm 0.29.0",
"crossterm",
"once_cell",
"serde",
"strict",
@@ -1743,7 +1743,7 @@ version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "847f11a14855fc490bd5d059821895c53e77eeb3c2b73ee3dded7ce77c93b231"
dependencies = [
"crossterm 0.29.0",
"crossterm",
"proc-macro2",
"quote",
"strict",
@@ -1817,22 +1817,6 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crossterm"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags 2.11.0",
"crossterm_winapi",
"mio",
"parking_lot",
"rustix 0.38.44",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm"
version = "0.29.0"
@@ -2152,7 +2136,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2339,7 +2323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3426,7 +3410,7 @@ dependencies = [
"clap_complete",
"criterion",
"cron",
"crossterm 0.28.1",
"crossterm",
"deadpool-postgres",
"dirs 6.0.0",
"dotenvy",
@@ -4150,7 +4134,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5488,7 +5472,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6395,7 +6379,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8045,7 +8029,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
+1 -2
View File
@@ -12,7 +12,6 @@ exclude = [
"tools-src/google-drive",
"tools-src/google-sheets",
"tools-src/google-slides",
"tools-src/composio",
"tools-src/slack",
"tools-src/telegram",
"fuzz",
@@ -89,7 +88,7 @@ async-trait = "0.1"
clap = { version = "4", features = ["derive", "env"] }
# Terminal
crossterm = "0.28"
crossterm = "0.29"
rustyline = { version = "17", features = ["custom-bindings", "derive", "with-file-history"] }
termimad = "0.34"
+14 -5
View File
@@ -3,6 +3,7 @@
This document tracks feature parity between IronClaw (Rust implementation) and OpenClaw (TypeScript reference implementation). Use this to coordinate work across developers.
**Legend:**
- ✅ Implemented
- 🚧 Partial (in progress or incomplete)
- ❌ Not implemented
@@ -204,7 +205,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Skills (modular capabilities) | ✅ | ✅ | Prompt-based skills with trust gating, attenuation, activation criteria, catalog, selector |
| Skill routing blocks | ✅ | 🚧 | ActivationCriteria (keywords, patterns, tags) but no "Use when / Don't use when" blocks |
| Skill path compaction | ✅ | ❌ | ~ prefix to reduce prompt tokens |
| Thinking modes (off/minimal/low/medium/high/xhigh/adaptive) | ✅ | | Configurable reasoning depth |
| Thinking modes (off/minimal/low/medium/high/xhigh/adaptive) | ✅ | 🚧 | thinkingConfig for Gemini models (thinkingBudget/thinkingLevel); no per-level control yet |
| Per-model thinkingDefault override | ✅ | ❌ | Override thinking level per model; Anthropic Claude 4.6 defaults to adaptive |
| Block-level streaming | ✅ | ❌ | |
| Tool-level streaming | ✅ | ❌ | |
@@ -236,9 +237,13 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| NEAR AI | ✅ | ✅ | - | Primary provider |
| Anthropic (Claude) | ✅ | 🚧 | - | Via NEAR AI proxy; Opus 4.5, Sonnet 4, Sonnet 4.6, adaptive thinking default |
| OpenAI | ✅ | 🚧 | - | Via NEAR AI proxy; GPT-5.4 + Codex OAuth |
| AWS Bedrock | ✅ | ❌ | P3 | |
| Google Gemini | ✅ | | P3 | |
| NVIDIA API | ✅ | | P3 | New provider |
| AWS Bedrock | ✅ | ✅ | - | Native Converse API via aws-sdk-bedrockruntime (requires `--features bedrock`) |
| Google Gemini | ✅ | | - | OAuth (PKCE + S256), function calling, thinkingConfig, generationConfig |
| io.net | ✅ | | P3 | Via `ionet` adapter |
| Mistral | ✅ | ✅ | P3 | Via `mistral` adapter |
| Yandex AI Studio | ✅ | ✅ | P3 | Via `yandex` adapter |
| Cloudflare Workers AI | ✅ | ✅ | P3 | Via `cloudflare` adapter |
| NVIDIA API | ✅ | ✅ | P3 | Via `nvidia` adapter and `providers.json` |
| OpenRouter | ✅ | ✅ | - | Via OpenAI-compatible provider (RigAdapter) |
| Tinfoil | ❌ | ✅ | - | Private inference provider (IronClaw-only) |
| OpenAI-compatible | ❌ | ✅ | - | Generic OpenAI-compatible endpoint (RigAdapter) |
@@ -466,7 +471,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Device pairing | ✅ | ❌ | |
| Tailscale identity | ✅ | ❌ | |
| Trusted-proxy auth | ✅ | ❌ | Header-based reverse proxy auth |
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth plus hosted extension/MCP OAuth broker; external auth-proxy rollout still pending |
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth + Gemini OAuth (PKCE, S256) + hosted extension/MCP OAuth broker; external auth-proxy rollout still pending |
| DM pairing verification | ✅ | ✅ | ironclaw pairing approve, host APIs |
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
| Per-group tool policies | ✅ | ❌ | |
@@ -523,6 +528,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
## Implementation Priorities
### P0 - Core (Already Done)
- ✅ TUI channel with approval overlays
- ✅ HTTP webhook channel
- ✅ DM pairing (ironclaw pairing list/approve, host APIs)
@@ -550,6 +556,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
- ✅ OpenAI-compatible / OpenRouter provider support
### P1 - High Priority
- ❌ Slack channel (real implementation)
- ✅ Telegram channel (WASM, DM pairing, caption, /start)
- ❌ WhatsApp channel
@@ -557,6 +564,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
- ✅ Hooks system (core lifecycle hooks + bundled/plugin/workspace hooks + outbound webhooks)
### P2 - Medium Priority
- ❌ Media handling (images, PDFs)
- ✅ Ollama/local model support (via rig::providers::ollama)
- ❌ Configuration hot-reload
@@ -565,6 +573,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
- ❌ Partial output preservation on abort
### P3 - Lower Priority
- ❌ Discord channel
- ❌ Matrix channel
- ❌ Other messaging platforms
+3
View File
@@ -12,6 +12,9 @@
<a href="#license"><img src="https://img.shields.io/badge/license-MIT%20OR%20Apache%202.0-blue.svg" alt="License: MIT OR Apache-2.0" /></a>
<a href="https://t.me/ironclawAI"><img src="https://img.shields.io/badge/Telegram-%40ironclawAI-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @ironclawAI" /></a>
<a href="https://www.reddit.com/r/ironclawAI/"><img src="https://img.shields.io/badge/Reddit-r%2FironclawAI-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/ironclawAI" /></a>
<a href="https://gitcgr.com/nearai/ironclaw">
<img src="https://gitcgr.com/badge/nearai/ironclaw.svg" alt="gitcgr" />
</a>
</p>
<p align="center">
+4 -4
View File
@@ -3,11 +3,11 @@
"wit_version": "0.3.0",
"type": "channel",
"name": "feishu",
"description": "Feishu/Lark Bot channel for receiving and responding to Feishu messages",
"description": "Feishu/Lark Bot channel for receiving and responding to Feishu messages via Event Subscription webhooks",
"auth": {
"secret_name": "feishu_app_id",
"display_name": "Feishu / Lark",
"instructions": "Create a bot at https://open.feishu.cn/app (Feishu) or https://open.larksuite.com/app (Lark). You need the App ID and App Secret.",
"instructions": "Create a bot at https://open.feishu.cn/app (Feishu) or https://open.larksuite.com/app (Lark). You need the App ID and App Secret. Note: IronClaw supports Event Subscription webhook delivery, but not Feishu's long-connection websocket mode.",
"setup_url": "https://open.feishu.cn/app",
"token_hint": "App ID looks like cli_XXXX, App Secret is a long alphanumeric string",
"env_var": "FEISHU_APP_ID"
@@ -16,7 +16,7 @@
"required_secrets": [
{
"name": "feishu_app_id",
"prompt": "Enter your Feishu/Lark App ID (from https://open.feishu.cn/app)",
"prompt": "Enter your Feishu/Lark App ID (from https://open.feishu.cn/app). Use webhook-based Event Subscription, not long-connection websocket mode.",
"optional": false
},
{
@@ -26,7 +26,7 @@
},
{
"name": "feishu_verification_token",
"prompt": "Enter your Feishu/Lark Verification Token (from Event Subscription settings)",
"prompt": "Enter your Feishu/Lark Verification Token (from Event Subscription webhook settings)",
"optional": true
}
],
+3 -1
View File
@@ -5,7 +5,9 @@
//!
//! This WASM component implements the channel interface for handling Feishu
//! webhooks (Event Subscription v2.0) and sending messages back via the
//! Feishu/Lark Bot API.
//! Feishu/Lark Bot API. IronClaw currently does not connect to Feishu's
//! long-connection websocket subscription mode; use Event Subscription
//! webhooks for this channel.
//!
//! # Features
//!
+48 -3
View File
@@ -1,8 +1,8 @@
# LLM Provider Configuration
IronClaw defaults to NEAR AI for model access, but supports any OpenAI-compatible
endpoint as well as Anthropic and Ollama directly. This guide covers the most common
configurations.
endpoint as well as Anthropic, Ollama, and Google Gemini directly. This guide covers
the most common configurations.
## Provider Overview
@@ -11,7 +11,7 @@ configurations.
| NEAR AI | `nearai` | OAuth (browser) | Default; multi-model |
| Anthropic | `anthropic` | `ANTHROPIC_API_KEY` | Claude models |
| OpenAI | `openai` | `OPENAI_API_KEY` | GPT models |
| Google Gemini | `gemini` | `GEMINI_API_KEY` | Gemini models |
| Google Gemini | `gemini_oauth` | OAuth (browser) | Gemini models; function calling |
| io.net | `ionet` | `IONET_API_KEY` | Intelligence API |
| Mistral | `mistral` | `MISTRAL_API_KEY` | Mistral models |
| Yandex AI Studio | `yandex` | `YANDEX_API_KEY` | YandexGPT models |
@@ -62,6 +62,51 @@ Popular models: `gpt-4o`, `gpt-4o-mini`, `o3-mini`
---
## Google Gemini (OAuth)
Uses Google OAuth with PKCE (S256) for authentication — no API key required.
On first run, a browser opens for Google account login. Credentials (including
refresh token) are saved to `~/.gemini/oauth_creds.json` with `0600` permissions.
```env
LLM_BACKEND=gemini_oauth
GEMINI_MODEL=gemini-2.5-flash
```
### Supported features
| Feature | Status | Notes |
|---|---|---|
| Function calling | ✅ | `functionDeclarations` / `functionCall` / `functionResponse` |
| `generationConfig` | ✅ | `temperature`, `maxOutputTokens` passed from request |
| `thinkingConfig` | ✅ | `thinkingBudget`/`thinkingLevel` for thinking-capable models (does NOT set `includeThoughts`) |
| `toolConfig` | ✅ | `functionCallingConfig.mode`: `AUTO`/`ANY`/`NONE` |
| SSE streaming | ✅ | Cloud Code API with `streamGenerateContent?alt=sse` |
| Token refresh | ✅ | Automatic via refresh token |
### Popular models
| Model | ID | Notes |
|---|---|---|
| Gemini 3.1 Pro | `gemini-3.1-pro-preview` | Latest, strongest reasoning |
| Gemini 3.1 Pro Custom Tools | `gemini-3.1-pro-preview-customtools` | Enhanced tool use |
| Gemini 3 Pro | `gemini-3-pro-preview` | Preview |
| Gemini 3 Flash | `gemini-3-flash-preview` | Fast preview with thinking |
| Gemini 3.1 Flash Lite | `gemini-3.1-flash-lite-preview` | Preview, lightweight |
| Gemini 2.5 Pro | `gemini-2.5-pro` | Stable, strong reasoning |
| Gemini 2.5 Flash | `gemini-2.5-flash` | Fast, good quality |
| Gemini 2.5 Flash Lite | `gemini-2.5-flash-lite` | Fastest, lightweight |
### Cloud Code API vs standard API
Models containing `-preview` (with hyphen) or `gemini-3` in the name, as well
as any `gemini-` model with major version >= 2, route through the Cloud Code
API (`cloudcode-pa.googleapis.com`) which supports SSE streaming
and project-scoped access. Other models use the standard Generative Language
API (`generativelanguage.googleapis.com`).
---
## GitHub Copilot
GitHub Copilot exposes chat endpoint at
+1 -1
View File
@@ -162,7 +162,7 @@ pub struct AgentDeps {
/// HTTP interceptor for trace recording/replay.
pub http_interceptor: Option<Arc<dyn crate::llm::recording::HttpInterceptor>>,
/// Audio transcription middleware for voice messages.
pub transcription: Option<Arc<crate::transcription::TranscriptionMiddleware>>,
pub transcription: Option<Arc<crate::llm::transcription::TranscriptionMiddleware>>,
/// Document text extraction middleware for PDF, DOCX, PPTX, etc.
pub document_extraction: Option<Arc<crate::document_extraction::DocumentExtractionMiddleware>>,
/// Sandbox readiness state for full-job routine dispatch.
+16 -3
View File
@@ -6,6 +6,7 @@
//! via the `LoopDelegate` trait.
use async_trait::async_trait;
use std::borrow::Cow;
use crate::agent::session::PendingApproval;
use crate::error::Error;
@@ -235,12 +236,12 @@ pub async fn run_agentic_loop(
///
/// `max` is a byte budget. The result is truncated at the last valid char
/// boundary at or before `max` bytes, so it is always valid UTF-8.
pub fn truncate_for_preview(s: &str, max: usize) -> String {
pub fn truncate_for_preview(s: &str, max: usize) -> Cow<'_, str> {
if s.len() <= max {
s.to_string()
Cow::Borrowed(s)
} else {
let end = crate::util::floor_char_boundary(s, max);
format!("{}...", &s[..end])
Cow::Owned(format!("{}...", &s[..end]))
}
}
@@ -597,12 +598,24 @@ mod tests {
assert_eq!(truncate_for_preview("hello", 10), "hello");
}
#[test]
fn test_truncate_short_string_borrows() {
let result = truncate_for_preview("hello", 10);
assert!(matches!(result, Cow::Borrowed("hello")));
}
#[test]
fn test_truncate_long_string_adds_ellipsis() {
let result = truncate_for_preview("hello world", 5);
assert_eq!(result, "hello...");
}
#[test]
fn test_truncate_long_string_owns() {
let result = truncate_for_preview("hello world", 5);
assert!(matches!(result, Cow::Owned(_)));
}
#[test]
fn test_truncate_multibyte_safe() {
let result = truncate_for_preview("café", 4);
+36 -5
View File
@@ -317,7 +317,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
.channels
.send_status(
&self.message.channel,
StatusUpdate::Thinking("Calling LLM...".into()),
StatusUpdate::Thinking(format!("Thinking (step {iteration})...")),
&self.message.metadata,
)
.await;
@@ -435,7 +435,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
.channels
.send_status(
&self.message.channel,
StatusUpdate::Thinking(format!("Executing {} tool(s)...", tool_calls.len())),
StatusUpdate::Thinking(contextual_tool_message(&tool_calls)),
&self.message.metadata,
)
.await;
@@ -915,7 +915,14 @@ pub(super) async fn execute_chat_tool_standalone(
params: &serde_json::Value,
job_ctx: &crate::context::JobContext,
) -> Result<String, Error> {
crate::tools::execute::execute_tool_with_safety(tools, safety, tool_name, params, job_ctx).await
crate::tools::execute::execute_tool_with_safety(
tools,
safety,
tool_name,
params.clone(),
job_ctx,
)
.await
}
/// Parsed auth result fields for emitting StatusUpdate::AuthRequired.
@@ -969,6 +976,30 @@ pub(super) fn check_auth_required(
Some((name, instructions))
}
/// Build a contextual thinking message based on tool names.
///
/// Instead of a generic "Executing 2 tool(s)..." this returns messages like
/// "Running command..." or "Fetching page..." for single-tool calls, falling
/// back to "Executing N tool(s)..." for multi-tool calls.
fn contextual_tool_message(tool_calls: &[crate::llm::ToolCall]) -> String {
if tool_calls.len() == 1 {
match tool_calls[0].name.as_str() {
"shell" => "Running command...".into(),
"web_fetch" => "Fetching page...".into(),
"memory_search" => "Searching memory...".into(),
"memory_write" => "Writing to memory...".into(),
"memory_read" => "Reading memory...".into(),
"http_request" => "Making HTTP request...".into(),
"file_read" => "Reading file...".into(),
"file_write" => "Writing file...".into(),
"json_transform" => "Transforming data...".into(),
name => format!("Running {name}..."),
}
} else {
format!("Executing {} tool(s)...", tool_calls.len())
}
}
/// Compact messages for retry after a context-length-exceeded error.
///
/// Keeps all `System` messages (which carry the system prompt and instructions),
@@ -1869,7 +1900,7 @@ mod tests {
Ok(ToolCompletionResponse {
content: None,
tool_calls: vec![ToolCall {
id: format!("call_{}", uuid::Uuid::new_v4()),
id: crate::llm::generate_tool_call_id(0, 0),
name: "echo".to_string(),
arguments: serde_json::json!({"message": "looping"}),
}],
@@ -2022,7 +2053,7 @@ mod tests {
Ok(ToolCompletionResponse {
content: None,
tool_calls: vec![ToolCall {
id: format!("call_{}", uuid::Uuid::new_v4()),
id: crate::llm::generate_tool_call_id(0, 0),
name: "nonexistent_tool".to_string(),
arguments: serde_json::json!({}),
}],
+2 -2
View File
@@ -529,8 +529,8 @@ pub fn normalize_cron_expression(schedule: &str) -> String {
let trimmed = schedule.trim();
let fields: Vec<&str> = trimmed.split_whitespace().collect();
match fields.len() {
5 => format!("0 {} *", trimmed),
6 => format!("{} *", trimmed),
5 => format!("0 {} *", fields.join(" ")),
6 => format!("{} *", fields.join(" ")),
_ => trimmed.to_string(),
}
}
+1 -5
View File
@@ -549,11 +549,7 @@ impl Scheduler {
// Delegate to shared tool execution pipeline
let output_str = crate::tools::execute::execute_tool_with_safety(
&tools,
&safety,
tool_name,
&normalized_params,
&job_ctx,
&tools, &safety, tool_name, params, &job_ctx,
)
.await?;
+22 -8
View File
@@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::channels::web::util::truncate_preview;
use crate::llm::{ChatMessage, ToolCall};
use crate::llm::{ChatMessage, ToolCall, generate_tool_call_id};
/// A session containing one or more threads.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -414,7 +414,12 @@ impl Thread {
/// completed actions in subsequent turns.
pub fn messages(&self) -> Vec<ChatMessage> {
let mut messages = Vec::new();
for turn in &self.turns {
// We use the enumeration index (`turn_idx`) rather than `turn.turn_number`
// intentionally: after `truncate_turns()`, the remaining turns are
// re-numbered starting from 0, so the enumeration index and turn_number
// are equivalent. Using the index avoids coupling to the field and keeps
// tool-call ID generation deterministic for the current message window.
for (turn_idx, turn) in self.turns.iter().enumerate() {
if turn.image_content_parts.is_empty() {
messages.push(ChatMessage::user(&turn.user_input));
} else {
@@ -425,13 +430,23 @@ impl Thread {
}
if !turn.tool_calls.is_empty() {
// Build ToolCall objects with synthetic stable IDs
let tool_calls: Vec<ToolCall> = turn
// Assign synthetic call IDs for this turn's tool calls, so that
// declarations and results can be consistently correlated.
let tool_calls_with_ids: Vec<(String, &_)> = turn
.tool_calls
.iter()
.enumerate()
.map(|(i, tc)| ToolCall {
id: format!("turn{}_{}", turn.turn_number, i),
.map(|(tc_idx, tc)| {
// Use provider-compatible tool call IDs derived from turn/tool indices.
(generate_tool_call_id(turn_idx, tc_idx), tc)
})
.collect();
// Build ToolCall objects using the synthetic call IDs.
let tool_calls: Vec<ToolCall> = tool_calls_with_ids
.iter()
.map(|(call_id, tc)| ToolCall {
id: call_id.clone(),
name: tc.name.clone(),
arguments: tc.parameters.clone(),
})
@@ -441,8 +456,7 @@ impl Thread {
messages.push(ChatMessage::assistant_with_tool_calls(None, tool_calls));
// Individual tool result messages, truncated to limit context size.
for (i, tc) in turn.tool_calls.iter().enumerate() {
let call_id = format!("turn{}_{}", turn.turn_number, i);
for (call_id, tc) in tool_calls_with_ids {
let content = if let Some(ref err) = tc.error {
// .error already contains the full error text;
// pass through without wrapping to avoid double-prefix.
+94 -25
View File
@@ -556,6 +556,33 @@ impl Agent {
.await;
}
// Emit per-turn cost summary
{
let usage = self.cost_guard().model_usage().await;
let (total_in, total_out, total_cost) =
usage
.values()
.fold((0u64, 0u64, rust_decimal::Decimal::ZERO), |acc, m| {
(
acc.0 + m.input_tokens,
acc.1 + m.output_tokens,
acc.2 + m.cost,
)
});
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::TurnCost {
input_tokens: total_in,
output_tokens: total_out,
cost_usd: format!("${:.4}", total_cost),
},
&message.metadata,
)
.await;
}
Ok(SubmissionResult::response(response))
}
Ok(AgenticLoopResult::NeedApproval { pending }) => {
@@ -927,7 +954,10 @@ impl Agent {
approved: bool,
always: bool,
) -> Result<SubmissionResult, Error> {
// Get pending approval for this thread
// Get pending approval for this thread.
// The take-verify sequence is atomic under a single lock acquisition
// to prevent a TOCTOU race where a concurrent operation could modify
// or delete the thread between take and restore (#1486).
let pending = {
let mut sess = session.lock().await;
let thread = sess
@@ -945,33 +975,31 @@ impl Agent {
return Ok(SubmissionResult::ok_with_message(""));
}
thread.take_pending_approval()
};
let taken = match thread.take_pending_approval() {
Some(p) => p,
None => {
tracing::debug!(
%thread_id,
"Ignoring stale approval: no pending approval found"
);
return Ok(SubmissionResult::ok_with_message(""));
}
};
let pending = match pending {
Some(p) => p,
None => {
tracing::debug!(
%thread_id,
"Ignoring stale approval: no pending approval found"
);
return Ok(SubmissionResult::ok_with_message(""));
// Verify request ID while still holding the lock — atomic with take
if let Some(req_id) = request_id
&& req_id != taken.request_id
{
// Restore atomically under same lock
thread.await_approval(taken);
return Ok(SubmissionResult::error(
"Request ID mismatch. Use the correct request ID.",
));
}
};
// Verify request ID if provided
if let Some(req_id) = request_id
&& req_id != pending.request_id
{
// Put it back and return error
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
thread.await_approval(pending);
}
return Ok(SubmissionResult::error(
"Request ID mismatch. Use the correct request ID.",
));
}
taken
// Lock dropped here — pending approval validated
};
if approved {
// If always, add to auto-approved set
@@ -2177,6 +2205,47 @@ mod tests {
assert!(t.pending_messages.is_empty());
}
#[test]
fn test_approval_request_id_mismatch_restores_pending() {
// Regression test for #1486: after a request_id mismatch, the pending
// approval must still be intact (take + verify + restore is atomic).
use crate::agent::session::{PendingApproval, Thread, ThreadState};
use uuid::Uuid;
let session_id = Uuid::new_v4();
let thread_id = Uuid::new_v4();
let mut thread = Thread::with_id(thread_id, session_id);
let correct_request_id = Uuid::new_v4();
let pending = PendingApproval {
request_id: correct_request_id,
tool_name: "shell".to_string(),
parameters: serde_json::json!({}),
display_parameters: serde_json::json!({}),
description: "test".to_string(),
tool_call_id: "call_0".to_string(),
context_messages: vec![],
deferred_tool_calls: vec![],
user_timezone: None,
allow_always: true,
};
thread.await_approval(pending);
assert_eq!(thread.state, ThreadState::AwaitingApproval);
// Simulate: take, verify mismatch, restore -- all must be atomic
let taken = thread.take_pending_approval().unwrap();
assert_eq!(taken.request_id, correct_request_id);
// On mismatch, restore
thread.await_approval(taken);
// Must still be in AwaitingApproval with pending intact
assert_eq!(thread.state, ThreadState::AwaitingApproval);
assert!(thread.pending_approval.is_some());
assert_eq!(
thread.pending_approval.as_ref().unwrap().request_id,
correct_request_id
);
}
// Helper function to extract the approval message without needing a full Agent instance
fn extract_approval_message(
session: &crate::agent::session::Session,
+19 -8
View File
@@ -325,9 +325,20 @@ impl AppBuilder {
};
let mut ws = Workspace::new_with_db(workspace_user_id, db.clone())
.with_search_config(&self.config.search);
if let Some(ref emb) = embeddings {
ws = ws.with_embeddings_cached(emb.clone(), emb_cache_config);
}
// Wire workspace-level settings (read scopes, memory layers)
if !self.config.workspace.read_scopes.is_empty() {
ws = ws.with_additional_read_scopes(self.config.workspace.read_scopes.clone());
tracing::info!(
user_id = workspace_user_id,
read_scopes = ?ws.read_user_ids(),
"Workspace configured with multi-scope reads"
);
}
ws = ws.with_memory_layers(self.config.workspace.memory_layers.clone());
let ws = Arc::new(ws);
tools.register_memory_tools(Arc::clone(&ws));
@@ -386,7 +397,7 @@ impl AppBuilder {
let b = tools
.register_builder_tool(llm.clone(), Some(self.config.builder.to_builder_config()))
.await;
tracing::info!("Builder mode enabled");
tracing::debug!("Builder mode enabled");
Some(b)
} else {
None
@@ -729,13 +740,13 @@ impl AppBuilder {
self.init_database().await?;
self.init_secrets().await?;
// Post-init validation: if a non-nearai backend was selected but
// credentials were never resolved (deferred resolution found no keys),
// fail early with a clear error instead of a confusing runtime failure.
if self.config.llm.backend != "nearai"
&& self.config.llm.backend != "bedrock"
&& self.config.llm.backend != "openai_codex"
&& self.config.llm.provider.is_none()
// Post-init validation: backends with dedicated config (nearai, gemini_oauth,
// bedrock, openai_codex) handle their own credential resolution. For registry-based
// backends, fail early if no provider config was resolved.
if !matches!(
self.config.llm.backend.as_str(),
"nearai" | "gemini_oauth" | "bedrock" | "openai_codex"
) && self.config.llm.provider.is_none()
{
let backend = &self.config.llm.backend;
anyhow::bail!(
+188 -93
View File
@@ -1,8 +1,11 @@
//! Boot screen displayed after all initialization completes.
//!
//! Shows a polished ANSI-styled status panel summarizing the agent's runtime
//! state: model, database, tool count, enabled features, active channels,
//! and the gateway URL.
//! Shows a compact ANSI-styled status panel with three tiers:
//! - **Tier 1 (always):** Name + version, model + backend.
//! - **Tier 2 (conditional):** Gateway URL, tunnel URL, non-default channels.
//! - **Tier 3 (removed):** Database, tool count, features → use `ironclaw status`.
use crate::cli::fmt;
/// All displayable fields for the boot screen.
pub struct BootInfo {
@@ -29,112 +32,76 @@ pub struct BootInfo {
pub tunnel_url: Option<String>,
/// Provider name for the managed tunnel (e.g., "ngrok").
pub tunnel_provider: Option<String>,
/// Time elapsed during startup. Shown at the bottom when present.
pub startup_elapsed: Option<std::time::Duration>,
}
/// Print the boot screen to stdout.
pub fn print_boot_screen(info: &BootInfo) {
// ANSI codes matching existing REPL palette
let bold = "\x1b[1m";
let cyan = "\x1b[36m";
let dim = "\x1b[90m";
let yellow = "\x1b[33m";
let yellow_underline = "\x1b[33;4m";
let reset = "\x1b[0m";
const KW: usize = 10;
let border = format!(" {dim}{}{reset}", "\u{2576}".repeat(58));
/// Print the boot screen to stdout.
///
/// **Tier 1 (always):** Name + version, model + backend.
/// **Tier 2 (conditional):** Gateway URL, tunnel URL, non-default channels.
/// **Tier 3 (removed):** Database, tool count, features — use `ironclaw status`.
pub fn print_boot_screen(info: &BootInfo) {
let border = format!(" {}", fmt::separator(58));
println!();
println!("{border}");
println!();
println!(" {bold}{}{reset} v{}", info.agent_name, info.version);
// ── Tier 1: always shown ──────────────────────────────────────────
println!(
" {}{}{} v{}",
fmt::bold(),
info.agent_name,
fmt::reset(),
info.version
);
println!();
// Model line
let model_display = if let Some(ref cheap) = info.cheap_model {
format!(
"{cyan}{}{reset} {dim}cheap{reset} {cyan}{}{reset}",
info.llm_model, cheap
"{}{}{} {}cheap{} {}{}{}",
fmt::accent(),
info.llm_model,
fmt::reset(),
fmt::dim(),
fmt::reset(),
fmt::accent(),
cheap,
fmt::reset(),
)
} else {
format!("{cyan}{}{reset}", info.llm_model)
format!("{}{}{}", fmt::accent(), info.llm_model, fmt::reset())
};
println!(
" {dim}model{reset} {model_display} {dim}via {}{reset}",
info.llm_backend
" {}{:<width$}{} {model_display} {}via {}{}",
fmt::dim(),
"model",
fmt::reset(),
fmt::dim(),
info.llm_backend,
fmt::reset(),
width = KW,
);
// Database line
let db_status = if info.db_connected {
"connected"
} else {
"none"
};
println!(
" {dim}database{reset} {cyan}{}{reset} {dim}({db_status}){reset}",
info.db_backend
);
// ── Tier 2: conditional ───────────────────────────────────────────
// Tools line
println!(
" {dim}tools{reset} {cyan}{}{reset} {dim}registered{reset}",
info.tool_count
);
// Features line
let mut features = Vec::new();
if info.embeddings_enabled {
if let Some(ref provider) = info.embeddings_provider {
features.push(format!("embeddings ({provider})"));
} else {
features.push("embeddings".to_string());
}
}
if info.heartbeat_enabled {
let mins = info.heartbeat_interval_secs / 60;
features.push(format!("heartbeat ({mins}m)"));
}
match info.docker_status {
crate::sandbox::detect::DockerStatus::Available => {
features.push("sandbox".to_string());
}
crate::sandbox::detect::DockerStatus::NotInstalled => {
features.push(format!("{yellow}sandbox (docker not installed){reset}"));
}
crate::sandbox::detect::DockerStatus::NotRunning => {
features.push(format!("{yellow}sandbox (docker not running){reset}"));
}
crate::sandbox::detect::DockerStatus::Disabled => {
// Don't show sandbox when disabled
}
}
if info.claude_code_enabled {
features.push("claude-code".to_string());
}
if info.routines_enabled {
features.push("routines".to_string());
}
if info.skills_enabled {
features.push("skills".to_string());
}
if !features.is_empty() {
println!(
" {dim}features{reset} {cyan}{}{reset}",
features.join(" ")
);
}
// Channels line
if !info.channels.is_empty() {
println!(
" {dim}channels{reset} {cyan}{}{reset}",
info.channels.join(" ")
);
}
// Gateway URL (highlighted)
// Gateway URL
if let Some(ref url) = info.gateway_url {
println!();
println!(" {dim}gateway{reset} {yellow_underline}{url}{reset}");
println!(
" {}{:<width$}{} {}{}{}",
fmt::dim(),
"gateway",
fmt::reset(),
fmt::link(),
url,
fmt::reset(),
width = KW,
);
}
// Tunnel URL
@@ -142,15 +109,140 @@ pub fn print_boot_screen(info: &BootInfo) {
let provider_tag = info
.tunnel_provider
.as_deref()
.map(|p| format!(" {dim}({p}){reset}"))
.map(|p| format!(" {}({}){}", fmt::dim(), p, fmt::reset()))
.unwrap_or_default();
println!(" {dim}tunnel{reset} {yellow_underline}{url}{reset}{provider_tag}");
println!(
" {}{:<width$}{} {}{}{}{}",
fmt::dim(),
"tunnel",
fmt::reset(),
fmt::link(),
url,
fmt::reset(),
provider_tag,
width = KW,
);
}
// Non-default channels (skip if only the default set)
let non_default: Vec<&str> = info
.channels
.iter()
.filter(|c| !matches!(c.as_str(), "repl" | "gateway"))
.map(|c| c.as_str())
.collect();
if !non_default.is_empty() {
println!(
" {}{:<width$}{} {}{}{}",
fmt::dim(),
"channels",
fmt::reset(),
fmt::accent(),
non_default.join(" "),
fmt::reset(),
width = KW,
);
}
// ── Tier 3: compact feature tags ──────────────────────────────────
let mut tags: Vec<String> = Vec::new();
// Database
if info.db_connected {
tags.push(format!("db:{}", info.db_backend));
}
// Tool count
if info.tool_count > 0 {
tags.push(format!("tools:{}", info.tool_count));
}
// Routines
if info.routines_enabled {
tags.push("routines".to_string());
}
// Heartbeat with interval
if info.heartbeat_enabled {
let interval = if info.heartbeat_interval_secs >= 3600
&& info.heartbeat_interval_secs.is_multiple_of(3600)
{
format!("{}h", info.heartbeat_interval_secs / 3600)
} else if info.heartbeat_interval_secs >= 60
&& info.heartbeat_interval_secs.is_multiple_of(60)
{
format!("{}m", info.heartbeat_interval_secs / 60)
} else {
format!("{}s", info.heartbeat_interval_secs)
};
tags.push(format!("heartbeat:{interval}"));
}
// Skills
if info.skills_enabled {
tags.push("skills".to_string());
}
// Sandbox / Docker
if info.sandbox_enabled {
let suffix = match info.docker_status {
crate::sandbox::detect::DockerStatus::Available => "",
crate::sandbox::detect::DockerStatus::NotRunning => ":stopped",
_ => ":unavail",
};
tags.push(format!("sandbox{suffix}"));
}
// Embeddings
if info.embeddings_enabled {
if let Some(ref provider) = info.embeddings_provider {
tags.push(format!("embeddings:{provider}"));
} else {
tags.push("embeddings".to_string());
}
}
// Claude Code bridge
if info.claude_code_enabled {
tags.push("claude-code".to_string());
}
if !tags.is_empty() {
println!(
" {}{:<width$}{} {}",
fmt::dim(),
"features",
fmt::reset(),
tags.join(" "),
width = KW,
);
}
// ── Footer ────────────────────────────────────────────────────────
println!();
println!("{border}");
println!();
println!(" /help for commands, /quit to exit");
// Startup elapsed
if let Some(elapsed) = info.startup_elapsed {
let millis = elapsed.as_millis();
let elapsed_str = if millis < 1000 {
format!("{millis}ms")
} else {
let secs = elapsed.as_secs_f64();
format!("{secs:.1}s")
};
println!(" {}ready in {}{}", fmt::dim(), elapsed_str, fmt::reset());
}
// Hint to run `ironclaw status` for full details
println!(
" {}Run `ironclaw status` for full system details.{}",
fmt::hint(),
fmt::reset()
);
println!();
}
@@ -187,6 +279,7 @@ mod tests {
],
tunnel_url: Some("https://abc123.ngrok.io".to_string()),
tunnel_provider: Some("ngrok".to_string()),
startup_elapsed: None,
};
// Should not panic
print_boot_screen(&info);
@@ -216,6 +309,7 @@ mod tests {
channels: vec![],
tunnel_url: None,
tunnel_provider: None,
startup_elapsed: None,
};
// Should not panic
print_boot_screen(&info);
@@ -245,6 +339,7 @@ mod tests {
channels: vec!["repl".to_string()],
tunnel_url: None,
tunnel_provider: None,
startup_elapsed: None,
};
// Should not panic
print_boot_screen(&info);
+25 -12
View File
@@ -568,14 +568,12 @@ impl Drop for PidLock {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::helpers::lock_env;
use std::process::Command;
use std::sync::Mutex;
use std::thread;
use std::time::{Duration, Instant};
use tempfile::tempdir;
static ENV_MUTEX: Mutex<()> = Mutex::new(());
#[test]
fn test_save_and_load_database_url() {
let dir = tempdir().unwrap();
@@ -669,8 +667,23 @@ INJECTED="pwned"#;
#[test]
fn test_ironclaw_env_path() {
let path = ironclaw_env_path();
assert!(path.ends_with(".ironclaw/.env"));
// Use compute_ironclaw_base_dir() directly to avoid LazyLock caching,
// which can be poisoned by whichever test initializes it first.
let _guard = lock_env();
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
// SAFETY: Under lock_env(), no concurrent env access.
unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") };
let path = compute_ironclaw_base_dir().join(".env");
assert!(
path.ends_with(".ironclaw/.env"),
"expected path ending with .ironclaw/.env, got: {}",
path.display()
);
if let Some(val) = old_val {
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) };
}
}
#[test]
@@ -836,7 +849,7 @@ INJECTED="pwned"#;
#[test]
fn test_libsql_autodetect_sets_backend_when_db_exists() {
let _guard = ENV_MUTEX.lock().unwrap();
let _guard = lock_env();
let old_val = std::env::var("DATABASE_BACKEND").ok();
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
unsafe { std::env::remove_var("DATABASE_BACKEND") };
@@ -907,7 +920,7 @@ INJECTED="pwned"#;
#[test]
fn test_libsql_autodetect_does_not_override_explicit_backend() {
let _guard = ENV_MUTEX.lock().unwrap();
let _guard = lock_env();
let old_val = std::env::var("DATABASE_BACKEND").ok();
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
unsafe { std::env::set_var("DATABASE_BACKEND", "postgres") };
@@ -1034,7 +1047,7 @@ INJECTED="pwned"#;
fn test_ironclaw_base_dir_default() {
// This test must run first (or in isolation) before the LazyLock is initialized.
// It verifies that when IRONCLAW_BASE_DIR is not set, the default path is used.
let _guard = ENV_MUTEX.lock().unwrap();
let _guard = lock_env();
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") };
@@ -1054,7 +1067,7 @@ INJECTED="pwned"#;
fn test_ironclaw_base_dir_env_override() {
// This test verifies that when IRONCLAW_BASE_DIR is set,
// the custom path is used. Must run before LazyLock is initialized.
let _guard = ENV_MUTEX.lock().unwrap();
let _guard = lock_env();
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/custom/ironclaw/path") };
@@ -1076,7 +1089,7 @@ INJECTED="pwned"#;
fn test_compute_base_dir_env_path_join() {
// Verifies that ironclaw_env_path correctly joins .env to the base dir.
// Uses compute_ironclaw_base_dir directly to avoid LazyLock caching.
let _guard = ENV_MUTEX.lock().unwrap();
let _guard = lock_env();
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/my/custom/dir") };
@@ -1098,7 +1111,7 @@ INJECTED="pwned"#;
#[test]
fn test_ironclaw_base_dir_empty_env() {
// Verifies that empty IRONCLAW_BASE_DIR falls back to default.
let _guard = ENV_MUTEX.lock().unwrap();
let _guard = lock_env();
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "") };
@@ -1120,7 +1133,7 @@ INJECTED="pwned"#;
#[test]
fn test_ironclaw_base_dir_special_chars() {
// Verifies that paths with special characters are handled correctly.
let _guard = ENV_MUTEX.lock().unwrap();
let _guard = lock_env();
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/tmp/test_with-special.chars") };
+6
View File
@@ -333,6 +333,12 @@ pub enum StatusUpdate {
},
/// Suggested follow-up messages for the user.
Suggestions { suggestions: Vec<String> },
/// Per-turn token usage and cost summary (shown as subtle metadata).
TurnCost {
input_tokens: u64,
output_tokens: u64,
cost_usd: String,
},
}
impl StatusUpdate {
+338 -126
View File
@@ -20,6 +20,7 @@
use std::borrow::Cow;
use std::io::{self, IsTerminal, Write};
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use async_trait::async_trait;
@@ -40,6 +41,7 @@ use tokio_stream::wrappers::ReceiverStream;
use crate::agent::truncate_for_preview;
use crate::bootstrap::ironclaw_base_dir;
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
use crate::cli::fmt;
use crate::error::ChannelError;
/// Max characters for tool result previews in the terminal.
@@ -119,7 +121,7 @@ impl Hinter for ReplHelper {
impl Highlighter for ReplHelper {
fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
Cow::Owned(format!("\x1b[90m{hint}\x1b[0m"))
Cow::Owned(format!("{}{hint}{}", fmt::dim(), fmt::reset()))
}
}
@@ -143,55 +145,207 @@ impl ConditionalEventHandler for EscInterruptHandler {
}
}
/// Approval action chosen by the interactive selector.
#[derive(Clone, Copy)]
enum ApprovalAction {
Approve,
Always,
Deny,
}
impl std::fmt::Display for ApprovalAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Approve => write!(f, "Approve (y)"),
Self::Always => write!(f, "Always approve (a)"),
Self::Deny => write!(f, "Deny (n)"),
}
}
}
impl ApprovalAction {
fn as_input(self) -> &'static str {
match self {
Self::Approve => "y",
Self::Always => "a",
Self::Deny => "n",
}
}
}
/// Interactive approval selector using crossterm raw mode.
/// Returns the approval action string ("y", "a", or "n").
fn run_approval_selector(allow_always: bool) -> Option<&'static str> {
use crossterm::{
cursor,
event::{self, Event as CtEvent, KeyCode as CtKeyCode, KeyEventKind},
execute,
terminal::{self, ClearType},
};
let options: Vec<ApprovalAction> = if allow_always {
vec![
ApprovalAction::Approve,
ApprovalAction::Always,
ApprovalAction::Deny,
]
} else {
vec![ApprovalAction::Approve, ApprovalAction::Deny]
};
let num = options.len();
let mut sel: usize = 0;
// Total lines: options + hint line
let total_lines = (num + 1) as u16;
let render = |sel: usize| {
let mut w = io::stderr();
let pipe = format!("{}{}", fmt::accent(), fmt::reset());
for (i, opt) in options.iter().enumerate() {
if i == sel {
let _ = write!(w, " {pipe} {}● {opt}{}\r\n", fmt::bold(), fmt::reset());
} else {
let _ = write!(w, " {pipe} {}○ {opt}{}\r\n", fmt::dim(), fmt::reset());
}
}
let _ = write!(
w,
" {}└{} {}↑↓ enter to select{}\r\n",
fmt::accent(),
fmt::reset(),
fmt::dim(),
fmt::reset()
);
let _ = w.flush();
};
let _ = terminal::enable_raw_mode();
render(sel);
let result = loop {
let Ok(evt) = event::read() else { break None };
if let CtEvent::Key(key) = evt {
if key.kind != KeyEventKind::Press {
continue;
}
match key.code {
CtKeyCode::Up | CtKeyCode::Char('k') => {
sel = if sel == 0 { num - 1 } else { sel - 1 };
}
CtKeyCode::Down | CtKeyCode::Char('j') => {
sel = (sel + 1) % num;
}
CtKeyCode::Enter => break Some(options[sel].as_input()),
CtKeyCode::Char('y') | CtKeyCode::Char('Y') => break Some("y"),
CtKeyCode::Char('a') | CtKeyCode::Char('A') if allow_always => break Some("a"),
CtKeyCode::Char('n') | CtKeyCode::Char('N') => break Some("n"),
CtKeyCode::Esc => break None,
_ => continue,
}
// Redraw: move up, clear, render
let mut w = io::stderr();
let _ = execute!(w, cursor::MoveUp(total_lines));
let _ = execute!(w, terminal::Clear(ClearType::FromCursorDown));
render(sel);
}
};
let _ = terminal::disable_raw_mode();
// Overwrite selector with the confirmed choice
let mut w = io::stderr();
let _ = execute!(w, cursor::MoveUp(total_lines));
let _ = execute!(w, terminal::Clear(ClearType::FromCursorDown));
let (label, color) = if let Some(action) = result {
let l = options
.iter()
.find(|o| o.as_input() == action)
.unwrap_or(&options[0]);
let c = if action == "n" {
fmt::error()
} else {
fmt::success()
};
(l.to_string(), c)
} else {
(ApprovalAction::Deny.to_string(), fmt::error())
};
let _ = writeln!(
w,
" {}└{} {color}● {label}{}",
fmt::accent(),
fmt::reset(),
fmt::reset()
);
result
}
/// Build a termimad skin with our color scheme.
fn make_skin() -> MadSkin {
let mut skin = MadSkin::default();
skin.set_headers_fg(termimad::crossterm::style::Color::Yellow);
skin.bold.set_fg(termimad::crossterm::style::Color::White);
skin.italic
.set_fg(termimad::crossterm::style::Color::Magenta);
skin.inline_code
.set_fg(termimad::crossterm::style::Color::Green);
skin.code_block
.set_fg(termimad::crossterm::style::Color::Green);
skin.set_headers_fg(crossterm::style::Color::Yellow);
skin.bold.set_fg(crossterm::style::Color::White);
skin.italic.set_fg(crossterm::style::Color::Magenta);
skin.inline_code.set_fg(crossterm::style::Color::Green);
skin.code_block.set_fg(crossterm::style::Color::Green);
skin.code_block.left_margin = 2;
skin
}
/// Truncate a string to `max_chars` using character boundaries.
///
/// For strings longer than `max_chars`, shows the first half and last half
/// separated by `...` so both ends are visible.
fn smart_truncate(s: &str, max_chars: usize) -> Cow<'_, str> {
let char_count = s.chars().count();
if char_count <= max_chars {
return Cow::Borrowed(s);
}
// Account for the 3-char "..." separator
let budget = max_chars.saturating_sub(3);
let head_len = budget / 2;
let tail_len = budget - head_len;
let head: String = s.chars().take(head_len).collect();
let tail: String = s
.chars()
.skip(char_count.saturating_sub(tail_len))
.collect();
Cow::Owned(format!("{head}...{tail}"))
}
/// Format JSON params as `key: value` lines for the approval card.
fn format_json_params(params: &serde_json::Value, indent: &str) -> String {
let max_val_len = fmt::term_width().saturating_sub(8);
match params {
serde_json::Value::Object(map) => {
let mut lines = Vec::new();
for (key, value) in map {
let val_str = match value {
serde_json::Value::String(s) => {
let display = if s.len() > 120 { &s[..120] } else { s };
format!("\x1b[32m\"{display}\"\x1b[0m")
let display = smart_truncate(s, max_val_len);
format!("{}\"{display}\"{}", fmt::success(), fmt::reset())
}
other => {
let rendered = other.to_string();
if rendered.len() > 120 {
format!("{}...", &rendered[..120])
} else {
rendered
}
smart_truncate(&rendered, max_val_len).into_owned()
}
};
lines.push(format!("{indent}\x1b[36m{key}\x1b[0m: {val_str}"));
lines.push(format!(
"{indent}{}{key}{}: {val_str}",
fmt::accent(),
fmt::reset()
));
}
lines.join("\n")
}
other => {
let pretty = serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string());
let truncated = if pretty.len() > 300 {
format!("{}...", &pretty[..300])
} else {
pretty
};
let truncated = smart_truncate(&pretty, 300);
truncated
.lines()
.map(|l| format!("{indent}\x1b[90m{l}\x1b[0m"))
.map(|l| format!("{indent}{}{l}{}", fmt::dim(), fmt::reset()))
.collect::<Vec<_>>()
.join("\n")
}
@@ -210,6 +364,12 @@ pub struct ReplChannel {
is_streaming: Arc<AtomicBool>,
/// When true, the one-liner startup banner is suppressed (boot screen shown instead).
suppress_banner: Arc<AtomicBool>,
/// Sender to inject messages into the agent loop (set after start()).
msg_tx: Arc<Mutex<Option<mpsc::Sender<IncomingMessage>>>>,
/// When true, the readline thread must yield stdin (approval selector or agent processing).
stdin_locked: Arc<AtomicBool>,
/// Number of transient status lines (Thinking) to erase on next output.
transient_lines: std::sync::atomic::AtomicU8,
}
impl ReplChannel {
@@ -226,6 +386,9 @@ impl ReplChannel {
debug_mode: Arc::new(AtomicBool::new(false)),
is_streaming: Arc::new(AtomicBool::new(false)),
suppress_banner: Arc::new(AtomicBool::new(false)),
msg_tx: Arc::new(Mutex::new(None)),
stdin_locked: Arc::new(AtomicBool::new(false)),
transient_lines: std::sync::atomic::AtomicU8::new(0),
}
}
@@ -242,6 +405,9 @@ impl ReplChannel {
debug_mode: Arc::new(AtomicBool::new(false)),
is_streaming: Arc::new(AtomicBool::new(false)),
suppress_banner: Arc::new(AtomicBool::new(false)),
msg_tx: Arc::new(Mutex::new(None)),
stdin_locked: Arc::new(AtomicBool::new(false)),
transient_lines: std::sync::atomic::AtomicU8::new(0),
}
}
@@ -253,6 +419,17 @@ impl ReplChannel {
fn is_debug(&self) -> bool {
self.debug_mode.load(Ordering::Relaxed)
}
/// Erase transient status lines (Thinking indicators) from the terminal.
fn clear_transient(&self) {
use crossterm::{cursor, execute, terminal};
let n = self.transient_lines.swap(0, Ordering::Relaxed);
if n > 0 {
let mut stderr = io::stderr();
let _ = execute!(stderr, cursor::MoveUp(n as u16));
let _ = execute!(stderr, terminal::Clear(terminal::ClearType::FromCursorDown));
}
}
}
impl Default for ReplChannel {
@@ -262,33 +439,30 @@ impl Default for ReplChannel {
}
fn print_help() {
// Bold white for section headers, bold cyan for commands, dim gray for descriptions
let h = "\x1b[1m"; // bold (section headers)
let c = "\x1b[1;36m"; // bold cyan (commands)
let d = "\x1b[90m"; // dim gray (descriptions)
let r = "\x1b[0m"; // reset
let h = fmt::bold();
let c = fmt::bold_accent();
let d = fmt::dim();
let r = fmt::reset();
let hi = fmt::hint();
println!();
println!(" {h}IronClaw REPL{r}");
println!();
println!(" {h}Commands{r}");
println!(" {c}/help{r} {d}show this help{r}");
println!(" {c}/debug{r} {d}toggle verbose output{r}");
println!(" {c}/quit{r} {c}/exit{r} {d}exit the repl{r}");
println!(" {h}Quick start{r}");
println!(" {c}/new{r} {hi}Start a new thread{r}");
println!(" {c}/compact{r} {hi}Compress context window{r}");
println!(" {c}/quit{r} {hi}Exit{r}");
println!();
println!(" {h}Conversation{r}");
println!(" {c}/undo{r} {d}undo the last turn{r}");
println!(" {c}/redo{r} {d}redo an undone turn{r}");
println!(" {c}/clear{r} {d}clear conversation{r}");
println!(" {c}/compact{r} {d}compact context window{r}");
println!(" {c}/new{r} {d}new conversation thread{r}");
println!(" {c}/interrupt{r} {d}stop current operation{r}");
println!(" {c}esc{r} {d}stop current operation{r}");
println!();
println!(" {h}Approval responses{r}");
println!(" {c}yes{r} ({c}y{r}) {d}approve tool execution{r}");
println!(" {c}no{r} ({c}n{r}) {d}deny tool execution{r}");
println!(" {c}always{r} ({c}a{r}) {d}approve for this session{r}");
println!(" {h}All commands{r}");
println!(
" {d}Conversation{r} {c}/new{r} {c}/clear{r} {c}/compact{r} {c}/undo{r} {c}/redo{r} {c}/summarize{r} {c}/suggest{r}"
);
println!(" {d}Threads{r} {c}/thread{r} {c}/resume{r} {c}/list{r}");
println!(" {d}Execution{r} {c}/interrupt{r} {d}(esc){r} {c}/cancel{r}");
println!(
" {d}System{r} {c}/tools{r} {c}/model{r} {c}/version{r} {c}/status{r} {c}/debug{r} {c}/heartbeat{r}"
);
println!(" {d}Session{r} {c}/help{r} {c}/quit{r}");
println!();
}
@@ -305,10 +479,15 @@ impl Channel for ReplChannel {
async fn start(&self) -> Result<MessageStream, ChannelError> {
let (tx, rx) = mpsc::channel(32);
// Store tx so send_status can inject approval responses directly
if let Ok(mut guard) = self.msg_tx.lock() {
*guard = Some(tx.clone());
}
let single_message = self.single_message.clone();
let user_id = self.user_id.clone();
let debug_mode = Arc::clone(&self.debug_mode);
let suppress_banner = Arc::clone(&self.suppress_banner);
let stdin_locked = Arc::clone(&self.stdin_locked);
let esc_interrupt_triggered_for_thread = Arc::new(AtomicBool::new(false));
std::thread::spawn(move || {
@@ -357,18 +536,33 @@ impl Channel for ReplChannel {
let _ = rl.load_history(&hist_path);
if !suppress_banner.load(Ordering::Relaxed) {
println!("\x1b[1mIronClaw\x1b[0m /help for commands, /quit to exit");
println!(
"{}IronClaw{} /help for commands, /quit to exit",
fmt::bold(),
fmt::reset()
);
println!();
}
loop {
// Yield stdin while approval selector or agent processing locks it
while stdin_locked.load(Ordering::Relaxed) {
std::thread::sleep(std::time::Duration::from_millis(50));
}
let prompt = if debug_mode.load(Ordering::Relaxed) {
"\x1b[33m[debug]\x1b[0m \x1b[1;36m\u{203A}\x1b[0m "
format!(
"{}[debug]{} {}\u{203A}{} ",
fmt::warning(),
fmt::reset(),
fmt::bold_accent(),
fmt::reset()
)
} else {
"\x1b[1;36m\u{203A}\x1b[0m "
format!("{}\u{203A}{} ", fmt::bold_accent(), fmt::reset())
};
match rl.readline(prompt) {
match rl.readline(&prompt) {
Ok(line) => {
let line = line.trim();
if line.is_empty() {
@@ -394,9 +588,9 @@ impl Channel for ReplChannel {
let current = debug_mode.load(Ordering::Relaxed);
debug_mode.store(!current, Ordering::Relaxed);
if !current {
println!("\x1b[90mdebug mode on\x1b[0m");
println!("{}debug mode on{}", fmt::dim(), fmt::reset());
} else {
println!("\x1b[90mdebug mode off\x1b[0m");
println!("{}debug mode off{}", fmt::dim(), fmt::reset());
}
continue;
}
@@ -405,7 +599,11 @@ impl Channel for ReplChannel {
let msg =
IncomingMessage::new("repl", &user_id, line).with_timezone(&sys_tz);
// Lock stdin before sending so readline doesn't restart
// while the agent is processing (approval selector needs stdin)
stdin_locked.store(true, Ordering::Relaxed);
if tx.blocking_send(msg).is_err() {
stdin_locked.store(false, Ordering::Relaxed);
break;
}
}
@@ -456,21 +654,23 @@ impl Channel for ReplChannel {
_msg: &IncomingMessage,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
let width = crossterm::terminal::size()
.map(|(w, _)| w as usize)
.unwrap_or(80);
let width = fmt::term_width();
// If we were streaming, the content was already printed via StreamChunk.
// Just finish the line and reset.
if self.is_streaming.swap(false, Ordering::Relaxed) {
println!();
println!();
self.stdin_locked.store(false, Ordering::Relaxed);
return Ok(());
}
// Clear any leftover thinking indicators
self.clear_transient();
// Dim separator line before the response
let sep_width = width.min(80);
eprintln!("\x1b[90m{}\x1b[0m", "\u{2500}".repeat(sep_width));
eprintln!("{}", fmt::separator(sep_width));
// Render markdown
let skin = make_skin();
@@ -478,6 +678,8 @@ impl Channel for ReplChannel {
print!("{text}");
println!();
// Unlock stdin so readline can resume
self.stdin_locked.store(false, Ordering::Relaxed);
Ok(())
}
@@ -490,31 +692,34 @@ impl Channel for ReplChannel {
match status {
StatusUpdate::Thinking(msg) => {
self.clear_transient();
let display = truncate_for_preview(&msg, CLI_STATUS_MAX);
eprintln!(" \x1b[90m\u{25CB} {display}\x1b[0m");
eprintln!(" {}\u{25CB} {display}{}", fmt::dim(), fmt::reset());
self.transient_lines.store(1, Ordering::Relaxed);
}
StatusUpdate::ToolStarted { name } => {
eprintln!(" \x1b[33m\u{25CB} {name}\x1b[0m");
self.clear_transient();
eprintln!(" {}\u{25CB} {name}{}", fmt::dim(), fmt::reset());
self.transient_lines.store(1, Ordering::Relaxed);
}
StatusUpdate::ToolCompleted { name, success, .. } => {
self.clear_transient();
if success {
eprintln!(" \x1b[32m\u{25CF} {name}\x1b[0m");
eprintln!(" {}\u{25CF} {name}{}", fmt::success(), fmt::reset());
} else {
eprintln!(" \x1b[31m\u{2717} {name} (failed)\x1b[0m");
eprintln!(" {}\u{2717} {name} (failed){}", fmt::error(), fmt::reset());
}
}
StatusUpdate::ToolResult { name: _, preview } => {
let display = truncate_for_preview(&preview, CLI_TOOL_RESULT_MAX);
eprintln!(" \x1b[90m{display}\x1b[0m");
eprintln!(" {}{display}{}", fmt::dim(), fmt::reset());
}
StatusUpdate::StreamChunk(chunk) => {
// Print separator on the false-to-true transition
if !self.is_streaming.swap(true, Ordering::Relaxed) {
let width = crossterm::terminal::size()
.map(|(w, _)| w as usize)
.unwrap_or(80);
let sep_width = width.min(80);
eprintln!("\x1b[90m{}\x1b[0m", "\u{2500}".repeat(sep_width));
self.clear_transient();
let sep_width = fmt::term_width().min(80);
eprintln!("{}", fmt::separator(sep_width));
}
print!("{chunk}");
let _ = io::stdout().flush();
@@ -525,73 +730,67 @@ impl Channel for ReplChannel {
browse_url,
} => {
eprintln!(
" \x1b[36m[job]\x1b[0m {title} \x1b[90m({job_id})\x1b[0m \x1b[4m{browse_url}\x1b[0m"
" {}[job]{} {title} {}({job_id}){} {}{browse_url}{}",
fmt::accent(),
fmt::reset(),
fmt::dim(),
fmt::reset(),
fmt::link(),
fmt::reset()
);
}
StatusUpdate::Status(msg) => {
if debug || msg.contains("approval") || msg.contains("Approval") {
let display = truncate_for_preview(&msg, CLI_STATUS_MAX);
eprintln!(" \x1b[90m{display}\x1b[0m");
eprintln!(" {}{display}{}", fmt::dim(), fmt::reset());
}
}
StatusUpdate::ApprovalNeeded {
request_id,
request_id: _,
tool_name,
description,
description: _,
parameters,
allow_always,
} => {
let term_width = crossterm::terminal::size()
.map(|(w, _)| w as usize)
.unwrap_or(80);
let box_width = (term_width.saturating_sub(4)).clamp(40, 60);
self.clear_transient();
let pipe = format!("{}{}", fmt::accent(), fmt::reset());
// Short request ID for the bottom border
let short_id = if request_id.len() > 8 {
&request_id[..8]
} else {
&request_id
};
// Top border: ┌ tool_name requires approval ───
let top_label = format!(" {tool_name} requires approval ");
let top_fill = box_width.saturating_sub(top_label.len() + 1);
let top_border = format!(
"\u{250C}\x1b[33m{top_label}\x1b[0m{}",
"\u{2500}".repeat(top_fill)
// Header: ◆ tool requires approval
eprintln!();
eprintln!(
" {}\u{25C6} {}{tool_name}{} requires approval",
fmt::accent(),
fmt::bold(),
fmt::reset()
);
// Bottom border: └─ short_id ─────
let bot_label = format!(" {short_id} ");
let bot_fill = box_width.saturating_sub(bot_label.len() + 2);
let bot_border = format!(
"\u{2514}\u{2500}\x1b[90m{bot_label}\x1b[0m{}",
"\u{2500}".repeat(bot_fill)
);
eprintln!();
eprintln!(" {top_border}");
eprintln!(" \u{2502} \x1b[90m{description}\x1b[0m");
eprintln!(" \u{2502}");
// Params
let param_lines = format_json_params(&parameters, " \u{2502} ");
// The format_json_params already includes the indent prefix
// but we need to handle the case where each line already starts with it
for line in param_lines.lines() {
eprintln!("{line}");
// Params: │ key value
let param_lines = format_json_params(&parameters, &format!(" {pipe} "));
if !param_lines.is_empty() {
eprintln!(" {pipe}");
for line in param_lines.lines() {
eprintln!("{line}");
}
}
eprintln!(" \u{2502}");
if allow_always {
eprintln!(
" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[34malways\x1b[0m (a) / \x1b[31mno\x1b[0m (n)"
);
} else {
eprintln!(" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[31mno\x1b[0m (n)");
}
eprintln!(" {bot_border}");
eprintln!();
eprintln!(" {pipe}");
// Run interactive selector directly from send_status
// stdin is already locked by Thinking/ToolStarted, so the
// readline thread is not competing for stdin.
let msg_tx = Arc::clone(&self.msg_tx);
let user_id = self.user_id.clone();
let lock_flag = Arc::clone(&self.stdin_locked);
tokio::task::spawn_blocking(move || {
let action = run_approval_selector(allow_always).unwrap_or("n");
// Unlock stdin so readline can resume after approval
lock_flag.store(false, Ordering::Relaxed);
let Ok(guard) = msg_tx.lock() else {
return;
};
if let Some(tx) = guard.as_ref() {
let msg = IncomingMessage::new("repl", &user_id, action);
let _ = tx.blocking_send(msg);
}
});
}
StatusUpdate::AuthRequired {
extension_name,
@@ -600,12 +799,16 @@ impl Channel for ReplChannel {
..
} => {
eprintln!();
eprintln!("\x1b[33m Authentication required for {extension_name}\x1b[0m");
eprintln!(
"{} Authentication required for {extension_name}{}",
fmt::warning(),
fmt::reset()
);
if let Some(ref instr) = instructions {
eprintln!(" {instr}");
}
if let Some(ref url) = setup_url {
eprintln!(" \x1b[4m{url}\x1b[0m");
eprintln!(" {}{url}{}", fmt::link(), fmt::reset());
}
eprintln!();
}
@@ -615,21 +818,32 @@ impl Channel for ReplChannel {
message,
} => {
if success {
eprintln!("\x1b[32m {extension_name}: {message}\x1b[0m");
eprintln!(
"{} {extension_name}: {message}{}",
fmt::success(),
fmt::reset()
);
} else {
eprintln!("\x1b[31m {extension_name}: {message}\x1b[0m");
eprintln!(
"{} {extension_name}: {message}{}",
fmt::error(),
fmt::reset()
);
}
}
StatusUpdate::ImageGenerated { path, .. } => {
if let Some(ref p) = path {
eprintln!("\x1b[36m [image] {p}\x1b[0m");
eprintln!("{} [image] {p}{}", fmt::accent(), fmt::reset());
} else {
eprintln!("\x1b[36m [image generated]\x1b[0m");
eprintln!("{} [image generated]{}", fmt::accent(), fmt::reset());
}
}
StatusUpdate::Suggestions { .. } => {
// Suggestions are only rendered by the web gateway
}
StatusUpdate::TurnCost { .. } => {
// Cost display is handled by the TUI channel
}
}
Ok(())
}
@@ -640,11 +854,9 @@ impl Channel for ReplChannel {
response: OutgoingResponse,
) -> Result<(), ChannelError> {
let skin = make_skin();
let width = crossterm::terminal::size()
.map(|(w, _)| w as usize)
.unwrap_or(80);
let width = fmt::term_width();
eprintln!("\x1b[34m\u{25CF}\x1b[0m notification");
eprintln!("{}\u{25CF}{} notification", fmt::accent(), fmt::reset());
let text = termimad::FmtText::from(&skin, &response.content, Some(width));
eprint!("{text}");
eprintln!();
+1 -1
View File
@@ -117,7 +117,7 @@ async fn register_channel(
wasm_router: &Arc<WasmChannelRouter>,
) -> (String, Box<dyn crate::channels::Channel>) {
let channel_name = loaded.name().to_string();
tracing::info!("Loaded WASM channel: {}", channel_name);
tracing::debug!("Loaded WASM channel: {}", channel_name);
let owner_actor_id = config
.channels
.wasm_channel_owner_ids
+2 -2
View File
@@ -3059,8 +3059,8 @@ fn status_to_wit(
},
metadata_json,
},
// Suggestions are web-gateway-only; skip for WASM channels
StatusUpdate::Suggestions { .. } => return None,
// Suggestions and turn cost are web-gateway-only; skip for WASM channels
StatusUpdate::Suggestions { .. } | StatusUpdate::TurnCost { .. } => return None,
})
}
+10
View File
@@ -415,6 +415,16 @@ impl Channel for GatewayChannel {
suggestions,
thread_id,
},
StatusUpdate::TurnCost {
input_tokens,
output_tokens,
cost_usd,
} => SseEvent::TurnCost {
input_tokens,
output_tokens,
cost_usd,
thread_id,
},
};
self.state.sse.broadcast(event);
+7 -1
View File
@@ -1822,7 +1822,13 @@ async fn memory_write_handler(
"Workspace not available".to_string(),
))?;
// Route through layer-aware methods when a layer is specified
// Route through layer-aware methods when a layer is specified.
//
// Note: unlike MemoryWriteTool, this endpoint does NOT block writes to
// identity files (IDENTITY.md, SOUL.md, etc.). The HTTP API is an
// authenticated admin interface; the supervisor uses it to seed identity
// files at startup. Identity-file protection is enforced at the tool
// layer (LLM-facing) where the write originates from an untrusted agent.
if let Some(ref layer_name) = req.layer {
let result = if req.append {
workspace
+1
View File
@@ -144,6 +144,7 @@ impl SseManager {
SseEvent::Heartbeat => "heartbeat",
SseEvent::ImageGenerated { .. } => "image_generated",
SseEvent::Suggestions { .. } => "suggestions",
SseEvent::TurnCost { .. } => "turn_cost",
SseEvent::ExtensionStatus { .. } => "extension_status",
};
Ok(Event::default().event(event_type).data(data))
+582 -84
View File
@@ -61,8 +61,16 @@ if (mql.addEventListener) {
mql.addListener(onSchemeChange);
}
// Bind theme toggle button (CSP-compliant — no inline onclick).
// Bind theme toggle buttons (CSP-compliant — no inline onclick).
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
document.getElementById('settings-theme-toggle')?.addEventListener('click', () => {
toggleTheme();
const btn = document.getElementById('settings-theme-toggle');
if (btn) {
const mode = localStorage.getItem('ironclaw-theme') || 'system';
btn.textContent = 'Theme: ' + mode.charAt(0).toUpperCase() + mode.slice(1);
}
});
let token = '';
let eventSource = null;
@@ -87,6 +95,19 @@ let authFlowPending = false;
let _ghostSuggestion = '';
let currentSettingsSubtab = 'inference';
// --- Streaming Debounce State ---
let _streamBuffer = '';
let _streamDebounceTimer = null;
const STREAM_DEBOUNCE_MS = 50;
// --- Connection Status Banner State ---
let _connectionLostTimer = null;
let _connectionLostAt = null;
let _reconnectAttempts = 0;
// --- Send Cooldown State ---
let _sendCooldown = false;
// --- Slash Commands ---
const SLASH_COMMANDS = [
@@ -126,12 +147,36 @@ function authenticate() {
return;
}
// Loading state for Connect button
const connectBtn = document.getElementById('auth-connect-btn');
if (connectBtn) {
connectBtn.disabled = true;
connectBtn.textContent = 'Connecting...';
}
// Test the token against the health-ish endpoint (chat/threads requires auth)
apiFetch('/api/chat/threads')
.then(() => {
sessionStorage.setItem('ironclaw_token', token);
document.getElementById('auth-screen').style.display = 'none';
document.getElementById('app').style.display = 'flex';
const authScreen = document.getElementById('auth-screen');
const app = document.getElementById('app');
// Cross-fade: fade out auth screen, then show app
if (authScreen) authScreen.style.opacity = '0';
// Show app container (invisible — opacity:0 in CSS) so layout computes
app.style.display = 'flex';
// Position tab indicator instantly (no transition) before fade-in
const indicator = document.getElementById('tab-indicator');
if (indicator) indicator.style.transition = 'none';
updateTabIndicator();
// Force layout so the instant position is applied, then restore transition
if (indicator) {
void indicator.offsetLeft;
indicator.style.transition = '';
}
// Now fade in
app.classList.add('visible');
// Hide auth screen after fade-out transition completes
setTimeout(() => { if (authScreen) authScreen.style.display = 'none'; }, 300);
// Strip token and log_level from URL so they're not visible in the address bar
const cleaned = new URL(window.location);
const urlLogLevel = cleaned.searchParams.get('log_level');
@@ -155,8 +200,14 @@ function authenticate() {
.catch(() => {
sessionStorage.removeItem('ironclaw_token');
document.getElementById('auth-screen').style.display = '';
document.getElementById('auth-screen').style.opacity = '';
document.getElementById('app').style.display = 'none';
document.getElementById('auth-error').textContent = I18n.t('auth.errorInvalid');
// Reset Connect button on error
if (connectBtn) {
connectBtn.disabled = false;
connectBtn.textContent = 'Connect';
}
});
}
@@ -164,29 +215,8 @@ document.getElementById('token-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') authenticate();
});
// --- Static element event bindings (CSP-compliant, no inline handlers) ---
document.getElementById('auth-connect-btn').addEventListener('click', () => authenticate());
document.getElementById('restart-overlay').addEventListener('click', () => cancelRestart());
document.getElementById('restart-close-btn').addEventListener('click', () => cancelRestart());
document.getElementById('restart-cancel-btn').addEventListener('click', () => cancelRestart());
document.getElementById('restart-confirm-btn').addEventListener('click', () => confirmRestart());
document.getElementById('language-btn').addEventListener('click', () => toggleLanguageMenu());
// Language option clicks handled by delegated data-action="switch-language" handler.
document.getElementById('restart-btn').addEventListener('click', () => triggerRestart());
document.getElementById('thread-new-btn').addEventListener('click', () => createNewThread());
document.getElementById('thread-toggle-btn').addEventListener('click', () => toggleThreadSidebar());
document.getElementById('assistant-thread').addEventListener('click', () => switchToAssistant());
document.getElementById('send-btn').addEventListener('click', () => sendMessage());
document.getElementById('memory-edit-btn').addEventListener('click', () => startMemoryEdit());
document.getElementById('memory-save-btn').addEventListener('click', () => saveMemoryEdit());
document.getElementById('memory-cancel-btn').addEventListener('click', () => cancelMemoryEdit());
document.getElementById('logs-server-level').addEventListener('change', function() { setServerLogLevel(this.value); });
document.getElementById('logs-pause-btn').addEventListener('click', () => toggleLogsPause());
document.getElementById('logs-clear-btn').addEventListener('click', () => clearLogs());
document.getElementById('wasm-install-btn').addEventListener('click', () => installWasmExtension());
document.getElementById('mcp-add-btn').addEventListener('click', () => addMcpServer());
document.getElementById('skill-search-btn').addEventListener('click', () => searchClawHub());
document.getElementById('skill-install-btn').addEventListener('click', () => installSkillFromForm());
// Note: main event listener registration is at the bottom of this file (search
// "Event Listener Registration"). Do NOT add duplicate listeners here.
// Auto-authenticate from URL param or saved session
(function autoAuth() {
@@ -221,7 +251,9 @@ function apiFetch(path, options) {
return fetch(path, opts).then((res) => {
if (!res.ok) {
return res.text().then(function(body) {
throw new Error(body || (res.status + ' ' + res.statusText));
const err = new Error(body || (res.status + ' ' + res.statusText));
err.status = res.status;
throw err;
});
}
if (res.status === 204) return null;
@@ -327,6 +359,25 @@ function connectSSE() {
eventSource.onopen = () => {
document.getElementById('sse-dot').classList.remove('disconnected');
document.getElementById('sse-status').textContent = I18n.t('status.connected');
_reconnectAttempts = 0;
// Dismiss connection-lost banner and show reconnected flash
if (_connectionLostTimer) {
clearTimeout(_connectionLostTimer);
_connectionLostTimer = null;
}
const lostBanner = document.getElementById('connection-banner');
if (lostBanner) {
const wasDisconnectedLong = _connectionLostAt && (Date.now() - _connectionLostAt > 10000);
lostBanner.textContent = 'Reconnected';
lostBanner.className = 'connection-banner connection-banner-success';
setTimeout(() => { lostBanner.remove(); }, 2000);
_connectionLostAt = null;
// If disconnected >10s, reload chat history to catch missed messages
if (wasDisconnectedLong && currentThreadId) {
loadHistory();
}
}
// If we were restarting, close the modal and reset button now that server is back
if (isRestarting) {
@@ -347,8 +398,28 @@ function connectSSE() {
};
eventSource.onerror = () => {
_reconnectAttempts++;
document.getElementById('sse-dot').classList.add('disconnected');
document.getElementById('sse-status').textContent = I18n.t('status.reconnecting');
// Update existing banner with attempt count
const existingBanner = document.getElementById('connection-banner');
if (existingBanner && existingBanner.classList.contains('connection-banner-warning')) {
existingBanner.textContent = 'Connection lost. Reconnecting... (attempt ' + _reconnectAttempts + ')';
}
// Start connection-lost banner timer (3s delay)
if (!_connectionLostTimer && !existingBanner) {
_connectionLostAt = _connectionLostAt || Date.now();
_connectionLostTimer = setTimeout(() => {
_connectionLostTimer = null;
// Only show if still disconnected
const dot = document.getElementById('sse-dot');
if (dot?.classList.contains('disconnected')) {
showConnectionBanner('Connection lost. Reconnecting... (attempt ' + _reconnectAttempts + ')', 'warning');
}
}, 3000);
}
};
eventSource.addEventListener('response', (e) => {
@@ -360,6 +431,19 @@ function connectSSE() {
}
return;
}
// Flush any remaining streaming buffer
if (_streamDebounceTimer) {
clearInterval(_streamDebounceTimer);
_streamDebounceTimer = null;
}
if (_streamBuffer) {
appendToLastAssistant(_streamBuffer);
_streamBuffer = '';
}
// Remove streaming attribute from active assistant message
const streamingMsg = document.querySelector('.message.assistant[data-streaming="true"]');
if (streamingMsg) streamingMsg.removeAttribute('data-streaming');
finalizeActivityGroup();
addMessage('assistant', data.content);
enableChatInput();
@@ -417,7 +501,31 @@ function connectSSE() {
const data = JSON.parse(e.data);
if (!isCurrentThread(data.thread_id)) return;
finalizeActivityGroup();
appendToLastAssistant(data.content);
// Mark the active assistant message as streaming
const container = document.getElementById('chat-messages');
let lastAssistant = container.querySelector('.message.assistant:last-of-type');
if (!lastAssistant) {
addMessage('assistant', '');
lastAssistant = container.querySelector('.message.assistant:last-of-type');
}
if (lastAssistant) lastAssistant.setAttribute('data-streaming', 'true');
// Accumulate chunks and debounce rendering at 50ms intervals
_streamBuffer += data.content;
// Force flush when buffer exceeds 10K chars to prevent memory buildup
if (_streamBuffer.length > 10000) {
appendToLastAssistant(_streamBuffer);
_streamBuffer = '';
}
if (!_streamDebounceTimer) {
_streamDebounceTimer = setInterval(() => {
if (_streamBuffer) {
appendToLastAssistant(_streamBuffer);
_streamBuffer = '';
}
}, STREAM_DEBOUNCE_MS);
}
});
eventSource.addEventListener('status', (e) => {
@@ -487,6 +595,22 @@ function connectSSE() {
}
});
eventSource.addEventListener('turn_cost', (e) => {
const event = JSON.parse(e.data);
if (!isCurrentThread(event.thread_id)) return;
// Add cost badge below last assistant message
const messages = document.querySelectorAll('.message.assistant');
const lastMsg = messages[messages.length - 1];
const tokens = (event.input_tokens || 0) + (event.output_tokens || 0);
if (lastMsg && tokens > 0) {
const badge = document.createElement('div');
badge.className = 'turn-cost-badge';
const cost = event.cost_usd ? ' \u00b7 ' + event.cost_usd : '';
badge.textContent = tokens.toLocaleString() + ' tokens' + cost;
lastMsg.appendChild(badge);
}
});
// Job event listeners (activity stream for all sandbox jobs)
const jobEventTypes = [
'job_message', 'job_tool_use', 'job_tool_result',
@@ -578,6 +702,7 @@ function clearSuggestionChips() {
function sendMessage() {
clearSuggestionChips();
removeWelcomeCard();
const input = document.getElementById('chat-input');
if (authFlowPending) {
showToast('Complete the auth step before sending chat messages.', 'info');
@@ -589,10 +714,11 @@ function sendMessage() {
console.warn('sendMessage: no thread selected, ignoring');
return;
}
if (_sendCooldown) return;
const content = input.value.trim();
if (!content && stagedImages.length === 0) return;
addMessage('user', content || '(images attached)');
const userMsg = addMessage('user', content || '(images attached)');
input.value = '';
autoResizeTextarea(input);
input.focus();
@@ -608,7 +734,33 @@ function sendMessage() {
method: 'POST',
body: body,
}).catch((err) => {
addMessage('system', 'Failed to send: ' + err.message);
// Handle rate limiting (429)
if (err.status === 429) {
showToast('Rate limited. Please wait.', 'error');
_sendCooldown = true;
const sendBtn = document.getElementById('send-btn');
if (sendBtn) sendBtn.disabled = true;
setTimeout(() => {
_sendCooldown = false;
if (sendBtn) sendBtn.disabled = false;
}, 2000);
}
// Keep the user message in DOM, add a retry link
if (userMsg) {
userMsg.classList.add('send-failed');
userMsg.style.borderStyle = 'dashed';
const retryLink = document.createElement('a');
retryLink.className = 'retry-link';
retryLink.href = '#';
retryLink.textContent = 'Retry';
retryLink.addEventListener('click', (e) => {
e.preventDefault();
if (userMsg.parentNode) userMsg.parentNode.removeChild(userMsg);
input.value = content;
sendMessage();
});
userMsg.appendChild(retryLink);
}
});
}
@@ -887,11 +1039,36 @@ function copyMessage(btn) {
});
}
let _lastMessageDate = null;
function maybeInsertTimeSeparator(container, timestamp) {
const date = timestamp ? new Date(timestamp) : new Date();
const dateStr = date.toDateString();
if (_lastMessageDate === dateStr) return;
_lastMessageDate = dateStr;
const now = new Date();
const today = now.toDateString();
const yesterday = new Date(now.getTime() - 86400000).toDateString();
let label;
if (dateStr === today) label = 'Today';
else if (dateStr === yesterday) label = 'Yesterday';
else label = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
const sep = document.createElement('div');
sep.className = 'time-separator';
sep.textContent = label;
container.appendChild(sep);
}
function addMessage(role, content) {
const container = document.getElementById('chat-messages');
maybeInsertTimeSeparator(container);
const div = createMessageElement(role, content);
container.appendChild(div);
container.scrollTop = container.scrollHeight;
return div;
}
function appendToLastAssistant(chunk) {
@@ -905,6 +1082,14 @@ function appendToLastAssistant(chunk) {
const content = last.querySelector('.message-content');
if (content) {
content.innerHTML = renderMarkdown(raw);
// Syntax highlighting for code blocks
if (typeof hljs !== 'undefined') {
requestAnimationFrame(() => {
content.querySelectorAll('pre code').forEach(block => {
hljs.highlightElement(block);
});
});
}
}
container.scrollTop = container.scrollHeight;
} else {
@@ -992,16 +1177,14 @@ function addToolCard(name) {
const body = document.createElement('div');
body.className = 'activity-tool-body';
body.style.display = 'none';
const output = document.createElement('pre');
output.className = 'activity-tool-output';
body.appendChild(output);
header.addEventListener('click', () => {
const isOpen = body.style.display !== 'none';
body.style.display = isOpen ? 'none' : 'block';
chevron.classList.toggle('expanded', !isOpen);
body.classList.toggle('expanded');
chevron.classList.toggle('expanded', body.classList.contains('expanded'));
});
card.appendChild(header);
@@ -1060,7 +1243,7 @@ function completeToolCard(name, success, error, parameters) {
// Auto-expand so the error is immediately visible
const body = entry.card.querySelector('.activity-tool-body');
const chevron = entry.card.querySelector('.activity-tool-chevron');
if (body) body.style.display = 'block';
if (body) body.classList.add('expanded');
if (chevron) chevron.classList.add('expanded');
}
}
@@ -1547,6 +1730,13 @@ function loadHistory(before) {
const isPaginating = !!before;
if (isPaginating) loadingOlder = true;
// Show skeleton while loading (only for fresh loads)
if (!isPaginating) {
const chatContainer = document.getElementById('chat-messages');
chatContainer.innerHTML = '';
chatContainer.appendChild(renderSkeleton('message', 3));
}
apiFetch(historyUrl).then((data) => {
const container = document.getElementById('chat-messages');
@@ -1564,6 +1754,10 @@ function loadHistory(before) {
addMessage('assistant', turn.response);
}
}
// Show welcome card when history is empty
if (data.turns.length === 0) {
showWelcomeCard();
}
// Show processing indicator if the last turn is still in-progress
var lastTurn = data.turns.length > 0 ? data.turns[data.turns.length - 1] : null;
if (lastTurn && !lastTurn.response && lastTurn.state === 'Processing') {
@@ -1610,6 +1804,30 @@ function createMessageElement(role, content) {
const div = document.createElement('div');
div.className = 'message ' + role;
const ts = document.createElement('span');
ts.className = 'message-timestamp';
ts.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
div.appendChild(ts);
// Message content
const contentEl = document.createElement('div');
contentEl.className = 'message-content';
if (role === 'user' || role === 'system') {
contentEl.textContent = content;
} else {
div.setAttribute('data-raw', content);
contentEl.innerHTML = renderMarkdown(content);
// Syntax highlighting for code blocks
if (typeof hljs !== 'undefined') {
requestAnimationFrame(() => {
contentEl.querySelectorAll('pre code').forEach(block => {
hljs.highlightElement(block);
});
});
}
}
div.appendChild(contentEl);
if (role === 'assistant' || role === 'user') {
div.classList.add('has-copy');
div.setAttribute('data-copy-text', content);
@@ -1625,15 +1843,6 @@ function createMessageElement(role, content) {
div.appendChild(copyBtn);
}
const body = document.createElement('div');
body.className = 'message-content';
if (role === 'user' || role === 'system') {
body.textContent = content;
} else {
div.setAttribute('data-raw', content);
body.innerHTML = renderMarkdown(content);
}
div.appendChild(body);
return div;
}
@@ -1731,6 +1940,13 @@ function debouncedLoadThreads() {
}
function loadThreads() {
// Show skeleton while loading
const threadListEl = document.getElementById('thread-list');
if (threadListEl && threadListEl.children.length === 0) {
threadListEl.innerHTML = '';
threadListEl.appendChild(renderSkeleton('row', 4));
}
apiFetch('/api/chat/threads').then((data) => {
// Pinned assistant thread
if (data.assistant_thread) {
@@ -1828,6 +2044,11 @@ function switchToAssistant() {
oldestTimestamp = null;
loadHistory();
loadThreads();
if (window.innerWidth <= 768) {
const sidebar = document.getElementById('thread-sidebar');
sidebar.classList.remove('expanded-mobile');
document.getElementById('thread-toggle-btn').innerHTML = '&raquo;';
}
}
function switchThread(threadId) {
@@ -1839,12 +2060,18 @@ function switchThread(threadId) {
oldestTimestamp = null;
loadHistory();
loadThreads();
if (window.innerWidth <= 768) {
const sidebar = document.getElementById('thread-sidebar');
sidebar.classList.remove('expanded-mobile');
document.getElementById('thread-toggle-btn').innerHTML = '&raquo;';
}
}
function createNewThread() {
apiFetch('/api/chat/thread/new', { method: 'POST' }).then((data) => {
currentThreadId = data.id || null;
document.getElementById('chat-messages').innerHTML = '';
showWelcomeCard();
loadThreads();
}).catch((err) => {
showToast('Failed to create thread: ' + err.message, 'error');
@@ -1853,9 +2080,17 @@ function createNewThread() {
function toggleThreadSidebar() {
const sidebar = document.getElementById('thread-sidebar');
sidebar.classList.toggle('collapsed');
const isMobile = window.innerWidth <= 768;
if (isMobile) {
sidebar.classList.toggle('expanded-mobile');
} else {
sidebar.classList.toggle('collapsed');
}
const btn = document.getElementById('thread-toggle-btn');
btn.innerHTML = sidebar.classList.contains('collapsed') ? '&raquo;' : '&laquo;';
const isOpen = isMobile
? sidebar.classList.contains('expanded-mobile')
: !sidebar.classList.contains('collapsed');
btn.innerHTML = isOpen ? '&laquo;' : '&raquo;';
}
// Chat input auto-resize and keyboard handling
@@ -1922,6 +2157,10 @@ chatInput.addEventListener('input', () => {
ghost.style.display = 'block';
wrapper.classList.add('has-ghost');
}
const sendBtn = document.getElementById('send-btn');
if (sendBtn) {
sendBtn.classList.toggle('active', chatInput.value.trim().length > 0);
}
});
chatInput.addEventListener('blur', () => {
// Small delay so mousedown on autocomplete item fires first
@@ -1943,8 +2182,13 @@ document.getElementById('chat-messages').addEventListener('scroll', function ()
});
function autoResizeTextarea(el) {
const prev = el.offsetHeight;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
const target = Math.min(el.scrollHeight, 120);
el.style.height = prev + 'px';
requestAnimationFrame(() => {
el.style.height = target + 'px';
});
}
// --- Tabs ---
@@ -1964,6 +2208,7 @@ function switchTab(tab) {
document.querySelectorAll('.tab-panel').forEach((p) => {
p.classList.toggle('active', p.id === 'tab-' + tab);
});
applyAriaAttributes();
if (tab === 'memory') loadMemoryTree();
if (tab === 'jobs') loadJobs();
@@ -1974,8 +2219,26 @@ function switchTab(tab) {
} else {
stopPairingPoll();
}
updateTabIndicator();
}
function updateTabIndicator() {
const indicator = document.getElementById('tab-indicator');
if (!indicator) return;
const activeBtn = document.querySelector('.tab-bar button[data-tab].active');
if (!activeBtn) {
indicator.style.width = '0';
return;
}
const bar = activeBtn.closest('.tab-bar');
const barRect = bar.getBoundingClientRect();
const btnRect = activeBtn.getBoundingClientRect();
indicator.style.left = (btnRect.left - barRect.left) + 'px';
indicator.style.width = btnRect.width + 'px';
}
window.addEventListener('resize', updateTabIndicator);
// --- Memory (filesystem tree) ---
let memorySearchTimeout = null;
@@ -4694,13 +4957,27 @@ document.addEventListener('keydown', (e) => {
return;
}
// Escape: close autocomplete, job detail, or blur input
// Mod+/: toggle shortcuts overlay
if (mod && e.key === '/') {
e.preventDefault();
toggleShortcutsOverlay();
return;
}
// Escape: close modals, autocomplete, job detail, or blur input
if (e.key === 'Escape') {
const acEl = document.getElementById('slash-autocomplete');
if (acEl && acEl.style.display !== 'none') {
hideSlashAutocomplete();
return;
}
// Close shortcuts overlay if open
const shortcutsOverlay = document.getElementById('shortcuts-overlay');
if (shortcutsOverlay?.style.display === 'flex') {
shortcutsOverlay.style.display = 'none';
return;
}
closeModals();
if (currentJobId) {
closeJobDetail();
} else if (inInput) {
@@ -4732,9 +5009,17 @@ function switchSettingsSubtab(subtab) {
searchInput.value = '';
searchInput.dispatchEvent(new Event('input'));
}
// On mobile, drill into detail view
if (window.innerWidth <= 768) {
document.querySelector('.settings-layout').classList.add('settings-detail-active');
}
loadSettingsSubtab(subtab);
}
function settingsBack() {
document.querySelector('.settings-layout').classList.remove('settings-detail-active');
}
function loadSettingsSubtab(subtab) {
if (subtab === 'inference') loadInferenceSettings();
else if (subtab === 'agent') loadAgentSettings();
@@ -4870,6 +5155,19 @@ function renderCardsSkeleton(count) {
return html;
}
function renderSkeleton(type, count) {
count = count || 3;
var container = document.createElement('div');
container.className = 'skeleton-container';
for (var i = 0; i < count; i++) {
var el = document.createElement('div');
el.className = 'skeleton-' + type;
el.innerHTML = '<div class="skeleton-bar shimmer"></div>';
container.appendChild(el);
}
return container;
}
function loadInferenceSettings() {
var container = document.getElementById('settings-inference-content');
container.innerHTML = renderSettingsSkeleton(6);
@@ -4888,11 +5186,13 @@ function loadInferenceSettings() {
};
// Inject available model IDs as suggestions for the selected_model field
var modelIds = (modelsData.data || []).map(function(m) { return m.id; }).filter(Boolean);
var llmGroup = INFERENCE_SETTINGS[0];
for (var i = 0; i < llmGroup.settings.length; i++) {
if (llmGroup.settings[i].key === 'selected_model') {
llmGroup.settings[i].suggestions = modelIds;
break;
if (modelIds.length > 0) {
var llmGroup = INFERENCE_SETTINGS[0];
for (var i = 0; i < llmGroup.settings.length; i++) {
if (llmGroup.settings[i].key === 'selected_model') {
llmGroup.settings[i].suggestions = modelIds;
break;
}
}
}
container.innerHTML = '';
@@ -5020,34 +5320,30 @@ function renderStructuredSettingsRow(def, value, activeValue) {
var placeholderText = activeValueText ? I18n.t('settings.envValue', { value: activeValueText }) : (def.placeholder || I18n.t('settings.envDefault'));
if (def.type === 'boolean') {
var boolSel = document.createElement('select');
boolSel.className = 'settings-select';
boolSel.setAttribute('data-setting-key', def.key);
boolSel.setAttribute('aria-label', ariaLabel);
var boolDefault = document.createElement('option');
boolDefault.value = '';
boolDefault.textContent = activeValue !== undefined && activeValue !== null
? '\u2014 ' + I18n.t('settings.envValue', { value: String(activeValue) }) + ' \u2014'
: '\u2014 ' + I18n.t('settings.useEnvDefault') + ' \u2014';
if (value === null || value === undefined) boolDefault.selected = true;
boolSel.appendChild(boolDefault);
var boolOn = document.createElement('option');
boolOn.value = 'true';
boolOn.textContent = I18n.t('settings.on');
if (value === true) boolOn.selected = true;
boolSel.appendChild(boolOn);
var boolOff = document.createElement('option');
boolOff.value = 'false';
boolOff.textContent = I18n.t('settings.off');
if (value === false) boolOff.selected = true;
boolSel.appendChild(boolOff);
boolSel.addEventListener('change', (function(k, el) {
return function() {
if (el.value === '') saveSetting(k, null);
else saveSetting(k, el.value === 'true');
};
})(def.key, boolSel));
inputWrap.appendChild(boolSel);
var toggle = document.createElement('div');
toggle.className = 'toggle-switch' + (value === 'true' || value === true ? ' on' : '');
toggle.setAttribute('role', 'switch');
toggle.setAttribute('aria-checked', value === 'true' || value === true ? 'true' : 'false');
toggle.setAttribute('aria-label', ariaLabel);
toggle.setAttribute('tabindex', '0');
var savedIndicator = document.createElement('span');
savedIndicator.className = 'settings-saved-indicator';
savedIndicator.textContent = I18n.t('settings.saved');
toggle.addEventListener('click', function() {
var isOn = this.classList.toggle('on');
this.setAttribute('aria-checked', isOn ? 'true' : 'false');
saveSetting(def.key, isOn ? 'true' : 'false', savedIndicator);
});
toggle.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.click();
}
});
inputWrap.appendChild(toggle);
inputWrap.appendChild(savedIndicator);
} else if (def.type === 'select' && def.options) {
var sel = document.createElement('select');
sel.className = 'settings-select';
@@ -5421,16 +5717,207 @@ function showToast(message, type) {
const container = document.getElementById('toasts');
const toast = document.createElement('div');
toast.className = 'toast toast-' + (type || 'info');
toast.textContent = message;
// Icon prefix
const icon = document.createElement('span');
icon.className = 'toast-icon';
if (type === 'success') icon.textContent = '\u2713';
else if (type === 'error') icon.textContent = '\u2717';
else icon.textContent = '\u2139';
toast.appendChild(icon);
// Message text
const text = document.createElement('span');
text.textContent = message;
toast.appendChild(text);
// Countdown bar
const countdown = document.createElement('div');
countdown.className = 'toast-countdown';
toast.appendChild(countdown);
container.appendChild(toast);
// Trigger slide-in
requestAnimationFrame(() => toast.classList.add('visible'));
setTimeout(() => {
toast.classList.remove('visible');
toast.addEventListener('transitionend', () => toast.remove());
toast.classList.add('dismissing');
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
// Fallback removal if transitionend doesn't fire
setTimeout(() => { if (toast.parentNode) toast.remove(); }, 500);
}, 4000);
}
// --- Welcome Card (Phase 4.2) ---
function showWelcomeCard() {
const container = document.getElementById('chat-messages');
if (!container || container.querySelector('.welcome-card')) return;
const card = document.createElement('div');
card.className = 'welcome-card';
const heading = document.createElement('h2');
heading.className = 'welcome-heading';
heading.textContent = I18n.t('welcome.heading');
card.appendChild(heading);
const desc = document.createElement('p');
desc.className = 'welcome-description';
desc.textContent = I18n.t('welcome.description');
card.appendChild(desc);
const chips = document.createElement('div');
chips.className = 'welcome-chips';
const suggestions = [
{ key: 'welcome.runTool', fallback: 'Run a tool' },
{ key: 'welcome.checkJobs', fallback: 'Check job status' },
{ key: 'welcome.searchMemory', fallback: 'Search memory' },
{ key: 'welcome.manageRoutines', fallback: 'Manage routines' },
{ key: 'welcome.systemStatus', fallback: 'System status' },
{ key: 'welcome.writeCode', fallback: 'Write code' },
];
suggestions.forEach(({ key, fallback }) => {
const chip = document.createElement('button');
chip.className = 'welcome-chip';
chip.textContent = I18n.t(key) || fallback;
chip.addEventListener('click', () => sendSuggestion(chip));
chips.appendChild(chip);
});
card.appendChild(chips);
container.appendChild(card);
}
function renderEmptyState({ icon, title, hint, action }) {
const wrapper = document.createElement('div');
wrapper.className = 'empty-state-card';
if (icon) {
const iconEl = document.createElement('div');
iconEl.className = 'empty-state-icon';
iconEl.textContent = icon;
wrapper.appendChild(iconEl);
}
if (title) {
const titleEl = document.createElement('div');
titleEl.className = 'empty-state-title';
titleEl.textContent = title;
wrapper.appendChild(titleEl);
}
if (hint) {
const hintEl = document.createElement('div');
hintEl.className = 'empty-state-hint';
hintEl.textContent = hint;
wrapper.appendChild(hintEl);
}
if (action) {
const btn = document.createElement('button');
btn.className = 'empty-state-action';
btn.textContent = action.label || 'Go';
if (action.onClick) btn.addEventListener('click', action.onClick);
wrapper.appendChild(btn);
}
return wrapper;
}
function sendSuggestion(btn) {
const textarea = document.getElementById('chat-input');
if (textarea) {
textarea.value = btn.textContent;
sendMessage();
}
}
function removeWelcomeCard() {
const card = document.querySelector('.welcome-card');
if (card) card.remove();
}
// --- Connection Status Banner (Phase 4.1) ---
function showConnectionBanner(message, type) {
const existing = document.getElementById('connection-banner');
if (existing) existing.remove();
const banner = document.createElement('div');
banner.id = 'connection-banner';
banner.className = 'connection-banner connection-banner-' + type;
banner.textContent = message;
document.body.appendChild(banner);
}
// --- Keyboard Shortcut Helpers (Phase 7.4) ---
function focusMemorySearch() {
const memSearch = document.getElementById('memory-search');
if (memSearch) {
if (currentTab !== 'memory') switchTab('memory');
memSearch.focus();
}
}
function toggleShortcutsOverlay() {
let overlay = document.getElementById('shortcuts-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'shortcuts-overlay';
overlay.className = 'shortcuts-overlay';
overlay.style.display = 'none';
overlay.innerHTML =
'<div class="shortcuts-content">'
+ '<h3>Keyboard Shortcuts</h3>'
+ '<div class="shortcut-row"><kbd>Ctrl/Cmd + 1-5</kbd> Switch tabs</div>'
+ '<div class="shortcut-row"><kbd>Ctrl/Cmd + N</kbd> New thread</div>'
+ '<div class="shortcut-row"><kbd>Ctrl/Cmd + K</kbd> Focus search/input</div>'
+ '<div class="shortcut-row"><kbd>Ctrl/Cmd + /</kbd> Toggle this overlay</div>'
+ '<div class="shortcut-row"><kbd>Escape</kbd> Close modals</div>'
+ '<button class="shortcuts-close">Close</button>'
+ '</div>';
document.body.appendChild(overlay);
overlay.querySelector('.shortcuts-close').addEventListener('click', () => {
overlay.style.display = 'none';
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.style.display = 'none';
});
}
overlay.style.display = overlay.style.display === 'flex' ? 'none' : 'flex';
}
function closeModals() {
// Close shortcuts overlay
const shortcutsOverlay = document.getElementById('shortcuts-overlay');
if (shortcutsOverlay) shortcutsOverlay.style.display = 'none';
// Close restart confirmation modal
const restartModal = document.getElementById('restart-confirm-modal');
if (restartModal) restartModal.style.display = 'none';
}
// --- ARIA Accessibility (Phase 5.2) ---
function applyAriaAttributes() {
const tabBar = document.querySelector('.tab-bar');
if (tabBar) tabBar.setAttribute('role', 'tablist');
document.querySelectorAll('.tab-bar button[data-tab]').forEach(btn => {
btn.setAttribute('role', 'tab');
btn.setAttribute('aria-selected', btn.classList.contains('active') ? 'true' : 'false');
});
document.querySelectorAll('.tab-panel').forEach(panel => {
panel.setAttribute('role', 'tabpanel');
panel.setAttribute('aria-hidden', panel.classList.contains('active') ? 'false' : 'true');
});
}
// Apply ARIA attributes on initial load
applyAriaAttributes();
// --- Utilities ---
function escapeHtml(str) {
@@ -5469,6 +5956,17 @@ document.getElementById('skill-search-btn').addEventListener('click', () => sear
document.getElementById('skill-install-btn').addEventListener('click', () => installSkillFromForm());
document.getElementById('settings-export-btn').addEventListener('click', () => exportSettings());
document.getElementById('settings-import-btn').addEventListener('click', () => importSettings());
document.getElementById('settings-back-btn')?.addEventListener('click', () => settingsBack());
// --- Mobile: close thread sidebar on outside click ---
document.addEventListener('click', function(e) {
const sidebar = document.getElementById('thread-sidebar');
if (sidebar && sidebar.classList.contains('expanded-mobile') &&
!sidebar.contains(e.target)) {
sidebar.classList.remove('expanded-mobile');
document.getElementById('thread-toggle-btn').innerHTML = '&raquo;';
}
});
// --- Delegated Event Handlers (for dynamically generated HTML) ---
+25
View File
@@ -521,4 +521,29 @@ I18n.register('en', {
'channels.replDesc': 'Simple read-eval-print loop for testing',
'channels.configureVia': 'Configure via {env}',
'channels.runWith': 'Run with: {cmd}',
// Welcome Card
'welcome.heading': 'What can I help you with?',
'welcome.description': 'IronClaw is your secure AI assistant. Choose a suggestion below or type your own message.',
'welcome.runTool': 'Run a tool',
'welcome.checkJobs': 'Check job status',
'welcome.searchMemory': 'Search memory',
'welcome.manageRoutines': 'Manage routines',
'welcome.systemStatus': 'System status',
'welcome.writeCode': 'Write code',
// Connection
'connection.disconnected': 'Disconnected — attempting to reconnect',
'connection.reconnecting': 'Reconnecting (attempt {count})...',
'connection.reconnected': 'Reconnected',
// Messages
'message.you': 'You',
'message.assistant': 'IronClaw',
'message.system': 'System',
'message.copy': 'Copy',
'message.copied': 'Copied!',
// Approval
'approval.pressY': 'Press Y to approve, N to deny',
});
+25
View File
@@ -520,4 +520,29 @@ I18n.register('zh-CN', {
'channels.replDesc': '用于测试的简单读取-求值-打印循环',
'channels.configureVia': '通过 {env} 配置',
'channels.runWith': '运行命令: {cmd}',
// Welcome Card
'welcome.heading': '有什么可以帮助您的?',
'welcome.description': 'IronClaw 是您的安全 AI 助手。选择下方的建议或输入您自己的消息。',
'welcome.runTool': '运行工具',
'welcome.checkJobs': '查看任务状态',
'welcome.searchMemory': '搜索记忆',
'welcome.manageRoutines': '管理例程',
'welcome.systemStatus': '系统状态',
'welcome.writeCode': '编写代码',
// Connection
'connection.disconnected': '已断开连接 — 正在尝试重新连接',
'connection.reconnecting': '正在重新连接(第 {count} 次尝试)...',
'connection.reconnected': '已重新连接',
// Messages
'message.you': '你',
'message.assistant': 'IronClaw',
'message.system': '系统',
'message.copy': '复制',
'message.copied': '已复制!',
// Approval
'approval.pressY': '按 Y 批准,N 拒绝',
});
+3
View File
@@ -92,6 +92,7 @@
<div id="app">
<!-- Tab Bar -->
<div class="tab-bar">
<div class="tab-indicator" id="tab-indicator"></div>
<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>
@@ -292,9 +293,11 @@
<button class="settings-subtab" data-settings-subtab="extensions" data-i18n="tab.extensions">Extensions</button>
<button class="settings-subtab" data-settings-subtab="mcp" data-i18n="settings.mcp">MCP</button>
<button class="settings-subtab" data-settings-subtab="skills" data-i18n="tab.skills">Skills</button>
<button class="settings-theme-toggle" id="settings-theme-toggle" data-i18n="theme.tooltipSystem" title="Toggle theme">Theme</button>
</div>
<div class="settings-content">
<div class="settings-toolbar">
<button id="settings-back-btn" class="settings-back-btn">&larr; Back</button>
<div class="settings-search">
<input type="text" id="settings-search-input" data-i18n-placeholder="settings.searchPlaceholder" placeholder="Search settings..." data-i18n-attr="aria-label" data-i18n="settings.searchPlaceholder" aria-label="Search settings...">
</div>
File diff suppressed because it is too large Load Diff
+11
View File
@@ -254,6 +254,16 @@ pub enum SseEvent {
thread_id: Option<String>,
},
/// Per-turn token usage and cost summary.
#[serde(rename = "turn_cost")]
TurnCost {
input_tokens: u64,
output_tokens: u64,
cost_usd: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
/// Extension activation status change (WASM channels).
#[serde(rename = "extension_status")]
ExtensionStatus {
@@ -797,6 +807,7 @@ impl WsServerMessage {
SseEvent::JobResult { .. } => "job_result",
SseEvent::ImageGenerated { .. } => "image_generated",
SseEvent::Suggestions { .. } => "suggestions",
SseEvent::TurnCost { .. } => "turn_cost",
SseEvent::ExtensionStatus { .. } => "extension_status",
};
let data = serde_json::to_value(event).unwrap_or(serde_json::Value::Null);
+2 -2
View File
@@ -68,7 +68,7 @@ impl WebhookServer {
reason: format!("Failed to bind to {}: {}", self.config.addr, e),
})?;
tracing::info!("Webhook server listening on {}", self.config.addr);
tracing::debug!("Webhook server listening on {}", self.config.addr);
let (shutdown_tx, shutdown_rx) = oneshot::channel();
self.shutdown_tx = Some(shutdown_tx);
@@ -129,7 +129,7 @@ impl WebhookServer {
});
self.handle = Some(handle);
tracing::info!("Webhook server listening on {}", new_addr);
tracing::debug!("Webhook server listening on {}", new_addr);
(old_shutdown_tx, old_handle)
}
+48 -13
View File
@@ -7,12 +7,13 @@
use std::path::PathBuf;
use crate::bootstrap::ironclaw_base_dir;
use crate::cli::fmt;
use crate::settings::Settings;
/// Run all diagnostic checks and print results.
pub async fn run_doctor_command() -> anyhow::Result<()> {
println!("IronClaw Doctor");
println!("===============\n");
println!();
println!(" {}IronClaw Doctor{}", fmt::bold(), fmt::reset());
let mut passed = 0u32;
let mut failed = 0u32;
@@ -21,7 +22,9 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
// Load settings once for checks that need them.
let settings = Settings::load();
// ── Settings & core config ─────────────────────────────────
// ── Core ─────────────────────────────────────────────────
section_header("Core");
check(
"Settings file",
@@ -63,7 +66,9 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
&mut skipped,
);
// ── Subsystem configuration checks ─────────────────────────
// ── Features ─────────────────────────────────────────────
section_header("Features");
check(
"Embeddings",
@@ -121,7 +126,9 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
&mut skipped,
);
// ── External binary checks ────────────────────────────────
// ── External ─────────────────────────────────────────────
section_header("External");
check(
"Docker daemon",
@@ -158,7 +165,18 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
// ── Summary ───────────────────────────────────────────────
println!();
println!(" {passed} passed, {failed} failed, {skipped} skipped");
println!(
" {}{} passed{}, {}{} failed{}, {}{} skipped{}",
fmt::success(),
passed,
fmt::reset(),
if failed > 0 { fmt::error() } else { fmt::dim() },
failed,
fmt::reset(),
fmt::dim(),
skipped,
fmt::reset(),
);
if failed > 0 {
println!("\n Some checks failed. This is normal if you don't use those features.");
@@ -167,21 +185,38 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
Ok(())
}
/// Print a section header with a separator and bold group name.
fn section_header(name: &str) {
println!();
println!(" {}", fmt::separator(36));
println!(" {}{}{}", fmt::bold(), name, fmt::reset());
println!();
}
// ── Individual checks ───────────────────────────────────────
fn check(name: &str, result: CheckResult, passed: &mut u32, failed: &mut u32, skipped: &mut u32) {
match result {
CheckResult::Pass(detail) => {
*passed += 1;
println!(" [pass] {name}: {detail}");
println!(
"{}",
fmt::check_line(fmt::StatusKind::Pass, name, &detail, 18)
);
}
CheckResult::Fail(detail) => {
*failed += 1;
println!(" [FAIL] {name}: {detail}");
println!(
"{}",
fmt::check_line(fmt::StatusKind::Fail, name, &detail, 18)
);
}
CheckResult::Skip(reason) => {
*skipped += 1;
println!(" [skip] {name}: {reason}");
println!(
"{}",
fmt::check_line(fmt::StatusKind::Skip, name, &reason, 18)
);
}
}
}
@@ -657,7 +692,7 @@ mod tests {
}
}
let _mutex = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
let _mutex = crate::config::helpers::lock_env();
let prev = std::env::var("LLM_BACKEND").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
@@ -777,7 +812,7 @@ mod tests {
#[test]
fn check_llm_config_shows_nearai_model_for_nearai_backend() {
let _guard = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
let _guard = crate::config::helpers::lock_env();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::remove_var("LLM_BACKEND");
@@ -804,7 +839,7 @@ mod tests {
#[test]
fn check_embeddings_disabled_by_default_returns_skip() {
let _guard = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
let _guard = crate::config::helpers::lock_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("EMBEDDING_ENABLED");
@@ -826,7 +861,7 @@ mod tests {
#[test]
fn check_routines_enabled_by_default() {
let _guard = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
let _guard = crate::config::helpers::lock_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("ROUTINES_ENABLED");
+296
View File
@@ -0,0 +1,296 @@
//! Shared terminal design system.
//!
//! Centralizes color tokens, rendering primitives, and width detection
//! for consistent CLI output. Respects `NO_COLOR` env var and non-TTY
//! output (piping to file, CI, etc.).
use std::io::IsTerminal;
// ── Color detection ─────────────────────────────────────────
/// Returns `true` when ANSI colors should be emitted.
///
/// Disabled when:
/// - `NO_COLOR` env var is set (any value — per <https://no-color.org/>)
/// - stdout is not a terminal (pipe, file redirect, CI)
fn colors_enabled() -> bool {
if std::env::var_os("NO_COLOR").is_some() {
return false;
}
std::io::stdout().is_terminal()
}
/// Returns `true` when the terminal supports 24-bit true-color.
///
/// Checks `$COLORTERM` for `truecolor` or `24bit`.
fn truecolor_enabled() -> bool {
std::env::var("COLORTERM")
.map(|v| v.eq_ignore_ascii_case("truecolor") || v.eq_ignore_ascii_case("24bit"))
.unwrap_or(false)
}
// ── Color tokens ────────────────────────────────────────────
/// Emerald green accent — primary brand color.
///
/// Uses true-color `#34d399` when supported, falls back to basic green.
pub fn accent() -> &'static str {
if !colors_enabled() {
return "";
}
if truecolor_enabled() {
"\x1b[38;2;52;211;153m"
} else {
"\x1b[32m"
}
}
/// Bold text.
pub fn bold() -> &'static str {
if colors_enabled() { "\x1b[1m" } else { "" }
}
/// Green — success indicators.
pub fn success() -> &'static str {
if colors_enabled() { "\x1b[32m" } else { "" }
}
/// Yellow — warning indicators.
pub fn warning() -> &'static str {
if colors_enabled() { "\x1b[33m" } else { "" }
}
/// Red — error indicators.
pub fn error() -> &'static str {
if colors_enabled() { "\x1b[31m" } else { "" }
}
/// Dim gray — labels, secondary text.
pub fn dim() -> &'static str {
if colors_enabled() { "\x1b[90m" } else { "" }
}
/// Yellow underline — URLs and links.
pub fn link() -> &'static str {
if colors_enabled() { "\x1b[33;4m" } else { "" }
}
/// Bold accent — commands and interactive elements.
///
/// Uses bold + true-color emerald when supported, falls back to bold green.
pub fn bold_accent() -> &'static str {
if !colors_enabled() {
return "";
}
if truecolor_enabled() {
"\x1b[1;38;2;52;211;153m"
} else {
"\x1b[1;32m"
}
}
/// Dim italic — contextual tips and hints.
pub fn hint() -> &'static str {
if colors_enabled() { "\x1b[2;3m" } else { "" }
}
/// Reset all attributes.
pub fn reset() -> &'static str {
if colors_enabled() { "\x1b[0m" } else { "" }
}
// ── Width detection ─────────────────────────────────────────
/// Detect terminal width, clamped to [40, 120].
pub fn term_width() -> usize {
crossterm::terminal::size()
.map(|(w, _)| w as usize)
.unwrap_or(80)
.clamp(40, 120)
}
// ── Rendering primitives ────────────────────────────────────
/// Horizontal separator line (dim `─` characters).
pub fn separator(width: usize) -> String {
format!("{}{}{}", dim(), "\u{2500}".repeat(width), reset())
}
/// Key-value line with right-padded dim key and accent value.
///
/// ```text
/// Database libsql (connected)
/// ```
pub fn kv_line(key: &str, value: &str, key_width: usize) -> String {
format!(
" {}{:<width$}{} {}{}{}",
dim(),
key,
reset(),
accent(),
value,
reset(),
width = key_width,
)
}
/// Status icon for check results.
///
/// - `pass` → green `✓`
/// - `fail` → red `✗`
/// - `skip` → dim `○`
pub fn status_icon(kind: StatusKind) -> String {
match kind {
StatusKind::Pass => format!("{}\u{2713}{}", success(), reset()),
StatusKind::Fail => format!("{}\u{2717}{}", error(), reset()),
StatusKind::Skip => format!("{}\u{25CB}{}", dim(), reset()),
}
}
/// Kind of status check result.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusKind {
Pass,
Fail,
Skip,
}
/// Top border of a box with an optional label.
///
/// ```text
/// ┌─ label ──────────────────┐
/// ```
pub fn box_top(label: &str, width: usize) -> String {
if label.is_empty() {
let fill = width.saturating_sub(2);
return format!("\u{250C}{}\u{2510}", "\u{2500}".repeat(fill));
}
let label_part = format!(" {} ", label);
// ┌ (1) + ─ (1) + label_part + fill + ┐ (1) = width
let fill = width.saturating_sub(label_part.len() + 3);
format!(
"\u{250C}\u{2500}{}{}{}\u{2510}",
bold(),
label_part,
reset(),
)
.replace("\u{2510}", &format!("{}\u{2510}", "\u{2500}".repeat(fill)))
}
/// Content line inside a box.
///
/// ```text
/// │ content │
/// ```
pub fn box_line(content: &str, width: usize) -> String {
let inner = width.saturating_sub(4); // │ + space + space + │
let padded = if content.len() >= inner {
content.to_string()
} else {
format!("{}{}", content, " ".repeat(inner - content.len()))
};
format!("\u{2502} {} \u{2502}", padded)
}
/// Bottom border of a box.
///
/// ```text
/// └──────────────────────────┘
/// ```
pub fn box_bottom(width: usize) -> String {
let fill = width.saturating_sub(2);
format!("\u{2514}{}\u{2518}", "\u{2500}".repeat(fill))
}
/// Format a check result line for doctor/status commands.
///
/// ```text
/// ✓ Database libsql (connected)
/// ✗ Docker not running — start with: open -a Docker
/// ○ Embeddings disabled
/// ```
pub fn check_line(kind: StatusKind, name: &str, detail: &str, name_width: usize) -> String {
format!(
" {} {:<width$} {}",
status_icon(kind),
name,
detail,
width = name_width,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn separator_produces_correct_width() {
// In test environment NO_COLOR or non-TTY may be active,
// so strip ANSI to count visible characters.
let s = separator(10);
let visible: String = strip_ansi(&s);
assert_eq!(visible.chars().count(), 10);
}
#[test]
fn kv_line_contains_key_and_value() {
let line = kv_line("model", "gpt-4o", 12);
let visible = strip_ansi(&line);
assert!(visible.contains("model"));
assert!(visible.contains("gpt-4o"));
}
#[test]
fn status_icon_all_kinds() {
// Just verify no panic for each variant
let _ = status_icon(StatusKind::Pass);
let _ = status_icon(StatusKind::Fail);
let _ = status_icon(StatusKind::Skip);
}
#[test]
fn box_drawing() {
let top = box_top("test", 30);
let line = box_line("content", 30);
let bottom = box_bottom(30);
assert!(top.contains('\u{250C}')); // ┌
assert!(line.contains('\u{2502}')); // │
assert!(bottom.contains('\u{2514}')); // └
}
#[test]
fn check_line_formatting() {
let line = check_line(StatusKind::Pass, "Database", "connected", 18);
let visible = strip_ansi(&line);
assert!(visible.contains("Database"));
assert!(visible.contains("connected"));
}
#[test]
fn term_width_in_range() {
let w = term_width();
assert!(w >= 40);
assert!(w <= 120);
}
/// Strip ANSI escape sequences for visible-character counting.
fn strip_ansi(s: &str) -> String {
let mut result = String::new();
let mut in_escape = false;
for c in s.chars() {
if c == '\x1b' {
in_escape = true;
continue;
}
if in_escape {
if c == 'm' {
in_escape = false;
}
continue;
}
result.push(c);
}
result
}
}
+8 -3
View File
@@ -18,6 +18,7 @@ mod channels;
mod completion;
mod config;
mod doctor;
pub mod fmt;
mod hooks;
#[cfg(feature = "import")]
pub mod import;
@@ -111,16 +112,20 @@ pub enum Command {
skip_auth: bool,
/// Reconfigure channels only
#[arg(long, conflicts_with_all = ["provider_only", "quick"])]
#[arg(long, conflicts_with_all = ["provider_only", "quick", "step"], help = "Deprecated: use --step channels")]
channels_only: bool,
/// Reconfigure LLM provider and model only
#[arg(long, conflicts_with_all = ["channels_only", "quick"])]
#[arg(long, conflicts_with_all = ["channels_only", "quick", "step"], help = "Deprecated: use --step provider")]
provider_only: bool,
/// Quick setup: auto-defaults everything except LLM provider and model
#[arg(long, conflicts_with_all = ["channels_only", "provider_only"])]
#[arg(long, conflicts_with_all = ["channels_only", "provider_only", "step"])]
quick: bool,
/// Run only specific setup steps (comma-separated: provider, channels, model, database, security)
#[arg(long, value_delimiter = ',', conflicts_with_all = ["channels_only", "provider_only", "quick"])]
step: Vec<String>,
},
/// Manage configuration settings
+12 -12
View File
@@ -758,7 +758,7 @@ mod tests {
use crate::cli::oauth_defaults::{
builtin_credentials, callback_host, callback_url, is_loopback_host, landing_html,
};
use crate::config::helpers::ENV_MUTEX;
use crate::config::helpers::lock_env;
#[test]
fn test_is_loopback_host() {
@@ -775,7 +775,7 @@ mod tests {
#[test]
fn test_callback_host_default() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
let original = std::env::var("OAUTH_CALLBACK_HOST").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
@@ -792,7 +792,7 @@ mod tests {
#[test]
fn test_callback_host_env_override() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
let original_host = std::env::var("OAUTH_CALLBACK_HOST").ok();
let original_url = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
@@ -819,7 +819,7 @@ mod tests {
#[test]
fn test_callback_url_default() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
// Clear both env vars to test default behavior
let original_url = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
let original_host = std::env::var("OAUTH_CALLBACK_HOST").ok();
@@ -843,7 +843,7 @@ mod tests {
#[test]
fn test_callback_url_env_override() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
@@ -1008,7 +1008,7 @@ mod tests {
#[test]
fn test_use_gateway_callback_false_by_default() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
@@ -1024,7 +1024,7 @@ mod tests {
#[test]
fn test_use_gateway_callback_true_for_hosted() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
@@ -1045,7 +1045,7 @@ mod tests {
#[test]
fn test_use_gateway_callback_false_for_localhost() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
@@ -1063,7 +1063,7 @@ mod tests {
#[test]
fn test_use_gateway_callback_false_for_empty() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
@@ -1083,7 +1083,7 @@ mod tests {
fn test_build_platform_state_with_instance() {
use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
@@ -1107,7 +1107,7 @@ mod tests {
fn test_build_platform_state_without_instance() {
use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
let original_oc = std::env::var("OPENCLAW_INSTANCE_NAME").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
@@ -1134,7 +1134,7 @@ mod tests {
fn test_build_platform_state_with_openclaw_instance() {
use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
let original_ic = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
let original_oc = std::env::var("OPENCLAW_INSTANCE_NAME").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
+57 -48
View File
@@ -6,6 +6,7 @@
use std::path::PathBuf;
use crate::bootstrap::ironclaw_base_dir;
use crate::cli::fmt;
use crate::settings::Settings;
/// Load settings from JSON and TOML config files, matching the runtime
@@ -38,22 +39,25 @@ fn load_settings_from(json_path: &std::path::Path, toml_path: &std::path::Path)
pub async fn run_status_command() -> anyhow::Result<()> {
let settings = load_settings();
println!("IronClaw Status");
println!("===============\n");
println!();
println!(" {}IronClaw Status{}", fmt::bold(), fmt::reset());
println!();
// Version
println!(
" Version: {} v{}",
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION")
"{}",
fmt::kv_line(
"Version",
&format!("{} v{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")),
12,
)
);
// Database
print!(" Database: ");
let db_backend = std::env::var("DATABASE_BACKEND")
.ok()
.unwrap_or_else(|| "postgres".to_string());
match db_backend.as_str() {
let db_value = match db_backend.as_str() {
"libsql" | "turso" | "sqlite" => {
let path = std::env::var("LIBSQL_PATH")
.map(std::path::PathBuf::from)
@@ -64,77 +68,77 @@ pub async fn run_status_command() -> anyhow::Result<()> {
} else {
""
};
println!("libSQL ({}{})", path.display(), turso);
format!("libSQL ({}{})", path.display(), turso)
} else {
println!("libSQL (file missing: {})", path.display());
format!("libSQL (file missing: {})", path.display())
}
}
_ => {
if std::env::var("DATABASE_URL").is_ok() {
match check_database().await {
Ok(()) => println!("connected (PostgreSQL)"),
Err(e) => println!("error ({})", e),
Ok(()) => "connected (PostgreSQL)".to_string(),
Err(e) => format!("error ({})", e),
}
} else {
println!("not configured");
"not configured".to_string()
}
}
}
};
println!("{}", fmt::kv_line("Database", &db_value, 12));
// Session / Auth
print!(" Session: ");
let session_path = crate::config::llm::default_session_path();
if session_path.exists() {
println!("found ({})", session_path.display());
let session_value = if session_path.exists() {
format!("found ({})", session_path.display())
} else {
println!("not found (run `ironclaw onboard`)");
}
"not found (run `ironclaw onboard`)".to_string()
};
println!("{}", fmt::kv_line("Session", &session_value, 12));
// Secrets (auto-detect from env only; skip keychain probe to avoid
// triggering macOS system password dialogs on a simple status check)
print!(" Secrets: ");
if std::env::var("SECRETS_MASTER_KEY").is_ok() {
println!("configured (env)");
let secrets_value = if std::env::var("SECRETS_MASTER_KEY").is_ok() {
"configured (env)".to_string()
} else {
// We don't probe the keychain here because get_generic_password()
// triggers macOS unlock+authorization dialogs, which is bad UX for
// a read-only status command. If onboarding completed with keychain
// storage, the key is there; we just can't cheaply verify it.
println!("env not set (keychain may be configured)");
}
"env not set (keychain may be configured)".to_string()
};
println!("{}", fmt::kv_line("Secrets", &secrets_value, 12));
// Embeddings
print!(" Embeddings: ");
let emb_enabled = settings.embeddings.enabled
|| std::env::var("OPENAI_API_KEY").is_ok()
|| std::env::var("EMBEDDING_ENABLED")
.map(|v| v == "true")
.unwrap_or(false);
if emb_enabled {
println!(
let emb_value = if emb_enabled {
format!(
"enabled (provider: {}, model: {})",
settings.embeddings.provider, settings.embeddings.model
);
)
} else {
println!("disabled");
}
"disabled".to_string()
};
println!("{}", fmt::kv_line("Embeddings", &emb_value, 12));
// WASM tools
print!(" WASM Tools: ");
let tools_dir = settings
.wasm
.tools_dir
.clone()
.unwrap_or_else(default_tools_dir);
if tools_dir.exists() {
let tools_value = if tools_dir.exists() {
let count = count_wasm_files(&tools_dir);
println!("{} installed ({})", count, tools_dir.display());
format!("{} installed ({})", count, tools_dir.display())
} else {
println!("directory not found ({})", tools_dir.display());
}
format!("directory not found ({})", tools_dir.display())
};
println!("{}", fmt::kv_line("WASM Tools", &tools_value, 12));
// WASM channels
print!(" Channels: ");
let channels_dir = settings
.channels
.wasm_channels_dir
@@ -153,35 +157,40 @@ pub async fn run_status_command() -> anyhow::Result<()> {
channel_info.push(format!("{} wasm", wasm_count));
}
}
println!("{}", channel_info.join(", "));
println!("{}", fmt::kv_line("Channels", &channel_info.join(", "), 12));
// Heartbeat
print!(" Heartbeat: ");
let hb_enabled = settings.heartbeat.enabled
|| std::env::var("HEARTBEAT_ENABLED")
.map(|v| v == "true")
.unwrap_or(false);
if hb_enabled {
println!("enabled (interval: {}s)", settings.heartbeat.interval_secs);
let hb_value = if hb_enabled {
format!("enabled (interval: {}s)", settings.heartbeat.interval_secs)
} else {
println!("disabled");
}
"disabled".to_string()
};
println!("{}", fmt::kv_line("Heartbeat", &hb_value, 12));
// MCP servers
print!(" MCP Servers: ");
match crate::tools::mcp::config::load_mcp_servers().await {
let mcp_value = match crate::tools::mcp::config::load_mcp_servers().await {
Ok(servers) => {
let enabled = servers.servers.iter().filter(|s| s.enabled).count();
let total = servers.servers.len();
println!("{} enabled / {} configured", enabled, total);
format!("{} enabled / {} configured", enabled, total)
}
Err(_) => println!("none configured"),
}
Err(_) => "none configured".to_string(),
};
println!("{}", fmt::kv_line("MCP Servers", &mcp_value, 12));
// Config path
println!();
println!(
"\n Config: {}",
crate::bootstrap::ironclaw_env_path().display()
"{}",
fmt::kv_line(
"Config",
&crate::bootstrap::ironclaw_env_path().display().to_string(),
12,
)
);
Ok(())
+3 -3
View File
@@ -63,12 +63,12 @@ impl BuilderModeConfig {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::helpers::ENV_MUTEX;
use crate::config::helpers::lock_env;
use crate::settings::Settings;
#[test]
fn resolve_falls_back_to_settings() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
let mut settings = Settings::default();
settings.builder.max_iterations = 99;
settings.builder.auto_register = false;
@@ -80,7 +80,7 @@ mod tests {
#[test]
fn env_overrides_settings() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
let mut settings = Settings::default();
settings.builder.timeout_secs = 123;
+3 -3
View File
@@ -113,7 +113,7 @@ impl ChannelsConfig {
let gateway = if gateway_enabled {
let user_id = optional_env("GATEWAY_USER_ID")?
.or_else(|| cs.gateway_user_id.clone())
.unwrap_or_else(|| "default".to_string());
.unwrap_or_else(|| owner_id.to_string());
Some(GatewayConfig {
host: optional_env("GATEWAY_HOST")?
@@ -236,7 +236,7 @@ fn default_channels_dir() -> PathBuf {
#[cfg(test)]
mod tests {
use crate::config::channels::*;
use crate::config::helpers::ENV_MUTEX;
use crate::config::helpers::lock_env;
use crate::settings::Settings;
#[test]
@@ -395,7 +395,7 @@ mod tests {
#[test]
fn resolve_uses_settings_channel_values_with_owner_scope_user_ids() {
let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let _guard = lock_env();
let mut settings = Settings::default();
settings.channels.http_enabled = true;
settings.channels.http_host = Some("127.0.0.2".to_string());
+7 -7
View File
@@ -196,7 +196,7 @@ impl EmbeddingsConfig {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::helpers::ENV_MUTEX;
use crate::config::helpers::lock_env;
use crate::settings::{EmbeddingsSettings, Settings};
use crate::testing::credentials::*;
@@ -215,7 +215,7 @@ mod tests {
#[test]
fn embeddings_disabled_not_overridden_by_openai_key() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_embedding_env();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
@@ -245,7 +245,7 @@ mod tests {
#[test]
fn embeddings_enabled_from_settings() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_embedding_env();
let settings = Settings {
@@ -265,7 +265,7 @@ mod tests {
#[test]
fn embeddings_env_override_takes_precedence() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_embedding_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
@@ -294,7 +294,7 @@ mod tests {
#[test]
fn embedding_base_url_parsed_from_env() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_embedding_env();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
@@ -313,7 +313,7 @@ mod tests {
#[test]
fn embedding_base_url_defaults_to_none() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_embedding_env();
let settings = Settings::default();
@@ -326,7 +326,7 @@ mod tests {
#[test]
fn cache_size_zero_rejected() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_embedding_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
+31 -1
View File
@@ -14,6 +14,16 @@ use crate::config::INJECTED_VARS;
#[cfg(test)]
pub(crate) static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Acquire the env-var mutex, recovering from poison.
///
/// A poisoned mutex means a previous test panicked while holding the lock.
/// The env state might be slightly stale, but cascading every subsequent
/// test into a `PoisonError` panic is far worse. Recover and carry on.
#[cfg(test)]
pub(crate) fn lock_env() -> std::sync::MutexGuard<'static, ()> {
ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner())
}
/// Thread-safe mutable overlay for env vars set at runtime.
///
/// Unlike `INJECTED_VARS` (which is set once at startup from the secrets
@@ -353,7 +363,7 @@ mod tests {
#[test]
fn real_env_var_takes_priority_over_runtime_override() {
let _guard = ENV_MUTEX.lock().unwrap();
let _guard = lock_env();
let key = "IRONCLAW_TEST_ENV_PRIORITY_42";
// Set runtime override
@@ -372,6 +382,26 @@ mod tests {
assert_eq!(env_or_override(key), Some("override_value".to_string()));
}
// --- lock_env poison recovery (regression for env mutex cascade) ---
#[test]
fn lock_env_recovers_from_poisoned_mutex() {
// Simulate a poisoned mutex: spawn a thread that panics while holding the lock.
let _ = std::thread::spawn(|| {
let _guard = ENV_MUTEX.lock().unwrap();
panic!("intentional poison");
})
.join();
// The mutex is now poisoned. lock_env() should recover, not cascade.
assert!(ENV_MUTEX.lock().is_err(), "mutex should be poisoned");
let _guard = lock_env(); // must not panic
drop(_guard);
// Clean up so this test doesn't leave ENV_MUTEX permanently poisoned.
ENV_MUTEX.clear_poison();
}
// --- validate_base_url tests (regression for #1103) ---
#[test]
+52 -29
View File
@@ -9,6 +9,7 @@ use crate::llm::config::*;
use crate::llm::registry::{ProviderProtocol, ProviderRegistry};
use crate::llm::session::SessionConfig;
use crate::settings::Settings;
impl LlmConfig {
/// Create a test-friendly config without reading env vars.
#[cfg(feature = "libsql")]
@@ -37,6 +38,7 @@ impl LlmConfig {
},
provider: None,
bedrock: None,
gemini_oauth: None,
openai_codex: None,
request_timeout_secs: 120,
cheap_model: None,
@@ -73,11 +75,16 @@ impl LlmConfig {
backend_lower == "nearai" || backend_lower == "near_ai" || backend_lower == "near";
let is_bedrock =
backend_lower == "bedrock" || backend_lower == "aws_bedrock" || backend_lower == "aws";
let is_gemini_oauth = backend_lower == "gemini_oauth" || backend_lower == "gemini-oauth";
let is_openai_codex = backend_lower == "openai_codex"
|| backend_lower == "openai-codex"
|| backend_lower == "codex";
if !is_nearai && !is_bedrock && !is_openai_codex && registry.find(&backend_lower).is_none()
if !is_nearai
&& !is_bedrock
&& !is_gemini_oauth
&& !is_openai_codex
&& registry.find(&backend_lower).is_none()
{
tracing::warn!(
"Unknown LLM backend '{}'. Will attempt as openai_compatible fallback.",
@@ -131,8 +138,8 @@ impl LlmConfig {
smart_routing_cascade: parse_optional_env("SMART_ROUTING_CASCADE", true)?,
};
// Resolve registry provider config (for non-NearAI, non-Bedrock, non-Codex backends)
let provider = if is_nearai || is_bedrock || is_openai_codex {
// Resolve registry provider config (for non-NearAI, non-Bedrock, non-Gemini, non-Codex backends)
let provider = if is_nearai || is_bedrock || is_gemini_oauth || is_openai_codex {
None
} else {
Some(Self::resolve_registry_provider(
@@ -213,6 +220,19 @@ impl LlmConfig {
let request_timeout_secs = parse_optional_env("LLM_REQUEST_TIMEOUT_SECS", 120)?;
let gemini_oauth = if backend_lower == "gemini_oauth" || backend_lower == "gemini-oauth" {
let model = Self::resolve_model("GEMINI_MODEL", settings, "gemini-2.5-flash")?;
let credentials_path = optional_env("GEMINI_CREDENTIALS_PATH")?
.map(PathBuf::from)
.unwrap_or_else(GeminiOauthConfig::default_credentials_path);
Some(GeminiOauthConfig {
model,
credentials_path,
})
} else {
None
};
// Generic cheap model (works with any backend).
// Falls back to NearAI-specific cheap_model in provider chain logic.
let cheap_model = optional_env("LLM_CHEAP_MODEL")?;
@@ -226,6 +246,8 @@ impl LlmConfig {
"nearai".to_string()
} else if is_bedrock {
"bedrock".to_string()
} else if is_gemini_oauth {
"gemini_oauth".to_string()
} else if is_openai_codex {
"openai_codex".to_string()
} else if let Some(ref p) = provider {
@@ -237,6 +259,7 @@ impl LlmConfig {
nearai,
provider,
bedrock,
gemini_oauth,
openai_codex,
request_timeout_secs,
cheap_model,
@@ -509,7 +532,7 @@ pub fn default_session_path() -> PathBuf {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::helpers::ENV_MUTEX;
use crate::config::helpers::lock_env;
use crate::settings::Settings;
use crate::testing::credentials::*;
@@ -525,7 +548,7 @@ mod tests {
#[test]
fn openai_compatible_uses_selected_model_when_llm_model_unset() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_openai_compatible_env();
let settings = Settings {
@@ -543,7 +566,7 @@ mod tests {
#[test]
fn openai_compatible_llm_model_env_overrides_selected_model() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_openai_compatible_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
@@ -667,7 +690,7 @@ mod tests {
#[test]
fn ollama_uses_selected_model_when_ollama_model_unset() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_ollama_env();
let settings = Settings {
@@ -684,7 +707,7 @@ mod tests {
#[test]
fn ollama_model_env_overrides_selected_model() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_ollama_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
@@ -710,7 +733,7 @@ mod tests {
#[test]
fn openai_compatible_preserves_dotted_model_name() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_openai_compatible_env();
let settings = Settings {
@@ -731,7 +754,7 @@ mod tests {
#[test]
fn registry_provider_resolves_groq() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("LLM_BACKEND");
@@ -756,7 +779,7 @@ mod tests {
#[test]
fn registry_provider_resolves_tinfoil() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("LLM_BACKEND");
@@ -784,7 +807,7 @@ mod tests {
#[test]
fn registry_provider_alias_resolves_zai() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("LLM_BACKEND");
@@ -809,7 +832,7 @@ mod tests {
#[test]
fn registry_provider_resolves_github_copilot_alias() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::set_var("LLM_BACKEND", "github-copilot");
@@ -857,7 +880,7 @@ mod tests {
#[test]
fn nearai_backend_has_no_registry_provider() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("LLM_BACKEND");
@@ -871,7 +894,7 @@ mod tests {
#[test]
fn backend_alias_normalized_to_canonical_id() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_openai_compatible_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
@@ -897,7 +920,7 @@ mod tests {
#[test]
fn unknown_backend_falls_back_to_openai_compatible() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_openai_compatible_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
@@ -921,7 +944,7 @@ mod tests {
#[test]
fn nearai_aliases_all_resolve_to_nearai() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
for alias in &["nearai", "near_ai", "near"] {
// SAFETY: Under ENV_MUTEX.
@@ -948,7 +971,7 @@ mod tests {
#[test]
fn base_url_resolution_priority() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_openai_compatible_env();
// SAFETY: Under ENV_MUTEX.
@@ -1006,7 +1029,7 @@ mod tests {
fn anthropic_oauth_token_sets_placeholder_api_key() {
use secrecy::ExposeSecret;
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_anthropic_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
@@ -1044,7 +1067,7 @@ mod tests {
fn anthropic_api_key_takes_priority_over_oauth() {
use secrecy::ExposeSecret;
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_anthropic_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
@@ -1077,7 +1100,7 @@ mod tests {
#[test]
fn non_anthropic_provider_has_no_oauth_token() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_anthropic_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
@@ -1185,7 +1208,7 @@ mod tests {
#[test]
fn test_request_timeout_defaults_to_120() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("LLM_REQUEST_TIMEOUT_SECS");
@@ -1196,7 +1219,7 @@ mod tests {
#[test]
fn test_request_timeout_configurable() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::set_var("LLM_REQUEST_TIMEOUT_SECS", "300");
@@ -1223,7 +1246,7 @@ mod tests {
#[test]
fn openai_codex_resolves_config() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_openai_codex_env();
let settings = Settings {
@@ -1243,7 +1266,7 @@ mod tests {
#[test]
fn openai_codex_model_env_resolution() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_openai_codex_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
@@ -1267,7 +1290,7 @@ mod tests {
#[test]
fn openai_codex_falls_back_to_openai_model() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_openai_codex_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
@@ -1291,7 +1314,7 @@ mod tests {
#[test]
fn openai_codex_falls_back_to_selected_model() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_openai_codex_env();
let settings = Settings {
@@ -1308,7 +1331,7 @@ mod tests {
/// Regression: SSRF validation on OPENAI_CODEX_API_URL (#1103).
#[test]
fn openai_codex_rejects_ssrf_api_url() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_openai_codex_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
@@ -1339,7 +1362,7 @@ mod tests {
/// Regression: SSRF validation on OPENAI_CODEX_AUTH_URL (#1103).
#[test]
fn openai_codex_rejects_ssrf_auth_url() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_openai_codex_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
+10 -9
View File
@@ -24,7 +24,7 @@ mod skills;
mod transcription;
mod tunnel;
mod wasm;
mod workspace;
pub(crate) mod workspace;
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex, Once};
@@ -56,8 +56,8 @@ pub use self::tunnel::TunnelConfig;
pub use self::wasm::WasmConfig;
pub use self::workspace::WorkspaceConfig;
pub use crate::llm::config::{
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER, OpenAiCodexConfig,
RegistryProviderConfig,
BedrockConfig, CacheRetention, GeminiOauthConfig, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER,
OpenAiCodexConfig, RegistryProviderConfig,
};
pub use crate::llm::session::SessionConfig;
@@ -178,9 +178,7 @@ impl Config {
},
transcription: TranscriptionConfig::default(),
search: WorkspaceSearchConfig::default(),
workspace: WorkspaceConfig {
memory_layers: vec![],
},
workspace: WorkspaceConfig::default(),
observability: crate::observability::ObservabilityConfig::default(),
relay: None,
}
@@ -313,11 +311,14 @@ impl Config {
let tunnel = TunnelConfig::resolve(settings)?;
let channels = ChannelsConfig::resolve(settings, &owner_id)?;
// Resolve workspace config using the gateway user_id for default layers.
let workspace_user_id = channels
.gateway
.as_ref()
.map(|gw| gw.user_id.clone())
.unwrap_or_else(|| "default".to_string());
.map(|gw| gw.user_id.as_str())
.unwrap_or("default");
let workspace = WorkspaceConfig::resolve(workspace_user_id)?;
Ok(Self {
owner_id: owner_id.clone(),
@@ -339,7 +340,7 @@ impl Config {
skills: SkillsConfig::resolve()?,
transcription: TranscriptionConfig::resolve(settings)?,
search: WorkspaceSearchConfig::resolve()?,
workspace: WorkspaceConfig::resolve(&workspace_user_id)?,
workspace,
observability: crate::observability::ObservabilityConfig {
backend: std::env::var("OBSERVABILITY_BACKEND").unwrap_or_else(|_| "none".into()),
},
+3 -3
View File
@@ -19,12 +19,12 @@ pub(crate) fn resolve_safety_config(
#[cfg(test)]
mod tests {
use super::*;
use crate::config::helpers::ENV_MUTEX;
use crate::config::helpers::lock_env;
use crate::settings::Settings;
#[test]
fn resolve_falls_back_to_settings() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
let mut settings = Settings::default();
settings.safety.max_output_length = 42;
settings.safety.injection_check_enabled = false;
@@ -36,7 +36,7 @@ mod tests {
#[test]
fn env_overrides_settings() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
let mut settings = Settings::default();
settings.safety.max_output_length = 42;
+5 -15
View File
@@ -594,9 +594,7 @@ mod tests {
#[test]
fn sandbox_resolve_falls_back_to_settings() {
let _guard = crate::config::helpers::ENV_MUTEX
.lock()
.expect("env mutex poisoned");
let _guard = crate::config::helpers::lock_env();
let mut settings = crate::settings::Settings::default();
settings.sandbox.cpu_shares = 99;
settings.sandbox.auto_pull_image = false;
@@ -610,9 +608,7 @@ mod tests {
#[test]
fn sandbox_env_overrides_settings() {
let _guard = crate::config::helpers::ENV_MUTEX
.lock()
.expect("env mutex poisoned");
let _guard = crate::config::helpers::lock_env();
let mut settings = crate::settings::Settings::default();
settings.sandbox.timeout_secs = 999;
@@ -628,9 +624,7 @@ mod tests {
#[test]
fn claude_code_resolve_uses_settings_enabled() {
let _guard = crate::config::helpers::ENV_MUTEX
.lock()
.expect("env mutex poisoned");
let _guard = crate::config::helpers::lock_env();
let mut settings = crate::settings::Settings::default();
settings.sandbox.claude_code_enabled = true;
@@ -640,9 +634,7 @@ mod tests {
#[test]
fn claude_code_resolve_defaults_disabled() {
let _guard = crate::config::helpers::ENV_MUTEX
.lock()
.expect("env mutex poisoned");
let _guard = crate::config::helpers::lock_env();
let settings = crate::settings::Settings::default();
let cfg = ClaudeCodeConfig::resolve(&settings).expect("resolve");
assert!(!cfg.enabled);
@@ -650,9 +642,7 @@ mod tests {
#[test]
fn claude_code_env_overrides_settings() {
let _guard = crate::config::helpers::ENV_MUTEX
.lock()
.expect("env mutex poisoned");
let _guard = crate::config::helpers::lock_env();
let mut settings = crate::settings::Settings::default();
settings.sandbox.claude_code_enabled = true;
+7 -7
View File
@@ -92,7 +92,7 @@ impl WorkspaceSearchConfig {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::helpers::ENV_MUTEX;
use crate::config::helpers::lock_env;
fn clear_search_env() {
// SAFETY: Only called under ENV_MUTEX in tests.
@@ -106,7 +106,7 @@ mod tests {
#[test]
fn defaults_when_no_env() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_search_env();
let config = WorkspaceSearchConfig::resolve().expect("should resolve");
@@ -118,7 +118,7 @@ mod tests {
#[test]
fn env_overrides() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_search_env();
// SAFETY: Under ENV_MUTEX.
@@ -140,7 +140,7 @@ mod tests {
#[test]
fn invalid_strategy_rejected() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_search_env();
// SAFETY: Under ENV_MUTEX.
@@ -156,7 +156,7 @@ mod tests {
#[test]
fn weighted_strategy_defaults() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_search_env();
// SAFETY: Under ENV_MUTEX.
@@ -175,7 +175,7 @@ mod tests {
#[test]
fn weighted_both_zero_rejected() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_search_env();
// SAFETY: Under ENV_MUTEX.
@@ -193,7 +193,7 @@ mod tests {
#[test]
fn rrf_both_zero_allowed() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
clear_search_env();
// SAFETY: Under ENV_MUTEX.
+9 -6
View File
@@ -89,7 +89,9 @@ impl TranscriptionConfig {
}
/// Create the transcription provider if enabled and configured.
pub fn create_provider(&self) -> Option<Box<dyn crate::transcription::TranscriptionProvider>> {
pub fn create_provider(
&self,
) -> Option<Box<dyn crate::llm::transcription::TranscriptionProvider>> {
if !self.enabled {
return None;
}
@@ -103,10 +105,11 @@ impl TranscriptionConfig {
"Audio transcription enabled via Chat Completions API"
);
let mut provider = crate::transcription::ChatCompletionsTranscriptionProvider::new(
api_key.clone(),
)
.with_model(&self.model);
let mut provider =
crate::llm::transcription::ChatCompletionsTranscriptionProvider::new(
api_key.clone(),
)
.with_model(&self.model);
if let Some(ref base_url) = self.base_url {
provider = provider.with_base_url(base_url);
@@ -121,7 +124,7 @@ impl TranscriptionConfig {
);
let mut provider =
crate::transcription::OpenAiWhisperProvider::new(api_key.clone())
crate::llm::transcription::OpenAiWhisperProvider::new(api_key.clone())
.with_model(&self.model);
if let Some(ref base_url) = self.base_url {
+3 -3
View File
@@ -95,12 +95,12 @@ impl WasmConfig {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::helpers::ENV_MUTEX;
use crate::config::helpers::lock_env;
use crate::settings::Settings;
#[test]
fn resolve_falls_back_to_settings() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
let mut settings = Settings::default();
settings.wasm.default_memory_limit = 42;
settings.wasm.cache_compiled = false;
@@ -112,7 +112,7 @@ mod tests {
#[test]
fn env_overrides_settings() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
let mut settings = Settings::default();
settings.wasm.default_fuel_limit = 42;
+70 -12
View File
@@ -2,18 +2,29 @@ use crate::config::helpers::optional_env;
use crate::error::ConfigError;
use crate::workspace::layer::MemoryLayer;
/// Workspace memory configuration.
/// Workspace-level configuration (memory layers, read scopes).
///
/// Controls memory layer definitions for privacy-aware writes.
/// Layers are parsed from the `MEMORY_LAYERS` env var (JSON array)
/// or default to a single private layer scoped to the gateway user.
#[derive(Debug, Clone)]
/// Parsed from environment variables. Lives outside of `GatewayConfig`
/// so that non-gateway channels can eventually use the same settings.
#[derive(Debug, Clone, Default)]
pub struct WorkspaceConfig {
/// Memory layer definitions (JSON in `MEMORY_LAYERS` env var, or defaults).
pub memory_layers: Vec<MemoryLayer>,
/// Additional user scopes for workspace reads.
///
/// When set, the workspace can read (search, read, list) from these
/// additional user scopes while writes remain isolated to the primary
/// `user_id`. Parsed from `WORKSPACE_READ_SCOPES` (comma-separated).
pub read_scopes: Vec<String>,
}
impl WorkspaceConfig {
pub(crate) fn resolve(user_id: &str) -> Result<Self, ConfigError> {
/// Resolve workspace config from environment variables.
///
/// `user_id` is used to derive default memory layers when `MEMORY_LAYERS`
/// is not set.
pub fn resolve(user_id: &str) -> Result<Self, ConfigError> {
// --- Memory layers ---
let memory_layers: Vec<MemoryLayer> = match optional_env("MEMORY_LAYERS")? {
Some(json_str) => {
serde_json::from_str(&json_str).map_err(|e| ConfigError::InvalidValue {
@@ -57,6 +68,20 @@ impl WorkspaceConfig {
message: format!("layer '{}' has an empty scope", layer.name),
});
}
if !layer
.scope
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(ConfigError::InvalidValue {
key: "MEMORY_LAYERS".to_string(),
message: format!(
"layer '{}' scope '{}' contains invalid characters \
(allowed: a-z, A-Z, 0-9, _, -)",
layer.name, layer.scope
),
});
}
}
// Check for duplicate layer names
@@ -72,20 +97,53 @@ impl WorkspaceConfig {
}
}
Ok(Self { memory_layers })
// --- Read scopes ---
let read_scopes: Vec<String> = optional_env("WORKSPACE_READ_SCOPES")?
.map(|s| {
s.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default();
for scope in &read_scopes {
if scope.len() > 128 {
let prefix: String = scope.chars().take(32).collect();
return Err(ConfigError::InvalidValue {
key: "WORKSPACE_READ_SCOPES".to_string(),
message: format!("scope '{prefix}...' exceeds 128 characters"),
});
}
if !scope
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(ConfigError::InvalidValue {
key: "WORKSPACE_READ_SCOPES".to_string(),
message: format!(
"scope '{}' contains invalid characters \
(allowed: a-z, A-Z, 0-9, _, -)",
scope
),
});
}
}
Ok(Self {
memory_layers,
read_scopes,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
// Serialize env-var-dependent tests to avoid races.
static ENV_LOCK: Mutex<()> = Mutex::new(());
use crate::config::helpers::lock_env;
fn with_env(key: &str, val: Option<&str>, f: impl FnOnce()) {
let _guard = ENV_LOCK.lock().unwrap();
let _guard = lock_env();
let prev = std::env::var(key).ok();
match val {
Some(v) => unsafe { std::env::set_var(key, v) },
+6 -6
View File
@@ -36,7 +36,7 @@ pub(crate) fn resolve_embedding_dimension() -> Option<usize> {
.unwrap_or(false);
if !enabled {
tracing::info!("Vector index setup skipped (EMBEDDING_ENABLED not set in env)");
tracing::debug!("Vector index setup skipped (EMBEDDING_ENABLED not set in env)");
return None;
}
@@ -1017,7 +1017,7 @@ mod tests {
mod resolve_dimension {
use super::*;
use crate::config::helpers::ENV_MUTEX;
use crate::config::helpers::lock_env;
fn clear_embedding_env() {
// SAFETY: called under ENV_MUTEX
@@ -1030,14 +1030,14 @@ mod tests {
#[test]
fn returns_none_when_disabled() {
let _guard = ENV_MUTEX.lock().expect("env mutex");
let _guard = lock_env();
clear_embedding_env();
assert!(resolve_embedding_dimension().is_none());
}
#[test]
fn returns_explicit_dimension() {
let _guard = ENV_MUTEX.lock().expect("env mutex");
let _guard = lock_env();
clear_embedding_env();
// SAFETY: under ENV_MUTEX
unsafe {
@@ -1053,7 +1053,7 @@ mod tests {
#[test]
fn infers_from_model() {
let _guard = ENV_MUTEX.lock().expect("env mutex");
let _guard = lock_env();
clear_embedding_env();
// SAFETY: under ENV_MUTEX
unsafe {
@@ -1069,7 +1069,7 @@ mod tests {
#[test]
fn defaults_to_1536_for_unknown_model() {
let _guard = ENV_MUTEX.lock().expect("env mutex");
let _guard = lock_env();
clear_embedding_env();
// SAFETY: under ENV_MUTEX
unsafe {
+98 -1
View File
@@ -97,7 +97,7 @@ pub async fn connect_with_handles(
.map_err(|e| DatabaseError::Pool(e.to_string()))?
};
backend.run_migrations().await?;
tracing::info!("libSQL database connected and migrations applied");
tracing::debug!("libSQL database connected and migrations applied");
handles.libsql_db = Some(backend.shared_db());
@@ -644,6 +644,103 @@ pub trait WorkspaceStore: Send + Sync {
embedding: Option<&[f32]>,
config: &SearchConfig,
) -> Result<Vec<SearchResult>, WorkspaceError>;
// ==================== Multi-scope read methods ====================
//
// Default implementations loop over user_ids calling single-scope methods,
// then merge results. Backends can override with efficient SQL (e.g.,
// `WHERE user_id = ANY($1::text[])`).
/// Hybrid search across multiple user scopes, merging results by score.
///
/// **Note:** The default implementation calls `hybrid_search` per scope and
/// merges by raw score. Because RRF scores are normalized independently
/// within each scope, scores are not directly comparable across scopes.
/// The Postgres backend overrides this with a single combined query that
/// applies RRF once to the unified result set.
async fn hybrid_search_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
query: &str,
embedding: Option<&[f32]>,
config: &SearchConfig,
) -> Result<Vec<SearchResult>, WorkspaceError> {
if user_ids.len() > 1 {
tracing::debug!(
scope_count = user_ids.len(),
"hybrid_search_multi: using default per-scope RRF merge; \
cross-scope score comparison may be unreliable"
);
}
let mut all_results = Vec::new();
for uid in user_ids {
let results = self
.hybrid_search(uid, agent_id, query, embedding, config)
.await?;
all_results.extend(results);
}
// Re-sort by score descending and truncate to limit
all_results.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
all_results.truncate(config.limit);
Ok(all_results)
}
/// List all file paths across multiple user scopes.
async fn list_all_paths_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
) -> Result<Vec<String>, WorkspaceError> {
let mut all_paths = Vec::new();
for uid in user_ids {
let paths = self.list_all_paths(uid, agent_id).await?;
all_paths.extend(paths);
}
all_paths.sort();
all_paths.dedup();
Ok(all_paths)
}
/// Get a document by path, searching across multiple user scopes.
///
/// Returns the first match found (tries each user_id in order).
async fn get_document_by_path_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
path: &str,
) -> Result<MemoryDocument, WorkspaceError> {
for uid in user_ids {
match self.get_document_by_path(uid, agent_id, path).await {
Ok(doc) => return Ok(doc),
Err(WorkspaceError::DocumentNotFound { .. }) => continue,
Err(e) => return Err(e),
}
}
Err(WorkspaceError::DocumentNotFound {
doc_type: path.to_string(),
user_id: format!("[{}]", user_ids.join(", ")),
})
}
/// List directory contents across multiple user scopes.
async fn list_directory_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
directory: &str,
) -> Result<Vec<WorkspaceEntry>, WorkspaceError> {
let mut all_entries = Vec::new();
for uid in user_ids {
all_entries.extend(self.list_directory(uid, agent_id, directory).await?);
}
Ok(crate::workspace::merge_workspace_entries(all_entries))
}
}
/// Backend-agnostic database supertrait.
+45
View File
@@ -717,4 +717,49 @@ impl WorkspaceStore for PgBackend {
.hybrid_search(user_id, agent_id, query, embedding, config)
.await
}
// Optimized multi-scope overrides using `ANY($1::text[])` SQL.
async fn hybrid_search_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
query: &str,
embedding: Option<&[f32]>,
config: &SearchConfig,
) -> Result<Vec<SearchResult>, WorkspaceError> {
self.repo
.hybrid_search_multi(user_ids, agent_id, query, embedding, config)
.await
}
async fn list_all_paths_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
) -> Result<Vec<String>, WorkspaceError> {
self.repo.list_all_paths_multi(user_ids, agent_id).await
}
async fn get_document_by_path_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
path: &str,
) -> Result<MemoryDocument, WorkspaceError> {
self.repo
.get_document_by_path_multi(user_ids, agent_id, path)
.await
}
async fn list_directory_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
directory: &str,
) -> Result<Vec<WorkspaceEntry>, WorkspaceError> {
self.repo
.list_directory_multi(user_ids, agent_id, directory)
.await
}
}
-3
View File
@@ -304,9 +304,6 @@ pub enum WorkspaceError {
#[error("I/O error: {reason}")]
IoError { reason: String },
#[error("Not found: {path}")]
NotFound { path: String },
#[error("Layer not found: {name}")]
LayerNotFound { name: String },
+6 -18
View File
@@ -7305,9 +7305,7 @@ mod tests {
#[test]
fn should_use_gateway_mode_true_for_tunnel_url() {
let _guard = crate::config::helpers::ENV_MUTEX
.lock()
.expect("env mutex poisoned");
let _guard = crate::config::helpers::lock_env();
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
@@ -7329,9 +7327,7 @@ mod tests {
#[test]
fn should_use_gateway_mode_false_without_tunnel() {
let _guard = crate::config::helpers::ENV_MUTEX
.lock()
.expect("env mutex poisoned");
let _guard = crate::config::helpers::lock_env();
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
unsafe {
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
@@ -7352,9 +7348,7 @@ mod tests {
#[test]
fn should_use_gateway_mode_false_for_loopback_tunnel() {
let _guard = crate::config::helpers::ENV_MUTEX
.lock()
.expect("env mutex poisoned");
let _guard = crate::config::helpers::lock_env();
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
unsafe {
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
@@ -7382,9 +7376,7 @@ mod tests {
impl EnvGuard {
fn new() -> Self {
let guard = crate::config::helpers::ENV_MUTEX
.lock()
.expect("env mutex poisoned");
let guard = crate::config::helpers::lock_env();
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
@@ -7442,9 +7434,7 @@ mod tests {
#[test]
fn gateway_callback_redirect_uri_does_not_duplicate_callback_path_from_env() {
let _guard = crate::config::helpers::ENV_MUTEX
.lock()
.expect("env mutex poisoned");
let _guard = crate::config::helpers::lock_env();
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
unsafe {
std::env::set_var(
@@ -7470,9 +7460,7 @@ mod tests {
#[test]
fn gateway_callback_redirect_uri_trims_trailing_slash_from_env_callback() {
let _guard = crate::config::helpers::ENV_MUTEX
.lock()
.expect("env mutex poisoned");
let _guard = crate::config::helpers::lock_env();
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
unsafe {
std::env::set_var(
-1
View File
@@ -72,7 +72,6 @@ pub mod skills;
pub mod timezone;
pub mod tools;
pub mod tracing_fmt;
pub mod transcription;
pub mod tunnel;
pub mod util;
pub mod webhooks;
+33
View File
@@ -165,6 +165,8 @@ pub struct LlmConfig {
pub provider: Option<RegistryProviderConfig>,
/// AWS Bedrock config (populated when backend=bedrock, requires --features bedrock).
pub bedrock: Option<BedrockConfig>,
/// Gemini OAuth config (populated when backend=gemini_oauth).
pub gemini_oauth: Option<GeminiOauthConfig>,
/// OpenAI Codex config (populated when backend=openai_codex).
pub openai_codex: Option<OpenAiCodexConfig>,
/// HTTP request timeout in seconds for LLM API calls.
@@ -267,3 +269,34 @@ impl NearAiConfig {
}
}
}
/// Configuration for Gemini OAuth integration.
///
/// Extended generation config parameters (topP, topK, seed, etc.) are read from
/// environment variables at request time:
/// - `GEMINI_TOP_P` — nucleus sampling (0.01.0)
/// - `GEMINI_TOP_K` — top-k sampling (integer)
/// - `GEMINI_SEED` — deterministic generation seed
/// - `GEMINI_PRESENCE_PENALTY` — presence penalty (-2.02.0)
/// - `GEMINI_FREQUENCY_PENALTY` — frequency penalty (-2.02.0)
/// - `GEMINI_RESPONSE_MIME_TYPE` — e.g. "application/json"
/// - `GEMINI_RESPONSE_JSON_SCHEMA` — JSON schema string for structured output
/// - `GEMINI_CACHED_CONTENT` — cached content resource name
/// - `GEMINI_CLI_CUSTOM_HEADERS` — custom headers (key:value,key:value)
/// - `GOOGLE_GENAI_API_VERSION` — API version (default: v1beta)
/// - `GEMINI_API_KEY` — optional API key for non-OAuth auth mode
/// - `GEMINI_API_KEY_AUTH_MECHANISM` — "x-goog-api-key" (default) or "bearer"
#[derive(Debug, Clone)]
pub struct GeminiOauthConfig {
pub model: String,
pub credentials_path: PathBuf,
}
impl GeminiOauthConfig {
pub fn default_credentials_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gemini")
.join("oauth_creds.json")
}
}
File diff suppressed because it is too large Load Diff
+57 -1
View File
@@ -18,6 +18,7 @@ pub mod config;
pub mod costs;
pub mod error;
pub mod failover;
pub mod gemini_oauth;
mod github_copilot;
pub(crate) mod github_copilot_auth;
mod nearai_chat;
@@ -34,6 +35,7 @@ mod rig_adapter;
pub mod session;
pub mod smart_routing;
mod token_refreshing;
pub mod transcription;
#[cfg(test)]
mod codex_test_helpers;
@@ -50,13 +52,14 @@ pub use config::{
};
pub use error::LlmError;
pub use failover::{CooldownConfig, FailoverProvider};
pub use gemini_oauth::GeminiOauthProvider;
pub use nearai_chat::{DEFAULT_MODEL, ModelInfo, NearAiChatProvider, default_models};
pub use openai_codex_provider::OpenAiCodexProvider;
pub use openai_codex_session::{OpenAiCodexSession, OpenAiCodexSessionManager};
pub use provider::{
ChatMessage, CompletionRequest, CompletionResponse, ContentPart, FinishReason, ImageUrl,
LlmProvider, ModelMetadata, Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse,
ToolDefinition, ToolResult,
ToolDefinition, ToolResult, generate_tool_call_id,
};
pub use reasoning::{
ActionPlan, Reasoning, ReasoningContext, RespondOutput, RespondResult, SILENT_REPLY_TOKEN,
@@ -93,6 +96,10 @@ pub async fn create_llm_provider(
return create_llm_provider_with_config(&config.nearai, session, timeout);
}
if config.backend == "gemini_oauth" || config.backend == "gemini-oauth" {
return create_gemini_oauth_provider(config);
}
// Bedrock uses a native AWS SDK, not the rig-core registry
if config.backend == "bedrock" {
#[cfg(feature = "bedrock")]
@@ -490,6 +497,19 @@ fn create_cheap_provider_for_backend(
});
}
if config.backend == "gemini_oauth" {
let Some(ref gemini_config) = config.gemini_oauth else {
return Err(LlmError::RequestFailed {
provider: "gemini_oauth".to_string(),
reason: "Gemini OAuth config not available for cheap model".to_string(),
});
};
let mut cheap_gemini_config = gemini_config.clone();
cheap_gemini_config.model = cheap_model.to_string();
let provider = GeminiOauthProvider::new(cheap_gemini_config)?;
return Ok(Some(Arc::new(provider)));
}
// Registry-based provider: clone config and swap model
let reg_config = config.provider.as_ref().ok_or_else(|| LlmError::RequestFailed {
provider: config.backend.clone(),
@@ -674,6 +694,17 @@ pub async fn build_provider_chain(
Ok((llm, cheap_llm, recording_handle))
}
pub fn create_gemini_oauth_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
let gemini_config = config
.gemini_oauth
.clone()
.ok_or_else(|| LlmError::AuthFailed {
provider: "gemini_oauth".to_string(),
})?;
let provider = gemini_oauth::GeminiOauthProvider::new(gemini_config)?;
Ok(Arc::new(provider))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -705,6 +736,7 @@ mod tests {
nearai: test_nearai_config(),
provider: None,
bedrock: None,
gemini_oauth: None,
request_timeout_secs: 120,
cheap_model: None,
smart_routing_cascade: true,
@@ -786,6 +818,30 @@ mod tests {
);
}
#[test]
fn test_create_cheap_llm_provider_gemini_oauth_creates_provider() {
let mut config = test_llm_config();
config.backend = "gemini_oauth".to_string();
config.cheap_model = Some("gemini-2.5-flash-lite".to_string());
config.gemini_oauth = Some(crate::config::GeminiOauthConfig {
model: "gemini-2.5-pro".to_string(),
credentials_path: std::path::PathBuf::from("/tmp/nonexistent-creds.json"),
});
let session = Arc::new(SessionManager::new(SessionConfig::default()));
let result = create_cheap_llm_provider(&config, session);
// Should succeed and return a provider (credentials validation is deferred
// until the first LLM call, not at construction time).
let provider = result.expect("gemini_oauth cheap provider should succeed");
assert!(provider.is_some(), "Should return Some(provider)");
assert_eq!(
provider.unwrap().model_name(),
"gemini-2.5-flash-lite",
"Cheap provider should use the overridden model name"
);
}
#[test]
fn test_cheap_model_name_resolution() {
// Generic takes priority
+1
View File
@@ -344,6 +344,7 @@ pub(crate) fn build_nearai_model_fetch_config() -> crate::config::LlmConfig {
nearai: crate::config::NearAiConfig::for_model_discovery(),
provider: None,
bedrock: None,
gemini_oauth: None,
request_timeout_secs: 120,
cheap_model: None,
smart_routing_cascade: false,
+3 -3
View File
@@ -361,7 +361,7 @@ pub fn landing_html(provider_name: &str, success: bool) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::helpers::ENV_MUTEX;
use crate::config::helpers::lock_env;
#[test]
fn loopback_detection() {
@@ -390,7 +390,7 @@ mod tests {
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn bind_rejects_wildcard_ipv4() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
let original = std::env::var("OAUTH_CALLBACK_HOST").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe { std::env::set_var("OAUTH_CALLBACK_HOST", "0.0.0.0") };
@@ -414,7 +414,7 @@ mod tests {
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn bind_rejects_wildcard_ipv6() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let _guard = lock_env();
let original = std::env::var("OAUTH_CALLBACK_HOST").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe { std::env::set_var("OAUTH_CALLBACK_HOST", "::") };
+97
View File
@@ -233,6 +233,32 @@ pub struct ToolCall {
pub arguments: serde_json::Value,
}
/// Generate a tool-call ID that satisfies all providers.
///
/// Mistral requires exactly 9 alphanumeric characters (`[a-zA-Z0-9]{9}`).
/// Other providers accept any non-empty string. By default we produce a
/// 9-char base-62 string derived from two seed values so the ID is both
/// deterministic (for replayed history) and provider-compatible.
pub fn generate_tool_call_id(seed_a: usize, seed_b: usize) -> String {
// Mix the two seeds into a single u64 using a simple hash-like combine.
let combined = (seed_a as u64)
.wrapping_mul(6364136223846793005)
.wrapping_add(seed_b as u64);
// Format as 9-char zero-padded base-62 (0-9, a-z, A-Z).
let mut buf = [b'0'; 9];
let mut val = combined;
for b in buf.iter_mut().rev() {
let digit = (val % 62) as u8;
*b = match digit {
0..=9 => b'0' + digit,
10..=35 => b'a' + (digit - 10),
_ => b'A' + (digit - 36),
};
val /= 62;
}
buf.iter().map(|&b| b as char).collect::<String>()
}
/// Result of a tool execution to send back to the LLM.
#[derive(Debug, Clone)]
pub struct ToolResult {
@@ -533,6 +559,77 @@ pub fn strip_unsupported_tool_params(
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn generate_tool_call_id_has_valid_format() {
let samples = [
(0usize, 0usize),
(1usize, 2usize),
(42usize, 999usize),
(usize::MAX, usize::MAX),
];
for (a, b) in samples {
let id = generate_tool_call_id(a, b);
assert_eq!(
id.len(),
9,
"tool-call ID must be exactly 9 characters for seeds ({a}, {b})"
);
assert!(
id.chars().all(|c| c.is_ascii_alphanumeric()),
"tool-call ID must be ASCII alphanumeric for seeds ({a}, {b}), got: {id}"
);
}
}
#[test]
fn generate_tool_call_id_is_deterministic_for_same_seeds() {
let pairs = [
(0usize, 0usize),
(1usize, 2usize),
(123usize, 456usize),
(usize::MAX, 0usize),
];
for (a, b) in pairs {
let id1 = generate_tool_call_id(a, b);
let id2 = generate_tool_call_id(a, b);
let id3 = generate_tool_call_id(a, b);
assert_eq!(
id1, id2,
"tool-call ID must be deterministic for seeds ({a}, {b})"
);
assert_eq!(
id2, id3,
"tool-call ID must be deterministic across multiple calls for seeds ({a}, {b})"
);
}
}
#[test]
fn generate_tool_call_id_differs_for_different_seeds_in_small_sample() {
let seed_pairs = [
(0usize, 1usize),
(1usize, 0usize),
(1usize, 2usize),
(2usize, 3usize),
(10usize, 20usize),
(100usize, 200usize),
];
let mut ids = HashSet::new();
for (a, b) in seed_pairs {
let id = generate_tool_call_id(a, b);
let inserted = ids.insert(id.clone());
assert!(
inserted,
"expected distinct tool-call IDs for different seeds, \
but duplicate ID '{id}' found for seeds ({a}, {b})"
);
}
}
#[test]
fn test_sanitize_preserves_valid_pairs() {
+20 -4
View File
@@ -23,6 +23,13 @@ You said you would perform an action, but you did not include any tool calls.\n\
Do NOT describe what you intend to do actually call the tool now.\n\
Use the tool_calls mechanism to invoke the appropriate tool.";
/// Seed value used as the second argument to `generate_tool_call_id` when
/// recovering tool calls from malformed LLM text responses. This must differ
/// from the `0` seed used in `rig_adapter::normalized_tool_call_id` to avoid
/// ID collisions between provider-generated and text-recovered tool calls at
/// the same positional index.
const RECOVERED_TOOL_CALL_SEED: usize = 99;
/// Detect when an LLM response expresses intent to call a tool without
/// actually issuing tool calls. Returns `true` if the text contains phrases
/// like "Let me search …" or "I'll fetch …" outside of fenced/indented code blocks.
@@ -1337,7 +1344,10 @@ fn recover_tool_calls_from_content(
.cloned()
.unwrap_or(serde_json::Value::Object(Default::default()));
calls.push(ToolCall {
id: format!("recovered_{}", calls.len()),
id: super::provider::generate_tool_call_id(
calls.len(),
RECOVERED_TOOL_CALL_SEED,
),
name: name.to_string(),
arguments,
});
@@ -1348,7 +1358,10 @@ fn recover_tool_calls_from_content(
let name = inner.trim();
if tool_names.contains(name) {
calls.push(ToolCall {
id: format!("recovered_{}", calls.len()),
id: super::provider::generate_tool_call_id(
calls.len(),
RECOVERED_TOOL_CALL_SEED,
),
name: name.to_string(),
arguments: serde_json::Value::Object(Default::default()),
});
@@ -1382,7 +1395,10 @@ fn recover_tool_calls_from_content(
let arguments = serde_json::from_str::<serde_json::Value>(args_str)
.unwrap_or(serde_json::Value::Object(Default::default()));
calls.push(ToolCall {
id: format!("recovered_{}", calls.len()),
id: super::provider::generate_tool_call_id(
calls.len(),
RECOVERED_TOOL_CALL_SEED,
),
name: name.to_string(),
arguments,
});
@@ -1393,7 +1409,7 @@ fn recover_tool_calls_from_content(
// No arguments or malformed — call with empty args
calls.push(ToolCall {
id: format!("recovered_{}", calls.len()),
id: super::provider::generate_tool_call_id(calls.len(), RECOVERED_TOOL_CALL_SEED),
name: name.to_string(),
arguments: serde_json::Value::Object(Default::default()),
});
+131 -16
View File
@@ -20,6 +20,7 @@ use rust_decimal_macros::dec;
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value as JsonValue;
use sha2::{Digest, Sha256};
use std::collections::HashSet;
@@ -400,11 +401,48 @@ fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<RigMessage
}
/// Responses-style providers require a non-empty tool call ID.
///
/// IDs must be compatible with providers like Mistral, which constrain IDs
/// to `[a-zA-Z0-9]{9}`. We therefore:
/// - pass through any non-empty raw ID that already matches this constraint;
/// - otherwise deterministically map the raw string into a provider-compliant ID;
/// - and when `raw` is empty/None, delegate to `generate_tool_call_id`.
fn normalized_tool_call_id(raw: Option<&str>, seed: usize) -> String {
match raw.map(str::trim).filter(|id| !id.is_empty()) {
Some(id) => id.to_string(),
None => format!("generated_tool_call_{seed}"),
// Trim and treat empty as None.
let trimmed = raw.and_then(|s| {
let t = s.trim();
if t.is_empty() { None } else { Some(t) }
});
if let Some(id) = trimmed {
// If the ID already satisfies `[a-zA-Z0-9]{9}`, pass it through unchanged.
if id.len() == 9 && id.chars().all(|c| c.is_ascii_alphanumeric()) {
return id.to_string();
}
// Otherwise, deterministically hash the raw ID and feed the hash-derived
// seed into the provider-level generator so that the encoding and any
// provider-specific constraints remain centralized in one place.
let digest = Sha256::digest(id.as_bytes());
// Derive a 64-bit value from the first 8 bytes of the digest, then
// split it into two usize seeds so we preserve all 64 bits of entropy
// even on 32-bit targets.
let hash64 = {
// SHA-256 always produces 32 bytes, so indexing the first 8 is safe.
let bytes: [u8; 8] = [
digest[0], digest[1], digest[2], digest[3], digest[4], digest[5], digest[6],
digest[7],
];
u64::from_be_bytes(bytes)
};
let hi_seed: usize = (hash64 >> 32) as usize;
let lo_seed: usize = (hash64 & 0xFFFF_FFFF) as usize;
return super::provider::generate_tool_call_id(hi_seed, lo_seed);
}
// Fallback for missing/empty raw IDs: use the provider-level generator,
// which already produces compliant IDs.
super::provider::generate_tool_call_id(seed, 0)
}
/// Convert IronClaw tool definitions to rig-core format.
@@ -813,8 +851,9 @@ mod tests {
#[test]
fn test_convert_messages_tool_result() {
// Use a conforming 9-char alphanumeric ID so it passes through unchanged.
let messages = vec![ChatMessage::tool_result(
"call_123",
"abcDE1234",
"search",
"result text",
)];
@@ -825,8 +864,8 @@ mod tests {
match &history[0] {
RigMessage::User { content } => match content.first() {
UserContent::ToolResult(r) => {
assert_eq!(r.id, "call_123");
assert_eq!(r.call_id.as_deref(), Some("call_123"));
assert_eq!(r.id, "abcDE1234");
assert_eq!(r.call_id.as_deref(), Some("abcDE1234"));
}
other => panic!("Expected tool result content, got: {:?}", other),
},
@@ -836,8 +875,9 @@ mod tests {
#[test]
fn test_convert_messages_assistant_with_tool_calls() {
// Use a conforming 9-char alphanumeric ID so it passes through unchanged.
let tc = IronToolCall {
id: "call_1".to_string(),
id: "Xt7mK9pQ2".to_string(),
name: "search".to_string(),
arguments: serde_json::json!({"query": "test"}),
};
@@ -851,7 +891,7 @@ mod tests {
assert!(content.iter().count() >= 2);
for item in content.iter() {
if let AssistantContent::ToolCall(tc) = item {
assert_eq!(tc.call_id.as_deref(), Some("call_1"));
assert_eq!(tc.call_id.as_deref(), Some("Xt7mK9pQ2"));
}
}
}
@@ -873,7 +913,14 @@ mod tests {
match &history[0] {
RigMessage::User { content } => match content.first() {
UserContent::ToolResult(r) => {
assert!(r.id.starts_with("generated_tool_call_"));
// Missing ID → normalized_tool_call_id generates a 9-char alphanumeric ID.
assert_eq!(
r.id.len(),
9,
"fallback ID should be 9 chars, got: {}",
r.id
);
assert!(r.id.chars().all(|c| c.is_ascii_alphanumeric()));
assert_eq!(r.call_id.as_deref(), Some(r.id.as_str()));
}
other => panic!("Expected tool result content, got: {:?}", other),
@@ -961,12 +1008,14 @@ mod tests {
_ => None,
});
let tc = tool_call.expect("should have a tool call");
assert!(!tc.id.is_empty(), "tool call id must not be empty");
assert!(
tc.id.starts_with("generated_tool_call_"),
"empty id should be replaced with generated id, got: {}",
// Empty ID → normalized_tool_call_id generates a 9-char alphanumeric ID.
assert_eq!(
tc.id.len(),
9,
"generated id should be 9 chars, got: {}",
tc.id
);
assert!(tc.id.chars().all(|c| c.is_ascii_alphanumeric()));
assert_eq!(tc.call_id.as_deref(), Some(tc.id.as_str()));
}
other => panic!("Expected Assistant message, got: {:?}", other),
@@ -990,11 +1039,14 @@ mod tests {
_ => None,
});
let tc = tool_call.expect("should have a tool call");
assert!(
tc.id.starts_with("generated_tool_call_"),
"whitespace-only id should be replaced, got: {:?}",
// Whitespace-only ID → normalized_tool_call_id generates a 9-char alphanumeric ID.
assert_eq!(
tc.id.len(),
9,
"generated id should be 9 chars, got: {}",
tc.id
);
assert!(tc.id.chars().all(|c| c.is_ascii_alphanumeric()));
}
other => panic!("Expected Assistant message, got: {:?}", other),
}
@@ -1381,4 +1433,67 @@ mod tests {
// Should be 2 separate User messages (text user + tool result user)
assert_eq!(history.len(), 2);
}
// -- normalized_tool_call_id tests --
#[test]
fn test_normalized_tool_call_id_conforming_passthrough() {
// A 9-char alphanumeric ID should pass through unchanged.
let id = normalized_tool_call_id(Some("abcDE1234"), 42);
assert_eq!(id, "abcDE1234");
}
#[test]
fn test_normalized_tool_call_id_non_conforming_hashed() {
// An ID that doesn't match [a-zA-Z0-9]{9} should be hashed into one.
let id = normalized_tool_call_id(Some("call_abc_long_id"), 0);
assert_eq!(id.len(), 9);
assert!(id.chars().all(|c| c.is_ascii_alphanumeric()));
// Should NOT be the raw input.
assert_ne!(id, "call_abc_l");
}
#[test]
fn test_normalized_tool_call_id_empty_input() {
let id = normalized_tool_call_id(Some(""), 5);
assert_eq!(id.len(), 9);
assert!(id.chars().all(|c| c.is_ascii_alphanumeric()));
}
#[test]
fn test_normalized_tool_call_id_whitespace_input() {
let id = normalized_tool_call_id(Some(" "), 5);
assert_eq!(id.len(), 9);
assert!(id.chars().all(|c| c.is_ascii_alphanumeric()));
// Empty and whitespace-only with the same seed should produce identical results.
let id_empty = normalized_tool_call_id(Some(""), 5);
assert_eq!(id, id_empty);
}
#[test]
fn test_normalized_tool_call_id_none_input() {
let id = normalized_tool_call_id(None, 7);
assert_eq!(id.len(), 9);
assert!(id.chars().all(|c| c.is_ascii_alphanumeric()));
// None and empty string with same seed should produce identical results.
let id_empty = normalized_tool_call_id(Some(""), 7);
assert_eq!(id, id_empty);
}
#[test]
fn test_normalized_tool_call_id_deterministic() {
let id1 = normalized_tool_call_id(Some("call_xyz_123"), 0);
let id2 = normalized_tool_call_id(Some("call_xyz_123"), 0);
assert_eq!(id1, id2, "same input must produce same output");
}
#[test]
fn test_normalized_tool_call_id_different_inputs_differ() {
let id_a = normalized_tool_call_id(Some("call_aaa"), 0);
let id_b = normalized_tool_call_id(Some("call_bbb"), 0);
assert_ne!(
id_a, id_b,
"different raw IDs should produce different hashed IDs"
);
}
}
+52 -7
View File
@@ -38,10 +38,49 @@ fn main() -> anyhow::Result<()> {
let _ = dotenvy::dotenv();
ironclaw::bootstrap::load_ironclaw_env();
tokio::runtime::Builder::new_multi_thread()
let result = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?
.block_on(async_main())
.block_on(async_main());
if let Err(ref e) = result {
format_top_level_error(e);
}
result
}
/// Format a top-level error with color and recovery hints.
fn format_top_level_error(err: &anyhow::Error) {
use ironclaw::cli::fmt;
let msg = format!("{err:#}");
eprintln!();
eprintln!(" {}\u{2717}{} {}", fmt::error(), fmt::reset(), msg);
// Provide recovery hints for common errors
let lower = msg.to_ascii_lowercase();
let hint = if lower.contains("database_url")
|| lower.contains("database") && lower.contains("not set")
{
Some("run `ironclaw onboard` or set DATABASE_URL in .env")
} else if lower.contains("connection refused") || lower.contains("connect error") {
Some("check that the database server is running")
} else if lower.contains("session") && lower.contains("not found") {
Some("run `ironclaw onboard` to set up authentication")
} else if lower.contains("secrets_master_key") {
Some("run `ironclaw onboard` or set SECRETS_MASTER_KEY in .env")
} else if lower.contains("already running") {
Some("stop the other instance or remove the stale PID file")
} else if lower.contains("onboard") {
Some("run `ironclaw onboard` to complete setup")
} else {
None
};
if let Some(hint_text) = hint {
eprintln!(" {}hint:{} {}", fmt::dim(), fmt::reset(), hint_text,);
}
eprintln!();
}
async fn async_main() -> anyhow::Result<()> {
@@ -190,6 +229,7 @@ async fn async_main() -> anyhow::Result<()> {
channels_only,
provider_only,
quick,
step,
}) => {
#[cfg(any(feature = "postgres", feature = "libsql"))]
{
@@ -198,6 +238,7 @@ async fn async_main() -> anyhow::Result<()> {
channels_only: *channels_only,
provider_only: *provider_only,
quick: *quick,
steps: step.clone(),
};
let mut wizard =
SetupWizard::try_with_config_and_toml(config, cli.config.as_deref())?;
@@ -205,7 +246,7 @@ async fn async_main() -> anyhow::Result<()> {
}
#[cfg(not(any(feature = "postgres", feature = "libsql")))]
{
let _ = (skip_auth, channels_only, provider_only, quick);
let _ = (skip_auth, channels_only, provider_only, quick, step);
eprintln!("Onboarding wizard requires the 'postgres' or 'libsql' feature.");
}
return Ok(());
@@ -233,6 +274,8 @@ async fn async_main() -> anyhow::Result<()> {
}
};
let startup_start = std::time::Instant::now();
// ── Agent startup ──────────────────────────────────────────────────
// Enhanced first-run detection
@@ -691,6 +734,7 @@ async fn async_main() -> anyhow::Result<()> {
.and_then(|t| t.public_url())
.or_else(|| config.tunnel.public_url.clone()),
tunnel_provider: active_tunnel.as_ref().map(|t| t.name().to_string()),
startup_elapsed: Some(startup_start.elapsed()),
};
ironclaw::boot_screen::print_boot_screen(&boot_info);
}
@@ -802,10 +846,11 @@ async fn async_main() -> anyhow::Result<()> {
cost_guard: components.cost_guard,
sse_tx: sse_sender,
http_interceptor,
transcription: config
.transcription
.create_provider()
.map(|p| Arc::new(ironclaw::transcription::TranscriptionMiddleware::new(p))),
transcription: config.transcription.create_provider().map(|p| {
Arc::new(ironclaw::llm::transcription::TranscriptionMiddleware::new(
p,
))
}),
document_extraction: Some(Arc::new(
ironclaw::document_extraction::DocumentExtractionMiddleware::new(),
)),
+3 -7
View File
@@ -164,19 +164,15 @@ pub async fn setup_orchestrator(
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
/// Serialize access to `ORCHESTRATOR_PORT` env var across test threads.
static ENV_LOCK: Mutex<()> = Mutex::new(());
use crate::config::helpers::lock_env;
#[test]
fn resolve_orchestrator_port_from_env() {
let _guard = ENV_LOCK.lock().unwrap();
let _guard = lock_env();
// Safety: env-var mutation requires unsafe in edition 2024;
// ENV_LOCK serializes concurrent access from other test threads.
// lock_env() serializes concurrent access from other test threads.
// Absent env var → default 50051
unsafe { std::env::remove_var("ORCHESTRATOR_PORT") };
+48 -23
View File
@@ -123,15 +123,32 @@ pub fn select_many(prompt: &str, options: &[(&str, bool)]) -> io::Result<Vec<usi
writeln!(stdout, "\r")?;
for (i, (label, _)) in options.iter().enumerate() {
let checkbox = if selected[i] { "[x]" } else { "[ ]" };
let prefix = if i == cursor_pos { ">" } else { " " };
if i == cursor_pos {
// Cursor line: cyan cursor, then colored checkbox
execute!(stdout, SetForegroundColor(Color::Cyan))?;
writeln!(stdout, " {} {} {}\r", prefix, checkbox, label)?;
write!(stdout, " \u{25b8} ")?;
if selected[i] {
execute!(stdout, SetForegroundColor(Color::Green))?;
write!(stdout, "[\u{2713}]")?;
} else {
execute!(stdout, SetForegroundColor(Color::DarkGrey))?;
write!(stdout, "[\u{00b7}]")?;
}
execute!(stdout, SetForegroundColor(Color::Cyan))?;
writeln!(stdout, " {}\r", label)?;
execute!(stdout, ResetColor)?;
} else {
writeln!(stdout, " {} {} {}\r", prefix, checkbox, label)?;
write!(stdout, " ")?;
if selected[i] {
execute!(stdout, SetForegroundColor(Color::Green))?;
write!(stdout, "[\u{2713}]")?;
execute!(stdout, ResetColor)?;
} else {
execute!(stdout, SetForegroundColor(Color::DarkGrey))?;
write!(stdout, "[\u{00b7}]")?;
execute!(stdout, ResetColor)?;
}
writeln!(stdout, " {}\r", label)?;
}
}
@@ -284,18 +301,12 @@ pub fn confirm(prompt: &str, default: bool) -> io::Result<bool> {
})
}
/// Print the IronClaw ASCII art banner in blue.
/// Print a minimal wordmark banner.
pub fn print_banner() {
let mut stdout = io::stdout();
let _ = execute!(stdout, SetForegroundColor(Color::Cyan));
use crate::cli::fmt;
println!();
println!(" {}ironclaw{}", fmt::bold_accent(), fmt::reset());
println!();
println!(r" ██╗██████╗ ██████╗ ███╗ ██╗ ██████╗██╗ █████╗ ██╗ ██╗");
println!(r" ██║██╔══██╗██╔═══██╗████╗ ██║██╔════╝██║ ██╔══██╗██║ ██║");
println!(r" ██║██████╔╝██║ ██║██╔██╗ ██║██║ ██║ ███████║██║ █╗ ██║");
println!(r" ██║██╔══██╗██║ ██║██║╚██╗██║██║ ██║ ██╔══██║██║███╗██║");
println!(r" ██║██║ ██║╚██████╔╝██║ ╚████║╚██████╗███████╗██║ ██║╚███╔███╔╝");
println!(r" ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ ");
let _ = execute!(stdout, ResetColor);
}
/// Print a styled header box.
@@ -310,24 +321,38 @@ pub fn print_header(text: &str) {
let border = "".repeat(width);
println!();
println!("{}", border);
println!("{}", border);
println!("{}", text);
println!("{}", border);
println!("{}", border);
println!();
}
/// Print a step indicator.
/// Print a compact dot-based step indicator.
///
/// `●` = completed (green/success), `◉` = current (accent), `○` = remaining (dim).
///
/// # Example
///
/// ```ignore
/// print_step(1, 3, "NEAR AI Authentication");
/// // Output: Step 1/3: NEAR AI Authentication
/// // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
/// print_step(3, 5, "Model Selection");
/// // Output: ● ● ◉ ○ ○ Model Selection
/// ```
pub fn print_step(current: usize, total: usize, name: &str) {
println!("Step {}/{}: {}", current, total, name);
println!("{}", "".repeat(32));
use crate::cli::fmt;
let mut dots = String::new();
for i in 1..=total {
if i > 1 {
dots.push(' ');
}
if i < current {
dots.push_str(&format!("{}\u{25CF}{}", fmt::success(), fmt::reset())); // ● green
} else if i == current {
dots.push_str(&format!("{}\u{25C9}{}", fmt::accent(), fmt::reset())); // ◉ accent
} else {
dots.push_str(&format!("{}\u{25CB}{}", fmt::dim(), fmt::reset())); // ○ dim
}
}
println!(" {} {}", dots, name);
println!();
}
+540 -235
View File
@@ -84,6 +84,8 @@ pub struct SetupConfig {
pub provider_only: bool,
/// Quick setup: auto-defaults everything except LLM provider and model.
pub quick: bool,
/// Run only specific setup steps (e.g. "provider", "channels", "model", "database", "security").
pub steps: Vec<String>,
}
/// Interactive setup wizard for IronClaw.
@@ -188,6 +190,55 @@ impl SetupWizard {
print_banner();
print_header("IronClaw Setup Wizard");
if !self.config.steps.is_empty() {
// Selective step mode: reconnect to existing DB and load settings,
// then run only the requested steps.
self.reconnect_existing_db().await?;
let valid_steps = ["provider", "channels", "model", "database", "security"];
for s in &self.config.steps {
if !valid_steps.contains(&s.as_str()) {
return Err(SetupError::Config(format!(
"Unknown step '{}'. Valid steps: {}",
s,
valid_steps.join(", ")
)));
}
}
let total = self.config.steps.len();
for (i, step_name) in self.config.steps.clone().iter().enumerate() {
let step_num = i + 1;
match step_name.as_str() {
"database" => {
print_step(step_num, total, "Database Connection");
self.step_database().await?;
}
"security" => {
print_step(step_num, total, "Security");
self.step_security().await?;
}
"provider" => {
print_step(step_num, total, "Inference Provider");
self.step_inference_provider().await?;
}
"model" => {
print_step(step_num, total, "Model Selection");
self.step_model_selection().await?;
}
"channels" => {
print_step(step_num, total, "Channel Configuration");
self.step_channels().await?;
}
_ => {} // already validated above
}
self.persist_after_step().await;
}
self.save_and_summarize().await?;
return Ok(());
}
if self.config.channels_only {
// Channels-only mode: reconnect to existing DB and load settings
// before running the channel step, so secrets and save work.
@@ -220,23 +271,23 @@ impl SetupWizard {
// Pre-populate backend from env so step_inference_provider
// can offer "Keep current provider?" instead of asking from scratch.
if self.settings.llm_backend.is_none() {
use crate::config::helpers::env_or_override;
if let Some(b) = env_or_override("LLM_BACKEND")
&& !b.trim().is_empty()
{
self.settings.llm_backend = Some(b.trim().to_string());
} else if env_or_override("NEARAI_API_KEY").is_some() {
if let Ok(b) = std::env::var("LLM_BACKEND") {
self.settings.llm_backend = Some(b);
} else if std::env::var("NEARAI_API_KEY").is_ok() {
self.settings.llm_backend = Some("nearai".to_string());
} else if env_or_override("ANTHROPIC_API_KEY").is_some()
|| env_or_override("ANTHROPIC_OAUTH_TOKEN").is_some()
} else if std::env::var("ANTHROPIC_API_KEY").is_ok()
|| std::env::var("ANTHROPIC_OAUTH_TOKEN").is_ok()
{
self.settings.llm_backend = Some("anthropic".to_string());
} else if env_or_override("OPENAI_API_KEY").is_some() {
} else if std::env::var("OPENAI_API_KEY").is_ok() {
self.settings.llm_backend = Some("openai".to_string());
} else if std::env::var("OPENROUTER_API_KEY").is_ok() {
self.settings.llm_backend = Some("openrouter".to_string());
}
}
if let Some(api_key) = crate::config::helpers::env_or_override("NEARAI_API_KEY")
if let Ok(api_key) = std::env::var("NEARAI_API_KEY")
&& !api_key.is_empty()
&& self.settings.llm_backend.as_deref() == Some("nearai")
{
// NEARAI_API_KEY is set and backend auto-detected — skip interactive prompts
@@ -254,6 +305,79 @@ impl SetupWizard {
print_info(&format!("Using default model: {default}"));
}
self.persist_after_step().await;
} else if self.settings.llm_backend.as_deref() == Some("anthropic")
&& let Some(api_key) = Self::detect_anthropic_key()
{
// Anthropic key detected — skip interactive prompts
print_info("Anthropic credentials found — using Anthropic provider");
let secret_name = if api_key.starts_with("sk-ant-oat") {
"llm_anthropic_oauth_token"
} else {
"llm_anthropic_api_key"
};
if let Ok(ctx) = self.init_secrets_context().await {
let key = SecretString::from(api_key.clone());
if let Err(e) = ctx.save_secret(secret_name, &key).await {
tracing::warn!("Failed to persist Anthropic key to secrets: {}", e);
}
}
self.llm_api_key = Some(SecretString::from(api_key));
let registry = crate::llm::ProviderRegistry::load();
if self.settings.selected_model.is_none() {
let default = registry
.find("anthropic")
.map(|d| d.default_model.as_str())
.unwrap_or("claude-sonnet-4-20250514");
self.settings.selected_model = Some(default.to_string());
print_info(&format!("Using default model: {default}"));
}
self.persist_after_step().await;
} else if let Ok(api_key) = std::env::var("OPENAI_API_KEY")
&& !api_key.is_empty()
&& self.settings.llm_backend.as_deref() == Some("openai")
{
// OpenAI key detected — skip interactive prompts
print_info("OPENAI_API_KEY found — using OpenAI provider");
if let Ok(ctx) = self.init_secrets_context().await {
let key = SecretString::from(api_key.clone());
if let Err(e) = ctx.save_secret("llm_openai_api_key", &key).await {
tracing::warn!("Failed to persist OPENAI_API_KEY to secrets: {}", e);
}
}
self.llm_api_key = Some(SecretString::from(api_key));
let registry = crate::llm::ProviderRegistry::load();
if self.settings.selected_model.is_none() {
let default = registry
.find("openai")
.map(|d| d.default_model.as_str())
.unwrap_or("gpt-5-mini");
self.settings.selected_model = Some(default.to_string());
print_info(&format!("Using default model: {default}"));
}
self.persist_after_step().await;
} else if let Ok(api_key) = std::env::var("OPENROUTER_API_KEY")
&& !api_key.is_empty()
&& self.settings.llm_backend.as_deref() == Some("openrouter")
{
// OpenRouter key detected — skip interactive prompts
print_info("OPENROUTER_API_KEY found — using OpenRouter provider");
if let Ok(ctx) = self.init_secrets_context().await {
let key = SecretString::from(api_key.clone());
if let Err(e) = ctx.save_secret("llm_openrouter_api_key", &key).await {
tracing::warn!("Failed to persist OPENROUTER_API_KEY to secrets: {}", e);
}
}
self.llm_api_key = Some(SecretString::from(api_key));
let registry = crate::llm::ProviderRegistry::load();
if self.settings.selected_model.is_none() {
let default = registry
.find("openrouter")
.map(|d| d.default_model.as_str())
.unwrap_or("openai/gpt-4o");
self.settings.selected_model = Some(default.to_string());
print_info(&format!("Using default model: {default}"));
}
self.persist_after_step().await;
} else {
print_step(1, 2, "Inference Provider");
self.step_inference_provider().await?;
@@ -1078,23 +1202,40 @@ impl SetupWizard {
.map(|s| s.display_name().to_string())
.unwrap_or_else(|| def.id.clone())
} else {
current.clone()
match current.as_str() {
"nearai" => "NEAR AI".to_string(),
"gemini_oauth" | "gemini-oauth" => "Gemini API (OAuth)".to_string(),
_ => {
if let Some(def) = registry.find(&current) {
def.setup
.as_ref()
.map(|s| s.display_name().to_string())
.unwrap_or_else(|| def.id.clone())
} else {
current.clone()
}
}
}
};
print_info(&format!("Current provider: {}", display));
println!();
let is_known = current == "nearai"
|| current == "bedrock"
|| current == "gemini_oauth"
|| current == "gemini-oauth"
|| current == "openai_codex"
|| registry.is_known(&current);
if is_known && confirm("Keep current provider?", true).map_err(SetupError::Io)? {
if current == "bedrock" {
// Keeping the existing Bedrock config — no need to re-run
// the full setup flow (region, auth, cross-region).
print_info("Keeping existing AWS Bedrock configuration.");
return Ok(());
}
if current == "gemini_oauth" || current == "gemini-oauth" {
print_info("Keeping existing Gemini CLI OAuth configuration.");
return Ok(());
}
if current == "openai_codex" {
print_info("Keeping existing OpenAI Codex configuration.");
return Ok(());
@@ -1113,33 +1254,100 @@ impl SetupWizard {
print_info("Select your inference provider:");
println!();
// Build menu: NearAI first, then OpenAI Codex, then registry providers, then Bedrock
// Build menu: NearAI first, then Gemini OAuth, then OpenAI Codex, then registry providers, then Bedrock
let selectable = registry.selectable();
let mut options: Vec<String> = Vec::with_capacity(2 + selectable.len());
let mut provider_ids: Vec<String> = Vec::with_capacity(2 + selectable.len());
options.push("NEAR AI - multi-model access via NEAR account".to_string());
provider_ids.push("nearai".to_string());
// Detect which providers have API keys already set in the environment.
let detected_env: HashMap<&str, bool> = [
("nearai", std::env::var("NEARAI_API_KEY").is_ok()),
(
"anthropic",
std::env::var("ANTHROPIC_API_KEY").is_ok()
|| std::env::var("ANTHROPIC_OAUTH_TOKEN").is_ok(),
),
("openai", std::env::var("OPENAI_API_KEY").is_ok()),
("openrouter", std::env::var("OPENROUTER_API_KEY").is_ok()),
]
.into_iter()
.collect();
options.push("OpenAI Codex - ChatGPT subscription (Plus/Pro/Max)".to_string());
provider_ids.push("openai_codex".to_string());
// Helper: build a label for a provider entry, prepending a checkmark if detected.
let make_label = |id: &str, name: &str, desc: &str| -> String {
if detected_env.get(id).copied().unwrap_or(false) {
format!("\u{2713} {:<15}- {}", name, desc)
} else {
format!(" {:<15}- {}", name, desc)
}
};
// Collect all entries as (provider_id, label, is_detected).
struct ProviderEntry {
id: String,
label: String,
detected: bool,
}
let mut entries: Vec<ProviderEntry> = Vec::with_capacity(2 + selectable.len());
entries.push(ProviderEntry {
id: "nearai".to_string(),
label: make_label("nearai", "NEAR AI", "multi-model access via NEAR account"),
detected: detected_env.get("nearai").copied().unwrap_or(false),
});
entries.push(ProviderEntry {
id: "gemini_oauth".to_string(),
label: make_label(
"gemini_oauth",
"Gemini CLI",
"Official Gemini API via Gemini CLI OAuth",
),
detected: false,
});
entries.push(ProviderEntry {
id: "openai_codex".to_string(),
label: make_label(
"openai_codex",
"OpenAI Codex",
"ChatGPT subscription (Plus/Pro/Max)",
),
detected: false,
});
for def in &selectable {
let label = format!(
"{:<17}- {}",
def.setup
.as_ref()
.map(|s| s.display_name())
.unwrap_or(&def.id),
def.description
);
options.push(label);
provider_ids.push(def.id.clone());
let display_name = def
.setup
.as_ref()
.map(|s| s.display_name())
.unwrap_or(&def.id);
entries.push(ProviderEntry {
id: def.id.clone(),
label: make_label(&def.id, display_name, &def.description),
detected: detected_env.get(def.id.as_str()).copied().unwrap_or(false),
});
}
// Bedrock is a special case (native AWS SDK, not registry-based)
options.push("AWS Bedrock - Claude & other models via AWS (IAM, SSO)".to_string());
provider_ids.push("bedrock".to_string());
entries.push(ProviderEntry {
id: "bedrock".to_string(),
label: make_label(
"bedrock",
"AWS Bedrock",
"Claude & other models via AWS (IAM, SSO)",
),
detected: false,
});
// Sort: detected providers first, preserving relative order within each group.
entries.sort_by_key(|e| !e.detected);
let mut options: Vec<String> = Vec::with_capacity(entries.len());
let mut provider_ids: Vec<String> = Vec::with_capacity(entries.len());
for entry in &entries {
options.push(entry.label.clone());
provider_ids.push(entry.id.clone());
}
let option_refs: Vec<&str> = options.iter().map(|s| s.as_str()).collect();
let choice = select_one("Provider:", &option_refs).map_err(SetupError::Io)?;
@@ -1147,6 +1355,8 @@ impl SetupWizard {
if selected_id == "bedrock" {
self.setup_bedrock().await?;
} else if selected_id == "gemini_oauth" {
self.setup_gemini_oauth().await?;
} else {
self.run_provider_setup(selected_id, &registry).await?;
}
@@ -1241,6 +1451,24 @@ impl SetupWizard {
Ok(())
}
/// Detect an Anthropic credential from the environment.
///
/// Checks `ANTHROPIC_API_KEY` first, then `ANTHROPIC_OAUTH_TOKEN`.
/// Returns the key/token string if found, or `None`.
fn detect_anthropic_key() -> Option<String> {
if let Ok(key) = std::env::var("ANTHROPIC_API_KEY")
&& !key.is_empty()
{
return Some(key);
}
if let Ok(token) = std::env::var("ANTHROPIC_OAUTH_TOKEN")
&& !token.is_empty()
{
return Some(token);
}
None
}
/// Update the selected LLM backend while preserving the current model when
/// the backend did not actually change.
fn set_llm_backend_preserving_model(&mut self, backend: &str) {
@@ -1795,6 +2023,40 @@ impl SetupWizard {
Ok(())
}
async fn setup_gemini_oauth(&mut self) -> Result<(), SetupError> {
self.settings.llm_backend = Some("gemini_oauth".to_string());
print_info("Starting Gemini CLI OAuth authentication...");
println!();
let creds_path = crate::config::GeminiOauthConfig::default_credentials_path();
let cred_manager =
crate::llm::gemini_oauth::CredentialManager::new(&creds_path).map_err(|e| {
SetupError::Config(format!(
"Failed to initialize Gemini credential manager: {}",
e
))
})?;
match cred_manager.get_valid_credential().await {
Ok(cred) => {
print_success("Gemini CLI authentication successful!");
if let Some(ref pid) = cred.project_id {
print_info(&format!("Cloud Code project: {}", pid));
}
}
Err(e) => {
return Err(SetupError::Config(format!(
"Gemini CLI authentication failed: {}. Please try again.",
e
)));
}
}
println!();
print_success("Gemini API configured via Gemini CLI");
Ok(())
}
/// Step 4: Model selection.
///
/// Branches on the selected LLM backend and fetches models from the
@@ -1818,109 +2080,157 @@ impl SetupWizard {
let backend = self.settings.llm_backend.as_deref().unwrap_or("nearai");
let registry = crate::llm::ProviderRegistry::load();
if backend == "nearai" {
// NEAR AI: use existing provider list_models()
let fetched = self.fetch_nearai_models().await;
let models = if fetched.is_empty() {
crate::llm::default_models()
} else {
fetched.iter().map(|m| (m.clone(), m.clone())).collect()
};
self.select_from_model_list(&models)?;
} else if let Some(def) = registry.find(backend) {
let can_list = def
.setup
.as_ref()
.map(|s| s.can_list_models())
.unwrap_or(false);
if can_list {
// Try to fetch models from the provider's /v1/models endpoint
let cached_key = self
.llm_api_key
.as_ref()
.map(|k| k.expose_secret().to_string());
let models = match backend {
"anthropic" => fetch_anthropic_models(cached_key.as_deref()).await,
"openai" => fetch_openai_models(cached_key.as_deref()).await,
"ollama" => {
let base_url = self
.settings
.ollama_base_url
.as_deref()
.or(def.default_base_url.as_deref())
.unwrap_or("http://localhost:11434");
let models = fetch_ollama_models(base_url).await;
if models.is_empty() {
print_info("No models found. Pull one first: ollama pull llama3");
}
models
}
_ => {
// Generic OpenAI-compatible model listing
let base_url = def.default_base_url.as_deref().unwrap_or("");
fetch_openai_compatible_models(base_url, cached_key.as_deref()).await
}
};
// Apply models_filter from setup hint (e.g., Groq "chat" filters non-chat models)
let models =
if let Some(filter) = def.setup.as_ref().and_then(|s| s.models_filter()) {
let filter_lower = filter.to_lowercase();
models
.into_iter()
.filter(|(id, _)| id.to_lowercase().contains(&filter_lower))
.collect()
} else {
models
};
if models.is_empty() {
// Fall back to manual entry
let default = &def.default_model;
let model_id = input(&format!("Model name (default: {default})"))
.map_err(SetupError::Io)?;
let model_id = if model_id.is_empty() {
default.clone()
} else {
model_id
};
self.settings.selected_model = Some(model_id.clone());
print_success(&format!("Selected {}", model_id));
match backend {
"nearai" => {
// NEAR AI: use existing provider list_models()
let fetched = self.fetch_nearai_models().await;
let models = if fetched.is_empty() {
crate::llm::default_models()
} else {
self.select_from_model_list(&models)?;
}
} else {
// Manual model entry
let default = &def.default_model;
fetched.iter().map(|m| (m.clone(), m.clone())).collect()
};
self.select_from_model_list(&models)?;
}
"gemini_oauth" | "gemini-oauth" => {
let default_models: Vec<(String, String)> = vec![
(
"gemini-3.1-pro-preview".into(),
"Gemini 3.1 Pro (Latest, strongest reasoning)".into(),
),
(
"gemini-3.1-pro-preview-customtools".into(),
"Gemini 3.1 Pro Custom Tools (Enhanced tool use)".into(),
),
(
"gemini-3-pro-preview".into(),
"Gemini 3 Pro (Preview)".into(),
),
(
"gemini-3-flash-preview".into(),
"Gemini 3 Flash (Fast preview with thinking)".into(),
),
(
"gemini-3.1-flash-lite-preview".into(),
"Gemini 3.1 Flash Lite (Preview, lightweight)".into(),
),
(
"gemini-2.5-pro".into(),
"Gemini 2.5 Pro (Stable, strong reasoning)".into(),
),
(
"gemini-2.5-flash".into(),
"Gemini 2.5 Flash (Fast, good quality)".into(),
),
(
"gemini-2.5-flash-lite".into(),
"Gemini 2.5 Flash Lite (Fastest, lightweight)".into(),
),
];
self.select_from_model_list(&default_models)?;
}
"bedrock" => {
let model_id =
input(&format!("Model name (default: {default})")).map_err(SetupError::Io)?;
let model_id = if model_id.is_empty() {
default.clone()
} else {
model_id
};
input("Bedrock model ID (e.g., anthropic.claude-v3-sonnet-20240229-v1:0)")
.map_err(SetupError::Io)?;
if model_id.is_empty() {
return Err(SetupError::Config("Model ID is required".to_string()));
}
self.settings.selected_model = Some(model_id.clone());
print_success(&format!("Selected {}", model_id));
}
} else if backend == "bedrock" {
let model_id = input("Bedrock model ID (e.g., anthropic.claude-opus-4-6-v1)")
.map_err(SetupError::Io)?;
if model_id.is_empty() {
return Err(SetupError::Config("Model ID is required".to_string()));
_ => {
if let Some(def) = registry.find(backend) {
let can_list = def
.setup
.as_ref()
.map(|s| s.can_list_models())
.unwrap_or(false);
if can_list {
// Try to fetch models from the provider's /v1/models endpoint
let cached_key = self
.llm_api_key
.as_ref()
.map(|k| k.expose_secret().to_string());
let models = match backend {
"anthropic" => fetch_anthropic_models(cached_key.as_deref()).await,
"openai" => fetch_openai_models(cached_key.as_deref()).await,
"ollama" => {
let base_url = self
.settings
.ollama_base_url
.as_deref()
.or(def.default_base_url.as_deref())
.unwrap_or("http://localhost:11434");
let models = fetch_ollama_models(base_url).await;
if models.is_empty() {
print_info(
"No models found. Pull one first: ollama pull llama3",
);
}
models
}
_ => {
// Generic OpenAI-compatible model listing
let base_url = def.default_base_url.as_deref().unwrap_or("");
fetch_openai_compatible_models(base_url, cached_key.as_deref())
.await
}
};
// Apply models_filter from setup hint
let models = if let Some(filter) =
def.setup.as_ref().and_then(|s| s.models_filter())
{
let filter_lower = filter.to_lowercase();
models
.into_iter()
.filter(|(id, _)| id.to_lowercase().contains(&filter_lower))
.collect()
} else {
models
};
if models.is_empty() {
// Fall back to manual entry
let default = &def.default_model;
let model_id = input(&format!("Model name (default: {default})"))
.map_err(SetupError::Io)?;
let model_id = if model_id.is_empty() {
default.clone()
} else {
model_id
};
self.settings.selected_model = Some(model_id.clone());
print_success(&format!("Selected {}", model_id));
} else {
self.select_from_model_list(&models)?;
}
} else {
// Manual model entry
let default = &def.default_model;
let model_id = input(&format!("Model name (default: {default})"))
.map_err(SetupError::Io)?;
let model_id = if model_id.is_empty() {
default.clone()
} else {
model_id
};
self.settings.selected_model = Some(model_id.clone());
print_success(&format!("Selected {}", model_id));
}
} else {
// Unknown provider, manual entry
let model_id = input("Model name (e.g., meta-llama/Llama-3-8b-chat-hf)")
.map_err(SetupError::Io)?;
if model_id.is_empty() {
return Err(SetupError::Config("Model name is required".to_string()));
}
self.settings.selected_model = Some(model_id.clone());
print_success(&format!("Selected {}", model_id));
}
}
self.settings.selected_model = Some(model_id.clone());
print_success(&format!("Selected {}", model_id));
} else {
// Unknown provider, manual entry
let model_id = input("Model name (e.g., meta-llama/Llama-3-8b-chat-hf)")
.map_err(SetupError::Io)?;
if model_id.is_empty() {
return Err(SetupError::Config("Model name is required".to_string()));
}
self.settings.selected_model = Some(model_id.clone());
print_success(&format!("Selected {}", model_id));
}
Ok(())
@@ -2976,8 +3286,11 @@ impl SetupWizard {
let _ = loaded;
}
/// Save settings to the database and `~/.ironclaw/.env`, then print summary.
/// Save settings to the database and `~/.ironclaw/.env`, then print
/// a warm completion card with the 3 key facts.
async fn save_and_summarize(&mut self) -> Result<(), SetupError> {
use crate::cli::fmt;
self.settings.onboard_completed = true;
// Final persist (idempotent — earlier incremental saves already wrote
@@ -2993,117 +3306,108 @@ impl SetupWizard {
// Write bootstrap env (also idempotent)
self.write_bootstrap_env()?;
// ── Completion card ───────────────────────────────────
let sep = fmt::separator(38);
println!();
print_success("Configuration saved to database");
println!(" {}", sep);
println!();
// Print summary
println!("Configuration Summary:");
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
// Title line: checkmark + "ironclaw is ready"
println!(
" {}\u{2713}{} {}ironclaw is ready{}",
fmt::success(),
fmt::reset(),
fmt::bold_accent(),
fmt::reset(),
);
println!();
let backend = self
.settings
.database_backend
.as_deref()
.unwrap_or("postgres");
match backend {
"libsql" => {
if let Some(ref path) = self.settings.libsql_path {
println!(" Database: libSQL ({})", path);
} else {
println!(" Database: libSQL (default path)");
}
if self.settings.libsql_url.is_some() {
println!(" Turso sync: enabled");
}
}
_ => {
if self.settings.database_url.is_some() {
println!(" Database: PostgreSQL (configured)");
}
}
}
match self.settings.secrets_master_key_source {
KeySource::Keychain => println!(" Security: OS keychain"),
KeySource::Env => println!(" Security: environment variable"),
KeySource::None => println!(" Security: disabled"),
}
if let Some(ref provider) = self.settings.llm_backend {
let display = match provider.as_str() {
"nearai" => "NEAR AI",
"anthropic" => "Anthropic",
"openai" => "OpenAI",
"ollama" => "Ollama",
"openai_compatible" => "OpenAI-compatible",
"bedrock" => "AWS Bedrock",
"openai_codex" => "OpenAI Codex",
other => other,
};
println!(" Provider: {}", display);
}
if let Some(ref model) = self.settings.selected_model {
// Fact 1: Provider + model
let provider_display = match self.settings.llm_backend.as_deref() {
Some("nearai") => "NEAR AI".to_string(),
Some("anthropic") => "Anthropic".to_string(),
Some("openai") => "OpenAI".to_string(),
Some("ollama") => "Ollama".to_string(),
Some("openai_compatible") => "OpenAI-compatible".to_string(),
Some("bedrock") => "AWS Bedrock".to_string(),
Some("openai_codex") => "OpenAI Codex".to_string(),
Some("gemini_oauth") => "Gemini CLI".to_string(),
Some(other) => other.to_string(),
None => "unknown".to_string(),
};
let model_suffix = if let Some(ref model) = self.settings.selected_model {
// Truncate long model names (char-based to avoid UTF-8 panic)
let display = if model.chars().count() > 40 {
let truncated: String = model.chars().take(37).collect();
let display = if model.chars().count() > 30 {
let truncated: String = model.chars().take(27).collect();
format!("{}...", truncated)
} else {
model.clone()
};
println!(" Model: {}", display);
}
if self.settings.embeddings.enabled {
println!(
" Embeddings: {} ({})",
self.settings.embeddings.provider, self.settings.embeddings.model
);
format!(" ({})", display)
} else {
println!(" Embeddings: disabled");
}
String::new()
};
let provider_value = format!("{}{}", provider_display, model_suffix);
println!(
" {}provider{} {}{}{}",
fmt::dim(),
fmt::reset(),
fmt::accent(),
provider_value,
fmt::reset(),
);
if let Some(ref tunnel_url) = self.settings.tunnel.public_url {
println!(" Tunnel: {} (static)", tunnel_url);
} else if let Some(ref provider) = self.settings.tunnel.provider {
println!(" Tunnel: {} (managed, starts at boot)", provider);
}
// Fact 2: Database
let db_display = match self.settings.database_backend.as_deref() {
Some("libsql") => "libSQL".to_string(),
Some("postgres") | Some("postgresql") => "PostgreSQL".to_string(),
Some(other) => other.to_string(),
None => "unknown".to_string(),
};
println!(
" {}database{} {}{}{}",
fmt::dim(),
fmt::reset(),
fmt::accent(),
db_display,
fmt::reset(),
);
let has_tunnel =
self.settings.tunnel.public_url.is_some() || self.settings.tunnel.provider.is_some();
println!(" Channels:");
println!(" - CLI/TUI: enabled");
if self.settings.channels.http_enabled {
let port = self.settings.channels.http_port.unwrap_or(8080);
println!(" - HTTP: enabled (port {})", port);
}
for channel_name in &self.settings.channels.wasm_channels {
let mode = if has_tunnel { "webhook" } else { "polling" };
println!(
" - {}: enabled ({})",
capitalize_first(channel_name),
mode
);
}
if self.settings.heartbeat.enabled {
println!(
" Heartbeat: every {} minutes",
self.settings.heartbeat.interval_secs / 60
);
}
// Fact 3: Security
let security_display = match self.settings.secrets_master_key_source {
KeySource::Keychain => "OS keychain",
KeySource::Env => "environment variable",
KeySource::None => "disabled",
};
println!(
" {}security{} {}{}{}",
fmt::dim(),
fmt::reset(),
fmt::accent(),
security_display,
fmt::reset(),
);
println!();
println!("To start the agent, run:");
println!(" ironclaw");
println!(" {}", sep);
println!();
println!("To change settings later:");
println!(" ironclaw config set <setting> <value>");
println!(" ironclaw onboard");
// Action hints
println!(
" {}Start chatting:{} {}ironclaw{}",
fmt::dim(),
fmt::reset(),
fmt::bold_accent(),
fmt::reset(),
);
println!(
" {}Full setup:{} {}ironclaw onboard{}",
fmt::dim(),
fmt::reset(),
fmt::bold_accent(),
fmt::reset(),
);
println!();
if self.config.quick {
@@ -3432,7 +3736,7 @@ mod tests {
use tempfile::tempdir;
use super::*;
use crate::config::helpers::ENV_MUTEX;
use crate::config::helpers::lock_env;
#[test]
fn test_wizard_creation() {
@@ -3448,6 +3752,7 @@ mod tests {
channels_only: false,
provider_only: false,
quick: false,
steps: vec![],
};
let wizard = SetupWizard::with_config(config);
assert!(wizard.config.skip_auth);
@@ -3455,7 +3760,7 @@ mod tests {
#[test]
fn test_wizard_owner_id_uses_resolved_env_scope() {
let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let _guard = lock_env();
let _owner = EnvGuard::set("IRONCLAW_OWNER_ID", " wizard-owner ");
let wizard = SetupWizard::new();
@@ -3464,7 +3769,7 @@ mod tests {
#[test]
fn test_wizard_owner_id_uses_toml_scope() {
let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let _guard = lock_env();
let _owner = EnvGuard::clear("IRONCLAW_OWNER_ID");
let dir = tempdir().unwrap(); // safety: test-only tempdir setup
let path = dir.path().join("config.toml");
@@ -3480,7 +3785,7 @@ mod tests {
fn test_try_with_config_and_toml_propagates_invalid_owner_env() {
use std::os::unix::ffi::OsStringExt;
let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let _guard = lock_env();
let original = std::env::var_os("IRONCLAW_OWNER_ID");
unsafe {
std::env::set_var("IRONCLAW_OWNER_ID", OsString::from_vec(vec![0x66, 0x80]));
@@ -3940,7 +4245,7 @@ mod tests {
fn test_build_nearai_model_fetch_config_picks_up_api_key_env() {
use secrecy::ExposeSecret;
let _lock = ENV_MUTEX.lock().unwrap();
let _lock = lock_env();
let _guard = EnvGuard::set("NEARAI_API_KEY", "test-cloud-api-key-12345");
let _guard2 = EnvGuard::clear("NEARAI_BASE_URL");
@@ -3964,7 +4269,7 @@ mod tests {
/// the config should have `api_key: None` (session token path).
#[test]
fn test_build_nearai_model_fetch_config_none_when_no_api_key() {
let _lock = ENV_MUTEX.lock().unwrap();
let _lock = lock_env();
let _guard = EnvGuard::clear("NEARAI_API_KEY");
let _guard2 = EnvGuard::clear("NEARAI_BASE_URL");
@@ -3983,7 +4288,7 @@ mod tests {
/// Regression test for #799: empty NEARAI_API_KEY should be treated as absent.
#[test]
fn test_build_nearai_model_fetch_config_none_when_empty_api_key() {
let _lock = ENV_MUTEX.lock().unwrap();
let _lock = lock_env();
let _guard = EnvGuard::set("NEARAI_API_KEY", "");
let config = build_nearai_model_fetch_config();
@@ -4001,7 +4306,7 @@ mod tests {
fn test_model_discovery_picks_up_injected_var() {
use secrecy::ExposeSecret;
let _lock = ENV_MUTEX.lock().unwrap();
let _lock = lock_env();
let _guard = EnvGuard::clear("NEARAI_API_KEY");
let _guard2 = EnvGuard::clear("NEARAI_BASE_URL");
@@ -4032,7 +4337,7 @@ mod tests {
/// the NEAR AI authentication menu.
#[test]
fn test_build_nearai_model_fetch_config_picks_up_runtime_env() {
let _lock = ENV_MUTEX.lock().unwrap();
let _lock = lock_env();
// Ensure the real env var is unset so the only source is the overlay.
let _guard = EnvGuard::clear("NEARAI_API_KEY");
+70 -1
View File
@@ -28,7 +28,7 @@ use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use async_trait::async_trait;
use rust_decimal::Decimal;
use tokio::sync::mpsc;
use tokio::sync::{Mutex as AsyncMutex, mpsc};
use crate::agent::AgentDeps;
use crate::channels::{
@@ -361,6 +361,75 @@ impl Channel for StubChannel {
}
}
/// Captured broadcast deliveries keyed by the target user or chat identifier.
pub type BroadcastCapture = Arc<AsyncMutex<Vec<(String, OutgoingResponse)>>>;
/// A lightweight channel double that only records `broadcast()` traffic.
///
/// This is useful for unit tests that need to assert message routing without
/// spinning up a full interactive channel harness.
pub struct RecordingBroadcastChannel {
name: &'static str,
captures: BroadcastCapture,
}
impl RecordingBroadcastChannel {
pub fn new(name: &'static str) -> (Self, BroadcastCapture) {
let captures = Arc::new(AsyncMutex::new(Vec::new()));
(
Self {
name,
captures: Arc::clone(&captures),
},
captures,
)
}
}
#[async_trait]
impl Channel for RecordingBroadcastChannel {
fn name(&self) -> &str {
self.name
}
async fn start(&self) -> Result<MessageStream, ChannelError> {
let (_tx, rx) = mpsc::channel::<IncomingMessage>(1);
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
}
async fn respond(
&self,
_msg: &IncomingMessage,
_response: OutgoingResponse,
) -> Result<(), ChannelError> {
Ok(())
}
async fn send_status(
&self,
_status: StatusUpdate,
_metadata: &serde_json::Value,
) -> Result<(), ChannelError> {
Ok(())
}
async fn broadcast(
&self,
user_id: &str,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
self.captures
.lock()
.await
.push((user_id.to_string(), response));
Ok(())
}
async fn health_check(&self) -> Result<(), ChannelError> {
Ok(())
}
}
/// Assembled test components.
pub struct TestHarness {
/// The agent dependencies, ready for use.
+3 -4
View File
@@ -271,12 +271,13 @@ impl Tool for MemoryWriteTool {
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Parse timezone once for targets that need it (daily_log).
let tz = crate::timezone::parse_timezone(&ctx.user_timezone).unwrap_or(chrono_tz::Tz::UTC);
// Resolve the target to a workspace path
let resolved_path = match target {
"memory" => paths::MEMORY.to_string(),
"daily_log" => {
let tz = crate::timezone::parse_timezone(&ctx.user_timezone)
.unwrap_or(chrono_tz::Tz::UTC);
let now = chrono::Utc::now().with_timezone(&tz);
format!("daily/{}.md", now.format("%Y-%m-%d"))
}
@@ -318,8 +319,6 @@ impl Tool for MemoryWriteTool {
}
}
"daily_log" => {
let tz = crate::timezone::parse_timezone(&ctx.user_timezone)
.unwrap_or(chrono_tz::Tz::UTC);
self.workspace
.append_daily_log_tz(content, tz)
.await
+76 -91
View File
@@ -80,6 +80,12 @@ fn metadata_notify_user(metadata: &serde_json::Value) -> Option<String> {
metadata_string(metadata, "notify_user").filter(|value| value != "default")
}
// Autonomous runs include `owner_id` when the job is executing on behalf of a
// durable owner scope instead of an interactive channel actor.
fn metadata_owner_id(metadata: &serde_json::Value) -> Option<String> {
metadata_string(metadata, "owner_id")
}
fn channel_matches_source(resolved_channel: Option<&str>, source_channel: Option<&str>) -> bool {
match (resolved_channel, source_channel) {
(None, _) => true,
@@ -91,11 +97,13 @@ fn channel_matches_source(resolved_channel: Option<&str>, source_channel: Option
async fn resolve_channel_fallback_target(
extension_manager: Option<&Arc<ExtensionManager>>,
channel: Option<&str>,
owner_scope_target: Option<&str>,
ctx_user_id: &str,
) -> Option<String> {
let channel_name = channel?;
if let Some(extension_manager) = extension_manager
// Prefer an explicit channel binding when the extension manager knows the
// durable delivery target (for example, a bound Telegram chat ID).
if let Some(channel_name) = channel
&& let Some(extension_manager) = extension_manager
&& let Some(target) = extension_manager
.notification_target_for_channel(channel_name)
.await
@@ -103,13 +111,19 @@ async fn resolve_channel_fallback_target(
return Some(target);
}
Some(ctx_user_id.to_string())
// `owner_id` is only present for autonomous owner-scoped executions.
// Interactive chat turns intentionally fall back to `ctx.user_id`, which is
// already the active conversation target for the current channel.
owner_scope_target
.map(ToOwned::to_owned)
.or_else(|| Some(ctx_user_id.to_string()))
}
struct MessageTargetResolution<'a> {
extension_manager: Option<&'a Arc<ExtensionManager>>,
explicit_target: Option<String>,
metadata_target: Option<String>,
owner_scope_target: Option<String>,
default_target: Option<String>,
channel: Option<&'a str>,
metadata_channel: Option<&'a str>,
@@ -133,6 +147,7 @@ async fn resolve_message_target(inputs: MessageTargetResolution<'_>) -> Option<S
return resolve_channel_fallback_target(
inputs.extension_manager,
inputs.channel,
inputs.owner_scope_target.as_deref(),
inputs.ctx_user_id,
)
.await;
@@ -145,9 +160,12 @@ async fn resolve_message_target(inputs: MessageTargetResolution<'_>) -> Option<S
}
if inputs.channel.is_some() {
// Shared per-turn conversation defaults are already scoped to the
// active interactive target, so owner scope metadata is irrelevant.
return resolve_channel_fallback_target(
inputs.extension_manager,
inputs.channel,
None,
inputs.ctx_user_id,
)
.await;
@@ -224,8 +242,9 @@ impl Tool for MessageTool {
.unwrap_or_else(|e| e.into_inner())
.clone();
let metadata_target = metadata_notify_user(&ctx.metadata);
let owner_scope_target = metadata_owner_id(&ctx.metadata);
let has_execution_routing_metadata =
metadata_channel.is_some() || metadata_target.is_some();
metadata_channel.is_some() || metadata_target.is_some() || owner_scope_target.is_some();
// Job metadata is authoritative for autonomous executions. The shared
// conversation defaults are only a legacy fallback when no execution-local
@@ -250,6 +269,7 @@ impl Tool for MessageTool {
extension_manager: self.extension_manager.as_ref(),
explicit_target,
metadata_target,
owner_scope_target,
default_target,
channel: channel.as_deref(),
metadata_channel: metadata_channel.as_deref(),
@@ -405,83 +425,13 @@ impl Tool for MessageTool {
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use tokio::sync::{Mutex, mpsc};
use crate::channels::{
Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate,
};
use crate::error::ChannelError;
type BroadcastCapture = Arc<Mutex<Vec<(String, OutgoingResponse)>>>;
struct RecordingChannel {
name: &'static str,
captures: BroadcastCapture,
}
impl RecordingChannel {
fn new(name: &'static str) -> (Self, BroadcastCapture) {
let captures = Arc::new(Mutex::new(Vec::new()));
(
Self {
name,
captures: Arc::clone(&captures),
},
captures,
)
}
}
#[async_trait]
impl Channel for RecordingChannel {
fn name(&self) -> &str {
self.name
}
async fn start(&self) -> Result<MessageStream, ChannelError> {
let (_tx, rx) = mpsc::channel::<IncomingMessage>(1);
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
}
async fn respond(
&self,
_msg: &IncomingMessage,
_response: OutgoingResponse,
) -> Result<(), ChannelError> {
Ok(())
}
async fn send_status(
&self,
_status: StatusUpdate,
_metadata: &serde_json::Value,
) -> Result<(), ChannelError> {
Ok(())
}
async fn broadcast(
&self,
user_id: &str,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
self.captures
.lock()
.await
.push((user_id.to_string(), response));
Ok(())
}
async fn health_check(&self) -> Result<(), ChannelError> {
Ok(())
}
}
use crate::testing::{BroadcastCapture, RecordingBroadcastChannel};
async fn message_tool_with_recording_channels()
-> (MessageTool, BroadcastCapture, BroadcastCapture) {
let channel_manager = ChannelManager::new();
let (gateway, gateway_captures) = RecordingChannel::new("gateway");
let (telegram, telegram_captures) = RecordingChannel::new("telegram");
let (gateway, gateway_captures) = RecordingBroadcastChannel::new("gateway");
let (telegram, telegram_captures) = RecordingBroadcastChannel::new("telegram");
channel_manager.add(Box::new(gateway)).await;
channel_manager.add(Box::new(telegram)).await;
@@ -870,28 +820,63 @@ mod tests {
}
#[tokio::test]
async fn message_tool_falls_back_to_ctx_user_when_channel_known() {
// Regression for owner-scoped notifications: a channel can be known
// even when the concrete delivery target is omitted, so the message
// tool should pass ctx.user_id through to the channel layer.
let tool = MessageTool::new(Arc::new(ChannelManager::new()));
async fn message_tool_falls_back_to_owner_scope_when_channel_known() {
let (tool, gateway_captures, telegram_captures) =
message_tool_with_recording_channels().await;
let mut ctx =
crate::context::JobContext::with_user("owner-scope", "routine-job", "price alert");
crate::context::JobContext::with_user("telegram", "routine-job", "price alert");
ctx.metadata = serde_json::json!({
"notify_channel": "telegram",
"owner_id": "owner-scope",
});
let result = tool
.execute(serde_json::json!({"content": "NEAR price is $5"}), &ctx)
.await
.expect("message tool should use owner scope before ctx.user_id");
assert_eq!(
result.result.as_str(),
Some("Sent message to telegram:owner-scope")
);
assert!(gateway_captures.lock().await.is_empty());
let telegram = telegram_captures.lock().await.clone();
assert_eq!(telegram.len(), 1);
assert_eq!(telegram[0].0, "owner-scope");
assert_eq!(telegram[0].1.content, "NEAR price is $5");
}
#[tokio::test]
async fn message_tool_falls_back_to_ctx_user_when_owner_scope_absent() {
let (tool, gateway_captures, telegram_captures) =
message_tool_with_recording_channels().await;
let mut ctx = crate::context::JobContext::with_user(
"interactive-chat-user",
"routine-job",
"price alert",
);
ctx.metadata = serde_json::json!({
"notify_channel": "telegram",
});
let result = tool
.execute(serde_json::json!({"content": "NEAR price is $5"}), &ctx)
.await;
.await
.expect(
"message tool should fall back to ctx.user_id when owner scope metadata is absent",
);
assert!(result.is_err()); // safety: test-only assertion
let err = result.unwrap_err().to_string();
let mentions_missing_target = err.contains("No target specified");
assert!(!mentions_missing_target); // safety: test-only assertion
let mentions_missing_channel = err.contains("No channel specified");
assert!(!mentions_missing_channel); // safety: test-only assertion
assert_eq!(
result.result.as_str(),
Some("Sent message to telegram:interactive-chat-user")
);
assert!(gateway_captures.lock().await.is_empty());
let telegram = telegram_captures.lock().await.clone();
assert_eq!(telegram.len(), 1);
assert_eq!(telegram[0].0, "interactive-chat-user");
assert_eq!(telegram[0].1.content, "NEAR price is $5");
}
#[tokio::test]
+10 -13
View File
@@ -19,7 +19,7 @@ pub async fn execute_tool_with_safety(
tools: &ToolRegistry,
safety: &SafetyLayer,
tool_name: &str,
params: &serde_json::Value,
params: serde_json::Value,
job_ctx: &JobContext,
) -> Result<String, Error> {
if tool_name.is_empty() {
@@ -35,7 +35,7 @@ pub async fn execute_tool_with_safety(
name: tool_name.to_string(),
})?;
let normalized_params = prepare_tool_params(tool.as_ref(), params);
let normalized_params = prepare_tool_params(tool.as_ref(), &params);
// Validate tool parameters
let validation = safety.validator().validate_tool_params(&normalized_params);
@@ -63,10 +63,7 @@ pub async fn execute_tool_with_safety(
// Execute with per-tool timeout
let timeout = tool.execution_timeout();
let start = std::time::Instant::now();
let result = tokio::time::timeout(timeout, async {
tool.execute(normalized_params.clone(), job_ctx).await
})
.await;
let result = tokio::time::timeout(timeout, tool.execute(normalized_params, job_ctx)).await;
let elapsed = start.elapsed();
match &result {
@@ -149,7 +146,7 @@ pub async fn execute_tool_simple(
tools: &ToolRegistry,
safety: &SafetyLayer,
tool_name: &str,
params: &serde_json::Value,
params: serde_json::Value,
job_ctx: &JobContext,
) -> Result<String, String> {
execute_tool_with_safety(tools, safety, tool_name, params, job_ctx)
@@ -308,7 +305,7 @@ mod tests {
&registry,
&safety,
"",
&serde_json::json!({}),
serde_json::json!({}),
&test_job_ctx(),
)
.await;
@@ -331,7 +328,7 @@ mod tests {
let params = serde_json::json!({"message": "hello"});
let result =
execute_tool_with_safety(&registry, &safety, "echo", &params, &test_job_ctx()).await;
execute_tool_with_safety(&registry, &safety, "echo", params, &test_job_ctx()).await;
assert!(result.is_ok(), "Echo tool should succeed");
let output = result.unwrap();
@@ -350,7 +347,7 @@ mod tests {
&registry,
&safety,
"nonexistent",
&serde_json::json!({}),
serde_json::json!({}),
&test_job_ctx(),
)
.await;
@@ -373,7 +370,7 @@ mod tests {
&registry,
&safety,
"fail_tool",
&serde_json::json!({}),
serde_json::json!({}),
&test_job_ctx(),
)
.await;
@@ -397,7 +394,7 @@ mod tests {
&registry,
&safety,
"slow_tool",
&serde_json::json!({}),
serde_json::json!({}),
&test_job_ctx(),
)
.await;
@@ -425,7 +422,7 @@ mod tests {
&registry,
&safety,
"array_echo",
&serde_json::json!({"values": "[\"1\", \"2\", 3]"}),
serde_json::json!({"values": "[\"1\", \"2\", 3]"}),
&test_job_ctx(),
)
.await
+61
View File
@@ -130,6 +130,16 @@ impl McpTransport for HttpMcpTransport {
)));
}
// MCP notifications commonly acknowledge with 202 Accepted and no body.
if response.status() == reqwest::StatusCode::ACCEPTED {
return Ok(McpResponse {
jsonrpc: "2.0".to_string(),
id: request.id,
result: None,
error: None,
});
}
// Determine response format from Content-Type.
let content_type = response
.headers()
@@ -506,4 +516,55 @@ mod tests {
let echoed = response.result.unwrap();
assert_eq!(echoed["authorization"], "Bearer custom-token");
}
async fn spawn_accepted_server() -> (String, tokio::task::JoinHandle<()>) {
use axum::{Router, routing::post};
use tokio::net::TcpListener;
async fn accepted() -> axum::http::StatusCode {
axum::http::StatusCode::ACCEPTED
}
let app = Router::new().route("/", post(accepted));
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("Failed to bind to an ephemeral port");
let addr = listener
.local_addr()
.expect("Failed to get listener's local address");
let url = format!("http://127.0.0.1:{}", addr.port());
let handle = tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("Test server failed to run");
});
(url, handle)
}
fn notification_request(method: &str) -> McpRequest {
McpRequest {
jsonrpc: "2.0".to_string(),
id: None,
method: method.to_string(),
params: None,
}
}
#[tokio::test]
async fn test_accepted_notification_returns_empty_response() {
let (url, _handle) = spawn_accepted_server().await;
let transport = HttpMcpTransport::new(&url, "accepted-test");
let request = notification_request("notifications/initialized");
let response = transport
.send(&request, &HashMap::new())
.await
.expect("202 notification response");
assert_eq!(response.jsonrpc, "2.0");
assert_eq!(response.id, request.id);
assert!(response.result.is_none());
assert!(response.error.is_none());
}
}
+1 -1
View File
@@ -604,7 +604,7 @@ impl ToolRegistry {
self.register(Arc::new(BuildSoftwareTool::new(Arc::clone(&builder))))
.await;
tracing::info!("Registered software builder tool");
tracing::debug!("Registered software builder tool");
builder
}
+2 -2
View File
@@ -206,7 +206,7 @@ impl WasmToolLoader {
})
.await?;
tracing::info!(
tracing::debug!(
name = name,
wasm_path = %wasm_path.display(),
"Loaded WASM tool from file"
@@ -306,7 +306,7 @@ impl WasmToolLoader {
}
if !results.loaded.is_empty() {
tracing::info!(
tracing::debug!(
count = results.loaded.len(),
tools = ?results.loaded,
"Loaded WASM tools from directory"
+1 -1
View File
@@ -312,7 +312,7 @@ impl WasmToolRuntime {
.insert(prepared.name.clone(), Arc::clone(&prepared));
}
tracing::info!(
tracing::debug!(
name = %prepared.name,
"Prepared WASM tool for execution"
);
+3 -3
View File
@@ -190,7 +190,7 @@ pub async fn start_managed_tunnel(
mut config: crate::config::Config,
) -> (crate::config::Config, Option<Box<dyn Tunnel>>) {
if config.tunnel.public_url.is_some() {
tracing::info!(
tracing::debug!(
"Static tunnel URL in use: {}",
config.tunnel.public_url.as_deref().unwrap_or("?")
);
@@ -216,7 +216,7 @@ pub async fn start_managed_tunnel(
match create_tunnel(provider_config) {
Ok(Some(tunnel)) => {
tracing::info!(
tracing::debug!(
"Starting {} tunnel on {}:{}...",
tunnel.name(),
gateway_host,
@@ -224,7 +224,7 @@ pub async fn start_managed_tunnel(
);
match tunnel.start(gateway_host, gateway_port).await {
Ok(url) => {
tracing::info!("Tunnel started: {}", url);
tracing::debug!("Tunnel started: {}", url);
config.tunnel.public_url = Some(url);
(config, Some(tunnel))
}
+9 -4
View File
@@ -462,9 +462,14 @@ impl LoopDelegate for ContainerDelegate {
..Default::default()
};
let result =
execute_tool_simple(&self.tools, &self.safety, &tc.name, &tc.arguments, &job_ctx)
.await;
let result = execute_tool_simple(
&self.tools,
&self.safety,
&tc.name,
tc.arguments.clone(),
&job_ctx,
)
.await;
self.post_event(
"tool_result",
@@ -472,7 +477,7 @@ impl LoopDelegate for ContainerDelegate {
"tool_name": tc.name,
"output": match &result {
Ok(output) => truncate_for_preview(output, 2000),
Err(e) => format!("Error: {}", truncate_for_preview(e, 500)),
Err(e) => format!("Error: {}", truncate_for_preview(e, 500)).into(),
},
"success": result.is_ok(),
}),
+70 -1
View File
@@ -800,12 +800,16 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
});
}
let error_preview = {
let msg = format!("Error: {}", e);
truncate_for_preview(&msg, 500).into_owned()
};
self.log_event(
"tool_result",
serde_json::json!({
"tool_name": selection.tool_name,
"success": false,
"output": truncate_for_preview(&format!("Error: {}", e), 500),
"output": error_preview,
}),
);
@@ -1434,6 +1438,9 @@ impl From<TaskOutput> for Result<String, Error> {
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::channels::ChannelManager;
use crate::llm::ToolSelection;
use super::*;
@@ -1444,6 +1451,8 @@ mod tests {
ToolCompletionResponse,
};
use crate::safety::SafetyLayer;
use crate::testing::{BroadcastCapture, RecordingBroadcastChannel};
use crate::tools::builtin::MessageTool;
use crate::tools::{Tool, ToolError as ToolExecError, ToolOutput};
/// A test tool that sleeps for a configurable duration before returning.
@@ -1535,6 +1544,20 @@ mod tests {
Worker::new(job_id, deps)
}
async fn make_worker_with_message_tool()
-> (Worker, Arc<MessageTool>, BroadcastCapture, BroadcastCapture) {
let channel_manager = ChannelManager::new();
let (gateway, gateway_captures) = RecordingBroadcastChannel::new("gateway");
let (telegram, telegram_captures) = RecordingBroadcastChannel::new("telegram");
channel_manager.add(Box::new(gateway)).await;
channel_manager.add(Box::new(telegram)).await;
let message_tool = Arc::new(MessageTool::new(Arc::new(channel_manager)));
let worker = make_worker(vec![message_tool.clone()]).await;
(worker, message_tool, gateway_captures, telegram_captures)
}
#[test]
fn test_tool_selection_preserves_call_id() {
let selection = ToolSelection {
@@ -2143,4 +2166,50 @@ mod tests {
assert_eq!(ctx.metadata, original); // safety: test
}
#[tokio::test]
async fn autonomous_message_tool_ignores_stale_gateway_context_when_routine_metadata_targets_telegram()
{
let (worker, message_tool, gateway_captures, telegram_captures) =
make_worker_with_message_tool().await;
message_tool
.set_context(
Some("gateway".to_string()),
Some("stale-gateway-target".to_string()),
)
.await;
worker
.context_manager()
.update_context(worker.job_id, |ctx| {
ctx.user_id = "telegram".to_string();
ctx.metadata = serde_json::json!({
"notify_channel": "telegram",
"owner_id": "owner-scope",
});
Ok::<(), String>(())
})
.await
.unwrap() // safety: test
.unwrap(); // safety: test
let result = worker
.execute_tool(
"message",
&serde_json::json!({"content": "hello from routine"}),
)
.await
.unwrap(); // safety: test
assert!(
result.contains("telegram:owner-scope"),
"expected telegram owner-scope routing, got: {result}"
);
assert!(gateway_captures.lock().await.is_empty());
let telegram = telegram_captures.lock().await.clone();
assert_eq!(telegram.len(), 1);
assert_eq!(telegram[0].0, "owner-scope");
assert_eq!(telegram[0].1.content, "hello from routine");
}
}
+21
View File
@@ -91,6 +91,27 @@ Default k=60. Results from both methods are combined, with documents appearing i
- **PostgreSQL:** `ts_rank_cd` for FTS, pgvector cosine distance for vectors, full RRF
- **libSQL:** FTS5 for keyword search + vector search via `libsql_vector_idx` (dimension set dynamically by `ensure_vector_index()` during startup)
## Multi-Scope Reads & Identity Isolation
When a workspace has additional read scopes (via `with_additional_read_scopes`), read operations can span multiple user scopes — a user with scopes `["alice", "shared"]` can read documents from both.
**Identity files are exempt from multi-scope reads.** The system prompt reads identity and configuration files from the **primary scope only** (`read_primary()`), never from secondary scopes:
| File | Read method | Rationale |
|------|------------|-----------|
| AGENTS.md | `read_primary()` | Agent instructions are per-user |
| SOUL.md | `read_primary()` | Core values are per-user |
| USER.md | `read_primary()` | User context is per-user |
| IDENTITY.md | `read_primary()` | Identity is per-user |
| TOOLS.md | `read_primary()` | Tool config is per-user |
| BOOTSTRAP.md | `read_primary()` | Onboarding is per-user |
| MEMORY.md | `read()` | Shared memory is a feature |
| daily/*.md | `read()` | Shared daily logs are a feature |
**Why:** Without this, a user with read access to another scope could silently inherit that scope's identity if their own copy is missing. The agent would present itself as the wrong user — a correctness and security issue.
**Design rule:** If you want shared identity across users, seed the same content into each user's scope at setup time. Don't rely on multi-scope fallback for identity files.
## Heartbeat System
Proactive periodic execution (default: 30 minutes):
+167 -4
View File
@@ -37,6 +37,25 @@ pub mod paths {
pub const ASSISTANT_DIRECTIVES: &str = "context/assistant-directives.md";
}
/// Paths treated as identity documents for multi-scope isolation.
///
/// These files are always read from the primary scope only — never from
/// secondary read scopes. This prevents silent identity inheritance
/// (e.g., user A accidentally presenting as user B).
pub const IDENTITY_PATHS: &[&str] = &[
paths::IDENTITY,
paths::SOUL,
paths::AGENTS,
paths::USER,
paths::TOOLS,
paths::BOOTSTRAP,
];
/// Check if a path is an identity document that must be isolated to primary scope.
pub fn is_identity_path(path: &str) -> bool {
IDENTITY_PATHS.contains(&path)
}
/// A memory document stored in the database.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryDocument {
@@ -101,10 +120,7 @@ impl MemoryDocument {
/// Check if this is a well-known identity document.
pub fn is_identity_document(&self) -> bool {
matches!(
self.path.as_str(),
paths::IDENTITY | paths::SOUL | paths::AGENTS | paths::USER
)
is_identity_path(&self.path)
}
}
@@ -128,6 +144,42 @@ impl WorkspaceEntry {
}
}
/// Merge workspace entries from multiple scopes into a deduplicated, sorted list.
///
/// When the same path appears in multiple scopes:
/// - Keeps the most recent `updated_at`
/// - If any scope marks it as a directory, the merged entry is a directory
pub fn merge_workspace_entries(
entries: impl IntoIterator<Item = WorkspaceEntry>,
) -> Vec<WorkspaceEntry> {
let mut seen = std::collections::HashMap::new();
for entry in entries {
seen.entry(entry.path.clone())
.and_modify(|existing: &mut WorkspaceEntry| {
// Keep the most recent updated_at (and its content_preview)
if let (Some(existing_ts), Some(new_ts)) = (&existing.updated_at, &entry.updated_at)
{
if new_ts > existing_ts {
existing.updated_at = Some(*new_ts);
existing.content_preview = entry.content_preview.clone();
}
} else if existing.updated_at.is_none() {
existing.updated_at = entry.updated_at;
existing.content_preview = entry.content_preview.clone();
}
// If either is a directory, mark as directory
if entry.is_directory {
existing.is_directory = true;
existing.content_preview = None;
}
})
.or_insert(entry);
}
let mut result: Vec<WorkspaceEntry> = seen.into_values().collect();
result.sort_by(|a, b| a.path.cmp(&b.path));
result
}
/// A chunk of a memory document for search indexing.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryChunk {
@@ -226,4 +278,115 @@ mod tests {
};
assert_eq!(entry.name(), "alpha");
}
#[test]
fn test_merge_workspace_entries_empty() {
let result = merge_workspace_entries(vec![]);
assert!(result.is_empty());
}
#[test]
fn test_merge_workspace_entries_keeps_newer_timestamp_and_preview() {
use chrono::TimeZone;
let old_ts = chrono::Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
let new_ts = chrono::Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap();
let entries = vec![
WorkspaceEntry {
path: "notes.md".to_string(),
is_directory: false,
updated_at: Some(old_ts),
content_preview: Some("old".to_string()),
},
WorkspaceEntry {
path: "notes.md".to_string(),
is_directory: false,
updated_at: Some(new_ts),
content_preview: Some("new".to_string()),
},
];
let result = merge_workspace_entries(entries);
assert_eq!(result.len(), 1);
assert_eq!(result[0].updated_at, Some(new_ts));
assert_eq!(result[0].content_preview, Some("new".to_string()));
}
#[test]
fn test_merge_workspace_entries_directory_wins() {
let entries = vec![
WorkspaceEntry {
path: "projects".to_string(),
is_directory: false,
updated_at: None,
content_preview: Some("file content".to_string()),
},
WorkspaceEntry {
path: "projects".to_string(),
is_directory: true,
updated_at: None,
content_preview: None,
},
];
let result = merge_workspace_entries(entries);
assert_eq!(result.len(), 1);
assert!(result[0].is_directory);
assert!(result[0].content_preview.is_none());
}
#[test]
fn test_merge_workspace_entries_fills_missing_timestamp() {
use chrono::TimeZone;
let ts = chrono::Utc.with_ymd_and_hms(2025, 3, 1, 0, 0, 0).unwrap();
let entries = vec![
WorkspaceEntry {
path: "a.md".to_string(),
is_directory: false,
updated_at: None,
content_preview: None,
},
WorkspaceEntry {
path: "a.md".to_string(),
is_directory: false,
updated_at: Some(ts),
content_preview: None,
},
];
let result = merge_workspace_entries(entries);
assert_eq!(result.len(), 1);
assert_eq!(result[0].updated_at, Some(ts));
}
#[test]
fn test_merge_workspace_entries_sorted_by_path() {
let entries = vec![
WorkspaceEntry {
path: "z.md".to_string(),
is_directory: false,
updated_at: None,
content_preview: None,
},
WorkspaceEntry {
path: "a.md".to_string(),
is_directory: false,
updated_at: None,
content_preview: None,
},
WorkspaceEntry {
path: "m.md".to_string(),
is_directory: false,
updated_at: None,
content_preview: None,
},
];
let result = merge_workspace_entries(entries);
assert_eq!(result.len(), 3);
assert_eq!(result[0].path, "a.md");
assert_eq!(result[1].path, "m.md");
assert_eq!(result[2].path, "z.md");
}
}
+366 -38
View File
@@ -52,7 +52,10 @@ mod repository;
mod search;
pub use chunker::{ChunkConfig, chunk_document};
pub use document::{MemoryChunk, MemoryDocument, WorkspaceEntry, paths};
pub use document::{
IDENTITY_PATHS, MemoryChunk, MemoryDocument, WorkspaceEntry, is_identity_path,
merge_workspace_entries, paths,
};
pub use embedding_cache::{CachedEmbeddingProvider, EmbeddingCacheConfig};
pub use embeddings::{
EmbeddingProvider, MockEmbeddings, NearAiEmbeddings, OllamaEmbeddings, OpenAiEmbeddings,
@@ -320,6 +323,48 @@ impl WorkspaceStorage {
}
}
}
// ==================== Multi-scope read methods ====================
async fn hybrid_search_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
query: &str,
embedding: Option<&[f32]>,
config: &SearchConfig,
) -> Result<Vec<SearchResult>, WorkspaceError> {
match self {
#[cfg(feature = "postgres")]
Self::Repo(repo) => {
repo.hybrid_search_multi(user_ids, agent_id, query, embedding, config)
.await
}
Self::Db(db) => {
db.hybrid_search_multi(user_ids, agent_id, query, embedding, config)
.await
}
}
}
async fn get_document_by_path_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
path: &str,
) -> Result<MemoryDocument, WorkspaceError> {
match self {
#[cfg(feature = "postgres")]
Self::Repo(repo) => {
repo.get_document_by_path_multi(user_ids, agent_id, path)
.await
}
Self::Db(db) => {
db.get_document_by_path_multi(user_ids, agent_id, path)
.await
}
}
}
}
/// Default template seeded into HEARTBEAT.md on first access.
@@ -340,9 +385,20 @@ const BOOTSTRAP_SEED: &str = include_str!("seeds/BOOTSTRAP.md");
/// Each workspace is scoped to a user (and optionally an agent).
/// Documents are persisted to the database and indexed for search.
/// Supports both PostgreSQL (via Repository) and libSQL (via Database trait).
///
/// ## Multi-scope reads
///
/// By default, a workspace reads from and writes to a single `user_id`.
/// With `with_additional_read_scopes`, read operations (search, read, list)
/// can span multiple user scopes while writes remain isolated to the primary
/// `user_id`. This enables cross-tenant read access (e.g., a user reading
/// from both their own workspace and a "shared" workspace).
pub struct Workspace {
/// User identifier (from channel).
/// User identifier (from channel). All writes go to this scope.
user_id: String,
/// User identifiers for read operations. Includes `user_id` as the first
/// element, plus any additional scopes added via `with_additional_read_scopes`.
read_user_ids: Vec<String>,
/// Optional agent ID for multi-agent isolation.
agent_id: Option<Uuid>,
/// Database storage backend.
@@ -371,6 +427,7 @@ impl Workspace {
let user_id_str = user_id.into();
let memory_layers = crate::workspace::layer::MemoryLayer::default_for_user(&user_id_str);
Self {
read_user_ids: vec![user_id_str.clone()],
user_id: user_id_str,
agent_id: None,
storage: WorkspaceStorage::Repo(Repository::new(pool)),
@@ -390,6 +447,7 @@ impl Workspace {
let user_id_str = user_id.into();
let memory_layers = crate::workspace::layer::MemoryLayer::default_for_user(&user_id_str);
Self {
read_user_ids: vec![user_id_str.clone()],
user_id: user_id_str,
agent_id: None,
storage: WorkspaceStorage::Db(db),
@@ -474,6 +532,12 @@ impl Workspace {
///
/// Also updates read_user_ids to include all layer scopes.
pub fn with_memory_layers(mut self, layers: Vec<crate::workspace::layer::MemoryLayer>) -> Self {
// Add layer scopes to read_user_ids (same dedup logic as with_additional_read_scopes)
for layer in &layers {
if !self.read_user_ids.contains(&layer.scope) {
self.read_user_ids.push(layer.scope.clone());
}
}
self.memory_layers = layers;
self
}
@@ -496,11 +560,37 @@ impl Workspace {
&self.memory_layers
}
/// Get the user ID.
/// Add additional user scopes for read operations.
///
/// The primary `user_id` is always included. Additional scopes allow
/// read operations (search, read, list) to span multiple tenants while
/// writes remain isolated to the primary scope.
///
/// Duplicate scopes are ignored.
pub fn with_additional_read_scopes(mut self, scopes: Vec<String>) -> Self {
for scope in scopes {
if !self.read_user_ids.contains(&scope) {
self.read_user_ids.push(scope);
}
}
self
}
/// Get the user ID (primary scope for writes).
pub fn user_id(&self) -> &str {
&self.user_id
}
/// Get the user IDs used for read operations.
pub fn read_user_ids(&self) -> &[String] {
&self.read_user_ids
}
/// Whether this workspace has multiple read scopes.
fn is_multi_scope(&self) -> bool {
self.read_user_ids.len() > 1
}
/// Get the agent ID.
pub fn agent_id(&self) -> Option<Uuid> {
self.agent_id
@@ -518,6 +608,33 @@ impl Workspace {
/// println!("{}", doc.content);
/// ```
pub async fn read(&self, path: &str) -> Result<MemoryDocument, WorkspaceError> {
let path = normalize_path(path);
if self.is_multi_scope() && is_identity_path(&path) {
// Identity files must only come from the primary scope.
self.storage
.get_document_by_path(&self.user_id, self.agent_id, &path)
.await
} else if self.is_multi_scope() {
self.storage
.get_document_by_path_multi(&self.read_user_ids, self.agent_id, &path)
.await
} else {
self.storage
.get_document_by_path(&self.user_id, self.agent_id, &path)
.await
}
}
/// Read a file from the **primary scope only**, ignoring additional read scopes.
///
/// Use this for identity and configuration files (AGENTS.md, SOUL.md, USER.md,
/// IDENTITY.md, TOOLS.md, BOOTSTRAP.md) where inheriting content from another
/// scope would be a correctness/security issue — the agent must never silently
/// present itself as the wrong user.
///
/// For memory files that should span scopes (MEMORY.md, daily logs), use
/// [`read`] instead.
pub async fn read_primary(&self, path: &str) -> Result<MemoryDocument, WorkspaceError> {
let path = normalize_path(path);
self.storage
.get_document_by_path(&self.user_id, self.agent_id, &path)
@@ -556,8 +673,15 @@ impl Workspace {
/// Uses a single `\n` separator (suitable for log-style entries).
/// For semantic separation (e.g., memory entries), use `append_memory()`
/// which uses `\n\n`.
///
/// Uses a read-modify-write pattern that is not concurrency-safe:
/// concurrent appends to the same path may lose writes.
pub async fn append(&self, path: &str, content: &str) -> Result<(), WorkspaceError> {
let path = normalize_path(path);
// Scan system-prompt-injected files for prompt injection.
if is_system_prompt_file(&path) && !content.is_empty() {
reject_if_injected(&path, content)?;
}
let doc = self
.storage
.get_or_create_document_by_path(&self.user_id, self.agent_id, &path)
@@ -672,6 +796,20 @@ impl Workspace {
}
/// Write to a layer, with append semantics.
///
/// Note: privacy classification only examines the new `content`, not the
/// full document after concatenation. See [`PatternPrivacyClassifier`]
/// limitations for details.
///
/// When a privacy redirect occurs, the append targets a **separate
/// document** in the private scope at the same path — the shared-scope
/// document is left unmodified. Subsequent multi-scope reads will return
/// the private copy (primary scope wins), effectively shadowing the
/// shared document at that path. The `WriteResult::redirected` flag
/// indicates when this has happened.
///
/// Uses a read-modify-write pattern that is not concurrency-safe:
/// concurrent appends to the same path may lose writes.
pub async fn append_to_layer(
&self,
layer_name: &str,
@@ -702,13 +840,25 @@ impl Workspace {
}
/// Check if a file exists.
///
/// When multi-scope reads are configured, checks across all read scopes.
pub async fn exists(&self, path: &str) -> Result<bool, WorkspaceError> {
let path = normalize_path(path);
match self
.storage
.get_document_by_path(&self.user_id, self.agent_id, &path)
.await
{
let result = if self.is_multi_scope() && is_identity_path(&path) {
// Identity files only checked in primary scope.
self.storage
.get_document_by_path(&self.user_id, self.agent_id, &path)
.await
} else if self.is_multi_scope() {
self.storage
.get_document_by_path_multi(&self.read_user_ids, self.agent_id, &path)
.await
} else {
self.storage
.get_document_by_path(&self.user_id, self.agent_id, &path)
.await
};
match result {
Ok(_) => Ok(true),
Err(WorkspaceError::DocumentNotFound { .. }) => Ok(false),
Err(e) => Err(e),
@@ -743,16 +893,55 @@ impl Workspace {
/// ```
pub async fn list(&self, directory: &str) -> Result<Vec<WorkspaceEntry>, WorkspaceError> {
let directory = normalize_directory(directory);
self.storage
.list_directory(&self.user_id, self.agent_id, &directory)
.await
if self.is_multi_scope() {
// Iterate per-scope rather than using list_directory_multi because
// we need to filter identity paths from secondary scopes only — the
// merged _multi result loses scope attribution.
let primary = self
.storage
.list_directory(&self.user_id, self.agent_id, &directory)
.await?;
let mut all_entries = primary;
for scope in &self.read_user_ids[1..] {
let entries = self
.storage
.list_directory(scope, self.agent_id, &directory)
.await?;
all_entries.extend(entries.into_iter().filter(|e| !is_identity_path(&e.path)));
}
Ok(merge_workspace_entries(all_entries))
} else {
self.storage
.list_directory(&self.user_id, self.agent_id, &directory)
.await
}
}
/// List all files recursively (flat list of all paths).
///
/// When multi-scope reads are configured, lists across all read scopes.
pub async fn list_all(&self) -> Result<Vec<String>, WorkspaceError> {
self.storage
.list_all_paths(&self.user_id, self.agent_id)
.await
if self.is_multi_scope() {
// Iterate per-scope rather than using list_all_paths_multi because
// we need to filter identity paths from secondary scopes only.
// Primary scope: all paths. Secondary scopes: filter identity paths.
let mut all_paths = self
.storage
.list_all_paths(&self.user_id, self.agent_id)
.await?;
for scope in &self.read_user_ids[1..] {
let paths = self.storage.list_all_paths(scope, self.agent_id).await?;
all_paths.extend(paths.into_iter().filter(|p| !is_identity_path(p)));
}
// Deduplicate and sort
all_paths.sort();
all_paths.dedup();
Ok(all_paths)
} else {
self.storage
.list_all_paths(&self.user_id, self.agent_id)
.await
}
}
// ==================== Convenience Methods ====================
@@ -787,7 +976,7 @@ impl Workspace {
/// comments, which the heartbeat runner treats as "effectively empty"
/// and skips the LLM call.
pub async fn heartbeat_checklist(&self) -> Result<Option<String>, WorkspaceError> {
match self.read(paths::HEARTBEAT).await {
match self.read_primary(paths::HEARTBEAT).await {
Ok(doc) => Ok(Some(doc.content)),
Err(WorkspaceError::DocumentNotFound { .. }) => Ok(Some(HEARTBEAT_SEED.to_string())),
Err(e) => Err(e),
@@ -795,7 +984,29 @@ impl Workspace {
}
/// Helper to read or create a file.
///
/// When multi-scope reads are configured, checks all read scopes before
/// creating. If the file exists in any scope, returns it. If not found in
/// any scope, creates it in the primary (write) scope.
///
/// **Important:** In multi-scope mode, the returned document may belong to
/// a secondary scope. Callers that intend to **write** to the document
/// (via `update_document(doc.id, ...)`) must NOT use this method — use
/// `storage.get_or_create_document_by_path(&self.user_id, ...)` instead
/// to guarantee writes target the primary scope. See `append_memory` for
/// the correct pattern.
async fn read_or_create(&self, path: &str) -> Result<MemoryDocument, WorkspaceError> {
if self.is_multi_scope() {
match self
.storage
.get_document_by_path_multi(&self.read_user_ids, self.agent_id, path)
.await
{
Ok(doc) => return Ok(doc),
Err(WorkspaceError::DocumentNotFound { .. }) => {}
Err(e) => return Err(e),
}
}
self.storage
.get_or_create_document_by_path(&self.user_id, self.agent_id, path)
.await
@@ -807,9 +1018,18 @@ impl Workspace {
///
/// This is for important facts, decisions, and preferences worth
/// remembering long-term.
///
/// Uses `get_or_create_document_by_path` with the primary `user_id`
/// instead of `self.memory()` to guarantee writes always target the
/// primary (write) scope. `self.memory()` delegates to `read_or_create`,
/// which in multi-scope mode may return a document owned by a secondary
/// scope; writing to that document by UUID would violate write isolation.
pub async fn append_memory(&self, entry: &str) -> Result<(), WorkspaceError> {
// Use double newline for memory entries (semantic separation)
let doc = self.memory().await?;
// Always get/create in the primary scope to preserve write isolation.
let doc = self
.storage
.get_or_create_document_by_path(&self.user_id, self.agent_id, paths::MEMORY)
.await?;
let new_content = if doc.content.is_empty() {
entry.to_string()
} else {
@@ -901,9 +1121,16 @@ impl Workspace {
// Safety net: if `profile_onboarding_completed` was already set (the
// LLM completed onboarding but forgot to delete BOOTSTRAP.md), skip
// injection to avoid repeating the first-run ritual.
//
// Identity and config files use read_primary() to prevent cross-scope
// bleed in multi-scope workspaces. Without this, a user with read access
// to other scopes could silently inherit another user's identity if their
// own copy is missing — the agent would present as the wrong person.
// Memory files (MEMORY.md, daily logs) intentionally use multi-scope
// read() since sharing memory across scopes is a feature.
let bootstrap_injected = if self.is_bootstrap_completed() {
if self
.read(paths::BOOTSTRAP)
.read_primary(paths::BOOTSTRAP)
.await
.is_ok_and(|d| !d.content.is_empty())
{
@@ -913,7 +1140,7 @@ impl Workspace {
);
}
false
} else if let Ok(doc) = self.read(paths::BOOTSTRAP).await
} else if let Ok(doc) = self.read_primary(paths::BOOTSTRAP).await
&& !doc.content.is_empty()
{
parts.push(format!("## First-Run Bootstrap\n\n{}", doc.content));
@@ -922,7 +1149,8 @@ impl Workspace {
false
};
// Load identity files in order of importance
// Load identity files in order of importance.
// These MUST use read_primary() — see comment above.
let identity_files = [
(paths::AGENTS, "## Agent Instructions"),
(paths::SOUL, "## Core Values"),
@@ -931,7 +1159,7 @@ impl Workspace {
];
for (path, header) in identity_files {
if let Ok(doc) = self.read(path).await
if let Ok(doc) = self.read_primary(path).await
&& !doc.content.is_empty()
{
parts.push(format!("{}\n\n{}", header, doc.content));
@@ -940,7 +1168,8 @@ impl Workspace {
// Tool notes: environment-specific guidance the agent or user has written.
// TOOLS.md does not control tool availability; it is guidance only.
if let Ok(doc) = self.read(paths::TOOLS).await
// Uses read_primary() — tool config is per-user, not inherited.
if let Ok(doc) = self.read_primary(paths::TOOLS).await
&& !doc.content.is_empty()
{
parts.push(format!("## Tool Notes\n\n{}", doc.content));
@@ -1231,6 +1460,8 @@ impl Workspace {
}
/// Search with custom configuration.
///
/// When multi-scope reads are configured, searches across all read scopes.
pub async fn search_with_config(
&self,
query: &str,
@@ -1250,15 +1481,46 @@ impl Workspace {
None
};
self.storage
.hybrid_search(
&self.user_id,
self.agent_id,
query,
embedding.as_deref(),
&config,
)
.await
if self.is_multi_scope() {
let results = self
.storage
.hybrid_search_multi(
&self.read_user_ids,
self.agent_id,
query,
embedding.as_deref(),
&config,
)
.await?;
// Post-filter: exclude identity documents from secondary scopes.
// Collect document IDs that are identity paths in secondary scopes.
let mut excluded_doc_ids = std::collections::HashSet::new();
for result in &results {
if is_identity_path(&result.document_path) {
// Check if this document belongs to a secondary scope
match self.storage.get_document_by_id(result.document_id).await {
Ok(doc) if doc.user_id != self.user_id => {
excluded_doc_ids.insert(result.document_id);
}
_ => {}
}
}
}
Ok(results
.into_iter()
.filter(|r| !excluded_doc_ids.contains(&r.document_id))
.collect())
} else {
self.storage
.hybrid_search(
&self.user_id,
self.agent_id,
query,
embedding.as_deref(),
&config,
)
.await
}
}
// ==================== Indexing ====================
@@ -1319,13 +1581,13 @@ impl Workspace {
// Check freshness BEFORE seeding identity files, otherwise the
// seeded files make the workspace look non-fresh and BOOTSTRAP.md
// never gets created.
let is_fresh_workspace = if self.read(paths::BOOTSTRAP).await.is_ok() {
let is_fresh_workspace = if self.read_primary(paths::BOOTSTRAP).await.is_ok() {
false // BOOTSTRAP already exists
} else {
let (agents_res, soul_res, user_res) = tokio::join!(
self.read(paths::AGENTS),
self.read(paths::SOUL),
self.read(paths::USER),
self.read_primary(paths::AGENTS),
self.read_primary(paths::SOUL),
self.read_primary(paths::USER),
);
matches!(agents_res, Err(WorkspaceError::DocumentNotFound { .. }))
&& matches!(soul_res, Err(WorkspaceError::DocumentNotFound { .. }))
@@ -1334,8 +1596,10 @@ impl Workspace {
let mut count = 0;
for (path, content) in seed_files {
// Skip files that already exist (never overwrite user edits)
match self.read(path).await {
// Skip files that already exist in the primary scope (never overwrite user edits).
// Uses read_primary to avoid false positives from secondary scopes —
// a file in another scope should not suppress seeding in this scope.
match self.read_primary(path).await {
Ok(_) => continue,
Err(WorkspaceError::DocumentNotFound { .. }) => {}
Err(e) => {
@@ -1356,7 +1620,8 @@ impl Workspace {
// may already have a profile from a previous install and doesn't need
// onboarding). This prevents existing users from getting a spurious
// first-run ritual after upgrading.
let has_profile = self.read(paths::PROFILE).await.is_ok_and(|d| {
// Uses read_primary() to avoid false positives from secondary scopes.
let has_profile = self.read_primary(paths::PROFILE).await.is_ok_and(|d| {
!d.content.trim().is_empty()
&& serde_json::from_str::<crate::profile::PsychographicProfile>(&d.content).is_ok()
});
@@ -1787,4 +2052,67 @@ mod seed_tests {
"BOOTSTRAP.md should NOT have been seeded with existing profile"
);
}
#[test]
fn test_default_single_scope() {
// Verify backward compatibility: default workspace has single read scope
// matching user_id.
let user_id = "alice";
let read_user_ids = [user_id.to_string()];
assert_eq!(read_user_ids.len(), 1);
assert_eq!(read_user_ids[0], user_id);
}
#[test]
fn test_additional_read_scopes() {
// Verify that additional read scopes are added correctly.
let user_id = "alice".to_string();
let mut read_user_ids = Vec::from([user_id.clone()]);
// Simulate with_additional_read_scopes logic
let scopes = ["shared", "team"];
for scope in scopes {
let s = scope.to_string();
if !read_user_ids.contains(&s) {
read_user_ids.push(s);
}
}
assert_eq!(read_user_ids.len(), 3);
assert_eq!(read_user_ids[0], "alice");
assert_eq!(read_user_ids[1], "shared");
assert_eq!(read_user_ids[2], "team");
}
#[test]
fn test_additional_read_scopes_dedup() {
// Verify that duplicate scopes are ignored.
let user_id = "alice".to_string();
let mut read_user_ids = Vec::from([user_id.clone()]);
let scopes = ["shared", "alice", "shared"];
for scope in scopes {
let s = scope.to_string();
if !read_user_ids.contains(&s) {
read_user_ids.push(s);
}
}
assert_eq!(read_user_ids.len(), 2);
assert_eq!(read_user_ids[0], "alice");
assert_eq!(read_user_ids[1], "shared");
}
#[test]
fn test_is_multi_scope_logic() {
// Test the multi-scope detection logic: > 1 means multi-scope
let single_count = 1_usize;
let multi_count = 2_usize;
// Single scope: not multi
assert!(single_count <= 1);
// Multi scope: is multi
assert!(multi_count > 1);
}
}
+199
View File
@@ -502,4 +502,203 @@ impl Repository {
})
.collect())
}
// ==================== Multi-scope search (optimized SQL) ====================
/// Hybrid search across multiple user scopes with efficient SQL.
///
/// Uses `user_id = ANY($1::text[])` instead of N separate queries.
pub async fn hybrid_search_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
query: &str,
embedding: Option<&[f32]>,
config: &SearchConfig,
) -> Result<Vec<SearchResult>, WorkspaceError> {
let fts_results = if config.use_fts {
self.fts_search_multi(user_ids, agent_id, query, config.pre_fusion_limit)
.await?
} else {
Vec::new()
};
let vector_results = if config.use_vector {
if let Some(embedding) = embedding {
self.vector_search_multi(user_ids, agent_id, embedding, config.pre_fusion_limit)
.await?
} else {
Vec::new()
}
} else {
Vec::new()
};
Ok(fuse_results(fts_results, vector_results, config))
}
/// FTS search across multiple user scopes.
async fn fts_search_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
query: &str,
limit: usize,
) -> Result<Vec<RankedResult>, WorkspaceError> {
let conn = self.conn().await?;
let rows = conn
.query(
r#"
SELECT c.id as chunk_id, c.document_id, d.path as document_path,
c.content,
ts_rank_cd(c.content_tsv, plainto_tsquery('english', $3)) as rank
FROM memory_chunks c
JOIN memory_documents d ON d.id = c.document_id
WHERE d.user_id = ANY($1::text[]) AND d.agent_id IS NOT DISTINCT FROM $2
AND c.content_tsv @@ plainto_tsquery('english', $3)
ORDER BY rank DESC
LIMIT $4
"#,
&[&user_ids, &agent_id, &query, &(limit as i64)],
)
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("FTS multi-scope query failed: {}", e),
})?;
Ok(rows
.iter()
.enumerate()
.map(|(i, row)| RankedResult {
chunk_id: row.get("chunk_id"),
document_id: row.get("document_id"),
document_path: row.get("document_path"),
content: row.get("content"),
rank: (i + 1) as u32,
})
.collect())
}
/// Vector search across multiple user scopes.
async fn vector_search_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
embedding: &[f32],
limit: usize,
) -> Result<Vec<RankedResult>, WorkspaceError> {
let conn = self.conn().await?;
let embedding_vec = Vector::from(embedding.to_vec());
let rows = conn
.query(
r#"
SELECT c.id as chunk_id, c.document_id, d.path as document_path,
c.content, 1 - (c.embedding <=> $3) as similarity
FROM memory_chunks c
JOIN memory_documents d ON d.id = c.document_id
WHERE d.user_id = ANY($1::text[]) AND d.agent_id IS NOT DISTINCT FROM $2
AND c.embedding IS NOT NULL
ORDER BY c.embedding <=> $3
LIMIT $4
"#,
&[&user_ids, &agent_id, &embedding_vec, &(limit as i64)],
)
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Vector multi-scope query failed: {}", e),
})?;
Ok(rows
.iter()
.enumerate()
.map(|(i, row)| RankedResult {
chunk_id: row.get("chunk_id"),
document_id: row.get("document_id"),
document_path: row.get("document_path"),
content: row.get("content"),
rank: (i + 1) as u32,
})
.collect())
}
/// List all file paths across multiple user scopes with a single query.
pub async fn list_all_paths_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
) -> Result<Vec<String>, WorkspaceError> {
let conn = self.conn().await?;
let rows = conn
.query(
r#"
SELECT DISTINCT path FROM memory_documents
WHERE user_id = ANY($1::text[]) AND agent_id IS NOT DISTINCT FROM $2
ORDER BY path
"#,
&[&user_ids, &agent_id],
)
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("List paths multi-scope failed: {}", e),
})?;
Ok(rows.iter().map(|row| row.get("path")).collect())
}
/// Get a document by path across multiple user scopes.
///
/// Returns the first match (ordered by the input user_ids priority).
pub async fn get_document_by_path_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
path: &str,
) -> Result<MemoryDocument, WorkspaceError> {
let conn = self.conn().await?;
let row = conn
.query_opt(
r#"
SELECT id, user_id, agent_id, path, content,
created_at, updated_at, metadata
FROM memory_documents
WHERE user_id = ANY($1::text[]) AND agent_id IS NOT DISTINCT FROM $2 AND path = $3
ORDER BY array_position($1::text[], user_id)
LIMIT 1
"#,
&[&user_ids, &agent_id, &path],
)
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("get_document_by_path_multi failed: {}", e),
})?;
match row {
Some(row) => Ok(self.row_to_document(&row)),
None => Err(WorkspaceError::DocumentNotFound {
doc_type: path.to_string(),
user_id: format!("[{}]", user_ids.join(", ")),
}),
}
}
/// List directory contents across multiple user scopes.
///
/// Iterates per scope and merges results. A future migration could add an
/// optimised SQL function, at which point this method can call it directly.
pub async fn list_directory_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
directory: &str,
) -> Result<Vec<WorkspaceEntry>, WorkspaceError> {
let mut all_entries = Vec::new();
for uid in user_ids {
all_entries.extend(self.list_directory(uid, agent_id, directory).await?);
}
Ok(crate::workspace::merge_workspace_entries(all_entries))
}
}
+1 -1
View File
@@ -134,7 +134,7 @@ mod tests {
match &routine.trigger {
Trigger::Cron { schedule, timezone } => {
assert_eq!(schedule, "0 0 9 * * *");
assert_eq!(schedule, "0 0 9 * * * *");
assert_eq!(timezone.as_deref(), Some("America/New_York"));
}
other => panic!("expected cron trigger, got {other:?}"),
+99
View File
@@ -0,0 +1,99 @@
use ironclaw::llm::ChatMessage;
use ironclaw::llm::gemini_oauth::GeminiOauthProvider;
/// Regression: Cloud Code API routing for Gemini 2.0+ models.
/// Gemini 1.x → legacy generativelanguage.googleapis.com
/// Gemini 2.0+ → Cloud Code API (cloudcode-pa.googleapis.com)
#[test]
fn test_regression_cloud_code_api_routing() {
// Legacy models (1.x) → false
assert!(!GeminiOauthProvider::model_uses_cloud_code_api(
"gemini-1.5-pro"
));
assert!(!GeminiOauthProvider::model_uses_cloud_code_api(
"gemini-1.5-flash"
));
// 2.0+ models → true
assert!(GeminiOauthProvider::model_uses_cloud_code_api(
"gemini-2.0-flash"
));
assert!(GeminiOauthProvider::model_uses_cloud_code_api(
"gemini-2.5-pro"
));
assert!(GeminiOauthProvider::model_uses_cloud_code_api(
"gemini-2.5-flash"
));
// Preview models with hyphen → true
assert!(GeminiOauthProvider::model_uses_cloud_code_api(
"gemini-3.1-pro-preview"
));
assert!(GeminiOauthProvider::model_uses_cloud_code_api(
"gemini-3-flash-preview"
));
// Gemini 3 family → true
assert!(GeminiOauthProvider::model_uses_cloud_code_api(
"gemini-3-pro"
));
}
/// Regression: "preview" false-positive fix.
/// `model.contains("-preview")` (with hyphen) prevents models whose name
/// happens to include "preview" without a hyphen prefix from being
/// mis-routed to Cloud Code API.
#[test]
fn test_regression_preview_false_positive_fix() {
// "my-preview-custom" still matches (contains "-preview")
assert!(GeminiOauthProvider::model_uses_cloud_code_api(
"my-preview-custom"
));
// "mypreviewcustom" does NOT match (no hyphen before "preview")
assert!(!GeminiOauthProvider::model_uses_cloud_code_api(
"mypreviewcustom"
));
// Non-Gemini models without "-preview" → false
assert!(!GeminiOauthProvider::model_uses_cloud_code_api(
"not-a-gemini-model"
));
}
/// Regression: model list consistency.
/// Wizard, list_models(), and LLM_PROVIDERS.md all return the same 8 models.
#[test]
fn test_regression_standardized_model_list() {
let expected_models = [
"gemini-3.1-pro-preview",
"gemini-3.1-pro-preview-customtools",
"gemini-3-pro-preview",
"gemini-3-flash-preview",
"gemini-3.1-flash-lite-preview",
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
];
// All standardized models must route to Cloud Code API (all are >= 2.0)
for model in &expected_models {
assert!(
GeminiOauthProvider::model_uses_cloud_code_api(model),
"Standardized model '{}' should route to Cloud Code API",
model
);
}
}
/// Regression: ChatMessage helper constructors.
#[test]
fn test_regression_chat_message_helpers() {
let user_msg = ChatMessage::user("hello");
assert_eq!(user_msg.role, ironclaw::llm::Role::User);
assert_eq!(user_msg.content, "hello");
let system_msg = ChatMessage::system("you are helpful");
assert_eq!(system_msg.role, ironclaw::llm::Role::System);
assert_eq!(system_msg.content, "you are helpful");
}
+195
View File
@@ -0,0 +1,195 @@
//! Tests for identity file scope isolation in multi-scope workspaces.
//!
//! When a workspace has multiple read scopes (e.g., Andrew can read from
//! "andrew", "grace", "household"), identity files (SOUL.md, USER.md,
//! IDENTITY.md, AGENTS.md) must ONLY come from the primary scope.
//!
//! Multi-scope reads are designed for memory sharing (MEMORY.md, daily logs),
//! not identity inheritance. Silently inheriting identity from another scope
//! is a correctness and security issue — the agent would present itself as
//! the wrong user.
//!
//! These tests verify that:
//! 1. Identity files are read from primary scope only
//! 2. If the primary scope's identity file is missing, it's absent from the
//! system prompt — never falls back to another scope
//! 3. Memory files (MEMORY.md) still benefit from multi-scope reads
#![cfg(feature = "libsql")]
use std::sync::Arc;
use ironclaw::db::Database;
use ironclaw::db::libsql::LibSqlBackend;
use ironclaw::workspace::{Workspace, paths};
async fn setup() -> (Arc<dyn Database>, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create temp dir");
let db_path = dir.path().join("test.db");
let backend = LibSqlBackend::new_local(&db_path).await.expect("create db");
backend.run_migrations().await.expect("run migrations");
let db: Arc<dyn Database> = Arc::new(backend);
(db, dir)
}
/// Seed a document into a specific user's workspace scope.
async fn seed(db: &Arc<dyn Database>, user_id: &str, path: &str, content: &str) {
let ws = Workspace::new_with_db(user_id, db.clone());
ws.write(path, content)
.await
.unwrap_or_else(|e| panic!("Failed to seed {path} for {user_id}: {e}"));
}
// ─── Test 1: Primary scope identity appears in system prompt ───────────
#[tokio::test]
async fn system_prompt_uses_primary_scope_identity() {
let (db, _dir) = setup().await;
// Seed Alice's identity files in her own scope
seed(&db, "alice", paths::SOUL, "Alice is kind and curious.").await;
seed(
&db,
"alice",
paths::USER,
"You are talking to Alice, a software engineer.",
)
.await;
// Seed Bob's identity files in his scope
seed(&db, "bob", paths::SOUL, "Bob is analytical and precise.").await;
seed(
&db,
"bob",
paths::USER,
"You are talking to Bob, a marine biologist.",
)
.await;
// Create Alice's workspace WITH multi-scope reads including Bob
let ws = Workspace::new_with_db("alice", db.clone())
.with_additional_read_scopes(vec!["bob".to_string()]);
let prompt = ws
.system_prompt_for_context(false)
.await
.expect("system_prompt_for_context failed");
// Alice's identity must appear
assert!(
prompt.contains("Alice is kind and curious"),
"Primary scope SOUL.md should appear in system prompt.\nPrompt:\n{prompt}"
);
assert!(
prompt.contains("Alice, a software engineer"),
"Primary scope USER.md should appear in system prompt.\nPrompt:\n{prompt}"
);
// Bob's identity must NOT appear
assert!(
!prompt.contains("Bob is analytical"),
"Secondary scope SOUL.md must NOT appear in system prompt.\nPrompt:\n{prompt}"
);
assert!(
!prompt.contains("Bob, a marine biologist"),
"Secondary scope USER.md must NOT appear in system prompt.\nPrompt:\n{prompt}"
);
}
// ─── Test 2: Missing primary identity does NOT fall back to other scope ─
#[tokio::test]
async fn missing_primary_identity_does_not_fallback_to_other_scope() {
let (db, _dir) = setup().await;
// Only seed Bob's identity — Alice has no identity files
seed(&db, "bob", paths::SOUL, "Bob is analytical and precise.").await;
seed(
&db,
"bob",
paths::USER,
"You are talking to Bob, a marine biologist.",
)
.await;
// Create Alice's workspace with multi-scope reads including Bob
let ws = Workspace::new_with_db("alice", db.clone())
.with_additional_read_scopes(vec!["bob".to_string()]);
let prompt = ws
.system_prompt_for_context(false)
.await
.expect("system_prompt_for_context failed");
// Bob's identity must NOT appear — Alice's missing identity should stay missing,
// not silently inherit from Bob's scope
assert!(
!prompt.contains("Bob"),
"When primary scope identity is missing, must NOT fall back to secondary scope.\n\
This would cause the agent to present itself as the wrong user.\nPrompt:\n{prompt}"
);
}
// ─── Test 3: MEMORY.md still benefits from multi-scope reads ────────────
#[tokio::test]
async fn memory_files_still_use_multi_scope_reads() {
let (db, _dir) = setup().await;
// Seed shared memory in the "shared" scope (not Alice's primary)
seed(
&db,
"shared",
paths::MEMORY,
"Shared grocery list: milk, eggs, bread.",
)
.await;
// Create Alice's workspace with read access to shared scope
let ws = Workspace::new_with_db("alice", db.clone())
.with_additional_read_scopes(vec!["shared".to_string()]);
let prompt = ws
.system_prompt_for_context(false)
.await
.expect("system_prompt_for_context failed");
// Shared memory SHOULD appear — multi-scope reads are correct for memory
assert!(
prompt.contains("grocery list"),
"MEMORY.md should still use multi-scope reads.\nPrompt:\n{prompt}"
);
}
// ─── Test 4: All identity files are scope-isolated ──────────────────────
#[tokio::test]
async fn all_identity_files_are_scope_isolated() {
let (db, _dir) = setup().await;
// Seed identity files ONLY in the "other" scope, not in Alice's
seed(&db, "other", paths::AGENTS, "You are Other's agent.").await;
seed(&db, "other", paths::SOUL, "Other's soul values.").await;
seed(&db, "other", paths::USER, "You are talking to Other.").await;
seed(&db, "other", paths::IDENTITY, "Other's identity.").await;
// Also seed BOOTSTRAP.md and TOOLS.md in other scope
seed(&db, "other", "BOOTSTRAP.md", "Other's bootstrap.").await;
seed(&db, "other", "TOOLS.md", "Other's tool notes.").await;
// Create Alice's workspace with read access to "other"
let ws = Workspace::new_with_db("alice", db.clone())
.with_additional_read_scopes(vec!["other".to_string()]);
let prompt = ws
.system_prompt_for_context(false)
.await
.expect("system_prompt_for_context failed");
// None of Other's identity/config files should appear
assert!(
!prompt.contains("Other"),
"No identity or config files from secondary scope should appear.\n\
Every identity file (AGENTS.md, SOUL.md, USER.md, IDENTITY.md, \
BOOTSTRAP.md, TOOLS.md) must read from primary scope only.\nPrompt:\n{prompt}"
);
}
+451
View File
@@ -0,0 +1,451 @@
#![cfg(feature = "libsql")]
//! Integration tests for multi-scope workspace reads using file-backed libSQL.
//!
//! Guards the PR2 contract: workspaces can read from multiple user scopes
//! while writes remain isolated to the primary scope.
use std::sync::Arc;
use ironclaw::db::Database;
use ironclaw::db::libsql::LibSqlBackend;
use ironclaw::workspace::Workspace;
async fn setup() -> (Arc<dyn Database>, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create temp dir");
let db_path = dir.path().join("test.db");
let backend = LibSqlBackend::new_local(&db_path).await.expect("create db");
backend.run_migrations().await.expect("run migrations");
let db: Arc<dyn Database> = Arc::new(backend);
(db, dir)
}
#[tokio::test]
async fn read_across_scopes() {
let (db, _dir) = setup().await;
// Write docs as the "shared" user
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
ws_shared
.write("docs/team-standup.md", "Team standup notes from Monday")
.await
.expect("shared write failed");
// Alice's workspace with "shared" as an additional read scope
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
.with_additional_read_scopes(vec!["shared".to_string()]);
// Alice can read shared docs
let doc = ws_alice
.read("docs/team-standup.md")
.await
.expect("cross-scope read failed");
assert_eq!(doc.content, "Team standup notes from Monday");
}
#[tokio::test]
async fn write_stays_in_primary_scope() {
let (db, _dir) = setup().await;
// Alice has "shared" as a read scope
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
.with_additional_read_scopes(vec!["shared".to_string()]);
// Alice writes a personal note
ws_alice
.write("notes/personal.md", "Alice's private note")
.await
.expect("alice write failed");
// The "shared" workspace should NOT see Alice's note
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
let result = ws_shared.read("notes/personal.md").await;
assert!(result.is_err(), "Shared scope should not see Alice's note");
}
#[tokio::test]
async fn list_paths_merges_across_scopes() {
let (db, _dir) = setup().await;
// Write as alice
let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
ws_alice_plain
.write("notes/personal.md", "My notes")
.await
.expect("alice write failed");
// Write as shared
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
ws_shared
.write("docs/shared-doc.md", "Shared document")
.await
.expect("shared write failed");
// Alice with multi-scope should see both
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
.with_additional_read_scopes(vec!["shared".to_string()]);
let all_paths = ws_alice.list_all().await.expect("list_all failed");
assert!(
all_paths.contains(&"notes/personal.md".to_string()),
"Should contain alice's note: {:?}",
all_paths
);
assert!(
all_paths.contains(&"docs/shared-doc.md".to_string()),
"Should contain shared doc: {:?}",
all_paths
);
}
#[tokio::test]
async fn list_directory_merges_across_scopes() {
let (db, _dir) = setup().await;
// Alice writes to docs/
let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
ws_alice_plain
.write("docs/alice-doc.md", "Alice's doc")
.await
.expect("alice write failed");
// Shared writes to docs/
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
ws_shared
.write("docs/shared-doc.md", "Shared doc")
.await
.expect("shared write failed");
// Alice with multi-scope lists docs/
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
.with_additional_read_scopes(vec!["shared".to_string()]);
let entries = ws_alice.list("docs").await.expect("list failed");
let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect();
assert!(
paths.contains(&"docs/alice-doc.md"),
"Should contain alice's doc: {:?}",
paths
);
assert!(
paths.contains(&"docs/shared-doc.md"),
"Should contain shared doc: {:?}",
paths
);
}
#[tokio::test]
async fn search_spans_scopes() {
let (db, _dir) = setup().await;
// Write searchable content in shared scope
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
ws_shared
.write(
"docs/architecture.md",
"The microservice architecture uses gRPC for inter-service communication",
)
.await
.expect("shared write failed");
// Write searchable content in alice scope
let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
ws_alice_plain
.write("notes/ideas.md", "Consider switching to GraphQL federation")
.await
.expect("alice write failed");
// Alice with multi-scope searches
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
.with_additional_read_scopes(vec!["shared".to_string()]);
// Search for content in the shared scope
let results = ws_alice
.search("microservice architecture gRPC", 10)
.await
.expect("search failed");
assert!(!results.is_empty(), "Should find results from shared scope");
}
#[tokio::test]
async fn read_priority_primary_first() {
let (db, _dir) = setup().await;
// Write same path in both scopes
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
ws_shared
.write("config/settings.md", "Shared settings v1")
.await
.expect("shared write failed");
let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
ws_alice_plain
.write("config/settings.md", "Alice's settings override")
.await
.expect("alice write failed");
// Alice with multi-scope should get her own version (primary scope wins)
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
.with_additional_read_scopes(vec!["shared".to_string()]);
let doc = ws_alice
.read("config/settings.md")
.await
.expect("read failed");
assert_eq!(
doc.content, "Alice's settings override",
"Primary scope should take priority"
);
}
#[tokio::test]
async fn exists_spans_scopes() {
let (db, _dir) = setup().await;
// Write a doc as "shared"
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
ws_shared
.write("docs/shared-only.md", "Shared content")
.await
.expect("shared write failed");
// Alice without multi-scope should NOT see it
let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
assert!(
!ws_alice_plain
.exists("docs/shared-only.md")
.await
.expect("exists failed"),
"Alice without multi-scope should not see shared doc"
);
// Alice with multi-scope should see it
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
.with_additional_read_scopes(vec!["shared".to_string()]);
assert!(
ws_alice
.exists("docs/shared-only.md")
.await
.expect("exists failed"),
"Alice with multi-scope should see shared doc"
);
}
#[tokio::test]
async fn append_stays_in_primary_scope() {
let (db, _dir) = setup().await;
// Write a document as "shared"
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
ws_shared
.write("notes/log.md", "shared original content")
.await
.expect("shared write failed");
// Alice has "shared" as a read scope and appends to the same path
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
.with_additional_read_scopes(vec!["shared".to_string()]);
ws_alice
.append("notes/log.md", "alice appended line")
.await
.expect("alice append failed");
// Shared document must be unchanged (write isolation)
let shared_doc = ws_shared
.read("notes/log.md")
.await
.expect("shared read failed");
assert_eq!(
shared_doc.content, "shared original content",
"Append must not modify the secondary scope's document"
);
// Alice should have her own copy with the appended content
let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
let alice_doc = ws_alice_plain
.read("notes/log.md")
.await
.expect("alice read failed");
assert_eq!(
alice_doc.content, "alice appended line",
"Append should create a new document in alice's scope"
);
}
#[tokio::test]
async fn append_memory_stays_in_primary_scope() {
let (db, _dir) = setup().await;
// Write MEMORY.md as "shared"
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
ws_shared
.write("MEMORY.md", "shared memory baseline")
.await
.expect("shared write failed");
// Alice has "shared" as a read scope and appends a memory entry
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
.with_additional_read_scopes(vec!["shared".to_string()]);
ws_alice
.append_memory("alice remembers this")
.await
.expect("alice append_memory failed");
// Shared MEMORY.md must be unchanged
let shared_doc = ws_shared
.read("MEMORY.md")
.await
.expect("shared read failed");
assert_eq!(
shared_doc.content, "shared memory baseline",
"append_memory must not modify the secondary scope's document"
);
// Alice should have her own MEMORY.md
let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
let alice_doc = ws_alice_plain
.read("MEMORY.md")
.await
.expect("alice read failed");
assert_eq!(
alice_doc.content, "alice remembers this",
"append_memory should create in alice's scope"
);
}
// ==================== Identity isolation tests ====================
#[tokio::test]
async fn identity_files_not_readable_from_secondary_scope() {
let (db, _dir) = setup().await;
let ws_other = Workspace::new_with_db("other-user", Arc::clone(&db));
ws_other
.write("IDENTITY.md", "I am the other user")
.await
.expect("write failed");
ws_other
.write("SOUL.md", "Other user soul overlay")
.await
.expect("write failed");
ws_other
.write("USER.md", "Other user profile")
.await
.expect("write failed");
ws_other
.write("AGENTS.md", "Other user agent config")
.await
.expect("write failed");
let ws_primary = Workspace::new_with_db("primary", Arc::clone(&db))
.with_additional_read_scopes(vec!["other-user".to_string()]);
for path in &["IDENTITY.md", "SOUL.md", "USER.md", "AGENTS.md"] {
let result = ws_primary.read(path).await;
assert!(
result.is_err(),
"Primary should NOT read other user's {} via secondary scope",
path
);
}
}
#[tokio::test]
async fn identity_files_not_in_search_from_secondary_scope() {
let (db, _dir) = setup().await;
let ws_other = Workspace::new_with_db("other-user", Arc::clone(&db));
ws_other
.write("SOUL.md", "Other user loves xylophone music passionately")
.await
.expect("write failed");
ws_other
.write(
"notes/music.md",
"Other user played xylophone at the concert",
)
.await
.expect("write failed");
let ws_primary = Workspace::new_with_db("primary", Arc::clone(&db))
.with_additional_read_scopes(vec!["other-user".to_string()]);
let results = ws_primary
.search("xylophone", 10)
.await
.expect("search failed");
let has_concert = results.iter().any(|r| r.content.contains("concert"));
assert!(
has_concert,
"Should find non-identity content from secondary scope"
);
let has_soul = results.iter().any(|r| r.content.contains("passionately"));
assert!(
!has_soul,
"SOUL.md content from secondary scope should not appear in search results"
);
}
#[tokio::test]
async fn identity_files_not_in_list_from_secondary_scope() {
let (db, _dir) = setup().await;
let ws_other = Workspace::new_with_db("other-user", Arc::clone(&db));
ws_other
.write("IDENTITY.md", "I am the other user")
.await
.expect("write failed");
ws_other
.write("notes/shared-note.md", "A shared note")
.await
.expect("write failed");
let ws_primary = Workspace::new_with_db("primary", Arc::clone(&db))
.with_additional_read_scopes(vec!["other-user".to_string()]);
let paths = ws_primary.list_all().await.expect("list failed");
assert!(
!paths.contains(&"IDENTITY.md".to_string()),
"IDENTITY.md from secondary scope should not appear"
);
assert!(
paths.contains(&"notes/shared-note.md".to_string()),
"Non-identity files should be listed"
);
}
#[tokio::test]
async fn empty_read_scopes_reads_primary_only() {
let (db, _dir) = setup().await;
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
ws_shared
.write("docs/note.md", "Shared note")
.await
.expect("write failed");
let ws_primary =
Workspace::new_with_db("primary", Arc::clone(&db)).with_additional_read_scopes(vec![]);
let result = ws_primary.read("docs/note.md").await;
assert!(
result.is_err(),
"Empty read scopes should not grant cross-scope access"
);
}
#[tokio::test]
async fn duplicate_read_scopes_handled() {
let (db, _dir) = setup().await;
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
ws_shared
.write("docs/note.md", "One note")
.await
.expect("write failed");
let ws_primary = Workspace::new_with_db("primary", Arc::clone(&db))
.with_additional_read_scopes(vec!["shared".to_string(), "shared".to_string()]);
let doc = ws_primary.read("docs/note.md").await.expect("read failed");
assert_eq!(doc.content, "One note");
}
+1 -1
View File
@@ -773,7 +773,7 @@ impl TestRigBuilder {
// 7. Create TestChannel and ChannelManager.
// When testing bootstrap, the channel must be named "gateway" because
// the bootstrap greeting targets only the gateway channel.
let test_channel = if keep_bootstrap {
let test_channel = if self.keep_bootstrap {
Arc::new(TestChannel::new().with_name("gateway"))
} else {
Arc::new(TestChannel::new())
+330
View File
@@ -407,3 +407,333 @@ async fn test_workspace_system_prompt() {
cleanup_user(&pool, user_id).await;
}
// ── Multi-scope workspace read tests ──────────────────────────────────
//
// These exercise the PostgreSQL-optimized `_multi` query paths
// (repository.rs) that the libSQL backend covers via default trait impls.
#[tokio::test]
async fn test_multi_scope_read_across_scopes() {
let pool = get_pool();
if try_connect(&pool).await.is_none() {
return;
}
let shared_id = "ms_shared_read";
let alice_id = "ms_alice_read";
cleanup_user(&pool, shared_id).await;
cleanup_user(&pool, alice_id).await;
// Write a doc as "shared"
let ws_shared = Workspace::new(shared_id, pool.clone());
ws_shared
.write("docs/team-standup.md", "Team standup notes from Monday")
.await
.expect("shared write failed");
// Alice with "shared" as an additional read scope
let ws_alice = Workspace::new(alice_id, pool.clone())
.with_additional_read_scopes(vec![shared_id.to_string()]);
let doc = ws_alice
.read("docs/team-standup.md")
.await
.expect("cross-scope read failed");
assert_eq!(doc.content, "Team standup notes from Monday");
cleanup_user(&pool, shared_id).await;
cleanup_user(&pool, alice_id).await;
}
#[tokio::test]
async fn test_multi_scope_write_stays_in_primary() {
let pool = get_pool();
if try_connect(&pool).await.is_none() {
return;
}
let shared_id = "ms_shared_write";
let alice_id = "ms_alice_write";
cleanup_user(&pool, shared_id).await;
cleanup_user(&pool, alice_id).await;
let ws_alice = Workspace::new(alice_id, pool.clone())
.with_additional_read_scopes(vec![shared_id.to_string()]);
ws_alice
.write("notes/personal.md", "Alice's private note")
.await
.expect("alice write failed");
// Shared workspace should NOT see Alice's note
let ws_shared = Workspace::new(shared_id, pool.clone());
let result = ws_shared.read("notes/personal.md").await;
assert!(result.is_err(), "Shared scope should not see Alice's note");
cleanup_user(&pool, shared_id).await;
cleanup_user(&pool, alice_id).await;
}
#[tokio::test]
async fn test_multi_scope_list_all_merges() {
let pool = get_pool();
if try_connect(&pool).await.is_none() {
return;
}
let shared_id = "ms_shared_list";
let alice_id = "ms_alice_list";
cleanup_user(&pool, shared_id).await;
cleanup_user(&pool, alice_id).await;
// Write as alice (plain, no multi-scope)
let ws_alice_plain = Workspace::new(alice_id, pool.clone());
ws_alice_plain
.write("notes/personal.md", "My notes")
.await
.expect("alice write failed");
// Write as shared
let ws_shared = Workspace::new(shared_id, pool.clone());
ws_shared
.write("docs/shared-doc.md", "Shared document")
.await
.expect("shared write failed");
// Alice with multi-scope should see both
let ws_alice = Workspace::new(alice_id, pool.clone())
.with_additional_read_scopes(vec![shared_id.to_string()]);
let all_paths = ws_alice.list_all().await.expect("list_all failed");
assert!(
all_paths.contains(&"notes/personal.md".to_string()),
"Should contain alice's note: {:?}",
all_paths
);
assert!(
all_paths.contains(&"docs/shared-doc.md".to_string()),
"Should contain shared doc: {:?}",
all_paths
);
cleanup_user(&pool, shared_id).await;
cleanup_user(&pool, alice_id).await;
}
#[tokio::test]
async fn test_multi_scope_list_directory_merges() {
let pool = get_pool();
if try_connect(&pool).await.is_none() {
return;
}
let shared_id = "ms_shared_dir";
let alice_id = "ms_alice_dir";
cleanup_user(&pool, shared_id).await;
cleanup_user(&pool, alice_id).await;
let ws_alice_plain = Workspace::new(alice_id, pool.clone());
ws_alice_plain
.write("docs/alice-doc.md", "Alice's doc")
.await
.expect("alice write failed");
let ws_shared = Workspace::new(shared_id, pool.clone());
ws_shared
.write("docs/shared-doc.md", "Shared doc")
.await
.expect("shared write failed");
let ws_alice = Workspace::new(alice_id, pool.clone())
.with_additional_read_scopes(vec![shared_id.to_string()]);
let entries = ws_alice.list("docs").await.expect("list failed");
let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect();
assert!(
paths.contains(&"docs/alice-doc.md"),
"Should contain alice's doc: {:?}",
paths
);
assert!(
paths.contains(&"docs/shared-doc.md"),
"Should contain shared doc: {:?}",
paths
);
cleanup_user(&pool, shared_id).await;
cleanup_user(&pool, alice_id).await;
}
#[tokio::test]
async fn test_multi_scope_read_priority_primary_first() {
let pool = get_pool();
if try_connect(&pool).await.is_none() {
return;
}
let shared_id = "ms_shared_prio";
let alice_id = "ms_alice_prio";
cleanup_user(&pool, shared_id).await;
cleanup_user(&pool, alice_id).await;
// Write same path in both scopes
let ws_shared = Workspace::new(shared_id, pool.clone());
ws_shared
.write("config/settings.md", "Shared settings v1")
.await
.expect("shared write failed");
let ws_alice_plain = Workspace::new(alice_id, pool.clone());
ws_alice_plain
.write("config/settings.md", "Alice's settings override")
.await
.expect("alice write failed");
// Alice with multi-scope should get her own version (primary scope wins)
let ws_alice = Workspace::new(alice_id, pool.clone())
.with_additional_read_scopes(vec![shared_id.to_string()]);
let doc = ws_alice
.read("config/settings.md")
.await
.expect("read failed");
assert_eq!(
doc.content, "Alice's settings override",
"Primary scope should take priority"
);
cleanup_user(&pool, shared_id).await;
cleanup_user(&pool, alice_id).await;
}
#[tokio::test]
async fn test_multi_scope_exists_spans_scopes() {
let pool = get_pool();
if try_connect(&pool).await.is_none() {
return;
}
let shared_id = "ms_shared_exists";
let alice_id = "ms_alice_exists";
cleanup_user(&pool, shared_id).await;
cleanup_user(&pool, alice_id).await;
let ws_shared = Workspace::new(shared_id, pool.clone());
ws_shared
.write("docs/shared-only.md", "Shared content")
.await
.expect("shared write failed");
// Alice without multi-scope should NOT see it
let ws_alice_plain = Workspace::new(alice_id, pool.clone());
assert!(
!ws_alice_plain
.exists("docs/shared-only.md")
.await
.expect("exists failed"),
"Alice without multi-scope should not see shared doc"
);
// Alice with multi-scope should see it
let ws_alice = Workspace::new(alice_id, pool.clone())
.with_additional_read_scopes(vec![shared_id.to_string()]);
assert!(
ws_alice
.exists("docs/shared-only.md")
.await
.expect("exists failed"),
"Alice with multi-scope should see shared doc"
);
cleanup_user(&pool, shared_id).await;
cleanup_user(&pool, alice_id).await;
}
#[tokio::test]
async fn test_multi_scope_search_spans_scopes() {
let pool = get_pool();
if try_connect(&pool).await.is_none() {
return;
}
let shared_id = "ms_shared_search";
let alice_id = "ms_alice_search";
cleanup_user(&pool, shared_id).await;
cleanup_user(&pool, alice_id).await;
let ws_shared = Workspace::new(shared_id, pool.clone());
ws_shared
.write(
"docs/architecture.md",
"The microservice architecture uses gRPC for inter-service communication",
)
.await
.expect("shared write failed");
let ws_alice_plain = Workspace::new(alice_id, pool.clone());
ws_alice_plain
.write("notes/ideas.md", "Consider switching to GraphQL federation")
.await
.expect("alice write failed");
let ws_alice = Workspace::new(alice_id, pool.clone())
.with_additional_read_scopes(vec![shared_id.to_string()]);
// Search for content in the shared scope
let results = ws_alice
.search_with_config(
"microservice gRPC architecture",
SearchConfig::default().fts_only(),
)
.await
.expect("search failed");
assert!(!results.is_empty(), "Should find results from shared scope");
cleanup_user(&pool, shared_id).await;
cleanup_user(&pool, alice_id).await;
}
#[tokio::test]
async fn test_multi_scope_append_stays_in_primary() {
let pool = get_pool();
if try_connect(&pool).await.is_none() {
return;
}
let shared_id = "ms_shared_append";
let alice_id = "ms_alice_append";
cleanup_user(&pool, shared_id).await;
cleanup_user(&pool, alice_id).await;
// Write a document as "shared"
let ws_shared = Workspace::new(shared_id, pool.clone());
ws_shared
.write("notes/log.md", "shared original content")
.await
.expect("shared write failed");
// Alice has "shared" as a read scope and appends to the same path
let ws_alice = Workspace::new(alice_id, pool.clone())
.with_additional_read_scopes(vec![shared_id.to_string()]);
ws_alice
.append("notes/log.md", "alice appended line")
.await
.expect("alice append failed");
// Shared document must be unchanged (write isolation)
let shared_doc = ws_shared
.read("notes/log.md")
.await
.expect("shared read failed");
assert_eq!(
shared_doc.content, "shared original content",
"Append must not modify the secondary scope's document"
);
// Alice should have her own copy with the appended content
let ws_alice_plain = Workspace::new(alice_id, pool.clone());
let alice_doc = ws_alice_plain
.read("notes/log.md")
.await
.expect("alice read failed");
assert_eq!(
alice_doc.content, "alice appended line",
"Append should create a new document in alice's scope"
);
cleanup_user(&pool, shared_id).await;
cleanup_user(&pool, alice_id).await;
}
-23
View File
@@ -1,23 +0,0 @@
[package]
name = "composio-tool"
version = "0.1.0"
edition = "2021"
description = "Composio integration tool for IronClaw (WASM component)"
license = "MIT OR Apache-2.0"
publish = false
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
wit-bindgen = "0.41.0"
[lib]
crate-type = ["cdylib"]
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
[workspace]
@@ -1,75 +0,0 @@
{
"version": "0.1.0",
"wit_version": "0.3.0",
"description": "Connect to 250+ apps (Gmail, GitHub, Slack, Notion, etc.) via Composio. Actions: list (browse tools), execute (run a tool), connect (OAuth-link an app), connected_accounts (list linked accounts). Authentication is handled via the 'composio_api_key' secret injected by the host.",
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["list", "execute", "connect", "connected_accounts"],
"description": "Action to perform"
},
"app": {
"type": "string",
"description": "App/toolkit slug (e.g., \"gmail\", \"github\", \"notion\")"
},
"tool_slug": {
"type": "string",
"description": "Tool action slug for execute (e.g., \"GMAIL_SEND_EMAIL\")"
},
"params": {
"description": "Parameters for the tool action (JSON object)"
},
"connected_account_id": {
"type": "string",
"description": "Specific connected account ID (auto-resolved if omitted)"
}
},
"required": ["action"],
"additionalProperties": false
},
"capabilities": {
"http": {
"allowlist": [
{
"host": "backend.composio.dev",
"path_prefix": "/api/v3/",
"methods": ["GET", "POST"]
}
],
"credentials": {
"composio_api_key": {
"secret_name": "composio_api_key",
"location": {
"type": "header",
"name": "x-api-key"
},
"host_patterns": ["backend.composio.dev"]
}
},
"rate_limit": {
"requests_per_minute": 30,
"requests_per_hour": 500
}
},
"secrets": {
"allowed_names": ["composio_api_key"]
}
},
"auth": {
"secret_name": "composio_api_key",
"display_name": "Composio",
"instructions": "Get an API key at app.composio.dev — go to Settings > API Keys to generate one.",
"setup_url": "https://app.composio.dev/",
"env_var": "COMPOSIO_API_KEY"
},
"setup": {
"required_secrets": [
{
"name": "composio_api_key",
"prompt": "Composio API key (from app.composio.dev)"
}
]
}
}
-425
View File
@@ -1,425 +0,0 @@
//! Composio WASM Tool for IronClaw.
//!
//! Connects to 250+ third-party apps via Composio's REST API (v3).
//! Provides a single multiplexed tool with actions: list, execute, connect,
//! connected_accounts.
//!
//! # Authentication
//!
//! Store your Composio API key:
//! `ironclaw secret set composio_api_key <key>`
//!
//! Get a key at: https://app.composio.dev/
wit_bindgen::generate!({
world: "sandboxed-tool",
path: "../../wit/tool.wit",
});
use serde::Deserialize;
const API_BASE: &str = "https://backend.composio.dev/api/v3";
const MAX_RETRIES: u32 = 3;
struct ComposioTool;
impl exports::near::agent::tool::Guest for ComposioTool {
fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response {
match execute_inner(&req.params, req.context.as_deref()) {
Ok(result) => exports::near::agent::tool::Response {
output: Some(result),
error: None,
},
Err(e) => exports::near::agent::tool::Response {
output: None,
error: Some(e),
},
}
}
fn schema() -> String {
SCHEMA.to_string()
}
fn description() -> String {
"Connect to 250+ apps (Gmail, GitHub, Slack, Notion, etc.) via Composio. \
Actions: \"list\" (browse tools), \"execute\" (run a tool), \
\"connect\" (OAuth-link an app), \"connected_accounts\" (list linked accounts). \
Authentication is handled via the 'composio_api_key' secret injected by the host."
.to_string()
}
}
#[derive(Debug, Deserialize)]
struct Params {
action: String,
app: Option<String>,
tool_slug: Option<String>,
params: Option<serde_json::Value>,
connected_account_id: Option<String>,
}
fn execute_inner(params_str: &str, context: Option<&str>) -> Result<String, String> {
let params: Params =
serde_json::from_str(params_str).map_err(|e| format!("Invalid parameters: {e}"))?;
if params.action.is_empty() {
return Err("'action' must not be empty".into());
}
// Pre-flight: verify API key is available.
if !near::agent::host::secret_exists("composio_api_key") {
return Err(
"Composio API key not found in secret store. Set it with: \
ironclaw secret set composio_api_key <key>. \
Get a key at: https://app.composio.dev/"
.into(),
);
}
// Extract an entity identifier from context if provided; prefer `entity_id`,
// then `user_id` (from JobContext), then `requester_id`, otherwise "default".
let entity_id = context
.and_then(|ctx| serde_json::from_str::<serde_json::Value>(ctx).ok())
.and_then(|v| {
v.get("entity_id")
.or_else(|| v.get("user_id"))
.or_else(|| v.get("requester_id"))
.and_then(|e| e.as_str())
.map(String::from)
})
.unwrap_or_else(|| "default".to_string());
match params.action.as_str() {
"list" => list_tools(params.app.as_deref()),
"execute" => {
let tool_slug = params
.tool_slug
.as_deref()
.ok_or("missing 'tool_slug' for execute action")?;
let action_params = params.params.unwrap_or(serde_json::json!({}));
execute_action(
tool_slug,
&action_params,
&entity_id,
params.connected_account_id.as_deref(),
)
}
"connect" => {
let app = params
.app
.as_deref()
.ok_or("missing 'app' for connect action")?;
connect_app(app, &entity_id)
}
"connected_accounts" => list_accounts(params.app.as_deref(), &entity_id),
other => Err(format!(
"unknown action \"{other}\", expected: list, execute, connect, connected_accounts"
)),
}
}
// ---------------------------------------------------------------------------
// API helpers
// ---------------------------------------------------------------------------
fn api_get(path: &str, query: &[(&str, &str)]) -> Result<serde_json::Value, String> {
let url = build_url(path, query);
let headers = serde_json::json!({
"Accept": "application/json",
"User-Agent": "IronClaw-Composio-Tool/0.1"
});
let response = http_with_retry("GET", &url, &headers.to_string(), None)?;
parse_json_response(&response.body, response.status)
}
fn api_post(path: &str, body: &serde_json::Value) -> Result<serde_json::Value, String> {
let url = build_url(path, &[]);
let headers = serde_json::json!({
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": "IronClaw-Composio-Tool/0.1"
});
let body_bytes = serde_json::to_vec(body).map_err(|e| format!("JSON serialize error: {e}"))?;
let response = http_with_retry("POST", &url, &headers.to_string(), Some(&body_bytes))?;
parse_json_response(&response.body, response.status)
}
fn http_with_retry(
method: &str,
url: &str,
headers: &str,
body: Option<&[u8]>,
) -> Result<near::agent::host::HttpResponse, String> {
let mut attempt = 0;
loop {
attempt += 1;
let resp = near::agent::host::http_request(method, url, headers, body, None)
.map_err(|e| format!("HTTP request failed: {e}"))?;
if resp.status >= 200 && resp.status < 300 {
return Ok(resp);
}
if attempt < MAX_RETRIES && (resp.status == 429 || resp.status >= 500) {
near::agent::host::log(
near::agent::host::LogLevel::Warn,
&format!(
"Composio API error {} (attempt {}/{}). Retrying...",
resp.status, attempt, MAX_RETRIES
),
);
continue;
}
// Truncate at byte level before UTF-8 conversion to avoid
// panicking on multibyte character boundaries.
let truncated_bytes = if resp.body.len() > 512 {
&resp.body[..512]
} else {
&resp.body
};
let truncated = String::from_utf8_lossy(truncated_bytes);
return Err(format!("Composio API error (HTTP {}): {truncated}", resp.status));
}
}
fn parse_json_response(body: &[u8], status: u16) -> Result<serde_json::Value, String> {
if !(200..300).contains(&status) {
// Truncate at byte level before UTF-8 conversion to avoid
// panicking on multibyte character boundaries.
let truncated_bytes = if body.len() > 512 { &body[..512] } else { body };
let truncated = String::from_utf8_lossy(truncated_bytes);
return Err(format!("Composio API {status}: {truncated}"));
}
let text = String::from_utf8(body.to_vec())
.map_err(|e| format!("non-UTF8 response: {e}"))?;
serde_json::from_str(&text).map_err(|e| format!("invalid JSON: {e}"))
}
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
fn list_tools(app: Option<&str>) -> Result<String, String> {
let query: Vec<(&str, &str)> = match app {
Some(a) => vec![("toolkit_slug", a)],
None => vec![],
};
let result = api_get("/tools", &query)?;
serde_json::to_string(&result).map_err(|e| format!("Failed to serialize output: {e}"))
}
fn execute_action(
tool_slug: &str,
params: &serde_json::Value,
entity_id: &str,
connected_account_id: Option<&str>,
) -> Result<String, String> {
// Auto-resolve connected account if not provided
let account_id = match connected_account_id {
Some(id) => id.to_string(),
None => resolve_account(tool_slug, entity_id)?,
};
let body = serde_json::json!({
"connected_account_id": account_id,
"entity_id": entity_id,
"input": params,
});
let result = api_post(&format!("/tools/execute/{}", url_encode(tool_slug)), &body)?;
serde_json::to_string(&result).map_err(|e| format!("Failed to serialize output: {e}"))
}
fn connect_app(app: &str, entity_id: &str) -> Result<String, String> {
// Resolve auth config for this app
let configs = api_get("/auth_configs", &[("toolkit_slug", app)])?;
let auth_config_id = configs
.as_array()
.and_then(|arr| arr.first())
.and_then(|c| c.get("id"))
.and_then(|id| id.as_str())
.ok_or_else(|| {
format!("no auth config found for {app} — configure it at app.composio.dev")
})?;
let body = serde_json::json!({
"auth_config_id": auth_config_id,
"user_id": entity_id,
});
let result = api_post("/connected_accounts/link", &body)?;
serde_json::to_string(&result).map_err(|e| format!("Failed to serialize output: {e}"))
}
fn list_accounts(app: Option<&str>, entity_id: &str) -> Result<String, String> {
let mut query = vec![("user_id", entity_id)];
if let Some(a) = app {
query.push(("toolkit_slug", a));
}
let result = api_get("/connected_accounts", &query)?;
serde_json::to_string(&result).map_err(|e| format!("Failed to serialize output: {e}"))
}
/// Look up the toolkit/app slug for a tool via the Composio API.
///
/// Querying the API is more reliable than parsing the tool slug string,
/// which breaks for multi-word app names (e.g., `GOOGLE_DRIVE_UPLOAD`
/// would incorrectly resolve to `"google"` instead of `"google_drive"`).
fn lookup_app_for_tool(tool_slug: &str) -> Result<String, String> {
let tools = api_get("/tools", &[("search", tool_slug)])?;
tools
.as_array()
.and_then(|arr| {
arr.iter().find(|t| {
t.get("slug")
.and_then(|s| s.as_str())
.map(|s| s.eq_ignore_ascii_case(tool_slug))
.unwrap_or(false)
})
})
.and_then(|t| t.get("toolkit_slug").or_else(|| t.get("appName")))
.and_then(|v| v.as_str())
.map(|s| s.to_ascii_lowercase())
.ok_or_else(|| {
format!("could not determine app for tool \"{tool_slug}\" — verify the slug is correct")
})
}
/// Auto-resolve connected account for a tool slug.
fn resolve_account(tool_slug: &str, entity_id: &str) -> Result<String, String> {
let app = lookup_app_for_tool(tool_slug)?;
let accounts = api_get("/connected_accounts", &[("user_id", entity_id), ("toolkit_slug", &app)])?;
accounts
.as_array()
.and_then(|arr| {
arr.iter()
.filter(|a| a.get("status").and_then(|s| s.as_str()) == Some("ACTIVE"))
.max_by_key(|a| {
a.get("updatedAt")
.and_then(|u| u.as_str())
.unwrap_or("")
.to_string()
})
})
.and_then(|a| a.get("id"))
.and_then(|id| id.as_str())
.map(|s| s.to_string())
.ok_or_else(|| {
format!("no connected account for {app} — use composio with action=\"connect\" first")
})
}
// ---------------------------------------------------------------------------
// URL helpers
// ---------------------------------------------------------------------------
fn build_url(path: &str, query: &[(&str, &str)]) -> String {
let mut url = format!("{API_BASE}{path}");
if !query.is_empty() {
url.push('?');
for (i, (k, v)) in query.iter().enumerate() {
if i > 0 {
url.push('&');
}
url.push_str(&url_encode(k));
url.push('=');
url.push_str(&url_encode(v));
}
}
url
}
/// Percent-encode a string for safe use in URL query parameters.
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 2);
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
b' ' => out.push_str("%20"),
_ => {
out.push('%');
out.push(char::from(b"0123456789ABCDEF"[(b >> 4) as usize]));
out.push(char::from(b"0123456789ABCDEF"[(b & 0xf) as usize]));
}
}
}
out
}
const SCHEMA: &str = r#"{
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["list", "execute", "connect", "connected_accounts"],
"description": "Action to perform"
},
"app": {
"type": "string",
"description": "App/toolkit slug (e.g., \"gmail\", \"github\", \"notion\")"
},
"tool_slug": {
"type": "string",
"description": "Tool action slug for execute (e.g., \"GMAIL_SEND_EMAIL\")"
},
"params": {
"description": "Parameters for the tool action (JSON object)"
},
"connected_account_id": {
"type": "string",
"description": "Specific connected account ID (auto-resolved if omitted)"
}
},
"required": ["action"],
"additionalProperties": false
}"#;
export!(ComposioTool);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_url_encode() {
assert_eq!(url_encode("hello world"), "hello%20world");
assert_eq!(url_encode("foo&bar=baz"), "foo%26bar%3Dbaz");
assert_eq!(url_encode("simple"), "simple");
}
#[test]
fn test_url_encode_multibyte() {
assert_eq!(url_encode("café"), "caf%C3%A9");
}
#[test]
fn test_build_url_no_query() {
let url = build_url("/tools", &[]);
assert_eq!(url, format!("{API_BASE}/tools"));
}
#[test]
fn test_build_url_with_query() {
let url = build_url("/tools", &[("toolkit_slug", "gmail"), ("search", "send")]);
assert!(url.starts_with(&format!("{API_BASE}/tools?")));
assert!(url.contains("toolkit_slug=gmail"));
assert!(url.contains("search=send"));
}
#[test]
fn test_build_url_encodes_special_chars() {
let url = build_url("/tools", &[("q", "my app+1")]);
assert!(url.contains("q=my%20app%2B1"));
}
}