Compare commits

..
Author SHA1 Message Date
Claude 1d9821d37a style(agent): fix formatting in approval TOCTOU fix
Apply cargo fmt to resolve CI formatting check failure.

https://claude.ai/code/session_013ZCQWoFHv2hASgHEGHzptg
2026-03-21 18:12:55 +00:00
ZakiandClaude Opus 4.6 52d935d744 fix(agent): eliminate TOCTOU race in approval thread resolution (#1486)
Restructure process_approval() to hold the session lock for the entire
check-take-verify-mutate sequence. Previously, the lock was dropped after
taking the pending approval and re-acquired to verify request_id and set
state, creating a window where concurrent operations could modify thread
state or lose the pending approval.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-21 10:07:58 -07:00
6232609080 feat(llm): add GitHub Copilot as LLM provider (#1512)
* Add github copilot as LLM provider.

* Fix Copilot in Openclaw

* security: harden Copilot OAuth token handling

C1: Use secrecy::SecretString for oauth_token and cached session token
    in CopilotTokenManager/CachedCopilotToken. Expose only at HTTP
    header injection point via .expose_secret().

C2: Document risks of hardcoded VS Code OAuth client ID and editor
    identity headers (ToS, rotation, staleness). Remove the unreliable
    paste-token setup path (setup_github_copilot_manual_token).

C3: Fix TOCTOU race in get_token() — re-check token validity after
    acquiring write lock so concurrent callers don't all perform
    redundant token exchanges.

I1: Remove dead empty else {} block in get_token().

I2: Map 401 responses to LlmError::AuthFailed instead of RequestFailed
    so retry/circuit-breaker logic handles auth failures correctly.

I3: Replace prepare_github_copilot_setup() with call to existing
    set_llm_backend_preserving_model() helper to avoid logic drift.

I4: Add unit tests for CopilotTokenManager (caching, invalidation,
    expiry/buffer behavior), poll response parsing (all OAuth device
    flow states), and DeviceCodeResponse/CopilotTokenResponse deserialization.

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

* fix: address review feedback and code improvements (takeover #1202)

- Fix ContentPart::Text being silently dropped in convert_messages
- Replace custom truncate_for_error with crate::util::floor_char_boundary
- Fix CLAUDE.md: accurately describe dedicated provider (not "OpenAI-compatible path")
- Fix "Github" -> "GitHub" capitalization in READMEs
- Add manual token paste option to setup wizard (not just device login)
- Fix missing extension_manager field in EngineContext (merge fixup)
- cargo fmt applied

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

* fix: address PR review feedback for GitHub Copilot provider

- Plumb request_timeout_secs into GithubCopilotProvider (was hardcoded 120s)
- Forward stop_sequences to Copilot API via OpenAI `stop` field
- Skip empty text part in multimodal message conversion
- Improve paste-token wizard hint with specific file path guidance

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

* fix: 401 retry, retryable token exchange errors, shared retry-after parsing

- Retry once inline on 401 after token invalidation (was returning
  AuthFailed immediately, guaranteeing user-visible failure)
- Map token exchange failures to RequestFailed (retryable) instead of
  AuthFailed (non-retryable by RetryProvider)
- Use shared crate::llm::retry::parse_retry_after for HTTP-date support
  and safe 60s default
- Improve paste-token wizard hint: mention `gh auth token` as primary source

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

* fix: 401 retry error mapping, retry status logging, token whitespace safety

- Map 401 retry get_token() failure to RequestFailed (retryable),
  consistent with initial token acquisition path
- Log retry response status before returning AuthFailed
- Trim oauth_token in exchange_copilot_token to prevent header panics
  from whitespace in env vars

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

---------

Co-authored-by: Fallenwood <[email protected]>
Co-authored-by: Copilot <[email protected]>
Co-authored-by: fallenwood <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-21 00:02:00 -07:00
1d6f7d5085 fix: persist startup-loaded MCP clients in ExtensionManager (#1509)
* fix: persist startup-loaded MCP clients in ExtensionManager

MCP servers loaded at startup had their tools registered in the
ToolRegistry but the client references were dropped. This caused
the ExtensionManager to report them as disconnected and broke
reconnection/session management.

Collect startup MCP clients from the JoinSet and inject them into
the ExtensionManager via a new inject_mcp_client() method. Also
fix missing extension_manager field in fire_webhook EngineContext.

[skip-regression-check]

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

* fix: address PR review — pub(crate) visibility and JoinError diagnostics

- Narrow inject_mcp_client to pub(crate) and guard against empty names
- Distinguish panic vs cancellation in MCP task JoinError logging

[skip-regression-check]

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

* merge: sync with staging, fix duplicate extension_manager field

[skip-regression-check]

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

* fix: validate extension name in inject_mcp_client

Add validate_extension_name() check to reject path traversal
characters in MCP client names, consistent with other entry points.

[skip-regression-check]

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-20 23:57:19 -07:00
9964d5dab8 feat(web-search): include thumbnail URLs in search results (#1313)
Brave's API returns thumbnail objects on many web results, but the
WASM tool was silently dropping them during deserialization. This adds
the thumbnail.src field to the output so downstream consumers (chat
UIs, agents) can render product images and rich previews.

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-authored-by: Illia Polosukhin <[email protected]>
2026-03-20 22:16:13 -07:00
212d661e20 feat(workspace): layered memory with sensitivity-based privacy redirect (#1112)
* feat(workspace): layered memory with sensitivity-based privacy redirect

Introduce MemoryLayer type for named memory layers with sensitivity
levels and write permissions. Layers map to synthetic user_id values
in workspace tables, enabling shared/private memory isolation.

- Add MemoryLayer, LayerSensitivity types with default_for_user()
- Add layer-aware write methods (write_to_layer, append_to_layer)
- Add PatternPrivacyClassifier to guard shared layer writes
- Add optional 'layer' parameter to memory_write tool and HTTP API
- Add 'redirected' and 'actual_layer' fields to write response
- Add MEMORY_LAYERS env var (JSON) for layer configuration
- Workspace user_id now derived from GATEWAY_USER_ID (was hardcoded "default")
- 10 integration tests for layered memory operations

Addresses prerequisite for Issue #59 (multi-tenancy).

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

* fix: add explicit default to memory_write layer schema

Add "default": "private" to the layer parameter's JSON schema so
LLM tool consumers can see the default without reading code.

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

* refactor: extract resolve_layer_target to deduplicate layer writes

Consolidate shared layer-lookup, writable check, and privacy
classification logic from write_to_layer and append_to_layer into a
single resolve_layer_target helper.

Flagged on #349 review — the duplication originates in this PR.

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

* fix: address review feedback on layered memory PR

- Fix email regex pipe bug in TLD character class (privacy.rs)
- Add append support to web memory_write handler via `append` field
- Validate MemoryLayer name/scope: reject empty, check duplicates
- Remove hardcoded 'private' default from tool schema; omit layer
  fields from output when no layer specified
- Document scope isolation risk for multi-tenant (Issue #59)

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

* fix: address adversarial review findings

- CRITICAL: fix identity file protection bypass via trailing slash
  (normalize target path before protection checks)
- HIGH: check private layer is writable before privacy redirect
- HIGH: map LayerNotFound/ReadOnly to proper 4xx HTTP status codes
- HIGH: honor `append` field in non-layer HTTP write path
- MEDIUM: remove redundant DB fetch in append_to_layer (narrower
  TOCTOU window)
- MEDIUM: remove dead memory_write_handler from handlers/memory.rs

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

* feat: opt-in privacy classifier, force override, confidence scoring

Address review feedback from @zmanian:

- Privacy classifier is now opt-in via with_privacy_classifier() instead
  of always-on. Default hardcoded patterns (doctor, therapy, email, phone)
  had unacceptable false positive rates in household contexts. LLM chooses
  the correct layer via system prompt; regex can't improve on that.
- Add ConfigurablePrivacyClassifier for operator-supplied patterns.
- PatternPrivacyClassifier defaults narrowed to hard PII only (SSN,
  credit card, credentials).
- Add force param to write_to_layer/append_to_layer to skip classifier.
- PrivacyClassifier trait returns SensitivityResult { is_sensitive,
  confidence } instead of bool, ready for probabilistic classifiers.

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

* fix: remove redundant heartbeat match arm in memory_write

The heartbeat arm was identical to the catch-all — resolved_path
already points to paths::HEARTBEAT when target is "heartbeat".

Addresses review feedback from gemini-code-assist on #1112.

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

* fix: return Result from PatternPrivacyClassifier::new()

Replace .expect() with proper error propagation per project
no-panics policy. Remove Default impl (unused in production).

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

* refactor: move memory_layers from GatewayConfig to WorkspaceConfig

Resolve merge conflicts between HEAD (transcription, search, env helpers)
and the workspace config branch. GatewayConfig no longer owns memory_layers;
WorkspaceConfig::resolve() handles parsing, validation (name length >64,
character set, empty scope, duplicates), and fallback defaults.

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

* test: strengthen privacy classifier and layer isolation coverage

Add 8 privacy classifier edge case tests (format variants, keywords,
longer documents, empty/partial inputs) and 5 layer write isolation
integration tests (cross-scope invisibility, overwrite, empty path,
sensitive-to-private no-redirect).

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

* fix: tautological test assertion and add WorkspaceConfig validation tests

Replace always-true `is_ok() || is_err()` in write_empty_path_to_layer
with actual behavior assertion (write succeeds with normalized empty path).

Add 8 unit tests for WorkspaceConfig::resolve() covering valid JSON parsing,
invalid JSON, empty/long/invalid-char layer names, empty scopes, duplicates,
and default fallback behavior.

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

* style: cargo fmt after staging merge

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: [email protected] <[email protected]>
2026-03-20 22:15:29 -07:00
[email protected]andClaude Opus 4.6 0d1a5c210b fix(deps): patch rustls-webpki vulnerability (RUSTSEC-2026-0049)
Update rustls-webpki 0.103.9 → 0.103.10. Exempt 0.102.8 which is
pinned by libsql's transitive dependency on an older rustls chain.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-20 20:44:13 -07:00
NigeandGitHub e6277a399f perf(safety): single-pass escape_xml_attr (#1028)
* perf(safety): make XML attribute escaping single-pass

* test(safety): annotate assertion for no-panics CI

* test(safety): inline no-panics suppression comment
2026-03-20 20:33:09 -07:00
[email protected]andClaude Opus 4.6 a4f6cda5c9 fix(routines): add missing extension_manager field in trigger_manual EngineContext
The EngineContext construction in trigger_manual was missing the
extension_manager field, causing compilation failure on libsql-only
builds (Windows CI).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-20 20:31:22 -07:00
c6d4abdb31 fix(ci): serialize env-mutating OAuth wildcard tests with ENV_MUTEX (#1280) (#1468)
Replace `unwrap_or_else(|e| e.into_inner())` with `expect("env mutex poisoned")`
in bind_rejects_wildcard_ipv4 and bind_rejects_wildcard_ipv6 tests to match the
ENV_MUTEX pattern used in oauth_defaults.rs. The old pattern silently recovered
from a poisoned mutex, potentially allowing concurrent env var access when a
prior test panicked while holding the lock.

[skip-regression-check]

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-20 20:30:56 -07:00
47ba486990 docs: Expand AGENTS.md with coding agents guidance (#1392)
* Expand AGENTS.md with repo guidance for coding agents

* Format AGENTS deeper docs as a multiline list

* Move scoping guidance to change-discipline section

* 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]>

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-03-20 20:29:27 -07:00
6d847c6009 feat(webhooks): add public webhook trigger endpoint for routines (#736)
* feat(webhooks): add public webhook trigger endpoint for routines

Add POST /api/webhooks/{path} endpoint that matches incoming webhooks
against routines with Trigger::Webhook, validates secrets using
constant-time comparison (subtle crate), and fires the matched routine
through the message pipeline.

Closes #651

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

* fix(webhooks): address PR review feedback - access control, targeted query, rate limiting

[skip-regression-check]

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

* fix(ci): add missing webhook_rate_limiter field and fix formatting

Add the webhook_rate_limiter field to the GatewayState initializer in
gateway_workflow_harness.rs and fix rustfmt formatting for the webhook
tuple in types.rs.

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

* fix(security): require webhook secret, add rate limiting, improve tests

Extract validate_webhook_secret() from the handler so the security-critical
secret validation logic (mandatory secret, constant-time comparison) is
directly testable without mocking the database layer. Improves the error
message for misconfigured routines to guide users toward the fix.

Replaces the previous unit tests (which only tested Rust pattern matching
and status code constants) with tests that exercise the actual validation
function against all rejection paths: missing secret (403), non-webhook
trigger (403), wrong secret (401), empty secret (401), and different-length
secret (401).

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

* Route webhook triggers through RoutineEngine instead of chat pipeline

Adds fire_webhook() to RoutineEngine and updates the webhook handler
to use it. This ensures webhook-triggered routines get proper run
tracking, guardrail enforcement (cooldown + max_concurrent),
notifications, and FullJob dispatch support.

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

* style: fix formatting in webhook handler

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: [email protected] <[email protected]>
2026-03-20 15:50:31 -07:00
9603fefd01 fix(setup): remove redundant LLM config and API keys from bootstrap .env (#1448)
* fix(setup): remove redundant LLM vars and API keys from bootstrap .env

Only true chicken-and-egg vars belong in ~/.ironclaw/.env — things needed
to connect to the DB or decrypt secrets (DATABASE_BACKEND, DATABASE_URL,
LIBSQL_PATH, SECRETS_MASTER_KEY, ONBOARD_COMPLETED).

LLM settings (LLM_BACKEND, LLM_BASE_URL, OLLAMA_BASE_URL, model name,
provider-specific URLs) are persisted to the DB via persist_settings()
and loaded by Config::from_db_with_toml() after connection. API keys are
stored encrypted in the secrets DB and injected via
inject_llm_keys_from_secrets(). Writing them as plaintext to .env was
redundant and a security regression.

Also fixes for_model_discovery() and build_nearai_model_fetch_config()
to use env_or_override() instead of std::env::var(), so they can read
NEARAI_API_KEY from the thread-safe overlay during the onboarding wizard
(where inject_single_var() sets the key after the user enters it).

Also fixes incorrect secret names in README (anthropic_api_key →
llm_anthropic_api_key, openai_api_key → llm_openai_api_key).

Supersedes #266

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

* fix: add missing fallback_deliverable field to job_monitor tests

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

* docs: address review comments on bootstrap .env and README

- Update write_bootstrap_env() docstring to reflect current behavior
  (no LLM vars, no credentials)
- Fix Layer 1 .env examples in README to remove LLM_BACKEND/LLM_BASE_URL
- Fix legacy secret name in README example (anthropic_api_key →
  llm_anthropic_api_key)
- Document channel/sandbox vars in bootstrap vars list
- Add cleanup comment in test explaining empty-value-as-unset behavior

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-20 14:07:19 -07:00
Henry ParkandGitHub d3b69e7be3 Fix CI approval flows and stale fixtures (#1478)
* Fix CI approval flows and stale fixtures

* Backfill approval thread mapping across channels
2026-03-20 12:21:46 -07:00
Henry ParkandGitHub ee6f5cd62a Use live owner tool scope for autonomous routines and jobs (#1453)
* Use live owner tool scope for autonomous runs

* Address autonomous tool scope review feedback

* Normalize routine context paths again
2026-03-20 10:12:32 -07:00
3da9810e87 feat(llm): Add OpenAI Codex (ChatGPT subscription) as LLM provider (#1461)
* feat(llm): add OpenAI Codex backend config and OAuth session manager

Add OpenAiCodex as a new LLM backend variant with config for auth
endpoint, API base URL, client ID, and session persistence path.

The session manager implements OpenAI's device code auth flow
(headless-friendly, no browser required on the server) with automatic
token refresh, following the same persistence pattern as the existing
NEAR AI session manager.

Closes #742

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

* feat(llm): add Responses API client and token-refreshing decorator

Native Responses API client for chatgpt.com/backend-api/codex/responses,
the endpoint that works with ChatGPT subscription tokens. Handles SSE
streaming, text completions, and tool call round-trips.

Token-refreshing decorator wraps the provider to pre-emptively refresh
OAuth tokens before API calls and retry once on auth failures. Reports
zero cost since billing is through subscription.

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

* feat(llm): wire OpenAI Codex into provider factory, CLI, and setup wizard

Connect the new provider to the LLM factory, add openai_codex to the
CLI --backend flag, and add it as an option in the onboarding wizard.

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

* fix(llm): address PR #744 review feedback (20 items)

Review fixes for the OpenAI Codex provider PR:

- Remove dead `generate_pkce()` code (device flow gets PKCE from server)
- Fix `refresh_tokens()` to use `.form()` instead of `.json()` per OAuth spec
- Inline codex dispatch into `build_provider_chain()` (single async function,
  no separate `assemble_provider_chain()` helper — matches main's pattern)
- Remove Clone from `OpenAiCodexSession`, restrict fields to `pub(crate)`
- Propagate HTTP client builder error instead of silent fallback
- Redact device code response body from debug log
- Change `set_model()` in TokenRefreshingProvider to delegate to inner
- Replace hardcoded `/tmp/` test path with `tempfile::tempdir()`
- Accept `request_timeout_secs` from config instead of hardcoded 300s
- Parse `Retry-After` header on 429 responses (matches nearai_chat.rs pattern)
- Reuse `normalize_schema_strict()` for Codex tool definitions
- Add warning log for dropped image attachments
- Add doc comments on `list_models()` and `include` field
- Add `OPENAI_CODEX_API_URL` to `.env.example`
- Fix codex error message in `create_llm_provider()` for clarity
- Revert unrelated `.worktrees` addition to `.gitignore`
- Update `src/llm/CLAUDE.md` with Codex provider docs

[skip-regression-check]

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

* fix: address review feedback and harden OpenAI Codex provider (takeover #744)

Security:
- Add SSRF validation (validate_base_url) on OPENAI_CODEX_AUTH_URL and
  OPENAI_CODEX_API_URL, matching the pattern used by all other base URL
  configs (regression test for #1103 included)

Correctness:
- Add missing cache_write_multiplier() and cache_read_discount() trait
  delegation in TokenRefreshingProvider
- Cap device-code polling backoff at 60s to prevent unbounded interval
  growth on repeated 429 responses
- Default expires_in to 3600s when server returns 0, preventing
  immediately-expired sessions
- Fix pre-existing SseEvent::JobResult missing fallback_deliverable field
  in job_monitor.rs tests

Cleanup:
- Extract duplicated make_test_jwt() and test_codex_config() into shared
  codex_test_helpers module

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

* fix: address PR review feedback on OpenAI Codex provider (#1461)

- Login command now resolves OPENAI_CODEX_* env overrides even when
  LLM_BACKEND isn't set to openai_codex (Copilot review)
- Setup wizard "Keep current provider?" for codex no longer re-triggers
  device code login — mirrors Bedrock's keep-and-return pattern (Copilot)
- Revert provider init log from info back to debug (Copilot)
- Add warning log when token expires_in=0, before defaulting to 3600s
  (Gemini review)

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

---------

Co-authored-by: Sanjeev Suresh <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-20 08:14:20 -07:00
cba1bc3799 feat(web): add light theme with dark/light/system toggle (#1457)
* feat(web): add light theme with dark/light/system toggle (#761)

Add three-state theme toggle (dark → light → system) to the Web Gateway:

- Extract 101 hardcoded CSS colors into 30+ CSS custom properties
- Add [data-theme='light'] overrides for all variables
- Add theme toggle button in tab-bar (moon/sun/monitor icons)
- Theme persists via localStorage, defaults to 'system'
- System mode follows OS prefers-color-scheme in real-time
- FOUC prevention via inline script in <head>
- Delayed CSS transition to avoid flash on initial load
- Pure CSS icon switching via data-theme-mode attribute

Closes #761

* fix: address review feedback and code improvements (takeover #853)

- Fix dark-mode readability bug: .stepper-step.failed and
  .image-preview-remove used --text-on-accent (#09090b) on
  var(--danger) background, making text unreadable. Changed to
  --text-on-danger (#fff).
- Restore hover visual feedback on .image-preview-remove:hover
  using filter: brightness(1.2) instead of redundant var(--danger).
- Use const/let instead of var in theme-init.js for consistency
  with app.js (per gemini-code-assist review feedback).

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

* fix: address CI failures and Copilot review feedback (takeover #853)

- Fix missing `fallback_deliverable` field in job_monitor test
  constructors (pre-existing staging issue surfaced by merge)
- Validate localStorage theme value against whitelist in both
  theme-init.js and app.js to prevent broken state from invalid values
- Add matchMedia addEventListener fallback for older Safari/WebKit
- Add i18n keys for theme tooltip and aria-live announcement strings
  (en + zh-CN) to match existing localization patterns
- Move .sr-only utility from inline <style> to style.css

[skip-regression-check]

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

---------

Co-authored-by: Gao Zheng <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-20 00:45:17 -07:00
1b97ef4feb fix: resolve wasm broadcast merge conflicts with staging (#395) (#1460)
* channels/wasm: implement telegram broadcast path for message tool

* channels/wasm: tighten telegram broadcast contract and tests

* fix: resolve merge conflicts with staging for wasm broadcast

- Remove duplicate broadcast() impls from WasmChannel and SharedWasmChannel
  (staging already has the generic call_on_broadcast path)
- Remove obsolete telegram-specific test helpers and tests that tested
  the old telegram-only broadcast logic
- Add test_broadcast_delegates_to_call_on_broadcast for the generic path
- Fix missing fallback_deliverable field in job_monitor test SseEvents

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

---------

Co-authored-by: davidpty <[email protected]>
Co-authored-by: firat.sertgoz <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-20 00:41:20 -07:00
c17626160c fix: skip credential validation for Bedrock backend (#1011)
Bedrock uses IAM credentials (instance roles, env vars, SSO) resolved
by the AWS SDK at call time, so `provider` is never set during startup.
Exclude it from the post-init validation that checks for missing API keys.

Closes #1009

Co-authored-by: brajul <[email protected]>
Co-authored-by: Illia Polosukhin <[email protected]>
2026-03-19 23:25:03 -07:00
e82f4bd2e5 fix: register sandbox jobs in ContextManager for query tool visibility (#1426)
* fix: register sandbox jobs in ContextManager for query tool visibility

Sandbox jobs created via execute_sandbox() were persisted to the database
but never registered in the in-memory ContextManager. Since all query tools
(list_jobs, job_status, job_events, cancel_job) only search the
ContextManager, sandbox jobs were invisible to the agent despite running
successfully in Docker containers.

Changes:
- Add register_sandbox_job() to ContextManager (pre-determined UUID,
  starts InProgress, respects max_jobs)
- Extract insert_context() helper to deduplicate create_job_for_user
  and register_sandbox_job
- Add update_context_state / update_context_state_async to sync
  ContextManager state on sandbox job completion/failure
- Extend job_monitor with spawn_job_monitor_with_context() and
  spawn_completion_watcher() so fire-and-forget jobs transition out
  of InProgress when the container finishes
- Make CancelJobTool sandbox-aware (stops container + updates DB)
- Wire sandbox deps into CancelJobTool in register_job_tools()
- 8 regression tests across context manager and job monitor

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

* fix: add missing allow_always field in PendingApproval test literal

Upstream commit 09e1c97 added the allow_always field to PendingApproval
but missed updating the test struct literal, breaking compilation.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-19 23:22:34 -07:00
Henry ParkandGitHub b952d229f9 fix: prefer execution-local message routing metadata (#1449)
* fix: prefer execution-local message routing metadata

* test: cover message routing fallback metadata

* refactor: simplify message target resolution

* fix: ignore stale channel defaults for notify user metadata
2026-03-19 23:07:55 -07:00
ef3d769742 fix(security): validate embedding base URLs to prevent SSRF (#1221)
* fix(security): validate embedding base URLs to prevent SSRF (#1103)

User-configurable base URLs (OLLAMA_BASE_URL, EMBEDDING_BASE_URL) were
passed directly to reqwest with no validation, allowing SSRF attacks
against cloud metadata endpoints, internal services, or file:// URIs.

Adds validate_base_url() that rejects:
- Non-HTTP(S) schemes (file://, ftp://)
- HTTP to non-localhost destinations (prevents credential leakage)
- HTTPS to private/loopback/link-local/metadata IPs (169.254.169.254,
  10.x, 192.168.x, 172.16-31.x, CGN 100.64/10)
- IPv4-mapped IPv6 bypass attempts

Validation runs at config resolution time so bad URLs fail at startup.

Closes #1103

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

* fix(security): add DNS resolution check, ULA blocking, and NEARAI_BASE_URL validation

Address review feedback:
- Resolve hostnames to IPs and check all resolved addresses against the
  blocklist (prevents DNS-based SSRF bypass where attacker uses a domain
  pointing to 169.254.169.254)
- Add IPv6 Unique Local Address (fc00::/7) to the blocklist
- Validate NEARAI_BASE_URL in llm config (was missing — especially
  dangerous since bearer tokens are forwarded to the configured URL)
- Allow DNS resolution failure gracefully (don't block startup when DNS
  is temporarily unavailable)

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

* style: fix formatting

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

* fix(security): add SSRF validation to all base URL chokepoints

- Add validate_base_url() in resolve_registry_provider() covering all
  LLM providers (OpenAI, Anthropic, Ollama, openai_compatible, etc.)
- Add validate_base_url() for NEARAI_AUTH_URL in LlmConfig::resolve()
- Add validate_base_url() for TRANSCRIPTION_BASE_URL in TranscriptionConfig
- Add missing SSRF test cases: CGN range, IPv4-mapped IPv6, ULA IPv6,
  URLs with credentials, empty/invalid URLs

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

* ci: re-trigger CI with latest changes

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

* ci: trigger new run with skip-regression-check label

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

* fix(security): validate embedding base URLs to prevent SSRF (#1103)

User-configurable base URLs (OLLAMA_BASE_URL, EMBEDDING_BASE_URL) were
passed directly to reqwest with no validation, allowing SSRF attacks
against cloud metadata endpoints, internal services, or file:// URIs.

Adds validate_base_url() that rejects:
- Non-HTTP(S) schemes (file://, ftp://)
- HTTP to non-localhost destinations (prevents credential leakage)
- HTTPS to private/loopback/link-local/metadata IPs (169.254.169.254,
  10.x, 192.168.x, 172.16-31.x, CGN 100.64/10)
- IPv4-mapped IPv6 bypass attempts

Validation runs at config resolution time so bad URLs fail at startup.

Closes #1103

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

* fix(security): add DNS resolution check, ULA blocking, and NEARAI_BASE_URL validation

Address review feedback:
- Resolve hostnames to IPs and check all resolved addresses against the
  blocklist (prevents DNS-based SSRF bypass where attacker uses a domain
  pointing to 169.254.169.254)
- Add IPv6 Unique Local Address (fc00::/7) to the blocklist
- Validate NEARAI_BASE_URL in llm config (was missing — especially
  dangerous since bearer tokens are forwarded to the configured URL)
- Allow DNS resolution failure gracefully (don't block startup when DNS
  is temporarily unavailable)

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

* style: fix formatting

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

* fix(security): add SSRF validation to all base URL chokepoints

- Add validate_base_url() in resolve_registry_provider() covering all
  LLM providers (OpenAI, Anthropic, Ollama, openai_compatible, etc.)
- Add validate_base_url() for NEARAI_AUTH_URL in LlmConfig::resolve()
- Add validate_base_url() for TRANSCRIPTION_BASE_URL in TranscriptionConfig
- Add missing SSRF test cases: CGN range, IPv4-mapped IPv6, ULA IPv6,
  URLs with credentials, empty/invalid URLs

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

* ci: re-trigger CI with latest changes

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

* ci: trigger new run with skip-regression-check label

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-authored-by: [email protected] <[email protected]>
2026-03-19 22:52:33 -07:00
31c3b5b041 feat(agent): activate stuck_threshold for time-based stuck job detection (#1234)
* feat(agent): activate stuck_threshold for time-based stuck job detection (#1223)

The stuck_threshold field on DefaultSelfRepair was defined but never used
(marked #[allow(dead_code)]). Jobs that got stuck in InProgress without
transitioning to Stuck state (e.g., deadlock, unhandled timeout) were
never detected by self-repair.

Changes:
- Add find_stuck_jobs_with_threshold() to ContextManager that detects
  InProgress jobs running longer than the threshold
- Wire stuck_threshold into detect_stuck_jobs() so it uses threshold-based
  detection alongside explicit Stuck state detection
- Remove dead_code annotation from stuck_threshold
- Accept InProgress jobs in the stuck job detection filter

Configurable via AGENT_STUCK_THRESHOLD_SECS (default: 300s).

Closes #1223

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

* fix(agent): address PR #1234 review feedback for stuck_threshold

- Transition InProgress jobs to Stuck before returning them from
  detect_stuck_jobs(), so attempt_recovery() (which requires Stuck
  state) works correctly on threshold-detected jobs
- Add detect-and-repair E2E test covering the full InProgress ->
  Stuck -> recovery -> InProgress cycle
- Rename idle_threshold -> elapsed_threshold in find_stuck_jobs_with_threshold
  for clarity
- Add `use std::time::Duration` import and remove fully qualified paths
- Update CLAUDE.md to reflect that stuck_threshold is now actively used

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

* fix: measure stuck_duration from Stuck transition, handle InProgress→Stuck in repair

- Fix stuck_duration computation to use the most recent Stuck transition
  timestamp instead of started_at, preventing jobs that ran for hours
  before becoming stuck from immediately exceeding the threshold
- Fix last_activity to also use the Stuck transition timestamp
- Transition InProgress jobs to Stuck before calling attempt_recovery()
  in repair_stuck_job(), since attempt_recovery() requires JobState::Stuck
- Add regression test verifying a recently-stuck job with old started_at
  is not misdetected as exceeding a 5-minute threshold

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

* fix(agent): address Copilot review comments on PR #1234

- Add comment in find_stuck_jobs_with_threshold() noting that started_at
  is not reset on Stuck->InProgress recovery, which may cause false
  positives for recovered jobs. Suggests tracking in_progress_since or
  using the most recent StateTransition as a future improvement.

- Fix misleading test comment in stuck_duration_measured_from_stuck_transition
  test: explicitly Stuck jobs are always returned regardless of threshold.
  The test verifies stuck_duration is near-zero, not that the job is excluded.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-authored-by: [email protected] <[email protected]>
2026-03-19 22:36:34 -07:00
806d402876 feat: chat onboarding and routine advisor (#927)
* feat: port NPA psychographic profiling system into IronClaw

Port the complete psychographic profiling system from NPA into IronClaw,
including enriched profile schema, conversational onboarding, profile
evolution, and three-tier prompt augmentation.

Personal onboarding moved from wizard Step 9 to first assistant
interaction per maintainer feedback — the First Contact system prompt
block now instructs the LLM to conduct a natural onboarding conversation
that builds the psychographic profile via memory_write.

Changes:
- Enrich profile.rs with 5 new structs, 9-dimension analysis framework,
  custom deserializers for backward compatibility, and rendering methods
- Add conversational onboarding engine with one-step-removed questioning
  technique, personality framework, and confidence-scored profile generation
- Add profile evolution with confidence gating, analysis metadata tracking,
  and weekly update routine
- Replace thin interaction style injection with three-tier system gated on
  confidence > 0.6 and profile recency
- Replace wizard Step 9 with First Contact system prompt block that drives
  conversational onboarding during the user's first interaction
- Add autonomy progression to SOUL.md seed and personality framework to
  AGENTS.md seed

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

* feat: replace chat-based onboarding with bootstrap greeting and workspace seeds

Remove the interactive onboarding_chat.rs engine in favor of a simpler
bootstrap flow: fresh workspaces get a proactive LLM greeting that
naturally profiles the user. Identity files are now seeded from
src/workspace/seeds/ instead of being hardcoded. Also removes the
identity-file write protection (seeds are now managed), adds routine
advisor integration, and includes an e2e trace for bootstrap greeting.

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

* feat(safety): sanitize identity file writes via Sanitizer to prevent prompt injection

Identity files (SOUL.md, AGENTS.md, USER.md, IDENTITY.md) are injected into
every system prompt. Rather than hard-blocking writes (which broke onboarding),
scan content through the existing Sanitizer and reject writes with High/Critical
severity injection patterns. Medium/Low warnings are logged but allowed.

Also clarifies AGENTS.md identity file roles (USER.md = user info, IDENTITY.md =
agent identity) and adds IDENTITY.md setup as an explicit bootstrap step.

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

* docs: update profile_onboarding_completed comment to reflect current wiring

The field is now actively used by the agent loop to suppress BOOTSTRAP.md
injection — remove the stale "not yet wired" TODO.

[skip-regression-check]

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

* fix(setup): use env_or_override for NEARAI_API_KEY in model fetch config

When the user authenticates via NEAR AI Cloud API key (option 4),
api_key_login() stores the key via set_runtime_env(). But
build_nearai_model_fetch_config() was using std::env::var() which
doesn't check the runtime overlay — so model listing fell back to
session-token auth and re-triggered the interactive NEAR AI
authentication menu.

Switch to env_or_override() which checks both real env vars and the
runtime overlay.

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

* fix(agent): correct channel/user_id in bootstrap greeting persist call

persist_assistant_response was called with channel="default",
user_id="system" but the assistant thread was created via
get_or_create_assistant_conversation("default", "gateway") which owns
the conversation as user_id="default", channel="gateway". The mismatch
caused ensure_writable_conversation to reject the write with:

  WARN Rejected write for unavailable thread id user=system channel=default

[skip-regression-check]

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

* fix(web): remove all inline event handlers for CSP compliance

The Content-Security-Policy header (added in f48fe95) blocks inline JS
via script-src 'self'. All onclick/onchange attributes in index.html
are replaced with getElementById().addEventListener() calls. Dynamic
inline handlers in app.js (jobs, routines, memory breadcrumb, code
blocks, TEE report) are replaced with data-action attributes and a
single delegated click handler on document.

[skip-regression-check]

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

* fix(agent): align bootstrap message user/channel and update fixture schema field

- Bootstrap IncomingMessage now uses ("default", "gateway") consistently
  with persist and session registration calls
- Update bootstrap_greeting.json fixture: schema_version → version to
  match current PROFILE_JSON_SCHEMA

[skip-regression-check]

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

* style: cargo fmt

[skip-regression-check]

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

* fix(safety): address PR review — expand injection scanning and harden profile sync

- BOOTSTRAP.md: fix target "profile" → "context/profile.json" so the
  write hits the correct path and triggers profile sync
- IDENTITY_FILES: add context/assistant-directives.md to the scanned
  set since it is also injected into the system prompt
- sync_profile_documents(): scan derived USER.md and assistant-directives
  content through Sanitizer before writing, rejecting High/Critical
  injection patterns
- profile_evolution_prompt(): wrap recent_messages_summary in <user_data>
  delimiters with untrusted-data instruction to mitigate indirect
  prompt injection
- routine-advisor skill: update cron examples from 6-field to standard
  5-field format for consistency with routine_create tool docs

[skip-regression-check]

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

* style: cargo fmt

[skip-regression-check]

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

* fix(setup): detect env-provided LLM keys during quick-mode onboarding

Quick-mode wizard now checks LLM_BACKEND, NEARAI_API_KEY,
ANTHROPIC_API_KEY, and OPENAI_API_KEY env vars to pre-populate
the provider setting, so users aren't re-prompted for credentials
they already supplied. Also teaches setup_nearai() to recognize
NEARAI_API_KEY from env (previously only checked session tokens).

Includes web UI cleanup (remove duplicate event listeners) and
e2e test response count adjustment.

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

* fix(test): update routine_create_list to expect 7-field normalized cron

The cron normalizer now always expands to 7-field format, so the
stored schedule is "0 0 9 * * * *" not "0 0 9 * * *".

[skip-regression-check]

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

* feat(setup): skip LLM provider prompts when NEARAI_API_KEY is present

In quick mode, if NEARAI_API_KEY is set in the environment and the
backend was auto-detected as nearai, skip the interactive inference
provider and model selection steps. The API key is persisted to the
secrets store and a default model is set automatically.

Also simplify the static fallback model list for nearai to a single
default entry.

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

* fix: unify default model, static bootstrap greeting, and web UI cleanup

- Add DEFAULT_MODEL const and default_models() fallback list in
  llm/nearai_chat.rs; use from config, wizard, and .env.example so the
  default model is defined in one place
- Restore multi-model fallback list in setup wizard (was reduced to 1)
- Move BOOTSTRAP_GREETING to module-level const (out of run() body)
- Replace LLM-based bootstrap with static greeting (persist to DB before
  channels start, then broadcast — eliminates startup LLM call and race)
- Fix double env::var read for NEARAI_API_KEY in quick setup path
- Move thread sidebar buttons into threads-section-header (web UI)
- Remove orphaned .thread-sidebar-header CSS and fix double blank line
- Update bootstrap e2e test for static greeting (no LLM trace needed)

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

* fix(safety): move prompt injection scanning into Workspace write/append

Addresses PR #927 review comments (#1, #3) — identity file write
protection and unsanitized profile fields in system prompt.

Instead of scanning at the tool layer (memory.rs) or the sync layer
(sync_profile_documents), injection scanning now lives in
Workspace::write() and Workspace::append() for all files that are
injected into the system prompt. This ensures every code path that
writes to these files is protected, including future ones.

- Add SYSTEM_PROMPT_FILES const and reject_if_injected() in workspace
- Add WorkspaceError::InjectionRejected variant
- Add map_write_err() in memory.rs to convert InjectionRejected to
  ToolError::NotAuthorized
- Remove redundant IDENTITY_FILES/Sanitizer from memory.rs
- Remove redundant sanitizer calls from sync_profile_documents()
- Move sanitization tests to workspace::tests
- Existing integration test (test_memory_write_rejects_injection)
  continues to pass through the new path

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

* style: cargo fmt

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

* fix: address Copilot review — merge marker order, orphan thread, stale fixture

- merge_profile_section: search for END marker after BEGIN position to
  avoid matching a stray END earlier in the file
- Bootstrap phase 2: use get_or_create_session + Thread::with_id instead
  of resolve_thread(None) to avoid creating an orphan thread
- setup_nearai: use env_or_override for NEARAI_API_KEY consistency with
  runtime overlay
- Delete orphaned bootstrap_greeting.json fixture (no test references it)
- Add test_merge_end_marker_must_follow_begin regression test

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

* style: cargo fmt

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

* style: fmt agent_loop.rs (CI stable rustfmt)

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

* fix: lazy-init sanitizer, check profile non-empty before skipping bootstrap

Address Copilot review:
- Use LazyLock<Sanitizer> to avoid rebuilding Aho-Corasick + regexes
  on every workspace write
- has_profile check now requires non-empty content, not just file
  existence, to prevent empty profile.json from suppressing onboarding
- Add seed_tests integration tests (libsql-backed) verifying:
  - Empty profile.json does not suppress BOOTSTRAP.md seeding
  - Non-empty profile.json correctly suppresses bootstrap for upgrades

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

* style: cargo fmt

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

* fix: duplicate language handler, empty LLM_BACKEND, test_rig style

Address Copilot review on PR #927:
- Remove duplicate language-option click listeners (delegated
  data-action handler already covers them)
- Guard LLM_BACKEND env prefill against empty string to prevent
  suppressing API-key-based auto-detection
- Use destructured local `keep_bootstrap` instead of `self.keep_bootstrap`
  in test_rig for consistency after destructure

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

* fix: update stale BOOTSTRAP.md write-protection comment [skip-regression-check]

BOOTSTRAP.md is now in SYSTEM_PROMPT_FILES and gets injection scanning
on write. The old comment incorrectly stated it was not write-protected.

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

* fix: replace debug_assert panics with graceful error returns [skip-regression-check]

debug_assert! in execute_tool_with_safety and JobContext::transition_to
panicked in test builds before the graceful error path could run.
Existing tests (test_cancel_job_completed, test_execute_empty_tool_name_returns_not_found)
already cover these paths — they were the ones failing.

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

* fix: address Copilot review — schema label, env var check, path normalization, profile validation

1. Label ANALYSIS_FRAMEWORK and PROFILE_JSON_SCHEMA sections separately
   in bootstrap prompt so the LLM knows which blob is the target structure.

2. Wizard quick-mode backend auto-detection now rejects empty env vars
   (std::env::var().is_ok_and(|v| !v.is_empty())) to avoid selecting the
   wrong backend when e.g. NEARAI_API_KEY="" is set.

3. Normalize the target path before comparing with paths::PROFILE in
   memory_write so non-canonical variants like "context//profile.json"
   still trigger profile sync.

4. seed_if_empty now requires valid JSON parse of context/profile.json
   before treating it as a populated profile. Corrupted content no longer
   permanently suppresses bootstrap seeding.

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

* style: cargo fmt

* fix: address Copilot review — append scan, profile validation, env_or_override

1. Workspace::append() now scans the combined content (existing + new)
   for prompt injection, not just the appended chunk. Prevents split-
   injection evasion across multiple appends.

2. seed_if_empty() now deserializes into PsychographicProfile instead of
   serde_json::Value for profile validation. Stray/legacy JSON that
   doesn't match the expected schema no longer suppresses bootstrap.

3. Wizard quick-mode backend auto-detection now uses env_or_override()
   to honor runtime overlays and injected secrets. LLM_BACKEND value
   is trimmed before storage.

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

* test: add bootstrap_onboarding_clears_bootstrap E2E trace test

Exercises the full onboarding flow end-to-end:
1. Bootstrap greeting fires automatically on fresh workspace
2. User converses for 3 turns (name, tools, work style)
3. Agent writes psychographic profile to context/profile.json
4. Profile sync generates USER.md and assistant-directives.md
5. Agent writes IDENTITY.md (chosen persona)
6. Agent clears BOOTSTRAP.md via memory_write(target: "bootstrap")

Verifies:
- BOOTSTRAP.md is non-empty before onboarding, empty after
- bootstrap_completed flag is set
- Profile contains expected user data (name, profession, interests)
- USER.md contains profile-derived content (name, tone, profession)
- Assistant-directives.md references user and communication style
- IDENTITY.md contains agent's chosen persona name
- All memory_write calls succeed

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

* fix: address Copilot review — slash collapse, env_or_override, cron trim [skip-regression-check]

1. memory.rs path normalization now uses the same char-by-char loop as
   Workspace::normalize_path() to fully collapse consecutive slashes
   (e.g. "context///profile.json" → "context/profile.json").

2. Quick-mode NEARAI_API_KEY check (line 239) now uses env_or_override()
   consistently with the backend auto-detection block above it.

3. normalize_cron_expression() trims input before field counting so the
   passthrough branch (7+ fields) also strips whitespace.

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

---------

Co-authored-by: Jay Zalowitz <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-19 22:20:34 -07:00
3a523347b0 fix: f32→f64 precision artifact in temperature causes provider 400 errors (#1450)
* fix: f32→f64 precision artifact in temperature causes provider 400 errors

Direct f32-as-f64 preserves the binary representation, producing values
like 0.699999988079071 instead of 0.7. Some OpenAI-compatible providers
(e.g. Zhipu GLM-5) reject these with a 400 error. Add round_f32_to_f64()
that formats to 6 decimal places before parsing back to f64.

* fix: address clippy redundant_closure lint (takeover #1418) [skip-regression-check]

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

* fix: use numeric rounding, update doc comment, remove duplicate assertion [skip-regression-check]

Address review feedback on #1450:
- Replace format!+parse with numeric rounding to avoid allocation
- Update doc comment to only mention temperature (not top_p)
- Remove duplicate assert_eq in test

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

---------

Co-authored-by: Boomboomdunce <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-19 21:46:25 -07:00
455f543ba5 fix(routines): surface errors when sandbox unavailable for full_job routines (#769)
* feat(db): add list_dispatched_routine_runs to RoutineStore trait

Add method to query routine runs with status='running' AND job_id IS NOT NULL,
enabling the routine engine to sync completion status from background jobs.
Implements for both PostgreSQL and libSQL backends.

[skip-regression-check]

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

* fix(routines): sync dispatched full-job runs with background job status (#697)

Full-job routines were immediately marked Ok on dispatch, so
failures/completions were never reflected in the routine run record.
Now dispatch returns Running status, and a periodic sync checks linked
jobs to update the run when the job completes, fails, or is cancelled.

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

* fix(routines): fail fast when sandbox unavailable at dispatch time (#697)

Thread sandbox_available bool from Docker detection through AgentDeps
to RoutineEngine. Full-job routines now fail immediately with a clear
error message when sandbox is enabled but Docker is not available,
instead of dispatching a job that silently fails.

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

* feat(startup): notify user when sandbox unavailable (#697)

When sandbox is enabled but Docker is not installed or not running,
send a user-visible warning through all channels at startup (with a
2s delay to let channels connect). Previously this was only logged
via tracing::warn, invisible to TUI/web users.

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

* style: fix formatting in routine_engine.rs

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

* fix(tests): set sandbox_available=true in test rig for full_job traces

Test rig doesn't use real Docker — full_job routines execute via trace
replay. Setting sandbox_available=true allows the routine_news_digest
trace test to dispatch full_job routines as before.

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

* fix(routines): address review feedback on sync_dispatched_runs (#697)

- Sanitize last_reason from job transitions before using in
  notifications (truncate to 500 chars, strip control characters)
- Treat Submitted as in-progress (can still transition to Failed),
  only Completed and Accepted are terminal success states
- Add test for sanitize_summary

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

* fix(tests): add missing sandbox_available field to test constructors

Staging added sandbox_available to AgentDeps and RoutineEngine::new.
Add the missing field/argument in test files to fix CI compilation.

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

* fix: sanitize job reason in notifications, fix state handling for Submitted/Accepted

- Enhance sanitize_summary to strip HTML tags and collapse whitespace,
  preventing injection via untrusted container job reasons
- Use char-boundary-safe truncation to avoid panics on multi-byte strings
- Treat Submitted and Accepted as in-progress states (continue polling)
  rather than terminal success, since they can still transition to Failed
- Increase channel-connect delay from 2s to 5s and add debug log for
  sandbox-unavailable warning delivery

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

* Replace sandbox_available bool with SandboxReadiness enum

Distinguishes DisabledByConfig from DockerUnavailable so full-job
routine errors give actionable guidance instead of a generic message.

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

* ci: re-trigger CI with latest changes

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

* fix: add missing owner_id arg to send_notification call

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

* fix: update e2e tests to use SandboxReadiness enum

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: [email protected] <[email protected]>
2026-03-19 21:20:41 -07:00
8526cde1be fix: restore libSQL vector search with dynamic dimensions (#1393)
* fix: restore libSQL vector search with dynamic embedding dimensions (#655)

The V9 migration dropped the libsql_vector_idx and changed
memory_chunks.embedding from F32_BLOB(1536) to BLOB, but the
documented brute-force cosine fallback was never implemented.
hybrid_search silently returned empty vector results — search was
FTS5-only on libSQL.

Add ensure_vector_index() which dynamically creates the vector index
with the correct F32_BLOB(N) dimension, inferred from EMBEDDING_DIMENSION
/ EMBEDDING_MODEL env vars during run_migrations(). Uses _migrations
version=0 as a metadata row to track the current dimension (no-op if
unchanged, rebuilds table on dimension change).

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

* style: move safety comments above multi-line assertions for rustfmt stability

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

* refactor: remove unnecessary safety comments from test code

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

* fix: address review comments from PR #1393 [skip-regression-check]

- Share model→dimension mapping via config::embeddings::default_dimension_for_model()
  instead of duplicating the match table (zmanian, Copilot)
- Add dimension bounds check (1..=65536) to prevent overflow (zmanian, Copilot)
- DROP stale memory_chunks_new before CREATE to handle crashed previous attempts
  (zmanian, Copilot)
- Use plain INSERT instead of INSERT OR IGNORE to surface constraint errors
  (Copilot)

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

* fix: add missing builder field to AgentDeps in telegram routing test [skip-regression-check]

The self-repair builder field was added to AgentDeps in #712 but this
test was not updated.

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

* fix: address zmanian's second review on PR #1393

- Add tracing::info when resolve_embedding_dimension returns None (#2)
- Document connection scoping for transaction safety (#1)
- Document _rowid preservation for FTS5 consistency (#4)
- Document precondition that migrations must run first (#5)
- Note F32_BLOB dimension enforcement in insert_chunk (#3)
- Add unit tests for resolve_embedding_dimension (#6)

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-19 20:51:37 -07:00
8920322589 fix: staging CI triage — consolidate retry parsing, fix flaky tests, add docs (#1427)
* fix: consolidate retry-after parsing and fix flaky OAuth env tests (#1288, #1280)

- Extract shared `parse_retry_after()` into `src/llm/retry.rs` supporting
  both delay-seconds and RFC2822 formats, replacing duplicated inline parsing
  in anthropic_oauth.rs, nearai_chat.rs, and embeddings.rs
- Fix flaky `bind_rejects_wildcard_*` tests in oauth_helpers.rs by adding
  `tokio::sync::Mutex` to serialize env var access (matching the ENV_MUTEX
  pattern in oauth_defaults.rs)
- Add regression tests for parse_retry_after edge cases

Closes #1288, #1280

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

* docs: add comments explaining CLI_ENABLED=false in service templates (#990)

Clarify that CLI_ENABLED=false is needed in daemon mode (launchd/systemd)
to prevent blocking on stdin when running as a background service.

Closes #990

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

* fix: address review comments on retry-after consolidation

- Change parse_retry_after() return type from Option<Duration> to Duration
  (it never returns None due to the 60s fallback)
- Fix doc comment: reference RFC 7231 §7.1.1 for HTTP-date, not RFC 2822
- Add parse_retry_after_http_date test for the RFC 2822 date parsing branch
- Remove stale per-file test helpers (parse_retry_after_*_for_test) that
  duplicated old inline logic instead of testing the shared function
- Remove unnecessary comments above #[cfg(test)] imports
- Use crate-wide ENV_MUTEX instead of local tokio::sync::Mutex in
  oauth_helpers tests to prevent cross-module env-var races

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

* fix: reword await_holding_lock safety comment

Drop runtime-flavor assumption; justify by short-lived awaited operation.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-19 18:33:15 -07:00
6b0f84bbe0 perf: use Arc in embedding cache to avoid clones on miss path (#1438)
* docs: add comments explaining CLI_ENABLED=false in service templates (#990)

Clarify that CLI_ENABLED=false is needed in daemon mode (launchd/systemd)
to prevent blocking on stdin when running as a background service.

Closes #990

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

* perf: use Arc<Vec<f32>> in embedding cache to avoid clones on miss path (#1429)

Store embeddings as Arc<Vec<f32>> internally so that cache insertions
share the allocation with the return value via Arc::clone instead of
cloning the entire float vector (6-12 KB per embedding).

- embed() miss path: Arc::try_unwrap avoids a clone when returning
  (the cache holds one Arc ref, the return path holds the other;
  try_unwrap succeeds when the thundering-herd path doesn't fire)
- embed_batch() miss path: cache first via Arc::clone, then
  try_unwrap for results — embeddings skipped due to capacity
  limits are returned without any clone
- Hit path still clones (trait returns Vec<f32>); a future trait
  change to Arc<Vec<f32>> could eliminate this too

Closes #1429

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

* style: fix formatting in embedding_cache.rs

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

* fix: address PR review — correct doc comment and remove dead try_unwrap

- Reword CacheEntry doc comment to accurately reflect that hit/miss paths
  still clone into a fresh Vec<f32> for callers; Arc sharing only helps
  in embed_batch when embeddings are skipped from caching
- Remove Arc::try_unwrap in embed() which could never succeed (cache
  always holds an Arc ref, so refcount >= 2)

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

* fix: revert embed() to plain Vec, keep Arc only in embed_batch()

In embed(), Arc adds overhead (allocation + refcount) without saving
any clones — the original pattern (clone for cache, return by move)
was already optimal. Arc only helps in embed_batch() where
capacity-skipped embeddings can be returned via try_unwrap.

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

* fix: move clone+Arc::new outside mutex in embed()

Clone the embedding and wrap in Arc before acquiring the lock so the
mutex is held only for the HashMap insert, not during the O(n) copy.

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

* refactor: drop Arc, use cache-then-move pattern instead

Arc was the wrong abstraction — the trait returns Vec<f32>, so Arc
can't avoid clones on return paths. Instead:

- embed(): skip clone in thundering-herd case (just touch timestamp)
- embed_batch(): cache first (clone only cacheable subset), then move
  originals into results (zero-copy). For N misses with K cacheable:
  old = 2N clones, new = K clones.
- CacheEntry reverted to plain Vec<f32>, no Arc overhead

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-19 18:33:04 -07:00
cac6f4013c Add owner-scoped permissions for full-job routines (#1440)
* docs: add comments explaining CLI_ENABLED=false in service templates (#990)

Clarify that CLI_ENABLED=false is needed in daemon mode (launchd/systemd)
to prevent blocking on stdin when running as a background service.

Closes #990

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

* Add owner-scoped full-job routine permissions

* Address PR review feedback

* Fix owner gate test timing

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-19 18:32:47 -07:00
Henry ParkandGitHub c4ab382522 Make hosted OAuth and MCP auth generic (#1375)
* Make hosted OAuth and MCP auth generic

* Address PR feedback and lint issues

* Suppress built-in Google secret in hosted proxy flows

* Align hosted OAuth secret suppression with proxy config

* Harden hosted OAuth callback helpers

* Tighten hosted OAuth URL rewriting
2026-03-19 15:50:54 -07:00
65062f3cc0 feat: structured fallback deliverables for failed/stuck jobs (#236)
* feat: structured fallback deliverables for failed/stuck jobs (#221)

When a job fails or gets stuck, build a FallbackDeliverable that captures
partial results, action statistics, cost, timing, and repair attempts.
This replaces opaque error strings with structured data users can act on.

- Add FallbackDeliverable, LastAction, ActionStats types in context/fallback.rs
- Store fallback in JobContext.metadata["fallback_deliverable"] on failure
- Surface fallback in job_status tool output and SSE job_result events
- Update mark_failed() and mark_stuck() in worker to build fallback
- 8 unit tests covering zero/mixed actions, truncation, timing, serialization

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

* fix: address review comments on fallback deliverables

- Fix doc comment: "200 chars" -> "200 bytes (UTF-8 safe)" since
  truncate_str operates on byte length, not character count.
- Add code comment documenting that SSE fallback_deliverable is
  currently always None (forward-compatible infrastructure).

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

* refactor: take Option<&FallbackDeliverable> instead of &Option<…>

Addresses Gemini review feedback: idiomatic Rust prefers
Option<&T> over &Option<T> for borrowed optional values.

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

* fix: guard against non-object metadata and add fallback test

- store_fallback_in_metadata now resets metadata to {} when it's any
  non-object type (string, array, number), not just null. Prevents
  panic on index assignment.
- Add test_job_status_includes_fallback_deliverable to verify the
  fallback field is surfaced in job_status tool output.

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

* fix: use sanitized output in fallback preview + add integration tests

Security fix: FallbackDeliverable::build() now uses output_sanitized
instead of output_raw, preventing secrets/PII from leaking through
the job_status tool and SSE job_result events.

Also adds:
- test_fallback_uses_sanitized_output: proves raw secrets don't leak
- test_store_fallback_in_metadata_roundtrip: full serialize/deserialize
- test_store_fallback_handles_non_object_metadata: edge case coverage
- test_store_fallback_none_is_noop: None input is safe

Addresses serrrfirat review feedback on PR #236.

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

* fix: harden fallback deliverables against review findings

- Truncate failure_reason to 1000 bytes to prevent metadata bloat
- Add tracing::warn on fallback serialization failure (was silently discarded)
- Fix module/struct docs to cover stuck jobs, remove stale SSE claim
- Fix job.rs test to use real FallbackDeliverable field names
- Add tests for failure_reason truncation and completed_at=None elapsed time
- Fix pre-existing clippy warning in settings.rs (field_reassign_with_default)

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

* fix: address Copilot review findings on fallback deliverables

- Fix output_raw/output_sanitized field swap in ActionRecord::succeed()
  so sanitized data actually goes into the sanitized field (security)
- Return None instead of empty Memory when get_memory fails in
  build_fallback, with tracing::warn for observability
- Replace manual elapsed calculation with ctx.elapsed() which already
  clamps negative durations

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

* fix: resolve rebase conflicts and update tests for parameter swap

- Add fallback field to SseEvent::JobResult in job_monitor
- Fix type annotation in fallback deliverable test
- Update test_action_record_succeed_sets_fields for new parameter order
- Use create_job_for_user in test (API changed on main)

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

* chore: trigger CI re-check after rebase

* fix: fall back to error message for failed action output_preview

When the last action is a failed tool call, output_sanitized is None,
leaving output_preview empty. Now falls back to the action's error
message so users see what went wrong.

[skip-regression-check]

* ci: add safety comments to test code for no-panics check

The CI no-panics grep check cannot distinguish test code inside
src/ files from production code. Add // safety: test annotations
to .unwrap(), .expect(), and assert!() calls in #[cfg(test)] modules.

* fix: clarify succeed() doc and avoid clone in output_preview

- Fix doc comment: output_raw is stored as pretty-printed JSON string,
  not a raw JSON value
- Borrow string slice directly in fallback preview to avoid cloning
  potentially large sanitized outputs before truncation

* refactor: reuse floor_char_boundary in truncate_str

Replace hand-rolled UTF-8 boundary logic with existing
crate::util::floor_char_boundary to reduce duplication.

* fix: rename SSE fallback field to fallback_deliverable for consistency

The SSE JobResult field was named `fallback` while everywhere else
(metadata key, job_status tool) uses `fallback_deliverable`. Align
the SSE wire format to avoid forcing clients to handle two names.

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-19 13:43:04 -07:00
86ae12747b feat: LRU embedding cache for workspace search (#1423)
* feat: LRU embedding cache for workspace search (#165)

Add CachedEmbeddingProvider that wraps any EmbeddingProvider with an
in-memory LRU cache keyed by SHA-256(model_name + text). This avoids
redundant HTTP calls when the same text is embedded multiple times
(common during reindexing and repeated searches).

- Cache uses HashMap + last_accessed tracking with manual LRU eviction
  (same pattern as llm::response_cache::CachedProvider)
- Lock is never held during HTTP calls to prevent blocking
- embed_batch() partitions into hits/misses and only fetches misses
- Default 10,000 entries (~58 MB for 1536-dim vectors)
- Configurable via EMBEDDING_CACHE_SIZE env var
- Workspace.with_embeddings() auto-wraps; with_embeddings_uncached()
  available for tests

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

* fix: address review comments on embedding cache

- Validate embed_batch return count matches expected miss count
- Replace unwrap_or_default() with proper error propagation
- Fix batch eviction: run final eviction pass after insert to enforce cap
- Fix test: use different-length inputs to verify ordering correctness
- Reject EMBEDDING_CACHE_SIZE=0 in config validation (minimum is 1)

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

* fix: replace .expect() with proper error handling in embed_batch

The all-cache-hits early-return path used .expect("all cache hits") which
violates the project convention of no .unwrap()/.expect() in production
code. Replaced with the same ok_or_else pattern used in the normal path.

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

* fix: clarify memory sizing docs and use saturating_add for eviction

- Update memory comments in embedding_cache.rs, config/embeddings.rs,
  and workspace/mod.rs to note the ~58 MB figure is payload-only
  (actual memory is higher due to HashMap/key/allocation overhead)
- Use saturating_add(1) instead of + 1 for eviction threshold to
  prevent overflow if max_entries is usize::MAX

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

* fix: address Copilot review on embedding cache

- Avoid double-clone per miss in embed_batch: move embedding into
  results, clone only for the cache entry
- Evict per-insert instead of after all inserts to keep peak memory
  bounded during large batches
- Clamp max_entries to at least 1 in constructor to prevent unexpected
  eviction behavior when set to 0 via the public API

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

* fix: reduce embedding_cache module visibility to private

Types are already re-exported via `pub use`, so the module itself
doesn't need to be public. Reduces unnecessary API surface.

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

* fix: address serrrfirat review feedback on embedding cache

- Add TODO comment for O(n) LRU eviction scalability
- Add thundering herd note at lock release in embed()
- Warn when cache max_entries exceeds 100k
- Use with_embeddings_uncached() in integration test
- Add tests: error_does_not_pollute_cache, embed_batch_empty_input
- Update README with cache-aware with_embeddings() docs

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

* fix: prevent u32 wrapping in FailThenSucceedMock failure counter

fetch_sub(1) wraps to u32::MAX when called past zero, silently
breaking the mock for 3+ calls. Switch to load-then-store to avoid
the wrapping bug in both embed() and embed_batch().

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

* fix: address Copilot and serrrfirat review findings on embedding cache

- Switch tokio::sync::Mutex to std::sync::Mutex (lock never held across
  .await — cheaper synchronous lock)
- Extract DEFAULT_EMBEDDING_CACHE_SIZE constant to avoid 10_000 duplication
  between EmbeddingCacheConfig and EmbeddingsConfig

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

* test: add all-misses batch test for embedding cache

Adds embed_batch_all_misses test covering the case where every text in a
batch is a cache miss — fulfilling the commitment from serrrfirat's review.

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

* chore: trigger CI re-check after rebase

* fix: use raw [u8;32] cache keys and pre-allocate HashMap capacity

Address Copilot review findings:
- cache_key() now returns [u8; 32] instead of hex String, avoiding a
  64-byte allocation per lookup
- HashMap::with_capacity(max_entries) avoids incremental reallocation
- Fix pre-existing staging compilation error in cli/routines.rs
  (missing max_tool_rounds/use_tools fields)

[skip-regression-check]

* fix: make cache accessors sync and update doc for [u8;32] keys

Address Copilot review:
- len(), is_empty(), clear() are now sync since they only take a
  std::sync::Mutex lock with no .await points
- Update cache_size doc comment to reflect [u8;32] keys instead of
  String keys

[skip-regression-check]

* fix: remove clone_on_copy for [u8; 32] cache keys

[skip-regression-check]

* ci: add safety comments to test code for no-panics check

The CI no-panics grep check cannot distinguish test code inside
src/ files from production code. Add // safety: test annotations
to .unwrap(), .expect(), and assert!() calls in #[cfg(test)] modules.

* fix: correct cache doc and demote hit/miss logs to trace

- Fix misleading "String keys" in memory comment (cache uses [u8; 32])
- Demote per-request hit/miss logs from debug to trace to reduce noise
  on hot paths (batch summary stays at trace too)

* docs: add missing Arc import in workspace README example

* perf: batch eviction in embed_batch to avoid O(n×m) cost

Replace per-insert evict_lru call with a single evict_k_oldest pass
that computes eviction count upfront and removes the k oldest entries
in one O(n) scan. Avoids O(n×m) HashMap iterations while holding the
mutex during batch inserts.

* fix: cap batch cache inserts at max_entries and use O(n) selection

- evict_k_oldest now uses select_nth_unstable_by_key for O(n) average
  partial selection instead of O(n log n) full sort
- embed_batch caps cached entries at max_entries when misses exceed
  capacity, preventing the cache from growing unbounded
- Added test: batch_exceeding_capacity_respects_max_entries

* fix: flatten test assert for fmt compatibility

Shorten assert message to fit single line so cargo fmt doesn't
split the safety annotation onto a separate line.

* fix: address review feedback and improve embedding cache (takeover #235)

- Fix merge conflict: add missing allow_always field in PendingApproval
- Thread EmbeddingCacheConfig through CLI memory commands so they respect
  EMBEDDING_CACHE_SIZE instead of silently using default (fixes #235 review)
- Cap HashMap pre-allocation at min(max_entries, 1024) to avoid upfront
  memory waste at large cache sizes
- Fix FailThenSucceedMock race: replace load+store with atomic fetch_update
- Remove noisy '// safety: test' comments (40+ lines of diff noise)
- Fix collapsed lines from comment removal
- Simplify redundant Ok(...collect()?) to just collect()

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

* fix(embedding-cache): skip eviction on concurrent duplicate insert

When the lock is released for the HTTP call, another caller may insert
the same key. Re-check under lock and just update the existing entry
without evicting, avoiding unnecessary cache churn under concurrency.

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

---------

Co-authored-by: ztsalexey <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: ztsalexey <[email protected]>
2026-03-19 13:37:55 -07:00
52ca9d6588 feat: receive relay events via webhook callbacks (#1254)
* feat: receive relay events via webhook callbacks instead of SSE

Replace the SSE pull model with push-based webhook callbacks from
channel-relay. Eliminates the reconnect loop, stream token auth,
and SSE parser — events arrive via HTTP POST to /relay/events.

- Add webhook handler with HMAC signature verification
- Simplify RelayChannel to use mpsc from webhook handler
- Remove SSE connect/reconnect/parse logic from RelayClient
- Add register_callback() to RelayClient for callback URL registration
- Update activation flow to create event channel and register callback
- Wire relay webhook endpoint into web gateway

* fix: address review feedback on webhook callback PR

- Return 503 when relay event channel is full/closed (enables retry)
- Reject malformed timestamps with 400 instead of proceeding
- Allow relay activation without settings store (no-store/ephemeral mode)
- Check installed_relay_extensions set in is_relay_channel for no-db mode
- Fix staging test constructors for new RelayChannel signature

* security: adapt relay client to new channel-relay auth model

Adapts the relay integration to the hardened channel-relay security model:

- Switch from X-API-Key header to Authorization: Bearer sk-agent-*
  for all relay API calls (chat-api token verification)
- Remove register_callback() — PUT /callbacks endpoint removed
- Remove event_callback_url from initiate_oauth() — parameter removed
- Make signing_secret a required field in RelayConfig (new env var:
  CHANNEL_RELAY_SIGNING_SECRET)
- Update integration tests for Bearer auth and removed endpoints

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

* security: use server-side approval tokens, remove caller-supplied routing

- Approval flow now calls POST /approvals to register server-side
  record, then embeds only the opaque approval_token in button value
- Remove instance_id parameter from proxy_provider() — channel-relay
  no longer accepts it (uses verified identity)
- Remove instance_id and user_id from initiate_oauth() — channel-relay
  derives them from the Bearer token
- Add create_approval() to RelayClient

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

* fix: pass webhook_url during OAuth so callback_url is set on connection

The channel-relay OAuth flow now accepts webhook_url to set the
callback_url during connection creation. IronClaw computes its webhook
URL from callback_base + webhook_path and passes it during initiate_oauth.

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

* security: remove webhook_url from OAuth initiation

Channel-relay now derives the callback URL from chat-api's instance_url.
IronClaw no longer supplies webhook_url during OAuth — the relay is the
authority on where events get delivered.

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

* chore: cargo fmt

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

* security: remove all URL params from OAuth initiation

IronClaw no longer supplies any URLs to channel-relay. The relay
derives all URLs from the trusted instance_url in chat-api.
initiate_oauth() takes no parameters.

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

* fix: restore CSRF nonce for OAuth callback validation

Re-add nonce generation and secret storage in auth_channel_relay.
The nonce is passed to channel-relay as state_nonce param (not a URL).
Channel-relay embeds it in the signed state and appends it to the
redirect URL so IronClaw's callback handler can validate and activate.

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

* security: per-instance callback signing secrets

relay_signing_secret() now prefers OPENCLAW_GATEWAY_TOKEN (per-instance)
over the shared CHANNEL_RELAY_SIGNING_SECRET. A compromised instance
can no longer forge callbacks to other instances on the same relay.
CHANNEL_RELAY_SIGNING_SECRET is now optional in RelayConfig.

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

* security: clean per-instance callback secrets, no shared secrets, no fallbacks

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

* fix: pass team_id to get_signing_secret for workspace-scoped lookup

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

* security: remove sender_id from create_approval — relay derives it

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

* fix: remove stale relay sender_id validation

* fix: harden relay webhook activation lifecycle

---------

Co-authored-by: Pierre <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-19 11:53:46 -07:00
09e1c97a27 fix(approval): make "always" auto-approve work for credentialed HTTP requests (#1257)
The HTTP tool returned `ApprovalRequirement::Always` for requests with
credentials, but `Always` is hardcoded to ignore the session auto-approve
set. This meant users who clicked "always" were re-prompted on every
subsequent HTTP call — the UI offered "always" but the backend ignored it.

Two fixes:
1. HTTP credentialed requests now return `UnlessAutoApproved` instead of
   `Always`, so the session auto-approve set is respected.
2. `StatusUpdate::ApprovalNeeded` now carries `allow_always: bool`. All
   channel UIs (Telegram, Slack, Signal, REPL, Web) conditionally hide
   the "always" option when a tool truly requires per-invocation approval
   (`ApprovalRequirement::Always`, e.g. destructive shell commands).

Also boxes `PendingApproval` in `AgenticLoopResult::NeedApproval` to fix
a pre-existing clippy `large_enum_variant` warning.

Regression tests included (test_credentialed_requests_respect_auto_approve,
test_allow_always_matches_approval_requirement) but CI heuristic cannot
detect them in cross-fork PR diffs.

[skip-regression-check]

Co-authored-by: Tyler <[email protected]>
2026-03-19 11:45:32 -07:00
71f41dd123 fix(feishu): parse flat token response from tenant_access_token API (#1419)
* fix(feishu): parse flat token response from tenant_access_token API

  The Feishu /auth/v3/tenant_access_token/internal endpoint returns a flat
  JSON response with tenant_access_token and expire at the top level, not
  nested under a "data" field. The previous code used FeishuApiResponse<T>
  which expects a "data" wrapper, causing all token exchanges to fail with
  "Token response missing data" despite receiving a valid HTTP 200 response.

  - Replace TenantAccessTokenData with TenantAccessTokenResponse that includes
  code/msg/tenant_access_token/expire at the top level
  - Deserialize token response directly instead of via FeishuApiResponse<T> wrapper
  - Add empty-token guard to catch malformed responses
  - No changes to FeishuApiResponse<T> or other API call paths

  Fixes #1391

* fix(feishu): address review feedback on token response parsing

- Remove #[serde(default)] from tenant_access_token and expire fields
  so deserialization fails explicitly when critical fields are missing
- Add expire > 0 validation guard to prevent refresh loops or overflow
- Use saturating_add/saturating_mul for expiry calculation
- Add 5 regression tests for TenantAccessTokenResponse deserialization

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

---------

Co-authored-by: reidliu <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-19 10:33:58 -07:00
71f9012de3 fix: skip NEAR AI session check when backend is not nearai (#1413)
* fix: skip NEAR AI session check when backend is not nearai

When a user configures a non-NEAR AI backend (e.g. Anthropic), the
doctor command was incorrectly failing with "session file not found"
even though no NEAR AI session is needed. The check now skips with a
descriptive message when LLM_BACKEND is not nearai/near_ai/near.

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

* fix(ci): avoid holding sync MutexGuard across await in doctor test

Convert check_nearai_session_skips_for_non_nearai_backend from
#[tokio::test] to #[test] with block_on, matching the pattern used by
all other ENV_MUTEX tests. Fixes clippy::await_holding_lock error.

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

---------

Co-authored-by: Kristian Glass <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-19 10:10:08 -07:00
38dafb96b1 chore: bump telegram channel version to 0.2.5 (#1410)
Bump registry version to pass check-version-bumps.sh after
channels-src/telegram/ changes.

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-19 09:47:40 -07:00
CPU-216andGitHub 9c34fe90f4 chore(ci): enforce test requirement for state machine and resilience changes (#1230) (#1304) 2026-03-19 09:35:37 -07:00
07c6ca72e9 fix: navigate telegram E2E tests to channels subtab (#1408)
* fix: navigate telegram E2E tests to channels subtab

wasm_channel extensions (like telegram) are now rendered in the
Settings → Channels subtab, not the Extensions subtab. Update
test_telegram_hot_activation to navigate there and use the correct
card selector. Also mock /api/gateway/status which loadChannelsStatus
fetches.

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

* fix: select telegram card by name, not first card in channels subtab

Built-in channel cards (Web Gateway, HTTP, etc.) render first in the
channels subtab content, so .first matches them instead of the
telegram extension card. Select by has_text="Telegram" to target
the correct card.

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

* refactor: make gateway_status_handler parameterizable in mock helper

Address review feedback: extract default gateway status handler and
accept an optional gateway_status_handler kwarg in mock_extension_lists
for test flexibility.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-19 08:11:15 -07:00
b9e5acf66e fix: add missing builder field and update E2E extensions tab navigation (#1400)
- Add `builder: None` to AgentDeps initializer in e2e_telegram_message_routing
  test (field added in #712 but test not updated)
- Update go_to_extensions() in test_telegram_hot_activation to navigate via
  settings tab -> extensions subtab (extensions tab was moved to settings)

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-18 23:38:33 -07:00
161 changed files with 17175 additions and 3094 deletions
+20 -2
View File
@@ -4,7 +4,7 @@ DATABASE_POOL_SIZE=10
# LLM Provider
# LLM_BACKEND=nearai # default
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, tinfoil
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, github_copilot, tinfoil, openai_codex
# LLM_REQUEST_TIMEOUT_SECS=120 # Increase for local LLMs (Ollama, vLLM, LM Studio)
# === Anthropic Direct ===
@@ -24,6 +24,17 @@ DATABASE_POOL_SIZE=10
# LLM_USE_CODEX_AUTH=true
# CODEX_AUTH_PATH=~/.codex/auth.json
# === GitHub Copilot ===
# Uses the OAuth token from your Copilot IDE sign-in (for example
# ~/.config/github-copilot/apps.json on Linux/macOS), or run `ironclaw onboard`
# and choose the GitHub device login flow.
# LLM_BACKEND=github_copilot
# GITHUB_COPILOT_TOKEN=gho_...
# GITHUB_COPILOT_MODEL=gpt-4o
# IronClaw injects standard VS Code Copilot headers automatically.
# Optional advanced headers for custom overrides:
# GITHUB_COPILOT_EXTRA_HEADERS=Copilot-Integration-Id:vscode-chat
# === NEAR AI (Chat Completions API) ===
# Two auth modes:
# 1. Session token (default): Uses browser OAuth (GitHub/Google) on first run.
@@ -31,7 +42,7 @@ DATABASE_POOL_SIZE=10
# Base URL defaults to https://private.near.ai
# 2. API key: Set NEARAI_API_KEY to use API key auth from cloud.near.ai.
# Base URL defaults to https://cloud-api.near.ai
NEARAI_MODEL=zai-org/GLM-5-FP8
NEARAI_MODEL=Qwen/Qwen3.5-122B-A10B
NEARAI_BASE_URL=https://private.near.ai
NEARAI_AUTH_URL=https://private.near.ai
# NEARAI_SESSION_TOKEN=sess_... # hosting providers: set this
@@ -92,6 +103,13 @@ NEARAI_AUTH_URL=https://private.near.ai
# long = 1-hour TTL, 2.0× (200%) write surcharge
# ANTHROPIC_CACHE_RETENTION=short
# === OpenAI Codex (ChatGPT subscription, OAuth) ===
# LLM_BACKEND=openai_codex
# OPENAI_CODEX_MODEL=gpt-5.3-codex # default
# OPENAI_CODEX_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann # override (rare)
# OPENAI_CODEX_AUTH_URL=https://auth.openai.com # override (rare)
# OPENAI_CODEX_API_URL=https://chatgpt.com/backend-api/codex # override (rare)
# For full provider setup guide see docs/LLM_PROVIDERS.md
# Channel Configuration
+41 -6
View File
@@ -43,12 +43,42 @@ jobs:
fi
fi
if [ "$IS_FIX" = false ]; then
echo "Not a fix PR — skipping regression test check."
# --- 1b. Does this PR touch high-risk state machine or resilience code? ---
CHANGED_FILES=$(git diff --name-only "${BASE_REF}...${HEAD_REF}")
TOUCHES_HIGH_RISK=false
HIGH_RISK_PATTERNS=(
"src/context/state.rs"
"src/agent/session.rs"
"src/llm/circuit_breaker.rs"
"src/llm/retry.rs"
"src/llm/failover.rs"
"src/agent/self_repair.rs"
"src/agent/agentic_loop.rs"
"src/tools/execute.rs"
"crates/ironclaw_safety/src/"
)
for pattern in "${HIGH_RISK_PATTERNS[@]}"; do
if echo "$CHANGED_FILES" | grep -q "$pattern"; then
TOUCHES_HIGH_RISK=true
echo "High-risk file matched: $pattern"
break
fi
done
# Skip only if NEITHER condition holds — no double-firing on fix PRs
if [ "$IS_FIX" = false ] && [ "$TOUCHES_HIGH_RISK" = false ]; then
echo "Not a fix PR and no high-risk files changed — skipping."
exit 0
fi
echo "Fix PR detected."
if [ "$IS_FIX" = true ]; then
echo "Fix PR detected."
fi
if [ "$TOUCHES_HIGH_RISK" = true ]; then
echo "High-risk state machine or resilience code modified."
fi
# --- 2. Skip label or commit message marker ---
if grep -qF ',skip-regression-check,' <<< ",$PR_LABELS,"; then
@@ -63,8 +93,6 @@ jobs:
fi
# --- 3. Exempt static-only / docs-only changes ---
CHANGED_FILES=$(git diff --name-only "${BASE_REF}...${HEAD_REF}")
if [ -z "$CHANGED_FILES" ]; then
echo "No changed files — skipping."
exit 0
@@ -110,5 +138,12 @@ jobs:
fi
# --- 5. No tests found ---
echo "::warning::This PR looks like a bug fix but contains no test changes. Every fix should include a regression test. Add a #[test] or #[tokio::test], or apply the 'skip-regression-check' label if not feasible."
if [ "$IS_FIX" = true ]; then
echo "::warning::This PR looks like a bug fix but contains no test changes."
fi
if [ "$TOUCHES_HIGH_RISK" = true ]; then
echo "::warning::This PR modifies high-risk state machine or resilience code but includes no test changes."
fi
echo "::warning::Please add tests exercising the changed behavior, or apply the 'skip-regression-check' label if not feasible."
exit 1
+89 -1
View File
@@ -1,6 +1,94 @@
# Agent Rules
## Feature Parity Update Policy
## Purpose and Precedence
- `AGENTS.md` is the quick-start contract for coding agents. It is not the full architecture spec.
- Read the relevant subsystem spec before changing a complex area. When a repo spec exists, treat it as authoritative.
Start with these deeper docs as needed:
- `CLAUDE.md`
- `src/agent/CLAUDE.md`
- `src/channels/web/CLAUDE.md`
- `src/db/CLAUDE.md`
- `src/llm/CLAUDE.md`
- `src/setup/README.md`
- `src/tools/README.md`
- `src/workspace/README.md`
- `src/NETWORK_SECURITY.md`
- `tests/e2e/CLAUDE.md`
## Architecture Mental Model
- Channels normalize external input into `IncomingMessage`; `ChannelManager` merges all active channel streams.
- `Agent` owns session/thread/turn handling, submission parsing, the LLM/tool loop, approvals, routines, and background runtime behavior.
- `AppBuilder` is the composition root that wires database, secrets, LLMs, tools, workspace, extensions, skills, hooks, and cost controls before the agent starts.
- The web gateway is a browser-facing API/UI layered on top of the same agent/session/tool systems, not a separate product path.
## Where to Work
- Agent/runtime behavior: `src/agent/`
- Web gateway/API/SSE/WebSocket: `src/channels/web/`
- Persistence and DB abstractions: `src/db/`
- Setup/onboarding/configuration flow: `src/setup/`
- LLM providers and routing: `src/llm/`
- Workspace, memory, embeddings, search: `src/workspace/`
- Extensions, tools, channels, MCP, WASM: `src/extensions/`, `src/tools/`, `src/channels/`
## Ownership and Composition Rules
- Keep `src/main.rs` and `src/app.rs` orchestration-focused. Do not move module-owned logic into entrypoints.
- Module-specific initialization should live in the owning module behind a public factory/helper, not be reimplemented ad hoc.
- Keep feature-flag branching inside the module that owns the abstraction whenever possible.
- Prefer extending existing traits and registries over hardcoding one-off integration paths.
## Repo-Wide Coding Rules
- Avoid `.unwrap()` and `.expect()` in production; prefer proper error handling. They are fine in tests, and in production only for truly infallible invariants (e.g., literals/regexes) with a safety comment.
- Keep clippy clean with zero warnings.
- Prefer `crate::` imports for cross-module references.
- Use strong types and enums over stringly-typed control flow when the shape is known.
## Database, Setup, and Config Rules
- New persistence behavior must support both PostgreSQL and libSQL.
- Add new DB operations to the shared DB trait first, then implement both backends.
- Treat bootstrap config, DB-backed settings, and encrypted secrets as distinct layers; do not collapse them casually.
- If onboarding or setup behavior changes, update `src/setup/README.md` in the same branch.
- Do not break config precedence, bootstrap env loading, DB-backed config reload, or post-secrets LLM re-resolution.
## Security and Runtime Invariants
- Review any change touching listeners, routes, auth, secrets, sandboxing, approvals, or outbound HTTP with a security mindset.
- Do not weaken bearer-token auth, webhook auth, CORS/origin checks, body limits, rate limits, allowlists, or secret-handling guarantees.
- Treat Docker containers and external services as untrusted.
- Session/thread/turn state matters. Submission parsing happens before normal chat handling.
- Skills are selected deterministically. Tool approval and auth flows are special paths and must not be mixed into normal chat history carelessly.
- Persistent memory is the workspace system, not just transcript storage; preserve file-like semantics, chunking/search behavior, and identity/system-prompt loading.
## Tools, Channels, and Extensions
- Use a built-in Rust tool for core internal capabilities tightly coupled to the runtime.
- Use WASM tools or WASM channels for sandboxed extensions and plugin-style integrations.
- Use MCP for external server integrations when the capability belongs outside the main binary.
- Preserve extension lifecycle expectations: install, authenticate/configure, activate, remove.
## Docs, Parity, and Testing
- If behavior changes, update the relevant docs/specs in the same branch.
- If you change implementation status for any feature tracked in `FEATURE_PARITY.md`, update that file in the same branch.
- Do not open a PR that changes feature behavior without checking `FEATURE_PARITY.md` for needed status updates (`❌`, `🚧`, `✅`, notes, and priorities).
- Add the narrowest tests that validate the change: unit tests for local logic, integration tests for runtime/DB/routing behavior, and E2E or trace coverage for gateway, approvals, extensions, or other user-visible flows.
## Risk and Change Discipline
- Keep changes scoped; avoid broad refactors unless the task truly requires them.
- Security, database schema, runtime, worker, CI, and secrets changes are high-risk. Call out rollback risks, compatibility concerns, and hidden side effects.
- Preserve existing defaults unless the task explicitly changes them.
- Avoid unrelated file churn and generated-file edits unless required.
- Respect a dirty worktree and never revert user changes you did not make.
## Before Finishing
- Confirm whether behavior changes require updates to `FEATURE_PARITY.md`, specs, API docs, or `CHANGELOG.md`.
- Run the most targeted tests/checks that cover the change.
- Re-check security-sensitive paths when touching auth, secrets, network listeners, sandboxing, or approvals.
- Keep the final diff scoped to the task.
+2
View File
@@ -158,6 +158,8 @@ src/
├── secrets/ # Secrets management (AES-256-GCM, OS keychain for master key)
├── profile.rs # Psychographic profile types, 9-dimension analysis framework
├── setup/ # 7-step onboarding wizard — see src/setup/README.md
├── skills/ # SKILL.md prompt extension system — see .claude/rules/skills.md
Generated
+7 -7
View File
@@ -2339,7 +2339,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -5575,7 +5575,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -5624,7 +5624,7 @@ dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki 0.103.9",
"rustls-webpki 0.103.10",
"subtle",
"zeroize",
]
@@ -5696,9 +5696,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.9"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"aws-lc-rs",
"ring",
@@ -6479,10 +6479,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
+2 -1
View File
@@ -242,6 +242,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| OpenRouter | ✅ | ✅ | - | Via OpenAI-compatible provider (RigAdapter) |
| Tinfoil | ❌ | ✅ | - | Private inference provider (IronClaw-only) |
| OpenAI-compatible | ❌ | ✅ | - | Generic OpenAI-compatible endpoint (RigAdapter) |
| GitHub Copilot | ✅ | ✅ | - | Dedicated provider with OAuth token exchange (`GithubCopilotProvider`) |
| Ollama (local) | ✅ | ✅ | - | via `rig::providers::ollama` (full support) |
| Perplexity | ✅ | ❌ | P3 | Freshness parameter for web_search |
| MiniMax | ✅ | ❌ | P3 | Regional endpoint selection |
@@ -465,7 +466,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 |
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth plus 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 | ✅ | ❌ | |
+1 -1
View File
@@ -168,7 +168,7 @@ written to `~/.ironclaw/.env` so they are available before the database connects
### Alternative LLM Providers
IronClaw defaults to NEAR AI but supports many LLM providers out of the box.
Built-in providers include **Anthropic**, **OpenAI**, **Google Gemini**, **MiniMax**,
Built-in providers include **Anthropic**, **OpenAI**, **GitHub Copilot**, **Google Gemini**, **MiniMax**,
**Mistral**, and **Ollama** (local). OpenAI-compatible services like **OpenRouter**
(300+ models), **Together AI**, **Fireworks AI**, and self-hosted servers (**vLLM**,
**LiteLLM**) are also supported.
+1 -1
View File
@@ -165,7 +165,7 @@ ironclaw onboard
### 替代 LLM 提供商
IronClaw 默认使用 NEAR AI,但开箱即用地支持多种 LLM 提供商。
内置提供商包括 **Anthropic**、**OpenAI**、**Google Gemini**、**MiniMax**、**Mistral** 和 **Ollama**(本地部署)。同时也支持 OpenAI 兼容服务,如 **OpenRouter**300+ 模型)、**Together AI**、**Fireworks AI** 以及自托管服务器(**vLLM**、**LiteLLM**)。
内置提供商包括 **Anthropic**、**OpenAI**、**GitHub Copilot**、**Google Gemini**、**MiniMax**、**Mistral** 和 **Ollama**(本地部署)。同时也支持 OpenAI 兼容服务,如 **OpenRouter**300+ 模型)、**Together AI**、**Fireworks AI** 以及自托管服务器(**vLLM**、**LiteLLM**)。
在向导中选择你的提供商,或直接设置环境变量:
+87 -13
View File
@@ -206,9 +206,17 @@ struct FeishuApiResponse<T> {
data: Option<T>,
}
/// Tenant access token response.
#[derive(Debug, Default, Deserialize)]
struct TenantAccessTokenData {
/// Tenant access token response (flat format).
///
/// Unlike most Feishu APIs that nest results under `data`, the
/// `/auth/v3/tenant_access_token/internal` endpoint returns `code`, `msg`,
/// `tenant_access_token`, and `expire` at the top level.
#[derive(Debug, Deserialize)]
struct TenantAccessTokenResponse {
#[serde(default)]
code: i32,
#[serde(default)]
msg: String,
tenant_access_token: String,
expire: i64,
}
@@ -770,9 +778,8 @@ fn obtain_tenant_token(api_base: &str) -> Result<String, String> {
));
}
let token_resp: FeishuApiResponse<TenantAccessTokenData> =
serde_json::from_slice(&response.body)
.map_err(|e| format!("Failed to parse token response: {}", e))?;
let token_resp: TenantAccessTokenResponse = serde_json::from_slice(&response.body)
.map_err(|e| format!("Failed to parse token response: {}", e))?;
if token_resp.code != 0 {
return Err(format!(
@@ -781,23 +788,33 @@ fn obtain_tenant_token(api_base: &str) -> Result<String, String> {
));
}
let data = token_resp
.data
.ok_or_else(|| "Token response missing data".to_string())?;
if token_resp.tenant_access_token.is_empty() {
return Err("Token response missing tenant_access_token".to_string());
}
if token_resp.expire <= 0 {
return Err(format!(
"Token response has invalid expire value: {}",
token_resp.expire
));
}
// Cache the token with expiry.
let now = channel_host::now_millis();
let expiry = now + (data.expire as u64) * 1000;
let expiry = now.saturating_add((token_resp.expire as u64).saturating_mul(1000));
let _ = channel_host::workspace_write(TOKEN_PATH, &data.tenant_access_token);
let _ = channel_host::workspace_write(TOKEN_PATH, &token_resp.tenant_access_token);
let _ = channel_host::workspace_write(TOKEN_EXPIRY_PATH, &expiry.to_string());
channel_host::log(
channel_host::LogLevel::Debug,
&format!("Tenant access token refreshed, expires in {}s", data.expire),
&format!(
"Tenant access token refreshed, expires in {}s",
token_resp.expire
),
);
Ok(data.tenant_access_token)
Ok(token_resp.tenant_access_token)
}
Err(e) => Err(format!("Token exchange request failed: {}", e)),
}
@@ -819,3 +836,60 @@ fn json_response(status: u16, body: serde_json::Value) -> OutgoingHttpResponse {
body: body_bytes,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_flat_token_response() {
let json = r#"{
"code": 0,
"msg": "ok",
"tenant_access_token": "t-abc123",
"expire": 7200
}"#;
let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.code, 0);
assert_eq!(resp.msg, "ok");
assert_eq!(resp.tenant_access_token, "t-abc123");
assert_eq!(resp.expire, 7200);
}
#[test]
fn parse_token_response_rejects_missing_token() {
let json = r#"{"code": 0, "msg": "ok", "expire": 7200}"#;
let result: Result<TenantAccessTokenResponse, _> = serde_json::from_str(json);
assert!(result.is_err(), "should fail when tenant_access_token is missing");
}
#[test]
fn parse_token_response_rejects_missing_expire() {
let json = r#"{"code": 0, "msg": "ok", "tenant_access_token": "t-abc"}"#;
let result: Result<TenantAccessTokenResponse, _> = serde_json::from_str(json);
assert!(result.is_err(), "should fail when expire is missing");
}
#[test]
fn parse_token_response_defaults_code_and_msg() {
let json = r#"{"tenant_access_token": "t-abc", "expire": 3600}"#;
let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.code, 0);
assert_eq!(resp.msg, "");
assert_eq!(resp.tenant_access_token, "t-abc");
assert_eq!(resp.expire, 3600);
}
#[test]
fn parse_token_error_response() {
let json = r#"{
"code": 10003,
"msg": "invalid app_id",
"tenant_access_token": "",
"expire": 0
}"#;
let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.code, 10003);
assert!(resp.tenant_access_token.is_empty());
}
}
+12
View File
@@ -243,6 +243,18 @@ mod tests {
assert!(wrapped.contains("Hello <world>"));
}
#[test]
fn test_wrap_for_llm_escapes_attr_chars() {
let config = SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: true,
};
let safety = SafetyLayer::new(&config);
let wrapped = safety.wrap_for_llm("bad&\"<>name", "ok", false);
assert!(wrapped.contains("name=\"bad&amp;&quot;&lt;&gt;name\"")); // safety: test assertion in #[cfg(test)] module
}
#[test]
fn test_sanitize_action_forces_sanitization_when_injection_check_disabled() {
let config = SafetyConfig {
+2
View File
@@ -15,6 +15,8 @@ ignore = [
"RUSTSEC-2026-0020",
# wasmtime wasi:http/types.fields panic — mitigated by fuel limits
"RUSTSEC-2026-0021",
# rustls-webpki CRL distributionPoint matching — 0.102.8 pinned by libsql transitive dep
"RUSTSEC-2026-0049",
]
[licenses]
+29
View File
@@ -17,6 +17,7 @@ configurations.
| Yandex AI Studio | `yandex` | `YANDEX_API_KEY` | YandexGPT models |
| MiniMax | `minimax` | `MINIMAX_API_KEY` | MiniMax-M2.7 models |
| Cloudflare Workers AI | `cloudflare` | `CLOUDFLARE_API_KEY` | Access to Workers AI |
| GitHub Copilot | `github_copilot` | `GITHUB_COPILOT_TOKEN` | Multi-models |
| Ollama | `ollama` | No | Local inference |
| AWS Bedrock | `bedrock` | AWS credentials | Native Converse API |
| OpenRouter | `openai_compatible` | `LLM_API_KEY` | 300+ models |
@@ -61,6 +62,34 @@ Popular models: `gpt-4o`, `gpt-4o-mini`, `o3-mini`
---
## GitHub Copilot
GitHub Copilot exposes chat endpoint at
`https://api.githubcopilot.com`. IronClaw uses that endpoint directly through the
built-in `github_copilot` provider.
```env
LLM_BACKEND=github_copilot
GITHUB_COPILOT_TOKEN=gho_...
GITHUB_COPILOT_MODEL=gpt-4o
# Optional advanced headers if your setup needs them:
# GITHUB_COPILOT_EXTRA_HEADERS=Copilot-Integration-Id:vscode-chat
```
`ironclaw onboard` can acquire this token for you using GitHub device login. If you
already signed into Copilot through VS Code or a JetBrains IDE, you can also reuse
the `oauth_token` stored in `~/.config/github-copilot/apps.json`. If you prefer,
`LLM_BACKEND=github-copilot` also works as an alias.
Popular models vary by subscription, but `gpt-4o` is a safe default. IronClaw keeps
model entry manual for this provider because GitHub Copilot model listing may require
extra integration headers on some clients. IronClaw automatically injects the standard
VS Code identity headers (`User-Agent`, `Editor-Version`, `Editor-Plugin-Version`,
`Copilot-Integration-Id`) and lets you override them with
`GITHUB_COPILOT_EXTRA_HEADERS`.
---
## Ollama (local)
Install Ollama from [ollama.com](https://ollama.com), pull a model, then:
+23
View File
@@ -77,6 +77,29 @@
"can_list_models": false
}
},
{
"id": "github_copilot",
"aliases": [
"github-copilot",
"githubcopilot",
"copilot"
],
"protocol": "github_copilot",
"default_base_url": "https://api.githubcopilot.com",
"api_key_env": "GITHUB_COPILOT_TOKEN",
"api_key_required": true,
"model_env": "GITHUB_COPILOT_MODEL",
"default_model": "gpt-4o",
"extra_headers_env": "GITHUB_COPILOT_EXTRA_HEADERS",
"description": "GitHub Copilot Chat API (OAuth token from IDE sign-in)",
"setup": {
"kind": "api_key",
"secret_name": "llm_github_copilot_token",
"key_url": "https://docs.github.com/en/copilot",
"display_name": "GitHub Copilot",
"can_list_models": false
}
},
{
"id": "tinfoil",
"aliases": [],
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "telegram",
"display_name": "Telegram Channel",
"kind": "channel",
"version": "0.2.4",
"version": "0.2.5",
"wit_version": "0.3.0",
"description": "Talk to your agent through a Telegram bot",
"keywords": [
+75
View File
@@ -0,0 +1,75 @@
---
name: delegation
version: 0.1.0
description: Helps users delegate tasks, break them into steps, set deadlines, and track progress via routines and memory.
activation:
keywords:
- delegate
- hand off
- assign task
- help me with
- take care of
- remind me to
- schedule
- plan my
- manage my
- track this
patterns:
- "can you.*handle"
- "I need (help|someone) to"
- "take over"
- "set up a reminder"
- "follow up on"
tags:
- personal-assistant
- task-management
- delegation
max_context_tokens: 1500
---
# Task Delegation Assistant
When the user wants to delegate a task or get help managing something, follow this process:
## 1. Clarify the Task
Ask what needs to be done, by when, and any constraints. Get enough detail to act independently but don't over-interrogate. If the request is clear, skip straight to planning.
## 2. Break It Down
Decompose the task into concrete, actionable steps. Use `memory_write` to persist the task plan to a path like `tasks/{task-name}.md` with:
- Clear description
- Steps with checkboxes
- Due date (if any)
- Status: pending/in-progress/done
## 3. Set Up Tracking
If the task is recurring or has a deadline:
- Create a routine using `routine_create` for scheduled check-ins
- Add a heartbeat item if it needs daily monitoring
- Set up an event-triggered routine if it depends on external input
## 4. Use Profile Context
Check `USER.md` for the user's preferences:
- **Proactivity level**: High = check in frequently. Low = only report on completion.
- **Communication style**: Match their preferred tone and detail level.
- **Focus areas**: Prioritize tasks that align with their stated goals.
## 5. Execute or Queue
- If you can do it now (search, draft, organize, calculate), do it immediately.
- If it requires waiting, external action, or follow-up, create a reminder routine.
- If it requires tools you don't have, explain what's needed and suggest alternatives.
## 6. Report Back
Always confirm the plan with the user before starting execution. After completing, update the task file in memory and notify the user with a concise summary.
## Communication Guidelines
- Be direct and action-oriented
- Confirm understanding before acting on ambiguous requests
- When in doubt about autonomy level, ask once then remember the answer
- Use `memory_write` to track delegation preferences for future reference
+118
View File
@@ -0,0 +1,118 @@
---
name: routine-advisor
version: 0.1.0
description: Suggests relevant cron routines based on user context, goals, and observed patterns
activation:
keywords:
- every day
- every morning
- every week
- routine
- automate
- remind me
- check daily
- monitor
- recurring
- schedule
- habit
- workflow
- keep forgetting
- always have to
- repetitive
- notifications
- digest
- summary
- review daily
- weekly review
patterns:
- "I (always|usually|often|regularly) (check|do|look at|review)"
- "every (morning|evening|week|day|monday|friday)"
- "I (wish|want) (I|it) (could|would) (automatically|auto)"
- "is there a way to (auto|schedule|set up)"
- "can you (check|monitor|watch|track).*for me"
- "I keep (forgetting|missing|having to)"
tags:
- automation
- scheduling
- personal-assistant
- productivity
max_context_tokens: 1500
---
# Routine Advisor
When the conversation suggests the user has a repeatable task or could benefit from automation, consider suggesting a routine.
## When to Suggest
Suggest a routine when you notice:
- The user describes doing something repeatedly ("I check my PRs every morning")
- The user mentions forgetting recurring tasks ("I keep forgetting to...")
- The user asks you to do something that sounds periodic
- You've learned enough about the user to propose a relevant automation
- The user has installed extensions that enable new monitoring capabilities
## How to Suggest
Be specific and concrete. Not "Want me to set up a routine?" but rather: "I noticed you review PRs every morning. Want me to create a daily 9am routine that checks your open PRs and sends you a summary?"
Always include:
1. What the routine would do (specific action)
2. When it would run (specific schedule in plain language)
3. How it would notify them (which channel they're on)
Wait for the user to confirm before creating.
## Pacing
- First 1-3 conversations: Do NOT suggest routines. Focus on helping and learning.
- After learning 2-3 user patterns: Suggest your first routine. Keep it simple.
- After 5+ conversations: Suggest more routines as patterns emerge.
- Never suggest more than 1 routine per conversation unless the user is clearly interested.
- If the user declines, wait at least 3 conversations before suggesting again.
## Creating Routines
Use the `routine_create` tool. Before creating, check `routine_list` to avoid duplicates.
Parameters:
- `trigger_type`: Usually "cron" for scheduled tasks
- `schedule`: Standard cron format. Common schedules:
- Daily 9am: `0 9 * * *`
- Weekday mornings: `0 9 * * MON-FRI`
- Weekly Monday: `0 9 * * MON`
- Every 2 hours during work: `0 9-17/2 * * MON-FRI`
- Sunday evening: `0 18 * * SUN`
- `action_type`: "lightweight" for simple checks, "full_job" for multi-step tasks
- `prompt`: Clear, specific instruction for what the routine should do
- `context_paths`: Workspace files to load as context (e.g., `["context/profile.json", "MEMORY.md"]`)
## Routine Ideas by User Type
**Developer:**
- Daily PR review digest (check open PRs, summarize what needs attention)
- CI/CD failure alerts (monitor build status)
- Weekly dependency update check
- Daily standup prep (summarize yesterday's work from daily logs)
**Professional:**
- Morning briefing (today's priorities from memory + any pending tasks)
- End-of-day summary (what was accomplished, what's pending)
- Weekly goal review (check progress against stated goals)
- Meeting prep reminders
**Health/Personal:**
- Daily exercise or habit check-in
- Weekly meal planning prompt
- Monthly budget review reminder
**General:**
- Daily news digest on topics of interest
- Weekly reflection prompt (what went well, what to improve)
- Periodic task/reminder check-in
- Regular cleanup of stale tasks or notes
- Weekly profile evolution (if the user has a profile in `context/profile.json`, suggest a Monday routine that reads the profile via `memory_read`, searches recent conversations for new patterns with `memory_search`, and updates the profile via `memory_write` if any fields should change with confidence > 0.6 — be conservative, only update with clear evidence)
## Awareness
Before suggesting, consider what tools and extensions are currently available. Only suggest routines the agent can actually execute. If a routine would need a tool that isn't installed, mention that too: "If you connect your calendar, I could also send you a morning briefing with today's meetings."
+1 -1
View File
@@ -113,7 +113,7 @@ Check-insert is done under a single write lock to prevent TOCTOU races. A cleanu
4. Detects broken tools via `store.get_broken_tools(5)` (threshold: 5 failures). Requires `with_store()` to be called; returns empty without a store.
5. Attempts to rebuild broken tools via `SoftwareBuilder`. Requires `with_builder()` to be called; returns `ManualRequired` without a builder.
Note: the `stuck_threshold` duration is stored but currently unused (marked `#[allow(dead_code)]`). Stuck detection relies on `JobState::Stuck` being set by the state machine, not wall-clock time comparison.
The `stuck_threshold` duration is used for time-based detection of `InProgress` jobs that have been running longer than the threshold. When `detect_stuck_jobs()` finds such jobs, it transitions them to `Stuck` before returning them, enabling the normal `attempt_recovery()` path.
Repair results: `Success`, `Retry`, `Failed`, `ManualRequired`. `Retry` does NOT notify the user (to avoid spam).
+180 -22
View File
@@ -10,6 +10,7 @@
use std::sync::Arc;
use futures::StreamExt;
use uuid::Uuid;
use crate::agent::context_monitor::ContextMonitor;
use crate::agent::heartbeat::spawn_heartbeat;
@@ -17,7 +18,7 @@ use crate::agent::routine_engine::{RoutineEngine, spawn_cron_ticker};
use crate::agent::self_repair::{DefaultSelfRepair, RepairResult, SelfRepair};
use crate::agent::session_manager::SessionManager;
use crate::agent::submission::{Submission, SubmissionParser, SubmissionResult};
use crate::agent::{HeartbeatConfig as AgentHeartbeatConfig, Router, Scheduler};
use crate::agent::{HeartbeatConfig as AgentHeartbeatConfig, Router, Scheduler, SchedulerDeps};
use crate::channels::{ChannelManager, IncomingMessage, OutgoingResponse};
use crate::config::{AgentConfig, HeartbeatConfig, RoutineConfig, SkillsConfig};
use crate::context::ContextManager;
@@ -31,6 +32,13 @@ use crate::skills::SkillRegistry;
use crate::tools::ToolRegistry;
use crate::workspace::Workspace;
/// Static greeting persisted to DB and broadcast on first launch.
///
/// Sent before the LLM is involved so the user sees something immediately.
/// The conversational onboarding (profile building, channel setup) happens
/// organically in the subsequent turns driven by BOOTSTRAP.md.
const BOOTSTRAP_GREETING: &str = include_str!("../workspace/seeds/GREETING.md");
/// Collapse a tool output string into a single-line preview for display.
pub(crate) fn truncate_for_preview(output: &str, max_chars: usize) -> String {
let collapsed: String = output
@@ -113,6 +121,17 @@ async fn resolve_routine_notification_target(
.await
}
pub(crate) fn chat_tool_execution_metadata(message: &IncomingMessage) -> serde_json::Value {
serde_json::json!({
"notify_channel": message.channel,
"notify_user": message
.routing_target()
.unwrap_or_else(|| message.user_id.clone()),
"notify_thread_id": message.thread_id,
"notify_metadata": message.metadata,
})
}
fn should_fallback_routine_notification(error: &ChannelError) -> bool {
!matches!(error, ChannelError::MissingRoutingTarget { .. })
}
@@ -146,6 +165,8 @@ pub struct AgentDeps {
pub transcription: Option<Arc<crate::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.
pub sandbox_readiness: crate::agent::routine_engine::SandboxReadiness,
/// Software builder for self-repair tool rebuilding.
pub builder: Option<Arc<dyn crate::tools::SoftwareBuilder>>,
}
@@ -207,9 +228,12 @@ impl Agent {
context_manager.clone(),
deps.llm.clone(),
deps.safety.clone(),
deps.tools.clone(),
deps.store.clone(),
deps.hooks.clone(),
SchedulerDeps {
tools: deps.tools.clone(),
extension_manager: deps.extension_manager.clone(),
store: deps.store.clone(),
hooks: deps.hooks.clone(),
},
);
if let Some(ref tx) = deps.sse_tx {
scheduler.set_sse_sender(tx.clone());
@@ -338,6 +362,32 @@ impl Agent {
/// Run the agent main loop.
pub async fn run(self) -> Result<(), Error> {
// Proactive bootstrap: persist the static greeting to DB *before*
// starting channels so the first web client sees it via history.
let bootstrap_thread_id = if self
.workspace()
.is_some_and(|ws| ws.take_bootstrap_pending())
{
tracing::debug!(
"Fresh workspace detected — persisting static bootstrap greeting to DB"
);
if let Some(store) = self.store() {
let thread_id = store
.get_or_create_assistant_conversation("default", "gateway")
.await
.ok();
if let Some(id) = thread_id {
self.persist_assistant_response(id, "gateway", "default", BOOTSTRAP_GREETING)
.await;
}
thread_id
} else {
None
}
} else {
None
};
// Start channels
let mut message_stream = self.channels.start_all().await?;
@@ -554,8 +604,10 @@ impl Agent {
Arc::clone(workspace),
notify_tx,
Some(self.scheduler.clone()),
self.deps.extension_manager.clone(),
self.tools().clone(),
self.safety().clone(),
self.deps.sandbox_readiness,
));
// Register routine tools
@@ -668,6 +720,30 @@ impl Agent {
None
};
// Bootstrap phase 2: register the thread in session manager and
// broadcast the greeting via SSE for any clients already connected.
// The greeting was already persisted to DB before start_all(), so
// clients that connect after this point will see it via history.
if let Some(id) = bootstrap_thread_id {
// Use get_or_create_session (not resolve_thread) to avoid creating
// an orphan thread. Then insert the DB-sourced thread directly.
let session = self.session_manager.get_or_create_session("default").await;
{
use crate::agent::session::Thread;
let mut sess = session.lock().await;
let thread = Thread::with_id(id, sess.id);
sess.active_thread = Some(id);
sess.threads.entry(id).or_insert(thread);
}
self.session_manager
.register_thread("default", "gateway", id, session)
.await;
let mut out = OutgoingResponse::text(BOOTSTRAP_GREETING.to_string());
out.thread_id = Some(id.to_string());
let _ = self.channels.broadcast("gateway", "default", out).await;
}
// Main message loop
tracing::debug!("Agent {} ready and listening", self.config.name);
@@ -861,9 +937,6 @@ impl Agent {
}
async fn handle_message(&self, message: &IncomingMessage) -> Result<Option<String>, Error> {
// Log at info level only for tracking without exposing PII (user_id can be a phone number)
tracing::info!(message_id = %message.id, "Processing message");
// Log sensitive details at debug level for troubleshooting
tracing::debug!(
message_id = %message.id,
@@ -942,19 +1015,59 @@ impl Agent {
}
}
// Resolve session and thread
tracing::debug!(
message_id = %message.id,
"Resolving session and thread"
);
let (session, thread_id) = self
.session_manager
.resolve_thread(
&message.user_id,
&message.channel,
message.conversation_scope(),
)
.await;
// Resolve session and thread. Approval submissions are allowed to
// target an already-loaded owned thread by UUID across channels so the
// web approval UI can approve work that originated from HTTP/other
// owner-scoped channels.
let approval_thread_uuid = if matches!(
submission,
Submission::ExecApproval { .. } | Submission::ApprovalResponse { .. }
) {
message
.conversation_scope()
.and_then(|thread_id| Uuid::parse_str(thread_id).ok())
} else {
None
};
let (session, thread_id) = if let Some(target_thread_id) = approval_thread_uuid {
let session = self
.session_manager
.get_or_create_session(&message.user_id)
.await;
let mut sess = session.lock().await;
if sess.threads.contains_key(&target_thread_id) {
sess.active_thread = Some(target_thread_id);
sess.last_active_at = chrono::Utc::now();
drop(sess);
self.session_manager
.register_thread(
&message.user_id,
&message.channel,
target_thread_id,
Arc::clone(&session),
)
.await;
(session, target_thread_id)
} else {
drop(sess);
self.session_manager
.resolve_thread(
&message.user_id,
&message.channel,
message.conversation_scope(),
)
.await
}
} else {
self.session_manager
.resolve_thread(
&message.user_id,
&message.channel,
message.conversation_scope(),
)
.await
};
tracing::debug!(
message_id = %message.id,
thread_id = %thread_id,
@@ -1124,9 +1237,10 @@ impl Agent {
#[cfg(test)]
mod tests {
use super::{
resolve_routine_notification_user, should_fallback_routine_notification,
truncate_for_preview,
chat_tool_execution_metadata, resolve_routine_notification_user,
should_fallback_routine_notification, truncate_for_preview,
};
use crate::channels::IncomingMessage;
use crate::error::ChannelError;
#[test]
@@ -1222,6 +1336,50 @@ mod tests {
assert_eq!(resolve_routine_notification_user(&metadata), None); // safety: test-only assertion
}
#[test]
fn chat_tool_execution_metadata_prefers_message_routing_target() {
let message = IncomingMessage::new("telegram", "owner-scope", "hello")
.with_sender_id("telegram-user")
.with_thread("thread-7")
.with_metadata(serde_json::json!({
"chat_id": 424242,
"chat_type": "private",
}));
let metadata = chat_tool_execution_metadata(&message);
assert_eq!(
metadata.get("notify_channel").and_then(|v| v.as_str()),
Some("telegram")
); // safety: test-only assertion
assert_eq!(
metadata.get("notify_user").and_then(|v| v.as_str()),
Some("424242")
); // safety: test-only assertion
assert_eq!(
metadata.get("notify_thread_id").and_then(|v| v.as_str()),
Some("thread-7")
); // safety: test-only assertion
}
#[test]
fn chat_tool_execution_metadata_falls_back_to_user_scope_without_route() {
let message = IncomingMessage::new("gateway", "owner-scope", "hello").with_sender_id("");
let metadata = chat_tool_execution_metadata(&message);
assert_eq!(
metadata.get("notify_channel").and_then(|v| v.as_str()),
Some("gateway")
); // safety: test-only assertion
assert_eq!(
metadata.get("notify_user").and_then(|v| v.as_str()),
Some("owner-scope")
); // safety: test-only assertion
assert_eq!(
metadata.get("notify_thread_id"),
Some(&serde_json::Value::Null)
); // safety: test-only assertion
}
#[test]
fn targeted_routine_notifications_do_not_fallback_without_owner_route() {
let error = ChannelError::MissingRoutingTarget {
+43 -13
View File
@@ -29,7 +29,7 @@ pub(super) enum AgenticLoopResult {
/// A tool requires approval before continuing.
NeedApproval {
/// The pending approval request to store.
pending: PendingApproval,
pending: Box<PendingApproval>,
},
}
@@ -144,12 +144,7 @@ impl Agent {
.with_requester_id(&message.sender_id);
job_ctx.http_interceptor = self.deps.http_interceptor.clone();
job_ctx.user_timezone = user_tz.name().to_string();
job_ctx.metadata = serde_json::json!({
"notify_channel": message.channel,
"notify_user": message.user_id,
"notify_thread_id": message.thread_id,
"notify_metadata": message.metadata,
});
job_ctx.metadata = crate::agent::agent_loop::chat_tool_execution_metadata(message);
// Build system prompts once for this turn. Two variants: with tools
// (normal iterations) and without (force_text final iteration).
@@ -217,9 +212,7 @@ impl Agent {
reason: format!("Exceeded maximum tool iterations ({max_tool_iterations})"),
}
.into()),
LoopOutcome::NeedApproval(pending) => {
Ok(AgenticLoopResult::NeedApproval { pending: *pending })
}
LoopOutcome::NeedApproval(pending) => Ok(AgenticLoopResult::NeedApproval { pending }),
}
}
@@ -482,6 +475,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
usize,
crate::llm::ToolCall,
Arc<dyn crate::tools::Tool>,
bool, // allow_always
)> = None;
for (idx, original_tc) in tool_calls.iter().enumerate() {
@@ -551,7 +545,8 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
&& let Some(tool) = tool_opt
{
use crate::tools::ApprovalRequirement;
let needs_approval = match tool.requires_approval(&tc.arguments) {
let requirement = tool.requires_approval(&tc.arguments);
let needs_approval = match requirement {
ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => {
let sess = self.session.lock().await;
@@ -586,7 +581,8 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
continue;
}
approval_needed = Some((idx, tc, tool));
let allow_always = !matches!(requirement, ApprovalRequirement::Always);
approval_needed = Some((idx, tc, tool, allow_always));
break;
}
}
@@ -887,7 +883,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
}
// Handle approval if a tool needed it
if let Some((approval_idx, tc, tool)) = approval_needed {
if let Some((approval_idx, tc, tool, allow_always)) = approval_needed {
let display_params = redact_params(&tc.arguments, tool.sensitive_params());
let pending = PendingApproval {
request_id: Uuid::new_v4(),
@@ -899,6 +895,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
context_messages: reason_ctx.messages.clone(),
deferred_tool_calls: tool_calls[approval_idx + 1..].to_vec(),
user_timezone: Some(self.user_tz.name().to_string()),
allow_always,
};
return Ok(Some(LoopOutcome::NeedApproval(Box::new(pending))));
@@ -1197,6 +1194,7 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
builder: None,
};
@@ -1365,6 +1363,35 @@ mod tests {
assert!(always_needs, "Always must always require approval");
}
/// Regression test: `allow_always` must be `false` for `Always` and
/// `true` for `UnlessAutoApproved`, so the UI hides the "always" button
/// for tools that truly cannot be auto-approved.
#[test]
fn test_allow_always_matches_approval_requirement() {
use crate::tools::ApprovalRequirement;
// Mirrors the expression used in dispatcher.rs and thread_ops.rs:
// let allow_always = !matches!(requirement, ApprovalRequirement::Always);
// UnlessAutoApproved → allow_always = true
let req = ApprovalRequirement::UnlessAutoApproved;
let allow_always = !matches!(req, ApprovalRequirement::Always);
assert!(
allow_always,
"UnlessAutoApproved should set allow_always = true"
);
// Always → allow_always = false
let req = ApprovalRequirement::Always;
let allow_always = !matches!(req, ApprovalRequirement::Always);
assert!(!allow_always, "Always should set allow_always = false");
// Never → allow_always = true (approval is never needed, but if it were, always would be ok)
let req = ApprovalRequirement::Never;
let allow_always = !matches!(req, ApprovalRequirement::Always);
assert!(allow_always, "Never should set allow_always = true");
}
#[test]
fn test_pending_approval_serialization_backcompat_without_deferred_calls() {
// PendingApproval from before the deferred_tool_calls field was added
@@ -1410,6 +1437,7 @@ mod tests {
},
],
user_timezone: None,
allow_always: true,
};
let json = serde_json::to_string(&pending).expect("serialize");
@@ -2038,6 +2066,7 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
builder: None,
};
@@ -2157,6 +2186,7 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
builder: None,
};
+228
View File
@@ -14,12 +14,15 @@
//! Agent Loop
//! ```
use std::sync::Arc;
use tokio::sync::{broadcast, mpsc};
use tokio::task::JoinHandle;
use uuid::Uuid;
use crate::channels::IncomingMessage;
use crate::channels::web::types::SseEvent;
use crate::context::{ContextManager, JobState};
/// Route context for forwarding job monitor events back to the user's channel.
#[derive(Debug, Clone)]
@@ -40,10 +43,23 @@ pub struct JobMonitorRoute {
/// Tool use/result and status events are intentionally skipped (too noisy for
/// the main agent's context window).
pub fn spawn_job_monitor(
job_id: Uuid,
event_rx: broadcast::Receiver<(Uuid, SseEvent)>,
inject_tx: mpsc::Sender<IncomingMessage>,
route: JobMonitorRoute,
) -> JoinHandle<()> {
spawn_job_monitor_with_context(job_id, event_rx, inject_tx, route, None)
}
/// Like `spawn_job_monitor`, but also transitions the job's in-memory state
/// when it receives a `JobResult` event. This ensures fire-and-forget sandbox
/// jobs don't stay `InProgress` forever in the `ContextManager`.
pub fn spawn_job_monitor_with_context(
job_id: Uuid,
mut event_rx: broadcast::Receiver<(Uuid, SseEvent)>,
inject_tx: mpsc::Sender<IncomingMessage>,
route: JobMonitorRoute,
context_manager: Option<Arc<ContextManager>>,
) -> JoinHandle<()> {
let short_id = job_id.to_string()[..8].to_string();
@@ -77,6 +93,26 @@ pub fn spawn_job_monitor(
}
}
SseEvent::JobResult { status, .. } => {
// Transition in-memory state so the job frees its
// max_jobs slot and query tools show the final state.
if let Some(ref cm) = context_manager {
let target = if status == "completed" {
JobState::Completed
} else {
JobState::Failed
};
let reason = if status != "completed" {
Some(format!("Container finished: {}", status))
} else {
None
};
let _ = cm
.update_context(job_id, |ctx| {
let _ = ctx.transition_to(target, reason);
})
.await;
}
let mut msg = IncomingMessage::new(
route.channel.clone(),
route.user_id.clone(),
@@ -121,6 +157,62 @@ pub fn spawn_job_monitor(
})
}
/// Lightweight watcher that only transitions ContextManager state on job
/// completion. Used when monitor routing metadata is absent (no channel to
/// inject messages into) but we still need to free the `max_jobs` slot.
pub fn spawn_completion_watcher(
job_id: Uuid,
mut event_rx: broadcast::Receiver<(Uuid, SseEvent)>,
context_manager: Arc<ContextManager>,
) -> JoinHandle<()> {
let short_id = job_id.to_string()[..8].to_string();
tokio::spawn(async move {
loop {
match event_rx.recv().await {
Ok((ev_job_id, SseEvent::JobResult { status, .. })) if ev_job_id == job_id => {
let target = if status == "completed" {
JobState::Completed
} else {
JobState::Failed
};
let reason = if status != "completed" {
Some(format!("Container finished: {}", status))
} else {
None
};
let _ = context_manager
.update_context(job_id, |ctx| {
let _ = ctx.transition_to(target, reason);
})
.await;
tracing::debug!(
job_id = %short_id,
status = %status,
"Completion watcher exiting (job finished)"
);
break;
}
Ok(_) => {}
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(
job_id = %short_id,
skipped = n,
"Completion watcher lagged"
);
}
Err(broadcast::error::RecvError::Closed) => {
tracing::debug!(
job_id = %short_id,
"Broadcast channel closed, stopping completion watcher"
);
break;
}
}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -211,6 +303,7 @@ mod tests {
job_id: job_id.to_string(),
status: "completed".to_string(),
session_id: None,
fallback_deliverable: None,
},
))
.unwrap();
@@ -293,4 +386,139 @@ mod tests {
let msg = IncomingMessage::new("monitor", "system", "test").into_internal();
assert!(msg.is_internal);
}
// === Regression: fire-and-forget sandbox jobs must transition out of InProgress ===
// Before this fix, spawn_job_monitor only forwarded SSE messages but never
// updated ContextManager. Background sandbox jobs stayed InProgress forever,
// permanently consuming a max_jobs slot.
#[tokio::test]
async fn test_monitor_transitions_context_on_completion() {
use crate::context::{ContextManager, JobState};
let cm = Arc::new(ContextManager::new(5));
let job_id = Uuid::new_v4();
cm.register_sandbox_job(job_id, "user-1", "Build app", "desc")
.await
.unwrap();
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
let handle = spawn_job_monitor_with_context(
job_id,
event_tx.subscribe(),
inject_tx,
test_route(),
Some(Arc::clone(&cm)),
);
// Send completion event
event_tx
.send((
job_id,
SseEvent::JobResult {
job_id: job_id.to_string(),
status: "completed".to_string(),
session_id: None,
fallback_deliverable: None,
},
))
.unwrap();
// Drain the injected message
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), inject_rx.recv()).await;
// Wait for monitor to exit
tokio::time::timeout(std::time::Duration::from_secs(1), handle)
.await
.expect("monitor should exit")
.expect("monitor should not panic");
// Job should now be Completed, not InProgress
let ctx = cm.get_context(job_id).await.unwrap();
assert_eq!(ctx.state, JobState::Completed);
}
#[tokio::test]
async fn test_monitor_transitions_context_on_failure() {
use crate::context::{ContextManager, JobState};
let cm = Arc::new(ContextManager::new(5));
let job_id = Uuid::new_v4();
cm.register_sandbox_job(job_id, "user-1", "Build app", "desc")
.await
.unwrap();
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
let handle = spawn_job_monitor_with_context(
job_id,
event_tx.subscribe(),
inject_tx,
test_route(),
Some(Arc::clone(&cm)),
);
// Send failure event
event_tx
.send((
job_id,
SseEvent::JobResult {
job_id: job_id.to_string(),
status: "failed".to_string(),
session_id: None,
fallback_deliverable: None,
},
))
.unwrap();
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), inject_rx.recv()).await;
tokio::time::timeout(std::time::Duration::from_secs(1), handle)
.await
.expect("monitor should exit")
.expect("monitor should not panic");
let ctx = cm.get_context(job_id).await.unwrap();
assert_eq!(ctx.state, JobState::Failed);
}
// === Regression: completion watcher (no route metadata) ===
// When monitor_route_from_ctx() returns None, spawn_completion_watcher
// must still transition the job so the max_jobs slot is freed.
#[tokio::test]
async fn test_completion_watcher_transitions_on_result() {
use crate::context::{ContextManager, JobState};
let cm = Arc::new(ContextManager::new(5));
let job_id = Uuid::new_v4();
cm.register_sandbox_job(job_id, "user-1", "Build app", "desc")
.await
.unwrap();
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
let handle = spawn_completion_watcher(job_id, event_tx.subscribe(), Arc::clone(&cm));
event_tx
.send((
job_id,
SseEvent::JobResult {
job_id: job_id.to_string(),
status: "completed".to_string(),
session_id: None,
fallback_deliverable: None,
},
))
.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(1), handle)
.await
.expect("watcher should exit")
.expect("watcher should not panic");
let ctx = cm.get_context(job_id).await.unwrap();
assert_eq!(ctx.state, JobState::Completed);
}
}
+2 -2
View File
@@ -39,8 +39,8 @@ pub use context_monitor::{CompactionStrategy, ContextBreakdown, ContextMonitor};
pub use heartbeat::{HeartbeatConfig, HeartbeatResult, HeartbeatRunner, spawn_heartbeat};
pub use router::{MessageIntent, Router};
pub use routine::{Routine, RoutineAction, RoutineRun, Trigger};
pub use routine_engine::RoutineEngine;
pub use scheduler::Scheduler;
pub use routine_engine::{RoutineEngine, SandboxReadiness};
pub use scheduler::{Scheduler, SchedulerDeps};
pub use self_repair::{BrokenTool, RepairResult, RepairTask, SelfRepair, StuckJob};
pub use session::{PendingApproval, PendingAuth, Session, Thread, ThreadState, Turn, TurnState};
pub use session_manager::SessionManager;
+139 -27
View File
@@ -79,6 +79,13 @@ pub enum Trigger {
#[serde(default)]
filters: std::collections::HashMap<String, String>,
},
/// Fire on incoming webhook POST to /api/webhooks/{path}.
Webhook {
/// Optional webhook path suffix (defaults to routine id).
path: Option<String>,
/// Optional shared secret for HMAC validation.
secret: Option<String>,
},
/// Only fires via tool call or CLI.
Manual,
}
@@ -90,6 +97,7 @@ impl Trigger {
Trigger::Cron { .. } => "cron",
Trigger::Event { .. } => "event",
Trigger::SystemEvent { .. } => "system_event",
Trigger::Webhook { .. } => "webhook",
Trigger::Manual => "manual",
}
}
@@ -171,6 +179,17 @@ impl Trigger {
filters,
})
}
"webhook" => {
let path = config
.get("path")
.and_then(|v| v.as_str())
.map(String::from);
let secret = config
.get("secret")
.and_then(|v| v.as_str())
.map(String::from);
Ok(Trigger::Webhook { path, secret })
}
"manual" => Ok(Trigger::Manual),
other => Err(RoutineError::UnknownTriggerType {
trigger_type: other.to_string(),
@@ -198,6 +217,10 @@ impl Trigger {
"event_type": event_type,
"filters": filters,
}),
Trigger::Webhook { path, secret } => serde_json::json!({
"path": path,
"secret": secret,
}),
Trigger::Manual => serde_json::json!({}),
}
}
@@ -235,11 +258,6 @@ pub enum RoutineAction {
/// Max reasoning iterations (default: 10).
#[serde(default = "default_max_iterations")]
max_iterations: u32,
/// Tool names pre-authorized for `Always`-approval tools (e.g. destructive
/// shell commands, cross-channel messaging). `UnlessAutoApproved` tools are
/// automatically permitted in routine jobs without listing them here.
#[serde(default)]
tool_permissions: Vec<String>,
},
}
@@ -264,19 +282,6 @@ fn clamp_max_tool_rounds(value: u64) -> u32 {
value.clamp(1, MAX_TOOL_ROUNDS_LIMIT as u64) as u32
}
/// Parse a `tool_permissions` JSON array into a `Vec<String>`.
pub fn parse_tool_permissions(value: &serde_json::Value) -> Vec<String> {
value
.get("tool_permissions")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
impl RoutineAction {
/// The string tag stored in the DB action_type column.
pub fn type_tag(&self) -> &'static str {
@@ -351,12 +356,10 @@ impl RoutineAction {
.and_then(|v| v.as_u64())
.unwrap_or(default_max_iterations() as u64)
as u32;
let tool_permissions = parse_tool_permissions(&config);
Ok(RoutineAction::FullJob {
title,
description,
max_iterations,
tool_permissions,
})
}
other => Err(RoutineError::UnknownActionType {
@@ -385,12 +388,10 @@ impl RoutineAction {
title,
description,
max_iterations,
tool_permissions,
} => serde_json::json!({
"title": title,
"description": description,
"max_iterations": max_iterations,
"tool_permissions": tool_permissions,
}),
}
}
@@ -516,16 +517,36 @@ pub fn content_hash(content: &str) -> u64 {
hasher.finish()
}
/// Normalize a cron expression to the 7-field format expected by the `cron` crate.
///
/// The `cron` crate requires: `sec min hour day-of-month month day-of-week year`.
/// Standard cron uses 5 fields: `min hour day-of-month month day-of-week`.
/// This function auto-expands:
/// - 5-field → prepend `0` (seconds) and append `*` (year)
/// - 6-field → append `*` (year)
/// - 7-field → pass through unchanged
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),
_ => trimmed.to_string(),
}
}
/// Parse a cron expression and compute the next fire time from now.
///
/// Accepts standard 5-field, 6-field, or 7-field cron expressions (auto-normalized).
/// When `timezone` is provided and valid, the schedule is evaluated in that
/// timezone and the result is converted back to UTC. Otherwise UTC is used.
pub fn next_cron_fire(
schedule: &str,
timezone: Option<&str>,
) -> Result<Option<DateTime<Utc>>, RoutineError> {
let normalized = normalize_cron_expression(schedule);
let cron_schedule =
cron::Schedule::from_str(schedule).map_err(|e| RoutineError::InvalidCron {
cron::Schedule::from_str(&normalized).map_err(|e| RoutineError::InvalidCron {
reason: e.to_string(),
})?;
if let Some(tz) = timezone.and_then(crate::timezone::parse_timezone) {
@@ -705,7 +726,7 @@ pub fn describe_cron(schedule: &str, timezone: Option<&str>) -> String {
mod tests {
use crate::agent::routine::{
MAX_TOOL_ROUNDS_LIMIT, RoutineAction, RoutineGuardrails, RunStatus, Trigger, content_hash,
describe_cron, next_cron_fire,
describe_cron, next_cron_fire, normalize_cron_expression,
};
#[test]
@@ -772,13 +793,47 @@ mod tests {
title: "Deploy review".to_string(),
description: "Review and deploy pending changes".to_string(),
max_iterations: 5,
tool_permissions: vec!["shell".to_string()],
};
let json = action.to_config_json();
let parsed = RoutineAction::from_db("full_job", json).expect("parse full_job");
assert!(
matches!(parsed, RoutineAction::FullJob { title, max_iterations, tool_permissions, .. }
if title == "Deploy review" && max_iterations == 5 && tool_permissions == vec!["shell".to_string()])
matches!(parsed, RoutineAction::FullJob { title, max_iterations, .. }
if title == "Deploy review"
&& max_iterations == 5)
);
}
#[test]
fn test_action_full_job_ignores_legacy_permission_fields() {
let parsed = RoutineAction::from_db(
"full_job",
serde_json::json!({
"title": "Deploy review",
"description": "Review and deploy pending changes",
"max_iterations": 5,
"tool_permissions": ["shell"],
"permission_mode": "inherit_owner"
}),
)
.expect("parse full_job");
assert!(matches!(
parsed,
RoutineAction::FullJob {
ref title,
ref description,
max_iterations,
..
} if title == "Deploy review"
&& description == "Review and deploy pending changes"
&& max_iterations == 5
));
assert_eq!(
parsed.to_config_json(),
serde_json::json!({
"title": "Deploy review",
"description": "Review and deploy pending changes",
"max_iterations": 5,
})
);
}
@@ -930,9 +985,66 @@ mod tests {
.type_tag(),
"system_event"
);
assert_eq!(
Trigger::Webhook {
path: None,
secret: None,
}
.type_tag(),
"webhook"
);
assert_eq!(Trigger::Manual.type_tag(), "manual");
}
#[test]
fn test_normalize_cron_5_field() {
// Standard cron: min hour dom month dow
assert_eq!(normalize_cron_expression("0 9 * * 1"), "0 0 9 * * 1 *");
assert_eq!(
normalize_cron_expression("0 9 * * MON-FRI"),
"0 0 9 * * MON-FRI *"
);
}
#[test]
fn test_normalize_cron_6_field() {
// 6-field: sec min hour dom month dow
assert_eq!(
normalize_cron_expression("0 0 9 * * MON-FRI"),
"0 0 9 * * MON-FRI *"
);
}
#[test]
fn test_normalize_cron_7_field_passthrough() {
// Already 7-field: no change
assert_eq!(
normalize_cron_expression("0 0 9 * * MON-FRI *"),
"0 0 9 * * MON-FRI *"
);
}
#[test]
fn test_next_cron_fire_5_field_accepted() {
// Standard 5-field cron should now work through normalization
let result = next_cron_fire("0 9 * * 1", None);
assert!(
result.is_ok(),
"5-field cron should be accepted: {result:?}"
);
assert!(result.unwrap().is_some());
}
#[test]
fn test_next_cron_fire_5_field_with_timezone() {
let result = next_cron_fire("0 9 * * MON-FRI", Some("America/New_York"));
assert!(
result.is_ok(),
"5-field cron with timezone should be accepted: {result:?}"
);
assert!(result.unwrap().is_some());
}
#[test]
fn test_action_lightweight_backward_compat_no_use_tools() {
// Simulate old DB record without use_tools field
+320 -67
View File
@@ -29,11 +29,13 @@ use crate::config::RoutineConfig;
use crate::context::{JobContext, JobState};
use crate::db::Database;
use crate::error::RoutineError;
use crate::extensions::ExtensionManager;
use crate::llm::{
ChatMessage, CompletionRequest, FinishReason, LlmProvider, ToolCall, ToolCompletionRequest,
};
use crate::tools::{
ApprovalContext, ApprovalRequirement, ToolError, ToolRegistry, prepare_tool_params,
ToolError, ToolRegistry, autonomous_allowed_tool_names, autonomous_unavailable_message,
prepare_tool_params,
};
use crate::workspace::Workspace;
use ironclaw_safety::SafetyLayer;
@@ -43,6 +45,17 @@ enum EventMatcher {
System { routine: Routine },
}
/// Distinguishes why sandbox is unavailable so error messages are accurate.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SandboxReadiness {
/// Docker is available and sandbox is enabled.
Available,
/// User explicitly disabled sandboxing (SANDBOX_ENABLED=false).
DisabledByConfig,
/// Sandbox is enabled but Docker is not running or not installed.
DockerUnavailable,
}
/// The routine execution engine.
pub struct RoutineEngine {
config: RoutineConfig,
@@ -57,10 +70,14 @@ pub struct RoutineEngine {
event_cache: Arc<RwLock<Vec<EventMatcher>>>,
/// Scheduler for dispatching jobs (FullJob mode).
scheduler: Option<Arc<Scheduler>>,
/// Owner-scoped extension activation state for autonomous tool resolution.
extension_manager: Option<Arc<ExtensionManager>>,
/// Tool registry for lightweight routine tool execution.
tools: Arc<ToolRegistry>,
/// Safety layer for tool output sanitization.
safety: Arc<SafetyLayer>,
/// Sandbox readiness state for full-job dispatch.
sandbox_readiness: SandboxReadiness,
/// Timestamp when this engine instance was created. Used by
/// `sync_dispatched_runs` to distinguish orphaned runs (from a previous
/// process) from actively-watched runs (from this process).
@@ -76,8 +93,10 @@ impl RoutineEngine {
workspace: Arc<Workspace>,
notify_tx: mpsc::Sender<OutgoingResponse>,
scheduler: Option<Arc<Scheduler>>,
extension_manager: Option<Arc<ExtensionManager>>,
tools: Arc<ToolRegistry>,
safety: Arc<SafetyLayer>,
sandbox_readiness: SandboxReadiness,
) -> Self {
Self {
config,
@@ -88,8 +107,10 @@ impl RoutineEngine {
running_count: Arc::new(AtomicUsize::new(0)),
event_cache: Arc::new(RwLock::new(Vec::new())),
scheduler,
extension_manager,
tools,
safety,
sandbox_readiness,
boot_time: Utc::now(),
}
}
@@ -686,8 +707,95 @@ impl RoutineEngine {
notify_tx: self.notify_tx.clone(),
running_count: self.running_count.clone(),
scheduler: self.scheduler.clone(),
extension_manager: self.extension_manager.clone(),
tools: self.tools.clone(),
safety: self.safety.clone(),
sandbox_readiness: self.sandbox_readiness,
};
tokio::spawn(async move {
execute_routine(engine, routine, run).await;
});
Ok(run_id)
}
/// Fire a routine from a webhook trigger.
///
/// Similar to `fire_manual` but records the trigger as `"webhook"` with the
/// webhook path as detail. Skips ownership check (auth is via webhook secret).
/// Enforces enabled check, cooldown, and concurrent run limit.
pub async fn fire_webhook(
&self,
routine_id: Uuid,
webhook_path: &str,
) -> Result<Uuid, RoutineError> {
let routine = self
.store
.get_routine(routine_id)
.await
.map_err(|e| RoutineError::Database {
reason: e.to_string(),
})?
.ok_or(RoutineError::NotFound { id: routine_id })?;
if !routine.enabled {
return Err(RoutineError::Disabled {
name: routine.name.clone(),
});
}
if !self.check_cooldown(&routine) {
return Err(RoutineError::Cooldown {
name: routine.name.clone(),
});
}
if !self.check_concurrent(&routine).await {
return Err(RoutineError::MaxConcurrent {
name: routine.name.clone(),
});
}
if self.running_count.load(Ordering::Relaxed) >= self.config.max_concurrent_routines {
return Err(RoutineError::MaxConcurrent {
name: routine.name.clone(),
});
}
let run_id = Uuid::new_v4();
let run = RoutineRun {
id: run_id,
routine_id: routine.id,
trigger_type: "webhook".to_string(),
trigger_detail: Some(webhook_path.to_string()),
started_at: Utc::now(),
completed_at: None,
status: RunStatus::Running,
result_summary: None,
tokens_used: None,
job_id: None,
created_at: Utc::now(),
};
if let Err(e) = self.store.create_routine_run(&run).await {
return Err(RoutineError::Database {
reason: format!("failed to create run record: {e}"),
});
}
let engine = EngineContext {
config: self.config.clone(),
store: self.store.clone(),
llm: self.llm.clone(),
workspace: self.workspace.clone(),
notify_tx: self.notify_tx.clone(),
running_count: self.running_count.clone(),
scheduler: self.scheduler.clone(),
extension_manager: self.extension_manager.clone(),
tools: self.tools.clone(),
safety: self.safety.clone(),
sandbox_readiness: self.sandbox_readiness,
};
tokio::spawn(async move {
@@ -721,8 +829,10 @@ impl RoutineEngine {
notify_tx: self.notify_tx.clone(),
running_count: self.running_count.clone(),
scheduler: self.scheduler.clone(),
extension_manager: self.extension_manager.clone(),
tools: self.tools.clone(),
safety: self.safety.clone(),
sandbox_readiness: self.sandbox_readiness,
};
// Record the run in DB, then spawn execution
@@ -857,8 +967,10 @@ struct EngineContext {
notify_tx: mpsc::Sender<OutgoingResponse>,
running_count: Arc<AtomicUsize>,
scheduler: Option<Arc<Scheduler>>,
extension_manager: Option<Arc<ExtensionManager>>,
tools: Arc<ToolRegistry>,
safety: Arc<SafetyLayer>,
sandbox_readiness: SandboxReadiness,
}
/// Execute a routine run. Handles both lightweight and full_job modes.
@@ -889,18 +1001,13 @@ async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun)
title,
description,
max_iterations,
tool_permissions,
} => {
execute_full_job(
&ctx,
&routine,
&run,
let execution = FullJobExecutionConfig {
title,
description,
*max_iterations,
tool_permissions,
)
.await
max_iterations: *max_iterations,
};
execute_full_job(&ctx, &routine, &run, &execution).await
}
};
@@ -1026,15 +1133,36 @@ fn sanitize_routine_name(name: &str) -> String {
/// non-active state (not Pending/InProgress/Stuck). Returns the final
/// `RunStatus` mapped from the job outcome. This keeps the routine run
/// active for the full job lifetime so concurrency guardrails apply.
struct FullJobExecutionConfig<'a> {
title: &'a str,
description: &'a str,
max_iterations: u32,
}
async fn execute_full_job(
ctx: &EngineContext,
routine: &Routine,
run: &RoutineRun,
title: &str,
description: &str,
max_iterations: u32,
tool_permissions: &[String],
execution: &FullJobExecutionConfig<'_>,
) -> Result<(RunStatus, Option<String>, Option<i32>), RoutineError> {
match ctx.sandbox_readiness {
SandboxReadiness::Available => {}
SandboxReadiness::DisabledByConfig => {
return Err(RoutineError::JobDispatchFailed {
reason: "Sandboxing is disabled (SANDBOX_ENABLED=false). \
Full-job routines require sandbox."
.to_string(),
});
}
SandboxReadiness::DockerUnavailable => {
return Err(RoutineError::JobDispatchFailed {
reason: "Sandbox is enabled but Docker is not available. \
Install Docker or set SANDBOX_ENABLED=false."
.to_string(),
});
}
}
let scheduler = ctx
.scheduler
.as_ref()
@@ -1042,8 +1170,10 @@ async fn execute_full_job(
reason: "scheduler not available".to_string(),
})?;
let mut metadata =
serde_json::json!({ "max_iterations": max_iterations, "owner_id": routine.user_id });
let mut metadata = serde_json::json!({
"max_iterations": execution.max_iterations,
"owner_id": routine.user_id
});
// Carry the routine's notify config in job metadata so the message tool
// can resolve channel/target per-job without global state mutation.
if let Some(channel) = &routine.notify.channel {
@@ -1051,17 +1181,12 @@ async fn execute_full_job(
}
metadata["notify_user"] = serde_json::json!(&routine.notify.user);
// Build approval context: UnlessAutoApproved tools are auto-approved for routines;
// Always tools require explicit listing in tool_permissions.
let approval_context = ApprovalContext::autonomous_with_tools(tool_permissions.iter().cloned());
let job_id = scheduler
.dispatch_job_with_context(
.dispatch_job(
&routine.user_id,
title,
description,
execution.title,
execution.description,
Some(metadata),
approval_context,
)
.await
.map_err(|e| RoutineError::JobDispatchFailed {
@@ -1082,7 +1207,7 @@ async fn execute_full_job(
tracing::info!(
routine = %routine.name,
job_id = %job_id,
max_iterations = max_iterations,
max_iterations = execution.max_iterations,
"Dispatched full job for routine, watching for completion"
);
@@ -1350,6 +1475,9 @@ async fn execute_lightweight_with_tools(
description: routine.name.clone(),
..Default::default()
};
let allowed_tools =
autonomous_allowed_tool_names(&ctx.tools, ctx.extension_manager.as_ref(), &routine.user_id)
.await;
loop {
iteration += 1;
@@ -1384,8 +1512,11 @@ async fn execute_lightweight_with_tools(
// Tool-enabled iteration
let tool_defs = ctx
.tools
.tool_definitions_excluding(ROUTINE_TOOL_DENYLIST)
.await;
.tool_definitions()
.await
.into_iter()
.filter(|tool| allowed_tools.contains(&tool.name))
.collect();
let request_messages = snapshot_messages_for_tool_iteration(&messages);
let request = ToolCompletionRequest::new(request_messages, tool_defs)
@@ -1420,7 +1551,7 @@ async fn execute_lightweight_with_tools(
// Execute tools sequentially
for tc in response.tool_calls {
let result = execute_routine_tool(ctx, &job_ctx, &tc).await;
let result = execute_routine_tool(ctx, &job_ctx, &allowed_tools, &tc).await;
// Sanitize and wrap result (including errors)
let result_content = match result {
@@ -1489,31 +1620,16 @@ fn snapshot_messages_for_tool_iteration(messages: &[ChatMessage]) -> Vec<ChatMes
snapshot
}
/// Tools that must never be callable from lightweight routines.
///
/// These tools pose autonomy-escalation risks: a routine could self-replicate,
/// modify its own triggers/prompts, delete other routines, or restart the agent.
const ROUTINE_TOOL_DENYLIST: &[&str] = &[
"routine_create",
"routine_update",
"routine_delete",
"routine_fire",
"restart",
];
/// Execute a single tool for a lightweight routine.
async fn execute_routine_tool(
ctx: &EngineContext,
job_ctx: &JobContext,
allowed_tools: &std::collections::HashSet<String>,
tc: &ToolCall,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
// Block tools that pose autonomy-escalation risks
if ROUTINE_TOOL_DENYLIST.contains(&tc.name.as_str()) {
return Err(format!(
"Tool '{}' is not available in lightweight routines",
tc.name
)
.into());
if !allowed_tools.contains(&tc.name) {
let message = autonomous_unavailable_message(&tc.name, &job_ctx.user_id);
return Err(message.into());
}
// Check if tool exists
@@ -1524,22 +1640,6 @@ async fn execute_routine_tool(
.ok_or_else(|| format!("Tool '{}' not found", tc.name))?;
let normalized_params = prepare_tool_params(tool.as_ref(), &tc.arguments);
// Check approval requirement: only allow Never tools in lightweight routines.
// UnlessAutoApproved and Always tools are blocked to prevent prompt injection attacks.
// Lightweight routines can be triggered by external events and may process untrusted data,
// making them vulnerable to prompt injection that could trick the LLM into calling
// sensitive tools. Blocking these tools entirely is the safest approach.
match tool.requires_approval(&normalized_params) {
ApprovalRequirement::Never => {}
ApprovalRequirement::UnlessAutoApproved | ApprovalRequirement::Always => {
return Err(format!(
"Tool '{}' requires manual approval and cannot be used in lightweight routines",
tc.name
)
.into());
}
}
// Validate tool parameters
let validation = ctx
.safety
@@ -1680,6 +1780,7 @@ pub fn spawn_cron_ticker(
// never races with FullJobWatcher instances from this process.
engine.sync_dispatched_runs().await;
engine.check_cron_triggers().await;
engine.sync_dispatched_runs().await;
}
})
}
@@ -1693,6 +1794,56 @@ fn truncate(s: &str, max: usize) -> String {
}
}
/// Sanitize a summary string from job transitions before using in notifications.
///
/// `last_reason` comes from untrusted container code, so we:
/// 1. Strip control characters (except newline) to prevent terminal injection
/// 2. Strip HTML tags to prevent injection in web-rendered notifications
/// 3. Collapse multiple whitespace/newlines to single spaces for cleaner output
/// 4. Truncate to 500 chars to prevent oversized notifications
#[cfg(test)]
fn sanitize_summary(s: &str) -> String {
// Strip control characters (keep newline for now, collapse later)
let no_control: String = s
.chars()
.filter(|c| !c.is_control() || *c == '\n')
.collect();
// Strip HTML tags (e.g. <script>, <img>, <a href=...>)
let no_html = strip_html_tags(&no_control);
// Collapse whitespace: multiple spaces/newlines become a single space
let collapsed: String = no_html.split_whitespace().collect::<Vec<_>>().join(" ");
// Truncate to reasonable length
if collapsed.len() <= 500 {
collapsed
} else {
// Find a safe char boundary for truncation
let mut end = 500;
while !collapsed.is_char_boundary(end) && end > 0 {
end -= 1;
}
format!("{}...", &collapsed[..end])
}
}
/// Remove HTML/XML tags from a string.
#[cfg(test)]
fn strip_html_tags(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut in_tag = false;
for c in s.chars() {
match c {
'<' => in_tag = true,
'>' if in_tag => in_tag = false,
_ if !in_tag => result.push(c),
_ => {}
}
}
result
}
#[cfg(test)]
mod tests {
use crate::agent::routine::{NotifyConfig, RunStatus};
@@ -1904,8 +2055,8 @@ mod tests {
];
for tool in &denylisted {
assert!(
super::ROUTINE_TOOL_DENYLIST.contains(tool),
"Tool '{}' should be in ROUTINE_TOOL_DENYLIST",
crate::tools::AUTONOMOUS_TOOL_DENYLIST.contains(tool),
"Tool '{}' should be in AUTONOMOUS_TOOL_DENYLIST",
tool
);
}
@@ -1916,8 +2067,8 @@ mod tests {
let allowed = vec!["echo", "time", "json", "http", "memory_search", "shell"];
for tool in &allowed {
assert!(
!super::ROUTINE_TOOL_DENYLIST.contains(tool),
"Tool '{}' should NOT be in ROUTINE_TOOL_DENYLIST",
!crate::tools::AUTONOMOUS_TOOL_DENYLIST.contains(tool),
"Tool '{}' should NOT be in AUTONOMOUS_TOOL_DENYLIST",
tool
);
}
@@ -1974,6 +2125,62 @@ mod tests {
assert_eq!(snapshot[2].content, "b"); // safety: test-only no-panics CI false positive
}
#[test]
fn test_running_status_does_not_notify() {
let config = NotifyConfig {
on_success: true,
on_failure: true,
on_attention: true,
..Default::default()
};
let should_notify = match RunStatus::Running {
RunStatus::Ok => config.on_success,
RunStatus::Attention => config.on_attention,
RunStatus::Failed => config.on_failure,
RunStatus::Running => false,
};
assert!(!should_notify);
}
#[test]
fn test_full_job_dispatch_returns_running_status() {
assert_eq!(RunStatus::Running.to_string(), "running");
}
#[test]
fn test_sandbox_readiness_disabled_by_config_error() {
use super::SandboxReadiness;
let readiness = SandboxReadiness::DisabledByConfig;
assert_ne!(readiness, SandboxReadiness::Available);
let err = crate::error::RoutineError::JobDispatchFailed {
reason: "Sandboxing is disabled (SANDBOX_ENABLED=false). \
Full-job routines require sandbox."
.to_string(),
};
let msg = err.to_string();
assert!(msg.contains("SANDBOX_ENABLED=false"));
assert!(msg.contains("require sandbox"));
}
#[test]
fn test_sandbox_readiness_docker_unavailable_error() {
use super::SandboxReadiness;
let readiness = SandboxReadiness::DockerUnavailable;
assert_ne!(readiness, SandboxReadiness::Available);
let err = crate::error::RoutineError::JobDispatchFailed {
reason: "Sandbox is enabled but Docker is not available. \
Install Docker or set SANDBOX_ENABLED=false."
.to_string(),
};
let msg = err.to_string();
assert!(msg.contains("Docker is not available"));
assert!(msg.contains("SANDBOX_ENABLED"));
}
/// Regression test for #1317: FullJobWatcher maps terminal job states correctly.
#[test]
fn test_full_job_watcher_state_mapping() {
@@ -2055,4 +2262,50 @@ mod tests {
);
}
}
#[test]
fn test_sanitize_summary_strips_control_chars() {
use super::sanitize_summary;
// Preserves normal text
assert_eq!(sanitize_summary("Job completed"), "Job completed");
// Strips control characters and collapses whitespace
assert_eq!(
sanitize_summary("line1\nline2\x00\x1b[31mred"),
"line1 line2[31mred"
);
// Truncates long strings
let long = "x".repeat(600);
let result = sanitize_summary(&long);
assert!(result.len() <= 503); // 500 + "..."
assert!(result.ends_with("..."));
}
#[test]
fn test_sanitize_summary_strips_html() {
use super::sanitize_summary;
assert_eq!(
sanitize_summary("Hello <script>alert('xss')</script> world"),
"Hello alert('xss') world"
);
assert_eq!(
sanitize_summary("<b>bold</b> and <a href=\"evil\">link</a>"),
"bold and link"
);
assert_eq!(sanitize_summary("<img src=x onerror=alert(1)>"), "");
}
#[test]
fn test_sanitize_summary_multibyte_truncation() {
use super::sanitize_summary;
// Ensure truncation doesn't panic on multi-byte chars near the boundary
let s = "a".repeat(498) + "\u{1F600}\u{1F600}"; // 498 + two 4-byte emoji
let result = sanitize_summary(&s);
assert!(result.len() <= 503);
assert!(result.ends_with("..."));
}
}
+58 -19
View File
@@ -14,10 +14,14 @@ use crate::config::AgentConfig;
use crate::context::{ContextManager, JobContext, JobState};
use crate::db::Database;
use crate::error::{Error, JobError};
use crate::extensions::ExtensionManager;
use crate::hooks::HookRegistry;
use crate::llm::LlmProvider;
use crate::safety::SafetyLayer;
use crate::tools::{ApprovalContext, ToolRegistry, prepare_tool_params};
use crate::tools::{
ApprovalContext, ToolRegistry, autonomous_allowed_tool_names, autonomous_unavailable_error,
prepare_tool_params,
};
use crate::worker::job::{Worker, WorkerDeps};
/// Message to send to a worker.
@@ -45,6 +49,14 @@ struct ScheduledSubtask {
handle: JoinHandle<Result<TaskOutput, Error>>,
}
/// Shared scheduler-owned dependencies that are forwarded into autonomous runs.
pub struct SchedulerDeps {
pub tools: Arc<ToolRegistry>,
pub extension_manager: Option<Arc<ExtensionManager>>,
pub store: Option<Arc<dyn Database>>,
pub hooks: Arc<HookRegistry>,
}
/// Schedules and manages parallel job execution.
pub struct Scheduler {
config: AgentConfig,
@@ -52,6 +64,7 @@ pub struct Scheduler {
llm: Arc<dyn LlmProvider>,
safety: Arc<SafetyLayer>,
tools: Arc<ToolRegistry>,
extension_manager: Option<Arc<ExtensionManager>>,
store: Option<Arc<dyn Database>>,
hooks: Arc<HookRegistry>,
/// SSE broadcast sender for live job event streaming.
@@ -71,18 +84,17 @@ impl Scheduler {
context_manager: Arc<ContextManager>,
llm: Arc<dyn LlmProvider>,
safety: Arc<SafetyLayer>,
tools: Arc<ToolRegistry>,
store: Option<Arc<dyn Database>>,
hooks: Arc<HookRegistry>,
deps: SchedulerDeps,
) -> Self {
Self {
config,
context_manager,
llm,
safety,
tools,
store,
hooks,
tools: deps.tools,
extension_manager: deps.extension_manager,
store: deps.store,
hooks: deps.hooks,
sse_tx: None,
http_interceptor: None,
jobs: Arc::new(RwLock::new(HashMap::new())),
@@ -120,14 +132,21 @@ impl Scheduler {
description: &str,
metadata: Option<serde_json::Value>,
) -> Result<Uuid, JobError> {
self.dispatch_job_inner(user_id, title, description, metadata, None)
.await
let approval_context = self.autonomous_approval_context(user_id).await;
self.dispatch_job_inner(
user_id,
title,
description,
metadata,
Some(approval_context),
)
.await
}
/// Dispatch a job with an explicit approval context for autonomous execution.
///
/// Same as `dispatch_job`, but the worker will use the given `ApprovalContext`
/// to determine which tools are pre-approved (instead of blocking all non-`Never` tools).
/// to determine the explicit autonomous allowlist for that job.
pub async fn dispatch_job_with_context(
&self,
user_id: &str,
@@ -216,6 +235,13 @@ impl Scheduler {
Ok(job_id)
}
async fn autonomous_approval_context(&self, user_id: &str) -> ApprovalContext {
ApprovalContext::autonomous_with_tools(
autonomous_allowed_tool_names(&self.tools, self.extension_manager.as_ref(), user_id)
.await,
)
}
/// Schedule a job for execution.
pub async fn schedule(&self, job_id: Uuid) -> Result<(), JobError> {
self.schedule_with_context(job_id, None).await
@@ -518,10 +544,7 @@ impl Scheduler {
let blocked =
ApprovalContext::is_blocked_or_default(&approval_context, tool_name, requirement);
if blocked {
return Err(crate::error::ToolError::AuthRequired {
name: tool_name.to_string(),
}
.into());
return Err(autonomous_unavailable_error(tool_name, &job_ctx.user_id).into());
}
// Delegate to shared tool execution pipeline
@@ -776,7 +799,18 @@ mod tests {
let tools = Arc::new(ToolRegistry::new());
let hooks = Arc::new(HookRegistry::default());
Scheduler::new(config, cm, llm, safety, tools, None, hooks)
Scheduler::new(
config,
cm,
llm,
safety,
SchedulerDeps {
tools,
extension_manager: None,
store: None,
hooks,
},
)
}
#[tokio::test]
@@ -1003,12 +1037,14 @@ mod tests {
async fn test_execute_tool_task_autonomous_unblocks_soft() {
let (tools, cm, safety, job_id) = setup_tools_and_job().await;
// Autonomous context auto-approves UnlessAutoApproved
// Autonomous execution only allows tools explicitly in scope.
let result = Scheduler::execute_tool_task(
tools.clone(),
cm.clone(),
safety.clone(),
Some(ApprovalContext::autonomous()),
Some(ApprovalContext::autonomous_with_tools([
"soft_gate".to_string()
])),
job_id,
"soft_gate",
serde_json::json!({}),
@@ -1040,8 +1076,11 @@ mod tests {
async fn test_execute_tool_task_autonomous_with_permissions() {
let (tools, cm, safety, job_id) = setup_tools_and_job().await;
// Autonomous context with explicit permission for hard_gate
let ctx = ApprovalContext::autonomous_with_tools(["hard_gate".to_string()]);
// Autonomous context with explicit permission for both tools.
let ctx = ApprovalContext::autonomous_with_tools([
"soft_gate".to_string(),
"hard_gate".to_string(),
]);
let result = Scheduler::execute_tool_task(
tools.clone(),
+137 -41
View File
@@ -66,6 +66,7 @@ pub trait SelfRepair: Send + Sync {
/// Default self-repair implementation.
pub struct DefaultSelfRepair {
context_manager: Arc<ContextManager>,
/// Jobs in `InProgress` longer than this are treated as stuck.
stuck_threshold: Duration,
max_repair_attempts: u32,
store: Option<Arc<dyn Database>>,
@@ -111,15 +112,58 @@ impl DefaultSelfRepair {
#[async_trait]
impl SelfRepair for DefaultSelfRepair {
async fn detect_stuck_jobs(&self) -> Vec<StuckJob> {
let stuck_ids = self.context_manager.find_stuck_jobs().await;
let stuck_ids = self
.context_manager
.find_stuck_jobs_with_threshold(Some(self.stuck_threshold))
.await;
let mut stuck_jobs = Vec::new();
for job_id in stuck_ids {
if let Ok(ctx) = self.context_manager.get_context(job_id).await
&& ctx.state == JobState::Stuck
&& matches!(ctx.state, JobState::Stuck | JobState::InProgress)
{
// Measure stuck_duration from the most recent Stuck transition,
// not from started_at (which reflects when the job first ran).
// InProgress jobs detected by threshold need to be transitioned
// to Stuck before they can be repaired (attempt_recovery requires
// Stuck state). These jobs already passed the threshold check in
// find_stuck_jobs_with_threshold, so skip the duration filter below.
let just_transitioned = ctx.state == JobState::InProgress;
if just_transitioned {
let reason = "exceeded stuck_threshold";
let transition = self
.context_manager
.update_context(job_id, |ctx| ctx.mark_stuck(reason))
.await;
match transition {
Ok(Ok(())) => {}
Ok(Err(e)) => {
tracing::warn!(
job = %job_id,
"Failed to mark InProgress job as Stuck: {}",
e
);
continue;
}
Err(e) => {
tracing::warn!(
job = %job_id,
"Failed to transition InProgress job to Stuck: {}",
e
);
continue;
}
}
}
// Re-fetch context after potential InProgress->Stuck transition
// so that stuck_since picks up the new transition timestamp.
let ctx = match self.context_manager.get_context(job_id).await {
Ok(c) => c,
Err(_) => continue,
};
// Use the timestamp of the most recent Stuck transition, not started_at.
// A job that ran for hours before becoming stuck should not immediately
// exceed the threshold — we measure from when it actually became stuck.
let stuck_since = ctx
.transitions
.iter()
@@ -134,8 +178,10 @@ impl SelfRepair for DefaultSelfRepair {
})
.unwrap_or_default();
// Only report jobs that have been stuck long enough
if stuck_duration < self.stuck_threshold {
// Only report already-Stuck jobs that have been stuck long enough.
// Jobs just transitioned from InProgress skip this check — they
// were already vetted by find_stuck_jobs_with_threshold.
if !just_transitioned && stuck_duration < self.stuck_threshold {
continue;
}
@@ -163,10 +209,17 @@ impl SelfRepair for DefaultSelfRepair {
});
}
// Try to recover the job
// Try to recover the job.
// If the job is still InProgress (detected via stuck_threshold), transition
// it to Stuck first so that attempt_recovery() can move it back to InProgress.
let result = self
.context_manager
.update_context(job.job_id, |ctx| ctx.attempt_recovery())
.update_context(job.job_id, |ctx| {
if ctx.state == JobState::InProgress {
ctx.transition_to(JobState::Stuck, Some("exceeded stuck_threshold".into()))?;
}
ctx.attempt_recovery()
})
.await;
match result {
@@ -489,6 +542,82 @@ mod tests {
);
}
#[tokio::test]
async fn detect_and_repair_in_progress_job_via_threshold() {
let cm = Arc::new(ContextManager::new(10));
let job_id = cm.create_job("Long running", "desc").await.unwrap();
// Transition to InProgress.
cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
.await
.unwrap()
.unwrap();
// Backdate started_at to simulate a job running for 10 minutes.
cm.update_context(job_id, |ctx| {
ctx.started_at = Some(Utc::now() - chrono::Duration::seconds(600));
})
.await
.unwrap();
// Use a 5-minute threshold so the 10-minute job is detected.
let repair = DefaultSelfRepair::new(Arc::clone(&cm), Duration::from_secs(300), 3);
// detect_stuck_jobs should find it and transition InProgress -> Stuck.
let stuck = repair.detect_stuck_jobs().await;
assert_eq!(stuck.len(), 1);
assert_eq!(stuck[0].job_id, job_id);
// After detection the job should now be in Stuck state.
let ctx = cm.get_context(job_id).await.unwrap();
assert_eq!(ctx.state, JobState::Stuck);
// Repair should recover it: Stuck -> InProgress.
let result = repair.repair_stuck_job(&stuck[0]).await.unwrap();
assert!(
matches!(result, RepairResult::Success { .. }),
"Expected Success, got: {:?}",
result
);
// Job should be back to InProgress after recovery.
let ctx = cm.get_context(job_id).await.unwrap();
assert_eq!(ctx.state, JobState::InProgress);
}
#[tokio::test]
async fn detect_broken_tools_returns_empty_without_store() {
let cm = Arc::new(ContextManager::new(10));
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(60), 3);
// No store configured, should return empty.
let broken = repair.detect_broken_tools().await;
assert!(broken.is_empty());
}
#[tokio::test]
async fn repair_broken_tool_returns_manual_without_builder() {
let cm = Arc::new(ContextManager::new(10));
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(60), 3);
let broken = BrokenTool {
name: "test-tool".to_string(),
failure_count: 10,
last_error: Some("crash".to_string()),
first_failure: Utc::now(),
last_failure: Utc::now(),
last_build_result: None,
repair_attempts: 0,
};
let result = repair.repair_broken_tool(&broken).await.unwrap();
assert!(
matches!(result, RepairResult::ManualRequired { .. }),
"Expected ManualRequired without builder, got: {:?}",
result
);
}
#[tokio::test]
async fn detect_stuck_jobs_filters_by_threshold() {
let cm = Arc::new(ContextManager::new(10));
@@ -581,39 +710,6 @@ mod tests {
);
}
#[tokio::test]
async fn detect_broken_tools_returns_empty_without_store() {
let cm = Arc::new(ContextManager::new(10));
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(60), 3);
// No store configured, should return empty.
let broken = repair.detect_broken_tools().await;
assert!(broken.is_empty());
}
#[tokio::test]
async fn repair_broken_tool_returns_manual_without_builder() {
let cm = Arc::new(ContextManager::new(10));
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(60), 3);
let broken = BrokenTool {
name: "test-tool".to_string(),
failure_count: 10,
last_error: Some("crash".to_string()),
first_failure: Utc::now(),
last_failure: Utc::now(),
last_build_result: None,
repair_attempts: 0,
};
let result = repair.repair_broken_tool(&broken).await.unwrap();
assert!(
matches!(result, RepairResult::ManualRequired { .. }),
"Expected ManualRequired without builder, got: {:?}",
result
);
}
/// Mock SoftwareBuilder that returns a successful build result.
struct MockBuilder {
build_count: std::sync::atomic::AtomicU32,
+11
View File
@@ -188,6 +188,15 @@ pub struct PendingApproval {
/// through the approval flow even if the approval message lacks timezone.
#[serde(default)]
pub user_timezone: Option<String>,
/// Whether the "always" auto-approve option should be offered to the user.
/// `false` when the tool returned `ApprovalRequirement::Always` (e.g.
/// destructive shell commands), meaning every invocation must be confirmed.
#[serde(default = "default_true")]
pub allow_always: bool,
}
fn default_true() -> bool {
true
}
/// A conversation thread within a session.
@@ -1106,6 +1115,7 @@ mod tests {
context_messages: vec![ChatMessage::user("do it")],
deferred_tool_calls: vec![],
user_timezone: None,
allow_always: false,
};
thread.await_approval(approval);
@@ -1132,6 +1142,7 @@ mod tests {
context_messages: vec![],
deferred_tool_calls: vec![],
user_timezone: None,
allow_always: true,
};
thread.await_approval(approval);
+27
View File
@@ -772,6 +772,33 @@ mod tests {
assert_ne!(resolved, tid);
}
#[tokio::test]
async fn test_register_then_resolve_same_uuid_on_second_channel_reuses_thread() {
use crate::agent::session::{Session, Thread};
let manager = SessionManager::new();
let tid = Uuid::new_v4();
let session = Arc::new(Mutex::new(Session::new("user-cross")));
{
let mut sess = session.lock().await;
let thread = Thread::with_id(tid, sess.id);
sess.threads.insert(tid, thread);
}
manager
.register_thread("user-cross", "http", tid, Arc::clone(&session))
.await;
manager
.register_thread("user-cross", "gateway", tid, Arc::clone(&session))
.await;
let (_, resolved) = manager
.resolve_thread("user-cross", "gateway", Some(&tid.to_string()))
.await;
assert_eq!(resolved, tid);
}
// === QA Plan P3 - 4.2: Concurrent session stress tests ===
#[tokio::test]
+2
View File
@@ -382,6 +382,8 @@ pub enum SubmissionResult {
description: String,
/// Parameters being passed.
parameters: serde_json::Value,
/// Whether "always" auto-approve should be offered to the user.
allow_always: bool,
},
/// Successfully processed (for control commands).
+197 -63
View File
@@ -506,7 +506,8 @@ impl Agent {
let tool_name = pending.tool_name.clone();
let description = pending.description.clone();
let parameters = pending.display_parameters.clone();
thread.await_approval(pending);
let allow_always = pending.allow_always;
thread.await_approval(*pending);
let _ = self
.channels
.send_status(
@@ -516,6 +517,7 @@ impl Agent {
tool_name: tool_name.clone(),
description: description.clone(),
parameters: parameters.clone(),
allow_always,
},
&message.metadata,
)
@@ -525,6 +527,7 @@ impl Agent {
tool_name,
description,
parameters,
allow_always,
})
}
Err(e) => {
@@ -865,7 +868,9 @@ impl Agent {
approved: bool,
always: bool,
) -> Result<SubmissionResult, Error> {
// Get pending approval for this thread
// Get pending approval for this thread.
// Hold the session lock for the entire check-take-verify-mutate sequence
// to eliminate the TOCTOU race window (fixes #1486).
let pending = {
let mut sess = session.lock().await;
let thread = sess
@@ -883,59 +888,52 @@ impl Agent {
return Ok(SubmissionResult::ok_with_message(""));
}
thread.take_pending_approval()
};
let pending = 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 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) {
// Verify request ID while still holding the lock
if let Some(req_id) = request_id
&& req_id != pending.request_id
{
// Restore the pending approval atomically without releasing the lock
thread.await_approval(pending);
return Ok(SubmissionResult::error(
"Request ID mismatch. Use the correct request ID.",
));
}
return Ok(SubmissionResult::error(
"Request ID mismatch. Use the correct request ID.",
));
}
// If approved with "always", set auto-approve while we still have the lock.
// Drop the thread borrow before calling sess methods to satisfy the borrow checker.
if approved && always {
let tool_name = pending.tool_name.clone();
sess.auto_approve_tool(&tool_name);
tracing::info!("Auto-approved tool '{}' for session {}", tool_name, sess.id);
}
// Set thread state to Processing for approved requests.
// Re-borrow the thread since the previous borrow was dropped.
if approved && let Some(thread) = sess.threads.get_mut(&thread_id) {
thread.state = ThreadState::Processing;
}
pending
};
if approved {
// If always, add to auto-approved set
if always {
let mut sess = session.lock().await;
sess.auto_approve_tool(&pending.tool_name);
tracing::info!(
"Auto-approved tool '{}' for session {}",
pending.tool_name,
sess.id
);
}
// Reset thread state to processing
{
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
thread.state = ThreadState::Processing;
}
}
// Execute the approved tool and continue the loop
let mut job_ctx =
JobContext::with_user(&message.user_id, "chat", "Interactive chat session")
.with_requester_id(&message.sender_id);
job_ctx.http_interceptor = self.deps.http_interceptor.clone();
job_ctx.metadata = crate::agent::agent_loop::chat_tool_execution_metadata(message);
// Prefer a valid timezone from the approval message, fall back to the
// resolved timezone stored when the approval was originally requested.
let tz_candidate = message
@@ -1069,28 +1067,31 @@ impl Agent {
usize,
crate::llm::ToolCall,
Arc<dyn crate::tools::Tool>,
bool, // allow_always
)> = None;
for (idx, tc) in deferred_tool_calls.iter().enumerate() {
if let Some(tool) = self.tools().get(&tc.name).await {
// Match dispatcher.rs: when auto_approve_tools is true, skip
// all approval checks (including ApprovalRequirement::Always).
let needs_approval = if self.config.auto_approve_tools {
false
let (needs_approval, allow_always) = if self.config.auto_approve_tools {
(false, true)
} else {
use crate::tools::ApprovalRequirement;
match tool.requires_approval(&tc.arguments) {
let requirement = tool.requires_approval(&tc.arguments);
let needs = match requirement {
ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => {
let sess = session.lock().await;
!sess.is_tool_auto_approved(&tc.name)
}
ApprovalRequirement::Always => true,
}
};
(needs, !matches!(requirement, ApprovalRequirement::Always))
};
if needs_approval {
approval_needed = Some((idx, tc.clone(), tool));
approval_needed = Some((idx, tc.clone(), tool, allow_always));
break; // remaining tools stay deferred
}
}
@@ -1298,7 +1299,7 @@ impl Agent {
}
// Handle approval if a tool needed it
if let Some((approval_idx, tc, tool)) = approval_needed {
if let Some((approval_idx, tc, tool, allow_always)) = approval_needed {
let new_pending = PendingApproval {
request_id: Uuid::new_v4(),
tool_name: tc.name.clone(),
@@ -1310,6 +1311,7 @@ impl Agent {
deferred_tool_calls: deferred_tool_calls[approval_idx + 1..].to_vec(),
// Carry forward the resolved timezone from the original pending approval
user_timezone: pending.user_timezone.clone(),
allow_always,
};
let request_id = new_pending.request_id;
@@ -1333,6 +1335,7 @@ impl Agent {
tool_name: tool_name.clone(),
description: description.clone(),
parameters: parameters.clone(),
allow_always,
},
&message.metadata,
)
@@ -1343,6 +1346,7 @@ impl Agent {
tool_name,
description,
parameters,
allow_always,
});
}
@@ -1411,7 +1415,8 @@ impl Agent {
let tool_name = new_pending.tool_name.clone();
let description = new_pending.description.clone();
let parameters = new_pending.display_parameters.clone();
thread.await_approval(new_pending);
let allow_always = new_pending.allow_always;
thread.await_approval(*new_pending);
let _ = self
.channels
.send_status(
@@ -1421,6 +1426,7 @@ impl Agent {
tool_name: tool_name.clone(),
description: description.clone(),
parameters: parameters.clone(),
allow_always,
},
&message.metadata,
)
@@ -1430,6 +1436,7 @@ impl Agent {
tool_name,
description,
parameters,
allow_always,
})
}
Err(e) => {
@@ -1447,17 +1454,25 @@ impl Agent {
);
{
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
thread.clear_pending_approval();
thread.complete_turn(&rejection);
// User message already persisted at turn start; save rejection response
self.persist_assistant_response(
thread_id,
&message.channel,
&message.user_id,
&rejection,
)
.await;
match sess.threads.get_mut(&thread_id) {
Some(thread) => {
thread.clear_pending_approval();
thread.complete_turn(&rejection);
// User message already persisted at turn start; save rejection response
self.persist_assistant_response(
thread_id,
&message.channel,
&message.user_id,
&rejection,
)
.await;
}
None => {
tracing::warn!(
%thread_id,
"Thread disappeared during approval rejection"
);
}
}
}
@@ -1949,6 +1964,7 @@ mod tests {
context_messages: vec![],
deferred_tool_calls: vec![],
user_timezone: None,
allow_always: false,
};
thread.await_approval(pending);
@@ -1998,6 +2014,124 @@ mod tests {
}
}
/// Regression test for #1486: verify that request_id mismatch atomically
/// restores the pending approval (no TOCTOU window where it could be lost).
#[test]
fn test_request_id_mismatch_preserves_pending_state() {
use crate::agent::session::{PendingApproval, Session, Thread, ThreadState};
use uuid::Uuid;
let session_id = Uuid::new_v4();
let thread_id = Uuid::new_v4();
let correct_request_id = Uuid::new_v4();
let wrong_request_id = Uuid::new_v4();
let mut thread = Thread::with_id(thread_id, session_id);
let pending = PendingApproval {
request_id: correct_request_id,
tool_name: "shell".to_string(),
parameters: serde_json::json!({"command": "rm -rf /"}),
display_parameters: serde_json::json!({"command": "[REDACTED]"}),
description: "Execute dangerous command".to_string(),
tool_call_id: "call_42".to_string(),
context_messages: vec![],
deferred_tool_calls: vec![],
user_timezone: None,
allow_always: false,
};
thread.await_approval(pending);
let mut session = Session::new("test-user");
session.threads.insert(thread_id, thread);
// Verify initial state
assert_eq!(
session.threads[&thread_id].state,
ThreadState::AwaitingApproval
);
assert!(session.threads[&thread_id].pending_approval.is_some());
// Simulate the atomic check-take-verify sequence from the fixed code:
// take the approval, then verify request_id, and restore on mismatch.
let thread = session.threads.get_mut(&thread_id).unwrap();
let taken = thread.take_pending_approval().unwrap();
assert!(thread.pending_approval.is_none(), "take should clear it");
// Wrong request_id -- restore atomically (within same "lock scope" in production)
assert_ne!(wrong_request_id, taken.request_id);
thread.await_approval(taken);
// Verify the pending approval is fully restored
assert_eq!(
session.threads[&thread_id].state,
ThreadState::AwaitingApproval
);
let restored = session.threads[&thread_id]
.pending_approval
.as_ref()
.expect("pending approval must be restored after request_id mismatch");
assert_eq!(restored.request_id, correct_request_id);
assert_eq!(restored.tool_name, "shell");
assert_eq!(restored.tool_call_id, "call_42");
}
/// Regression test for #1486: verify that approval with "always" flag
/// correctly transitions thread state and registers auto-approve in a
/// single lock scope.
#[test]
fn test_approval_with_always_sets_auto_approve_and_processing() {
use crate::agent::session::{PendingApproval, Session, Thread, ThreadState};
use uuid::Uuid;
let session_id = Uuid::new_v4();
let thread_id = Uuid::new_v4();
let request_id = Uuid::new_v4();
let mut thread = Thread::with_id(thread_id, session_id);
let pending = PendingApproval {
request_id,
tool_name: "web_fetch".to_string(),
parameters: serde_json::json!({"url": "https://example.com"}),
display_parameters: serde_json::json!({"url": "https://example.com"}),
description: "Fetch a web page".to_string(),
tool_call_id: "call_99".to_string(),
context_messages: vec![],
deferred_tool_calls: vec![],
user_timezone: None,
allow_always: true,
};
thread.await_approval(pending);
let mut session = Session::new("test-user");
session.threads.insert(thread_id, thread);
// Simulate the combined lock scope: take + verify + auto_approve + state transition
// Simulate the combined lock scope: take + verify + auto_approve + state transition.
// Use separate scopes to mirror the borrow pattern in production code.
let taken = {
let thread = session.threads.get_mut(&thread_id).unwrap();
thread.take_pending_approval().unwrap()
};
// Request ID matches
assert_eq!(taken.request_id, request_id);
// Auto-approve the tool (thread borrow dropped, so session is accessible)
session.auto_approve_tool(&taken.tool_name);
// Re-borrow thread and set state to Processing
{
let thread = session.threads.get_mut(&thread_id).unwrap();
thread.state = ThreadState::Processing;
}
// Verify both mutations happened atomically
assert!(session.is_tool_auto_approved("web_fetch"));
assert_eq!(session.threads[&thread_id].state, ThreadState::Processing);
// Pending approval should be consumed (not restored)
assert!(session.threads[&thread_id].pending_approval.is_none());
}
// Helper function to extract the approval message without needing a full Agent instance
fn extract_approval_message(
session: &crate::agent::session::Session,
+64 -8
View File
@@ -25,7 +25,7 @@ use crate::tools::ToolRegistry;
use crate::tools::mcp::{McpProcessManager, McpSessionManager};
use crate::tools::wasm::SharedCredentialRegistry;
use crate::tools::wasm::WasmToolRuntime;
use crate::workspace::{EmbeddingProvider, Workspace};
use crate::workspace::{EmbeddingCacheConfig, EmbeddingProvider, Workspace};
/// Fully initialized application components, ready for channel wiring
/// and agent construction.
@@ -312,12 +312,23 @@ impl AppBuilder {
.create_provider(&self.config.llm.nearai.base_url, self.session.clone());
// Register memory tools if database is available
let workspace_user_id = self
.config
.channels
.gateway
.as_ref()
.map(|gw| gw.user_id.as_str())
.unwrap_or("default");
let workspace = if let Some(ref db) = self.db {
let mut ws = Workspace::new_with_db(&self.config.owner_id, db.clone())
let emb_cache_config = EmbeddingCacheConfig {
max_entries: self.config.embeddings.cache_size,
};
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(emb.clone());
ws = ws.with_embeddings_cached(emb.clone(), emb_cache_config);
}
ws = ws.with_memory_layers(self.config.workspace.memory_layers.clone());
let ws = Arc::new(ws);
tools.register_memory_tools(Arc::clone(&ws));
Some(ws)
@@ -525,7 +536,7 @@ impl AppBuilder {
server_name,
e
);
return;
return None;
}
};
@@ -542,6 +553,10 @@ impl AppBuilder {
tool_count,
server_name
);
return Some((
server_name,
Arc::new(client),
));
}
Err(e) => {
tracing::warn!(
@@ -572,14 +587,27 @@ impl AppBuilder {
}
}
}
None
});
}
let mut startup_clients = Vec::new();
while let Some(result) = join_set.join_next().await {
if let Err(e) = result {
tracing::warn!("MCP server loading task panicked: {}", e);
match result {
Ok(Some(client_pair)) => {
startup_clients.push(client_pair);
}
Ok(None) => {}
Err(e) => {
if e.is_panic() {
tracing::error!("MCP server loading task panicked: {}", e);
} else {
tracing::warn!("MCP server loading task failed: {}", e);
}
}
}
}
return startup_clients;
}
Err(e) => {
if matches!(
@@ -597,10 +625,12 @@ impl AppBuilder {
}
}
}
Vec::new()
}
};
let (dev_loaded_tool_names, _) = tokio::join!(wasm_tools_future, mcp_servers_future);
let (dev_loaded_tool_names, startup_mcp_clients) =
tokio::join!(wasm_tools_future, mcp_servers_future);
// Load registry catalog entries for extension discovery
let mut catalog_entries = match crate::registry::RegistryCatalog::load_or_embedded() {
@@ -662,6 +692,17 @@ impl AppBuilder {
));
tools.register_extension_tools(Arc::clone(&manager));
tracing::debug!("Extension manager initialized with in-chat discovery tools");
if !startup_mcp_clients.is_empty() {
tracing::info!(
count = startup_mcp_clients.len(),
"Injecting startup MCP clients into extension manager"
);
for (name, client) in startup_mcp_clients {
manager.inject_mcp_client(name, client).await;
}
}
Some(manager)
};
@@ -691,7 +732,11 @@ impl AppBuilder {
// 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.provider.is_none() {
if self.config.llm.backend != "nearai"
&& self.config.llm.backend != "bedrock"
&& self.config.llm.backend != "openai_codex"
&& self.config.llm.provider.is_none()
{
let backend = &self.config.llm.backend;
anyhow::bail!(
"LLM_BACKEND={backend} is configured but no credentials were found. \
@@ -720,6 +765,17 @@ impl AppBuilder {
dev_loaded_tool_names,
) = self.init_extensions(&tools, &hooks).await?;
// Load bootstrap-completed flag from settings so that existing users
// who already completed onboarding don't re-get bootstrap injection.
if let Some(ref ws) = workspace {
let toml_path = crate::settings::Settings::default_toml_path();
if let Ok(Some(settings)) = crate::settings::Settings::load_toml(&toml_path)
&& settings.profile_onboarding_completed
{
ws.mark_bootstrap_completed();
}
}
// Seed workspace and backfill embeddings
if let Some(ref ws) = workspace {
// Import workspace files from disk FIRST if WORKSPACE_IMPORT_DIR is set.
+5
View File
@@ -305,6 +305,11 @@ pub enum StatusUpdate {
tool_name: String,
description: String,
parameters: serde_json::Value,
/// When `true`, the UI should offer an "always" option that auto-approves
/// future calls to this tool for the rest of the session. When `false`
/// (i.e. `ApprovalRequirement::Always`), the tool must be approved every
/// time and the "always" button should be hidden.
allow_always: bool,
},
/// Extension needs user authentication (token or OAuth).
AuthRequired {
+5
View File
@@ -239,6 +239,11 @@ impl ChannelManager {
pub async fn get_channel(&self, name: &str) -> Option<Arc<dyn Channel>> {
self.channels.read().await.get(name).cloned()
}
/// Remove a channel from the manager.
pub async fn remove(&self, name: &str) -> Option<Arc<dyn Channel>> {
self.channels.write().await.remove(name)
}
}
impl Default for ChannelManager {
+193 -383
View File
@@ -1,16 +1,16 @@
//! Channel trait implementation for channel-relay SSE streams.
//! Channel trait implementation for channel-relay webhook callbacks.
//!
//! `RelayChannel` connects to a channel-relay service via SSE, converts
//! incoming events to `IncomingMessage`s, and sends responses via the
//! relay's provider-specific proxy API (Slack).
//! `RelayChannel` receives events from channel-relay via HTTP POST callbacks
//! (pushed through an mpsc channel by the webhook handler), converts them
//! to `IncomingMessage`s, and sends responses via the relay's provider-specific
//! proxy API (Slack).
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use tokio::sync::{RwLock, mpsc};
use tokio::sync::mpsc;
use crate::channels::relay::client::{RelayClient, RelayError};
use crate::channels::relay::client::{ChannelEvent, RelayClient};
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
use crate::error::ChannelError;
@@ -39,44 +39,34 @@ impl RelayProvider {
}
}
/// Channel implementation that connects to a channel-relay SSE stream.
/// Channel implementation that receives events from channel-relay via webhook callbacks.
pub struct RelayChannel {
client: RelayClient,
provider: RelayProvider,
stream_token: Arc<RwLock<String>>,
team_id: String,
instance_id: String,
user_id: String,
/// SSE stream long-poll timeout in seconds.
stream_timeout_secs: u64,
/// Initial exponential backoff in milliseconds.
backoff_initial_ms: u64,
/// Maximum exponential backoff in milliseconds.
backoff_max_ms: u64,
/// Handle to the reconnect task for clean shutdown.
reconnect_handle: RwLock<Option<tokio::task::JoinHandle<()>>>,
/// Handle to the SSE parser task for clean shutdown.
parser_handle: Arc<RwLock<Option<tokio::task::JoinHandle<()>>>>,
/// Maximum consecutive reconnect failures before giving up.
max_consecutive_failures: u64,
/// Sender side of the event channel — shared with the webhook handler.
event_tx: mpsc::Sender<ChannelEvent>,
/// Receiver side — taken once by `start()`.
event_rx: tokio::sync::Mutex<Option<mpsc::Receiver<ChannelEvent>>>,
}
impl RelayChannel {
/// Create a new relay channel for Slack (default provider).
pub fn new(
client: RelayClient,
stream_token: String,
team_id: String,
instance_id: String,
user_id: String,
event_tx: mpsc::Sender<ChannelEvent>,
event_rx: mpsc::Receiver<ChannelEvent>,
) -> Self {
Self::new_with_provider(
client,
RelayProvider::Slack,
stream_token,
team_id,
instance_id,
user_id,
event_tx,
event_rx,
)
}
@@ -84,44 +74,24 @@ impl RelayChannel {
pub fn new_with_provider(
client: RelayClient,
provider: RelayProvider,
stream_token: String,
team_id: String,
instance_id: String,
user_id: String,
event_tx: mpsc::Sender<ChannelEvent>,
event_rx: mpsc::Receiver<ChannelEvent>,
) -> Self {
Self {
client,
provider,
stream_token: Arc::new(RwLock::new(stream_token)),
team_id,
instance_id,
user_id,
stream_timeout_secs: 86400,
backoff_initial_ms: 1000,
backoff_max_ms: 60000,
reconnect_handle: RwLock::new(None),
parser_handle: Arc::new(RwLock::new(None)),
max_consecutive_failures: 50,
event_tx,
event_rx: tokio::sync::Mutex::new(Some(event_rx)),
}
}
/// Set backoff/timeout parameters from relay config values.
pub fn with_timeouts(
mut self,
stream_timeout_secs: u64,
backoff_initial_ms: u64,
backoff_max_ms: u64,
) -> Self {
self.stream_timeout_secs = stream_timeout_secs;
self.backoff_initial_ms = backoff_initial_ms;
self.backoff_max_ms = backoff_max_ms;
self
}
/// Set the maximum number of consecutive reconnect failures before giving up.
pub fn with_max_failures(mut self, max: u64) -> Self {
self.max_consecutive_failures = max;
self
/// Get a clone of the event sender for wiring into the webhook endpoint.
pub fn event_sender(&self) -> mpsc::Sender<ChannelEvent> {
self.event_tx.clone()
}
/// Build a provider-appropriate proxy body for sending a message.
@@ -151,15 +121,9 @@ impl RelayChannel {
team_id: &str,
method: &str,
body: serde_json::Value,
) -> Result<serde_json::Value, RelayError> {
) -> Result<serde_json::Value, crate::channels::relay::client::RelayError> {
self.client
.proxy_provider(
self.provider.as_str(),
team_id,
method,
body,
Some(&self.instance_id),
)
.proxy_provider(self.provider.as_str(), team_id, method, body)
.await
}
}
@@ -172,204 +136,82 @@ impl Channel for RelayChannel {
async fn start(&self) -> Result<MessageStream, ChannelError> {
let channel_name = self.name().to_string();
let token = self.stream_token.read().await.clone();
let (stream, initial_parser_handle) = self
.client
.connect_stream(&token, self.stream_timeout_secs)
.await
.map_err(|e| ChannelError::StartupFailed {
name: channel_name.clone(),
reason: e.to_string(),
})?;
*self.parser_handle.write().await = Some(initial_parser_handle);
// Take the receiver (can only start once)
let mut event_rx =
self.event_rx
.lock()
.await
.take()
.ok_or_else(|| ChannelError::StartupFailed {
name: channel_name.clone(),
reason: "RelayChannel already started".to_string(),
})?;
let (tx, rx) = mpsc::channel(64);
// Spawn the stream reader + reconnect task
let client = self.client.clone();
let stream_token = Arc::clone(&self.stream_token);
let instance_id = self.instance_id.clone();
let user_id = self.user_id.clone();
let team_id = self.team_id.clone();
let stream_timeout_secs = self.stream_timeout_secs;
let backoff_initial_ms = self.backoff_initial_ms;
let backoff_max_ms = self.backoff_max_ms;
let max_consecutive_failures = self.max_consecutive_failures;
let parser_handle = Arc::clone(&self.parser_handle);
let provider_str = self.provider.as_str().to_string();
let relay_name = channel_name.clone();
let handle = tokio::spawn(async move {
use futures::StreamExt;
let mut current_stream = stream;
let mut backoff_ms = backoff_initial_ms;
let mut consecutive_failures: u64 = 0;
loop {
// Read events from the current stream
while let Some(event) = current_stream.next().await {
// Reset backoff and failure count on successful event
backoff_ms = backoff_initial_ms;
consecutive_failures = 0;
// Validate required fields
if event.sender_id.is_empty()
|| event.channel_id.is_empty()
|| event.provider_scope.is_empty()
{
tracing::debug!(
event_type = %event.event_type,
sender_id = %event.sender_id,
channel_id = %event.channel_id,
"Relay: skipping event with missing required fields"
);
continue;
}
// Skip non-message events
if !event.is_message() {
tracing::debug!(
event_type = %event.event_type,
"Relay: skipping non-message event"
);
continue;
}
tracing::info!(
// Spawn a task that reads events from the webhook handler and converts to IncomingMessage
tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
// Validate required fields
if event.sender_id.is_empty()
|| event.channel_id.is_empty()
|| event.provider_scope.is_empty()
{
tracing::debug!(
event_type = %event.event_type,
sender = %event.sender_id,
channel = %event.channel_id,
provider = %provider_str,
"Relay: received message from {}", provider_str
sender_id = %event.sender_id,
channel_id = %event.channel_id,
"Relay: skipping event with missing required fields"
);
let msg = IncomingMessage::new(&relay_name, &event.sender_id, event.text())
.with_user_name(event.display_name())
.with_metadata(serde_json::json!({
"team_id": event.team_id(),
"channel_id": event.channel_id,
"sender_id": event.sender_id,
"sender_name": event.display_name(),
"event_type": event.event_type,
"thread_id": event.thread_id,
"provider": event.provider,
}));
let msg = if let Some(ref thread_id) = event.thread_id {
msg.with_thread(thread_id)
} else {
msg.with_thread(&event.channel_id)
};
if tx.send(msg).await.is_err() {
tracing::info!("Relay channel receiver dropped, stopping");
return;
}
continue;
}
// Stream ended, attempt reconnect with backoff
consecutive_failures += 1;
if consecutive_failures >= max_consecutive_failures {
tracing::error!(
channel = %relay_name,
failures = consecutive_failures,
"Relay channel giving up after {} consecutive failures",
consecutive_failures
// Skip non-message events
if !event.is_message() {
tracing::debug!(
event_type = %event.event_type,
"Relay: skipping non-message event"
);
break;
continue;
}
tracing::warn!(
backoff_ms = backoff_ms,
failures = consecutive_failures,
"Relay SSE stream ended, reconnecting..."
tracing::info!(
event_type = %event.event_type,
sender = %event.sender_id,
channel = %event.channel_id,
provider = %provider_str,
"Relay: received message from {}", provider_str
);
tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
backoff_ms = (backoff_ms * 2).min(backoff_max_ms);
// Try to reconnect
let token = stream_token.read().await.clone();
match client.connect_stream(&token, stream_timeout_secs).await {
Ok((new_stream, new_parser)) => {
tracing::info!("Relay SSE stream reconnected");
consecutive_failures = 0;
backoff_ms = backoff_initial_ms;
current_stream = new_stream;
// Abort old parser before replacing
if let Some(old) = parser_handle.write().await.take() {
old.abort();
}
*parser_handle.write().await = Some(new_parser);
}
Err(RelayError::TokenExpired) => {
// Attempt token renewal
tracing::info!("Relay stream token expired, renewing...");
match client.renew_token(&instance_id, &user_id).await {
Ok(new_token) => {
*stream_token.write().await = new_token.clone();
match client.connect_stream(&new_token, stream_timeout_secs).await {
Ok((new_stream, new_parser)) => {
tracing::info!(
"Relay SSE stream reconnected with new token"
);
consecutive_failures = 0;
backoff_ms = backoff_initial_ms;
current_stream = new_stream;
if let Some(old) = parser_handle.write().await.take() {
old.abort();
}
*parser_handle.write().await = Some(new_parser);
}
Err(e) => {
tracing::error!(
error = %e,
"Failed to reconnect after token renewal"
);
}
}
}
Err(e) => {
tracing::error!(
error = %e,
"Failed to renew relay stream token"
);
}
}
}
Err(e) => {
tracing::error!(error = %e, "Failed to reconnect relay SSE stream");
}
}
let msg = IncomingMessage::new(&relay_name, &event.sender_id, event.text())
.with_user_name(event.display_name())
.with_metadata(serde_json::json!({
"team_id": event.team_id(),
"channel_id": event.channel_id,
"sender_id": event.sender_id,
"sender_name": event.display_name(),
"event_type": event.event_type,
"thread_id": event.thread_id,
"provider": event.provider,
}));
// Check if the team is still valid (skip when team_id is unknown,
// e.g. when no DB store was available at activation time)
if !team_id.is_empty() {
match client.list_connections(&instance_id).await {
Ok(conns) => {
let has_team =
conns.iter().any(|c| c.team_id == team_id && c.connected);
if !has_team {
tracing::warn!(
team_id = %team_id,
"Team no longer connected, stopping relay channel"
);
return;
}
}
Err(e) => {
tracing::warn!(
error = %e,
"Could not verify team connection, will retry next iteration"
);
}
}
let msg = if let Some(ref thread_id) = event.thread_id {
msg.with_thread(thread_id)
} else {
msg.with_thread(&event.channel_id)
};
if tx.send(msg).await.is_err() {
tracing::info!("Relay channel receiver dropped, stopping");
return;
}
}
});
*self.reconnect_handle.write().await = Some(handle);
tracing::info!("Relay event channel closed");
});
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
Ok(Box::pin(stream))
@@ -423,6 +265,7 @@ impl Channel for RelayChannel {
tool_name,
description,
parameters,
allow_always: _,
} = status
else {
return Ok(());
@@ -450,28 +293,24 @@ impl Channel for RelayChannel {
name: self.name().to_string(),
reason: "Missing channel_id for approval buttons".into(),
})?;
let sender_id = metadata
.get("sender_id")
.and_then(|v| v.as_str())
.ok_or_else(|| ChannelError::SendFailed {
name: self.name().to_string(),
reason: "Missing sender_id for approval buttons".into(),
})?;
let thread_id = metadata.get("thread_id").and_then(|v| v.as_str());
let team_id = metadata
.get("team_id")
.and_then(|v| v.as_str())
.unwrap_or(&self.team_id);
// Button value payload (Slack limits button values to 2000 chars;
// safe with typical UUIDs but documented here as a constraint)
// Register server-side approval record and get opaque token.
// The button value contains ONLY the token — no routing fields.
let approval_token = self
.client
.create_approval(team_id, channel_id, thread_id, &request_id)
.await
.map_err(|e| ChannelError::SendFailed {
name: self.name().to_string(),
reason: format!("Failed to register approval: {e}"),
})?;
let value_payload = serde_json::json!({
"instance_id": self.instance_id,
"team_id": team_id,
"channel_id": channel_id,
"thread_ts": thread_id,
"request_id": request_id,
"sender_id": sender_id,
"approval_token": approval_token,
});
let value_str = value_payload.to_string();
@@ -582,12 +421,8 @@ impl Channel for RelayChannel {
}
async fn shutdown(&self) -> Result<(), ChannelError> {
if let Some(handle) = self.reconnect_handle.write().await.take() {
handle.abort();
}
if let Some(handle) = self.parser_handle.write().await.take() {
handle.abort();
}
// Relay cleanup is driven by the extension manager dropping the shared
// sender and removing the channel from the channel manager.
Ok(())
}
}
@@ -605,27 +440,20 @@ mod tests {
.expect("client")
}
fn make_channel() -> RelayChannel {
let (tx, rx) = mpsc::channel(64);
RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx, rx)
}
#[test]
fn relay_channel_name() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let channel = make_channel();
assert_eq!(channel.name(), DEFAULT_RELAY_NAME);
}
#[test]
fn conversation_context_extracts_metadata() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let channel = make_channel();
let metadata = serde_json::json!({
"sender_name": "bob",
@@ -640,8 +468,6 @@ mod tests {
#[test]
fn metadata_shape_includes_event_type_and_sender_name() {
// Regression: metadata JSON must include event_type and sender_name
// for downstream routing (DM vs channel) and conversation_context().
let metadata = serde_json::json!({
"team_id": "T123",
"channel_id": "C456",
@@ -651,43 +477,19 @@ mod tests {
"thread_id": null,
"provider": "slack",
});
// event_type must be present for DM-vs-channel routing
assert_eq!(
metadata.get("event_type").and_then(|v| v.as_str()),
Some("direct_message")
);
// sender_name must be present for conversation_context
assert_eq!(
metadata.get("sender_name").and_then(|v| v.as_str()),
Some("alice")
);
}
#[test]
fn with_timeouts_sets_values() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
)
.with_timeouts(43200, 2000, 120000);
assert_eq!(channel.stream_timeout_secs, 43200);
assert_eq!(channel.backoff_initial_ms, 2000);
assert_eq!(channel.backoff_max_ms, 120000);
}
#[test]
fn build_send_body_slack() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let channel = make_channel();
let (method, body) = channel.build_send_body("C456", "hello", Some("1234567.890"));
assert_eq!(method, "chat.postMessage");
assert_eq!(body["channel"], "C456");
@@ -695,72 +497,95 @@ mod tests {
assert_eq!(body["thread_ts"], "1234567.890");
}
#[test]
fn parser_handle_is_shared_arc() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
// parser_handle should be an Arc — cloning should give a second reference
let handle_clone = Arc::clone(&channel.parser_handle);
// Both point to the same allocation
assert!(Arc::ptr_eq(&channel.parser_handle, &handle_clone));
#[tokio::test]
async fn start_processes_events() {
let (tx, rx) = mpsc::channel(64);
let channel =
RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx.clone(), rx);
let mut stream = channel.start().await.unwrap();
// Send an event
tx.send(ChannelEvent {
id: "1".into(),
event_type: "message".into(),
provider: "slack".into(),
provider_scope: "T123".into(),
channel_id: "C456".into(),
sender_id: "U789".into(),
sender_name: Some("alice".into()),
content: Some("hello".into()),
thread_id: None,
raw: serde_json::Value::Null,
timestamp: None,
})
.await
.unwrap();
use futures::StreamExt;
let msg = tokio::time::timeout(std::time::Duration::from_secs(1), stream.next())
.await
.unwrap()
.unwrap();
assert_eq!(msg.content, "hello");
assert_eq!(msg.user_id, "U789");
}
#[test]
fn with_max_failures_sets_value() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
)
.with_max_failures(10);
#[tokio::test]
async fn start_skips_non_message_events() {
let (tx, rx) = mpsc::channel(64);
let channel =
RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx.clone(), rx);
assert_eq!(channel.max_consecutive_failures, 10);
}
let mut stream = channel.start().await.unwrap();
#[test]
fn default_max_failures_is_50() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
assert_eq!(channel.max_consecutive_failures, 50);
}
// Send a non-message event (should be skipped)
tx.send(ChannelEvent {
id: "1".into(),
event_type: "reaction".into(),
provider: "slack".into(),
provider_scope: "T123".into(),
channel_id: "C456".into(),
sender_id: "U789".into(),
sender_name: None,
content: None,
thread_id: None,
raw: serde_json::Value::Null,
timestamp: None,
})
.await
.unwrap();
#[test]
fn empty_team_id_accepted_at_construction() {
// Regression: empty team_id (when no DB store is available) must not
// prevent channel construction or cause immediate shutdown.
let channel = RelayChannel::new(
test_client(),
"token".into(),
String::new(), // empty team_id
"inst1".into(),
"user1".into(),
);
assert_eq!(channel.team_id, "");
// The reconnect loop now skips team validation when team_id is empty,
// so the channel remains alive.
// Send a real message
tx.send(ChannelEvent {
id: "2".into(),
event_type: "message".into(),
provider: "slack".into(),
provider_scope: "T123".into(),
channel_id: "C456".into(),
sender_id: "U789".into(),
sender_name: None,
content: Some("real message".into()),
thread_id: None,
raw: serde_json::Value::Null,
timestamp: None,
})
.await
.unwrap();
use futures::StreamExt;
let msg = tokio::time::timeout(std::time::Duration::from_secs(1), stream.next())
.await
.unwrap()
.unwrap();
assert_eq!(msg.content, "real message");
}
#[tokio::test]
async fn test_send_status_non_approval_is_noop() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let channel = make_channel();
let metadata = serde_json::json!({});
let result = channel
.send_status(
@@ -775,13 +600,7 @@ mod tests {
#[tokio::test]
async fn test_send_status_approval_non_dm_skips() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let channel = make_channel();
let metadata = serde_json::json!({
"event_type": "message",
"channel_id": "C456",
@@ -794,6 +613,7 @@ mod tests {
tool_name: "shell".into(),
description: "run command".into(),
parameters: serde_json::json!({}),
allow_always: true,
},
&metadata,
)
@@ -804,13 +624,7 @@ mod tests {
#[tokio::test]
async fn test_send_status_approval_dm_missing_channel_id_errors() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let channel = make_channel();
let metadata = serde_json::json!({
"event_type": "direct_message",
"sender_id": "U789",
@@ -822,6 +636,7 @@ mod tests {
tool_name: "shell".into(),
description: "run command".into(),
parameters: serde_json::json!({}),
allow_always: true,
},
&metadata,
)
@@ -835,14 +650,8 @@ mod tests {
}
#[tokio::test]
async fn test_send_status_approval_dm_missing_sender_id_errors() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
async fn test_send_status_approval_dm_without_sender_id_is_ok() {
let channel = make_channel();
let metadata = serde_json::json!({
"event_type": "direct_message",
"channel_id": "C456",
@@ -854,6 +663,7 @@ mod tests {
tool_name: "shell".into(),
description: "run command".into(),
parameters: serde_json::json!({}),
allow_always: true,
},
&metadata,
)
@@ -861,8 +671,8 @@ mod tests {
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("sender_id"),
"expected sender_id error, got: {err}"
!err.contains("sender_id"),
"sender_id should not be required anymore, got: {err}"
);
}
}
+90 -205
View File
@@ -1,15 +1,10 @@
//! HTTP client for the channel-relay service.
//!
//! Wraps reqwest for all channel-relay API calls: OAuth initiation,
//! SSE streaming, token renewal, and Slack API proxy.
//! approvals, signing-secret fetch, and Slack API proxy.
use std::pin::Pin;
use std::task::{Context, Poll};
use futures::Stream;
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
/// Known relay event types.
pub mod event_types {
@@ -18,7 +13,7 @@ pub mod event_types {
pub const MENTION: &str = "mention";
}
/// A parsed SSE event from the channel-relay stream.
/// A parsed event from the channel-relay webhook callback.
///
/// Field names match the channel-relay `ChannelEvent` struct exactly.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -123,21 +118,19 @@ impl RelayClient {
///
/// Calls `GET /oauth/slack/auth` with `redirect(Policy::none())` and
/// returns the `Location` header (Slack OAuth URL) without following it.
pub async fn initiate_oauth(
&self,
instance_id: &str,
user_id: &str,
callback_url: &str,
) -> Result<String, RelayError> {
/// Initiate Slack OAuth. Channel-relay derives all URLs from the trusted
/// instance_url in chat-api. IronClaw only passes an optional CSRF nonce
/// for validating the callback — no URLs.
pub async fn initiate_oauth(&self, state_nonce: Option<&str>) -> Result<String, RelayError> {
let mut query: Vec<(&str, &str)> = vec![];
if let Some(nonce) = state_nonce {
query.push(("state_nonce", nonce));
}
let resp = self
.http
.get(format!("{}/oauth/slack/auth", self.base_url))
.header("X-API-Key", self.api_key.expose_secret())
.query(&[
("instance_id", instance_id),
("user_id", user_id),
("callback", callback_url),
])
.bearer_auth(self.api_key.expose_secret())
.query(&query)
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
@@ -173,104 +166,69 @@ impl RelayClient {
}
}
/// Connect to the SSE event stream.
/// Register a pending approval and return the opaque approval token.
///
/// Returns a stream of parsed `ChannelEvent`s and the `JoinHandle` of the
/// background SSE parser task. The caller is responsible for reconnection
/// logic on stream end/error and for aborting the handle on shutdown.
pub async fn connect_stream(
/// Calls `POST /approvals` with the target team/channel/request identifiers.
/// The returned token is embedded in Slack button values instead of routing fields.
/// The relay derives the authorized approver from the connection's authed_user_id.
pub async fn create_approval(
&self,
stream_token: &str,
stream_timeout_secs: u64,
) -> Result<(ChannelEventStream, tokio::task::JoinHandle<()>), RelayError> {
let resp = self
.http
.get(format!("{}/stream", self.base_url))
.query(&[("token", stream_token)])
.timeout(std::time::Duration::from_secs(stream_timeout_secs))
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
return Err(RelayError::TokenExpired);
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(RelayError::Api {
status: status.as_u16(),
message: body,
});
}
// Spawn a background task that reads the SSE stream and sends parsed events
let (tx, rx) = mpsc::channel(64);
let byte_stream = resp.bytes_stream();
let handle = tokio::spawn(parse_sse_stream(byte_stream, tx));
Ok((ChannelEventStream { rx }, handle))
}
/// Renew an expired stream token.
///
/// Calls `POST /stream/renew` with API key auth, returns a new stream token.
pub async fn renew_token(
&self,
instance_id: &str,
user_id: &str,
team_id: &str,
channel_id: &str,
thread_ts: Option<&str>,
request_id: &str,
) -> Result<String, RelayError> {
let mut body = serde_json::json!({
"team_id": team_id,
"channel_id": channel_id,
"request_id": request_id,
});
if let Some(ts) = thread_ts {
body["thread_ts"] = serde_json::Value::String(ts.to_string());
}
let resp = self
.http
.post(format!("{}/stream/renew", self.base_url))
.header("X-API-Key", self.api_key.expose_secret())
.json(&serde_json::json!({
"instance_id": instance_id,
"user_id": user_id,
}))
.post(format!("{}/approvals", self.base_url))
.bearer_auth(self.api_key.expose_secret())
.json(&body)
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
let status = resp.status();
if !status.is_success() {
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(RelayError::Api {
status: status.as_u16(),
status,
message: body,
});
}
let body: serde_json::Value = resp
let result: serde_json::Value = resp
.json()
.await
.map_err(|e| RelayError::Protocol(e.to_string()))?;
body.get("stream_token")
.or_else(|| body.get("token"))
result
.get("approval_token")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| RelayError::Protocol("Response missing stream_token field".to_string()))
.ok_or_else(|| RelayError::Protocol("missing approval_token in response".to_string()))
}
/// Proxy an API call through channel-relay for any provider.
///
/// Calls `POST /proxy/{provider}/{method}?team_id=X&instance_id=Y` with the given JSON body.
pub async fn proxy_provider(
&self,
provider: &str,
team_id: &str,
method: &str,
body: serde_json::Value,
instance_id: Option<&str>,
) -> Result<serde_json::Value, RelayError> {
let mut query: Vec<(&str, &str)> = vec![("team_id", team_id)];
if let Some(iid) = instance_id {
query.push(("instance_id", iid));
}
let query: Vec<(&str, &str)> = vec![("team_id", team_id)];
let resp = self
.http
.post(format!("{}/proxy/{}/{}", self.base_url, provider, method))
.header("X-API-Key", self.api_key.expose_secret())
.bearer_auth(self.api_key.expose_secret())
.query(&query)
.json(&body)
.send()
@@ -291,12 +249,58 @@ impl RelayClient {
.map_err(|e| RelayError::Protocol(e.to_string()))
}
/// Fetch the per-instance callback signing secret from channel-relay.
///
/// Calls `GET /relay/signing-secret` (authenticated) and returns the decoded
/// 32-byte secret. Called once at activation time; the result is cached in the
/// extension manager so subsequent calls to `relay_signing_secret()` use it.
pub async fn get_signing_secret(&self, team_id: &str) -> Result<Vec<u8>, RelayError> {
let resp = self
.http
.get(format!("{}/relay/signing-secret", self.base_url))
.bearer_auth(self.api_key.expose_secret())
.query(&[("team_id", team_id)])
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(RelayError::Api {
status,
message: body,
});
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| RelayError::Protocol(e.to_string()))?;
body.get("signing_secret")
.and_then(|v| v.as_str())
.ok_or_else(|| RelayError::Protocol("missing signing_secret in response".to_string()))
.and_then(|raw| {
let decoded = hex::decode(raw).map_err(|e| {
RelayError::Protocol(format!("invalid signing_secret hex: {e}"))
})?;
if decoded.len() != 32 {
return Err(RelayError::Protocol(format!(
"invalid signing_secret length: expected 32 bytes, got {}",
decoded.len()
)));
}
Ok(decoded)
})
}
/// List active connections for an instance.
pub async fn list_connections(&self, instance_id: &str) -> Result<Vec<Connection>, RelayError> {
let resp = self
.http
.get(format!("{}/connections", self.base_url))
.header("X-API-Key", self.api_key.expose_secret())
.bearer_auth(self.api_key.expose_secret())
.query(&[("instance_id", instance_id)])
.send()
.await
@@ -317,91 +321,6 @@ impl RelayClient {
}
}
/// Async stream of parsed channel events from SSE.
pub struct ChannelEventStream {
rx: mpsc::Receiver<ChannelEvent>,
}
impl Stream for ChannelEventStream {
type Item = ChannelEvent;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
self.rx.poll_recv(cx)
}
}
/// Parse SSE format from a reqwest bytes stream.
///
/// SSE format:
/// ```text
/// event: message
/// data: {"key": "value"}
///
/// ```
/// Blank line terminates an event.
async fn parse_sse_stream(
byte_stream: impl futures::Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Send + 'static,
tx: mpsc::Sender<ChannelEvent>,
) {
use futures::StreamExt;
let mut buffer = Vec::<u8>::new();
let mut event_type = String::new();
let mut data_lines = Vec::new();
let mut byte_stream = std::pin::pin!(byte_stream);
while let Some(chunk_result) = byte_stream.next().await {
let chunk = match chunk_result {
Ok(c) => c,
Err(e) => {
tracing::debug!(error = %e, "SSE stream chunk error");
break;
}
};
buffer.extend_from_slice(&chunk);
// Process complete lines (decode UTF-8 only on full lines to avoid
// corruption when multi-byte characters span chunk boundaries)
while let Some(newline_pos) = buffer.iter().position(|&b| b == b'\n') {
let line = String::from_utf8_lossy(&buffer[..newline_pos])
.trim_end_matches('\r')
.to_string();
buffer.drain(..=newline_pos);
if line.is_empty() {
// Blank line = end of event
if !data_lines.is_empty() {
let data = data_lines.join("\n");
if let Ok(mut event) = serde_json::from_str::<ChannelEvent>(&data) {
if event.event_type.is_empty() && !event_type.is_empty() {
event.event_type = event_type.clone();
}
if tx.send(event).await.is_err() {
return; // receiver dropped
}
} else {
tracing::debug!(
event_type = %event_type,
data_len = data.len(),
"Failed to parse SSE event data as ChannelEvent"
);
}
}
event_type.clear();
data_lines.clear();
} else if let Some(value) = line.strip_prefix("event:") {
event_type = value.trim().to_string();
} else if let Some(value) = line.strip_prefix("data:") {
data_lines.push(value.trim().to_string());
}
// Ignore other fields (id:, retry:, comments)
}
}
tracing::debug!("SSE stream ended");
}
/// Errors from relay client operations.
#[derive(Debug, thiserror::Error)]
pub enum RelayError {
@@ -413,9 +332,6 @@ pub enum RelayError {
#[error("Protocol error: {0}")]
Protocol(String),
#[error("Stream token expired")]
TokenExpired,
}
#[cfg(test)]
@@ -494,9 +410,6 @@ mod tests {
message: "unauthorized".into(),
};
assert_eq!(err.to_string(), "API error (HTTP 401): unauthorized");
let err = RelayError::TokenExpired;
assert_eq!(err.to_string(), "Stream token expired");
}
#[test]
@@ -518,32 +431,4 @@ mod tests {
assert!(make(event_types::DIRECT_MESSAGE).is_message());
assert!(make(event_types::MENTION).is_message());
}
#[tokio::test]
async fn parse_sse_handles_multibyte_utf8_across_chunks() {
// The crab emoji (🦀) is 4 bytes: [0xF0, 0x9F, 0xA6, 0x80].
// Split it across two chunks to verify no U+FFFD corruption.
let event_json = r#"{"event_type":"message","content":"hello 🦀 world","provider_scope":"T1","channel_id":"C1","sender_id":"U1"}"#;
let full = format!("event: message\ndata: {}\n\n", event_json);
let bytes = full.as_bytes();
// Find the crab emoji and split mid-character
let crab_pos = bytes
.windows(4)
.position(|w| w == [0xF0, 0x9F, 0xA6, 0x80])
.expect("crab emoji not found");
let split_at = crab_pos + 2; // split in the middle of the 4-byte emoji
let chunk1 = bytes::Bytes::copy_from_slice(&bytes[..split_at]);
let chunk2 = bytes::Bytes::copy_from_slice(&bytes[split_at..]);
let chunks: Vec<Result<bytes::Bytes, reqwest::Error>> = vec![Ok(chunk1), Ok(chunk2)];
let stream = futures::stream::iter(chunks);
let (tx, mut rx) = mpsc::channel(8);
parse_sse_stream(stream, tx).await;
let event = rx.recv().await.expect("should receive event");
assert_eq!(event.text(), "hello 🦀 world");
}
}
+4 -3
View File
@@ -1,12 +1,13 @@
//! Channel-relay integration for connecting to external messaging platforms
//! (Slack) via the channel-relay service.
//!
//! The relay service handles OAuth, credential storage, webhook ingestion,
//! and SSE event streaming. IronClaw consumes the SSE stream and sends
//! messages via the relay's proxy API.
//! The relay service handles OAuth, credential storage, and webhook ingestion.
//! IronClaw receives events via webhook callbacks and sends messages via the
//! relay's proxy API.
pub mod channel;
pub mod client;
pub mod webhook;
pub use channel::{DEFAULT_RELAY_NAME, RelayChannel};
pub use client::RelayClient;
+66
View File
@@ -0,0 +1,66 @@
//! Shared relay webhook signature verification helpers.
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
/// Verify a relay callback HMAC signature.
pub fn verify_relay_signature(
secret: &[u8],
timestamp: &str,
body: &[u8],
signature: &str,
) -> bool {
verify_signature(secret, timestamp, body, signature)
}
fn verify_signature(secret: &[u8], timestamp: &str, body: &[u8], signature: &str) -> bool {
let mut mac = match HmacSha256::new_from_slice(secret) {
Ok(m) => m,
Err(_) => return false,
};
mac.update(timestamp.as_bytes());
mac.update(b".");
mac.update(body);
let expected = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
subtle::ConstantTimeEq::ct_eq(expected.as_bytes(), signature.as_bytes()).into()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_signature(secret: &[u8], timestamp: &str, body: &[u8]) -> String {
let mut mac = HmacSha256::new_from_slice(secret).unwrap();
mac.update(timestamp.as_bytes());
mac.update(b".");
mac.update(body);
format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
}
#[test]
fn verify_valid_signature() {
let secret = b"test-secret";
let body = b"hello";
let ts = "1234567890";
let sig = make_signature(secret, ts, body);
assert!(verify_signature(secret, ts, body, &sig));
}
#[test]
fn verify_wrong_secret_fails() {
let body = b"hello";
let ts = "1234567890";
let sig = make_signature(b"correct", ts, body);
assert!(!verify_signature(b"wrong", ts, body, &sig));
}
#[test]
fn verify_tampered_body_fails() {
let secret = b"secret";
let ts = "1234567890";
let sig = make_signature(secret, ts, b"original");
assert!(!verify_signature(secret, ts, b"tampered", &sig));
}
}
+8 -3
View File
@@ -539,6 +539,7 @@ impl Channel for ReplChannel {
tool_name,
description,
parameters,
allow_always,
} => {
let term_width = crossterm::terminal::size()
.map(|(w, _)| w as usize)
@@ -582,9 +583,13 @@ impl Channel for ReplChannel {
}
eprintln!(" \u{2502}");
eprintln!(
" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[34malways\x1b[0m (a) / \x1b[31mno\x1b[0m (n)"
);
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!();
}
+11 -3
View File
@@ -915,20 +915,28 @@ impl Channel for SignalChannel {
tool_name,
description: _,
parameters,
allow_always,
} = &status
&& let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str())
{
let params_json = serde_json::to_string_pretty(parameters).unwrap_or_default();
let always_line = if *allow_always {
format!(
"\n• `always` or `a` - Approve and auto-approve future {} requests",
tool_name
)
} else {
String::new()
};
let message = format!(
"⚠️ *Approval Required*\n\n\
*Request ID:* `{}`\n\
*Tool:* {}\n\
*Parameters:*\n```\n{}\n```\n\n\
Reply with:\n\
`yes` or `y` - Approve this request\n\
`always` or `a` - Approve and auto-approve future {} requests\n\
`yes` or `y` - Approve this request{}\n\
`no` or `n` - Deny",
request_id, tool_name, params_json, tool_name
request_id, tool_name, params_json, always_line
);
self.send_status_message(target_str, &message).await;
}
+37 -9
View File
@@ -2043,6 +2043,7 @@ impl WasmChannel {
tool_name,
description,
parameters,
allow_always,
..
} => {
// WASM channels (Telegram, Slack, etc.) cannot render
@@ -2081,6 +2082,11 @@ impl WasmChannel {
})
.unwrap_or_default();
let reply_hint = if *allow_always {
"Reply \"yes\" to approve, \"no\" to deny, or \"always\" to auto-approve."
} else {
"Reply \"yes\" to approve or \"no\" to deny."
};
let prompt = format!(
"Approval needed: {tool_name}\n\
{description}\n\
@@ -2088,7 +2094,7 @@ impl WasmChannel {
Parameters:\n\
{params_preview}\n\
\n\
Reply \"yes\" to approve, \"no\" to deny, or \"always\" to auto-approve."
{reply_hint}"
);
let metadata_json = serde_json::to_string(metadata).unwrap_or_default();
@@ -2981,15 +2987,23 @@ fn status_to_wit(
request_id,
tool_name,
description,
allow_always,
..
} => wit_channel::StatusUpdate {
status: wit_channel::StatusType::ApprovalNeeded,
message: format!(
"Approval needed for tool '{}'. {}\nRequest ID: {}\nReply with: yes (or /approve), no (or /deny), or always (or /always).",
tool_name, description, request_id
),
metadata_json,
},
} => {
let reply_hint = if *allow_always {
"yes (or /approve), no (or /deny), or always (or /always)"
} else {
"yes (or /approve) or no (or /deny)"
};
wit_channel::StatusUpdate {
status: wit_channel::StatusType::ApprovalNeeded,
message: format!(
"Approval needed for tool '{}'. {}\nRequest ID: {}\nReply with: {}.",
tool_name, description, request_id, reply_hint
),
metadata_json,
}
}
StatusUpdate::JobStarted {
job_id,
title,
@@ -3300,6 +3314,7 @@ mod tests {
use std::sync::Arc;
use crate::channels::Channel;
use crate::channels::OutgoingResponse;
use crate::channels::wasm::capabilities::ChannelCapabilities;
use crate::channels::wasm::runtime::{
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
@@ -3387,6 +3402,16 @@ mod tests {
assert!(channel.health_check().await.is_err());
}
#[tokio::test]
async fn test_broadcast_delegates_to_call_on_broadcast() {
let channel = create_test_channel();
// With `component: None`, call_on_broadcast short-circuits to Ok(()).
let result = channel
.broadcast("146032821", OutgoingResponse::text("hello"))
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_execute_poll_no_wasm_returns_empty() {
// When there's no WASM module (None component), execute_poll
@@ -3670,6 +3695,7 @@ mod tests {
tool_name: "http_request".into(),
description: "Fetch weather".into(),
parameters: serde_json::json!({"url": "https://wttr.in"}),
allow_always: true,
},
&metadata,
)
@@ -4131,6 +4157,7 @@ mod tests {
tool_name: "http_request".to_string(),
description: "Fetch weather data".to_string(),
parameters: serde_json::json!({"url": "https://api.weather.test"}),
allow_always: true,
},
&metadata,
)
@@ -4156,6 +4183,7 @@ mod tests {
tool_name: "http_request".to_string(),
description: "Fetch weather data".to_string(),
parameters: serde_json::json!({"url": "https://api.weather.test"}),
allow_always: true,
},
&metadata,
)
+2 -19
View File
@@ -123,25 +123,8 @@ pub async fn memory_read_handler(
}))
}
pub async fn memory_write_handler(
State(state): State<Arc<GatewayState>>,
Json(req): Json<MemoryWriteRequest>,
) -> Result<Json<MemoryWriteResponse>, (StatusCode, String)> {
let workspace = state.workspace.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Workspace not available".to_string(),
))?;
workspace
.write(&req.path, &req.content)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(MemoryWriteResponse {
path: req.path,
status: "written",
}))
}
// memory_write_handler lives in server.rs (layer-aware version with append,
// privacy redirect, and proper error status codes).
pub async fn memory_search_handler(
State(state): State<Arc<GatewayState>>,
+1
View File
@@ -26,3 +26,4 @@ pub mod routines;
pub mod settings;
#[allow(dead_code)]
pub mod static_files;
pub mod webhooks;
+3 -1
View File
@@ -303,7 +303,9 @@ fn routine_error_status(err: &RoutineError) -> StatusCode {
match err {
RoutineError::NotFound { .. } => StatusCode::NOT_FOUND,
RoutineError::NotAuthorized { .. } => StatusCode::FORBIDDEN,
RoutineError::Disabled { .. } | RoutineError::MaxConcurrent { .. } => StatusCode::CONFLICT,
RoutineError::Disabled { .. }
| RoutineError::Cooldown { .. }
| RoutineError::MaxConcurrent { .. } => StatusCode::CONFLICT,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
+197
View File
@@ -0,0 +1,197 @@
//! Public webhook trigger endpoint for routine webhook triggers.
//!
//! `POST /api/webhooks/{path}` — matches the path against routines with
//! `Trigger::Webhook { path, secret }`, validates the secret via constant-time
//! comparison, and fires the matching routine through the `RoutineEngine`.
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
http::{HeaderMap, StatusCode},
};
use subtle::ConstantTimeEq;
use crate::agent::routine::Trigger;
use crate::channels::web::server::GatewayState;
/// Validate the webhook secret for a routine.
///
/// Returns `Ok(())` if the routine has a configured secret and the provided
/// secret matches via constant-time comparison. Returns an appropriate HTTP
/// error if the secret is missing (403) or invalid (401).
fn validate_webhook_secret(
trigger: &Trigger,
provided_secret: &str,
) -> Result<(), (StatusCode, String)> {
// Require webhook secret — routines without a secret cannot be triggered via webhook
let expected_secret = match trigger {
Trigger::Webhook {
secret: Some(s), ..
} => s,
_ => {
return Err((
StatusCode::FORBIDDEN,
"Webhook secret not configured for this routine. \
Set a secret with: ironclaw routine update <id> --webhook-secret <secret>"
.to_string(),
));
}
};
if !bool::from(provided_secret.as_bytes().ct_eq(expected_secret.as_bytes())) {
return Err((
StatusCode::UNAUTHORIZED,
"Invalid webhook secret".to_string(),
));
}
Ok(())
}
/// Handle incoming webhook POST to `/api/webhooks/{path}`.
///
/// This endpoint is **public** (no gateway auth token required) but protected
/// by the per-routine webhook secret sent via the `X-Webhook-Secret` header.
pub async fn webhook_trigger_handler(
State(state): State<Arc<GatewayState>>,
Path(path): Path<String>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
// Rate limit check
if !state.webhook_rate_limiter.check() {
return Err((
StatusCode::TOO_MANY_REQUESTS,
"Rate limit exceeded. Try again shortly.".to_string(),
));
}
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
// Targeted query instead of loading all routines
let routine = store
.get_webhook_routine_by_path(&path)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((
StatusCode::NOT_FOUND,
"No routine matches this webhook path".to_string(),
))?;
let provided_secret = headers
.get("x-webhook-secret")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
validate_webhook_secret(&routine.trigger, provided_secret)?;
// Fire through the RoutineEngine so guardrails, run tracking,
// notifications, and FullJob dispatch all work correctly.
let engine = {
let guard = state.routine_engine.read().await;
guard.as_ref().cloned().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Routine engine not available".to_string(),
))?
};
let run_id = engine.fire_webhook(routine.id, &path).await.map_err(|e| {
let status = match &e {
crate::error::RoutineError::NotFound { .. } => StatusCode::NOT_FOUND,
crate::error::RoutineError::Disabled { .. }
| crate::error::RoutineError::Cooldown { .. }
| crate::error::RoutineError::MaxConcurrent { .. } => StatusCode::CONFLICT,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, e.to_string())
})?;
Ok(Json(serde_json::json!({
"status": "triggered",
"routine_id": routine.id,
"routine_name": routine.name,
"run_id": run_id,
})))
}
#[cfg(test)]
mod tests {
use super::*;
/// Routines with `secret: None` must be rejected with 403.
#[test]
fn test_validate_rejects_missing_secret() {
let trigger = Trigger::Webhook {
path: Some("my-hook".to_string()),
secret: None,
};
let result = validate_webhook_secret(&trigger, "any-secret");
let (status, msg) = result.unwrap_err();
assert_eq!(status, StatusCode::FORBIDDEN);
assert!(
msg.contains("not configured"),
"Error should tell user to configure a secret, got: {msg}"
);
}
/// Non-webhook triggers must be rejected with 403.
#[test]
fn test_validate_rejects_non_webhook_trigger() {
let trigger = Trigger::Manual;
let result = validate_webhook_secret(&trigger, "any-secret");
let (status, _) = result.unwrap_err();
assert_eq!(status, StatusCode::FORBIDDEN);
}
/// Correct secret passes validation.
#[test]
fn test_validate_accepts_correct_secret() {
let trigger = Trigger::Webhook {
path: Some("my-hook".to_string()),
secret: Some("s3cret-token".to_string()),
};
assert!(validate_webhook_secret(&trigger, "s3cret-token").is_ok());
}
/// Wrong secret returns 401.
#[test]
fn test_validate_rejects_wrong_secret() {
let trigger = Trigger::Webhook {
path: Some("my-hook".to_string()),
secret: Some("correct-secret".to_string()),
};
let result = validate_webhook_secret(&trigger, "wrong-secret");
let (status, msg) = result.unwrap_err();
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert!(msg.contains("Invalid"), "Expected 'Invalid' in: {msg}");
}
/// Empty provided secret returns 401 (not a false positive).
#[test]
fn test_validate_rejects_empty_provided_secret() {
let trigger = Trigger::Webhook {
path: Some("my-hook".to_string()),
secret: Some("real-secret".to_string()),
};
let result = validate_webhook_secret(&trigger, "");
let (status, _) = result.unwrap_err();
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
/// Constant-time comparison: secrets of different lengths are still rejected
/// (not short-circuited in a way that leaks length info).
#[test]
fn test_validate_rejects_different_length_secret() {
let trigger = Trigger::Webhook {
path: None,
secret: Some("short".to_string()),
};
let result = validate_webhook_secret(&trigger, "a-much-longer-secret-value");
let (status, _) = result.unwrap_err();
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
}
+4
View File
@@ -98,6 +98,7 @@ impl GatewayChannel {
skill_catalog: None,
chat_rate_limiter: server::RateLimiter::new(30, 60),
oauth_rate_limiter: server::RateLimiter::new(10, 60),
webhook_rate_limiter: server::RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
@@ -136,6 +137,7 @@ impl GatewayChannel {
skill_catalog: self.state.skill_catalog.clone(),
chat_rate_limiter: server::RateLimiter::new(30, 60),
oauth_rate_limiter: server::RateLimiter::new(10, 60),
webhook_rate_limiter: server::RateLimiter::new(10, 60),
registry_entries: self.state.registry_entries.clone(),
cost_guard: self.state.cost_guard.clone(),
routine_engine: Arc::clone(&self.state.routine_engine),
@@ -374,6 +376,7 @@ impl Channel for GatewayChannel {
tool_name,
description,
parameters,
allow_always,
} => SseEvent::ApprovalNeeded {
request_id,
tool_name,
@@ -381,6 +384,7 @@ impl Channel for GatewayChannel {
parameters: serde_json::to_string_pretty(&parameters)
.unwrap_or_else(|_| parameters.to_string()),
thread_id,
allow_always,
},
StatusUpdate::AuthRequired {
extension_name,
+300 -243
View File
@@ -19,6 +19,7 @@ use axum::{
routing::{get, post},
};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use tokio::sync::{mpsc, oneshot};
use tokio_stream::StreamExt;
use tower_http::cors::{AllowHeaders, CorsLayer};
@@ -35,7 +36,10 @@ use crate::channels::web::handlers::jobs::{
jobs_events_handler, jobs_list_handler, jobs_prompt_handler, jobs_restart_handler,
jobs_summary_handler,
};
use crate::channels::web::handlers::routines::{routines_delete_handler, routines_toggle_handler};
use crate::channels::web::handlers::routines::{
routines_delete_handler, routines_detail_handler, routines_list_handler,
routines_summary_handler, routines_toggle_handler, routines_trigger_handler,
};
use crate::channels::web::handlers::skills::{
skills_install_handler, skills_list_handler, skills_remove_handler, skills_search_handler,
};
@@ -63,6 +67,16 @@ pub type PromptQueue = Arc<
pub type RoutineEngineSlot =
Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>;
fn redact_oauth_state_for_logs(state: &str) -> String {
let digest = Sha256::digest(state.as_bytes());
let mut short_hash = String::with_capacity(12);
for byte in &digest[..6] {
use std::fmt::Write as _;
let _ = write!(&mut short_hash, "{byte:02x}");
}
format!("sha256:{short_hash}:len={}", state.len())
}
/// Simple sliding-window rate limiter.
///
/// Tracks the number of requests in the current window. Resets when the window expires.
@@ -176,6 +190,8 @@ pub struct GatewayState {
pub chat_rate_limiter: RateLimiter,
/// Rate limiter for OAuth callback endpoints (10 requests per 60 seconds).
pub oauth_rate_limiter: RateLimiter,
/// Rate limiter for webhook trigger endpoints (10 requests per 60 seconds).
pub webhook_rate_limiter: RateLimiter,
/// Registry catalog entries for the available extensions API.
/// Populated at startup from `registry/` manifests, independent of extension manager.
pub registry_entries: Vec<crate::extensions::RegistryEntry>,
@@ -218,6 +234,11 @@ pub async fn start_server(
.route(
"/oauth/slack/callback",
get(slack_relay_oauth_callback_handler),
)
.route("/relay/events", post(relay_events_handler))
.route(
"/api/webhooks/{path}",
post(crate::channels::web::handlers::webhooks::webhook_trigger_handler),
);
// Protected routes (require auth)
@@ -329,6 +350,7 @@ pub async fn start_server(
.route("/", get(index_handler))
.route("/style.css", get(css_handler))
.route("/app.js", get(js_handler))
.route("/theme-init.js", get(theme_init_handler))
.route("/favicon.ico", get(favicon_handler))
.route("/i18n/index.js", get(i18n_index_handler))
.route("/i18n/en.js", get(i18n_en_handler))
@@ -450,6 +472,16 @@ async fn js_handler() -> impl IntoResponse {
)
}
async fn theme_init_handler() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "no-cache"),
],
include_str!("static/theme-init.js"),
)
}
async fn favicon_handler() -> impl IntoResponse {
(
[
@@ -565,22 +597,35 @@ async fn oauth_callback_handler(
}
};
// Strip instance prefix from state for registry lookup.
// Platform nginx sends `state=instance:nonce` but flows are keyed by nonce only.
let lookup_key = oauth_defaults::strip_instance_prefix(&state_param);
let decoded_state = match oauth_defaults::decode_hosted_oauth_state(&state_param) {
Ok(decoded) => decoded,
Err(error) => {
let redacted_state = redact_oauth_state_for_logs(&state_param);
tracing::warn!(
state = %redacted_state,
error = %error,
"OAuth callback received with malformed state"
);
clear_auth_mode(&state).await;
return oauth_error_page("IronClaw");
}
};
let lookup_key = decoded_state.flow_id.clone();
let flow = ext_mgr
.pending_oauth_flows()
.write()
.await
.remove(lookup_key);
.remove(&lookup_key);
let flow = match flow {
Some(f) => f,
None => {
let redacted_state = redact_oauth_state_for_logs(&state_param);
let redacted_lookup_key = redact_oauth_state_for_logs(&lookup_key);
tracing::warn!(
state = %state_param,
lookup_key = %lookup_key,
state = %redacted_state,
lookup_key = %redacted_lookup_key,
"OAuth callback received with unknown or expired state"
);
clear_auth_mode(&state).await;
@@ -607,33 +652,29 @@ async fn oauth_callback_handler(
}
// Exchange the authorization code for tokens.
// Use the platform exchange proxy when configured (keeps client_secret off container),
// otherwise call the provider's token URL directly.
let exchange_proxy_url = std::env::var("IRONCLAW_OAUTH_EXCHANGE_URL").ok();
// Use the platform exchange proxy when configured, otherwise call the
// provider's token URL directly.
let exchange_proxy_url = oauth_defaults::exchange_proxy_url();
let result: Result<(), String> = async {
let token_response = if let (Some(proxy_url), None) = (&exchange_proxy_url, &flow.resource)
{
// Use the platform exchange proxy when configured and no resource
// parameter is needed. The proxy holds client_secret server-side so
// the container never sees it. MCP flows (resource.is_some()) bypass
// the proxy because it doesn't forward the RFC 8707 resource param.
let token_response = if let Some(proxy_url) = &exchange_proxy_url {
let gateway_token = flow.gateway_token.as_deref().unwrap_or_default();
oauth_defaults::exchange_via_proxy(
oauth_defaults::exchange_via_proxy(oauth_defaults::ProxyTokenExchangeRequest {
proxy_url,
gateway_token,
&code,
&flow.redirect_uri,
flow.code_verifier.as_deref(),
&flow.access_token_field,
)
token_url: &flow.token_url,
client_id: &flow.client_id,
client_secret: flow.client_secret.as_deref(),
code: &code,
redirect_uri: &flow.redirect_uri,
code_verifier: flow.code_verifier.as_deref(),
access_token_field: &flow.access_token_field,
extra_token_params: &flow.token_exchange_extra_params,
})
.await
.map_err(|e| e.to_string())?
} else {
// Direct token exchange: uses exchange_oauth_code_with_resource so MCP
// flows can include the RFC 8707 `resource` parameter to scope the
// issued token to the specific MCP server.
oauth_defaults::exchange_oauth_code_with_resource(
oauth_defaults::exchange_oauth_code_with_params(
&flow.token_url,
&flow.client_id,
flow.client_secret.as_deref(),
@@ -641,7 +682,7 @@ async fn oauth_callback_handler(
&flow.redirect_uri,
flow.code_verifier.as_deref(),
&flow.access_token_field,
flow.resource.as_deref(),
&flow.token_exchange_extra_params,
)
.await
.map_err(|e| e.to_string())?
@@ -668,10 +709,8 @@ async fn oauth_callback_handler(
.await
.map_err(|e| e.to_string())?;
// For MCP OAuth flows (identified by resource field), persist the
// client_id so token refresh works without re-authentication.
// The CLI flow stores this in authorize_mcp_server(); the gateway
// callback must do the same.
// Persist the client_id for flows that need it after the session ends
// (for example DCR-based MCP refresh).
if let Some(ref client_id_secret) = flow.client_id_secret_name {
let params = crate::secrets::CreateSecretParams::new(client_id_secret, &flow.client_id)
.with_provider(flow.provider.as_ref().cloned().unwrap_or_default());
@@ -752,11 +791,103 @@ async fn oauth_callback_handler(
axum::response::Html(html).into_response()
}
/// Webhook endpoint for receiving relay events from channel-relay.
///
/// PUBLIC route — authenticated via HMAC signature (X-Relay-Signature header).
async fn relay_events_handler(
State(state): State<Arc<GatewayState>>,
headers: axum::http::HeaderMap,
body: axum::body::Bytes,
) -> impl IntoResponse {
let ext_mgr = match state.extension_manager.as_ref() {
Some(mgr) => mgr,
None => {
return (StatusCode::SERVICE_UNAVAILABLE, "not ready").into_response();
}
};
let signing_secret = match ext_mgr.relay_signing_secret() {
Some(s) => s,
None => {
return (StatusCode::SERVICE_UNAVAILABLE, "relay not configured").into_response();
}
};
// Verify signature
let signature = match headers
.get("x-relay-signature")
.and_then(|v| v.to_str().ok())
{
Some(s) => s.to_string(),
None => {
return (StatusCode::UNAUTHORIZED, "missing signature").into_response();
}
};
let timestamp = match headers
.get("x-relay-timestamp")
.and_then(|v| v.to_str().ok())
{
Some(t) => t.to_string(),
None => {
return (StatusCode::UNAUTHORIZED, "missing timestamp").into_response();
}
};
// Check timestamp freshness (5 min window)
let ts: i64 = match timestamp.parse() {
Ok(t) => t,
Err(_) => {
return (StatusCode::BAD_REQUEST, "malformed timestamp").into_response();
}
};
let now = chrono::Utc::now().timestamp();
if (now - ts).abs() > 300 {
return (StatusCode::UNAUTHORIZED, "stale timestamp").into_response();
}
// Verify HMAC: sha256(secret, timestamp + "." + body)
if !crate::channels::relay::webhook::verify_relay_signature(
&signing_secret,
&timestamp,
&body,
&signature,
) {
return (StatusCode::UNAUTHORIZED, "invalid signature").into_response();
}
// Parse event
let event: crate::channels::relay::client::ChannelEvent = match serde_json::from_slice(&body) {
Ok(e) => e,
Err(e) => {
tracing::warn!(error = %e, "relay callback invalid JSON");
return (StatusCode::BAD_REQUEST, "invalid JSON").into_response();
}
};
// Push to relay channel
let event_tx_guard = ext_mgr.relay_event_tx();
let event_tx = event_tx_guard.lock().await;
match event_tx.as_ref() {
Some(tx) => {
if let Err(e) = tx.try_send(event) {
tracing::warn!(error = %e, "relay event channel full or closed");
return (StatusCode::SERVICE_UNAVAILABLE, "event queue full").into_response();
}
}
None => {
return (StatusCode::SERVICE_UNAVAILABLE, "relay channel not active").into_response();
}
}
Json(serde_json::json!({"ok": true})).into_response()
}
/// OAuth callback for Slack via channel-relay.
///
/// This is a PUBLIC route (no Bearer token required) because channel-relay
/// redirects the user's browser here after Slack OAuth completes.
/// Query params: `stream_token`, `provider`, `team_id`.
/// Query params: `provider`, `team_id`.
async fn slack_relay_oauth_callback_handler(
State(state): State<Arc<GatewayState>>,
Query(params): Query<std::collections::HashMap<String, String>>,
@@ -773,27 +904,6 @@ async fn slack_relay_oauth_callback_handler(
.into_response();
}
// Validate stream_token: required, non-empty, max 2048 bytes
let stream_token = match params.get("stream_token") {
Some(t) if !t.is_empty() && t.len() <= 2048 => t.clone(),
Some(t) if t.len() > 2048 => {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Error</h2><p>Invalid callback parameters.</p></body></html>"
.to_string(),
)
.into_response();
}
_ => {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Error</h2><p>Invalid callback parameters.</p></body></html>"
.to_string(),
)
.into_response();
}
};
// Validate team_id format: empty or T followed by alphanumeric (max 20 chars)
let team_id = params.get("team_id").cloned().unwrap_or_default();
if !team_id.is_empty() {
@@ -879,30 +989,16 @@ async fn slack_relay_oauth_callback_handler(
let _ = ext_mgr.secrets().delete(&state.user_id, &state_key).await;
let result: Result<(), String> = async {
// Store the stream token as a secret
let token_key = format!("relay:{}:stream_token", DEFAULT_RELAY_NAME);
let _ = ext_mgr.secrets().delete(&state.user_id, &token_key).await;
ext_mgr
.secrets()
.create(
&state.user_id,
crate::secrets::CreateSecretParams {
name: token_key,
value: secrecy::SecretString::from(stream_token),
provider: Some(provider.clone()),
expires_at: None,
},
)
.await
.map_err(|e| format!("Failed to store stream token: {}", e))?;
let store = state.store.as_ref().ok_or_else(|| {
"Relay activation requires persistent settings storage; no-db mode is unsupported."
.to_string()
})?;
// Store team_id in settings
if let Some(ref store) = state.store {
let team_id_key = format!("relay:{}:team_id", DEFAULT_RELAY_NAME);
let _ = store
.set_setting(&state.user_id, &team_id_key, &serde_json::json!(team_id))
.await;
}
let team_id_key = format!("relay:{}:team_id", DEFAULT_RELAY_NAME);
let _ = store
.set_setting(&state.user_id, &team_id_key, &serde_json::json!(team_id))
.await;
// Activate the relay channel
ext_mgr
@@ -1726,14 +1822,53 @@ async fn memory_write_handler(
"Workspace not available".to_string(),
))?;
workspace
.write(&req.path, &req.content)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Route through layer-aware methods when a layer is specified
if let Some(ref layer_name) = req.layer {
let result = if req.append {
workspace
.append_to_layer(layer_name, &req.path, &req.content, req.force)
.await
} else {
workspace
.write_to_layer(layer_name, &req.path, &req.content, req.force)
.await
}
.map_err(|e| {
use crate::error::WorkspaceError;
let status = match &e {
WorkspaceError::LayerNotFound { .. } => StatusCode::BAD_REQUEST,
WorkspaceError::LayerReadOnly { .. } => StatusCode::FORBIDDEN,
WorkspaceError::PrivacyRedirectFailed => StatusCode::UNPROCESSABLE_ENTITY,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, e.to_string())
})?;
return Ok(Json(MemoryWriteResponse {
path: req.path,
status: "written",
redirected: Some(result.redirected),
actual_layer: Some(result.actual_layer),
}));
}
// Non-layer path: honor the append field
if req.append {
workspace
.append(&req.path, &req.content)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
} else {
workspace
.write(&req.path, &req.content)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
}
Ok(Json(MemoryWriteResponse {
path: req.path,
status: "written",
redirected: None,
actual_layer: None,
}))
}
@@ -2315,164 +2450,6 @@ async fn pairing_approve_handler(
}
}
// --- Routines handlers ---
async fn routines_list_handler(
State(state): State<Arc<GatewayState>>,
) -> Result<Json<RoutineListResponse>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let routines = store
.list_all_routines()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let items: Vec<RoutineInfo> = routines.iter().map(RoutineInfo::from_routine).collect();
Ok(Json(RoutineListResponse { routines: items }))
}
async fn routines_summary_handler(
State(state): State<Arc<GatewayState>>,
) -> Result<Json<RoutineSummaryResponse>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let routines = store
.list_all_routines()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let total = routines.len() as u64;
let enabled = routines.iter().filter(|r| r.enabled).count() as u64;
let disabled = total - enabled;
let failing = routines
.iter()
.filter(|r| r.consecutive_failures > 0)
.count() as u64;
let today_start = chrono::Utc::now()
.date_naive()
.and_hms_opt(0, 0, 0)
.map(|dt| dt.and_utc());
let runs_today = if let Some(start) = today_start {
routines
.iter()
.filter(|r| r.last_run_at.is_some_and(|ts| ts >= start))
.count() as u64
} else {
0
};
Ok(Json(RoutineSummaryResponse {
total,
enabled,
disabled,
failing,
runs_today,
}))
}
async fn routines_detail_handler(
State(state): State<Arc<GatewayState>>,
Path(id): Path<String>,
) -> Result<Json<RoutineDetailResponse>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let routine_id = Uuid::parse_str(&id)
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?;
let routine = store
.get_routine(routine_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
let runs = store
.list_routine_runs(routine_id, 20)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let recent_runs: Vec<RoutineRunInfo> = runs
.iter()
.map(|run| RoutineRunInfo {
id: run.id,
trigger_type: run.trigger_type.clone(),
started_at: run.started_at.to_rfc3339(),
completed_at: run.completed_at.map(|dt| dt.to_rfc3339()),
status: format!("{:?}", run.status),
result_summary: run.result_summary.clone(),
tokens_used: run.tokens_used,
job_id: run.job_id,
})
.collect();
let routine_info = RoutineInfo::from_routine(&routine);
Ok(Json(RoutineDetailResponse {
id: routine.id,
name: routine.name.clone(),
description: routine.description.clone(),
enabled: routine.enabled,
trigger_type: routine_info.trigger_type,
trigger_raw: routine_info.trigger_raw,
trigger_summary: routine_info.trigger_summary,
trigger: serde_json::to_value(&routine.trigger).unwrap_or_default(),
action: serde_json::to_value(&routine.action).unwrap_or_default(),
guardrails: serde_json::to_value(&routine.guardrails).unwrap_or_default(),
notify: serde_json::to_value(&routine.notify).unwrap_or_default(),
last_run_at: routine.last_run_at.map(|dt| dt.to_rfc3339()),
next_fire_at: routine.next_fire_at.map(|dt| dt.to_rfc3339()),
run_count: routine.run_count,
consecutive_failures: routine.consecutive_failures,
created_at: routine.created_at.to_rfc3339(),
recent_runs,
}))
}
async fn routines_trigger_handler(
State(state): State<Arc<GatewayState>>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let engine = {
let guard = state.routine_engine.read().await;
guard.as_ref().cloned().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Routine engine not available".to_string(),
))?
};
let routine_id = Uuid::parse_str(&id)
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?;
let run_id = engine
.fire_manual(routine_id, Some(&state.user_id))
.await
.map_err(|e| {
let status = match &e {
crate::error::RoutineError::NotFound { .. } => StatusCode::NOT_FOUND,
crate::error::RoutineError::NotAuthorized { .. } => StatusCode::FORBIDDEN,
crate::error::RoutineError::Disabled { .. }
| crate::error::RoutineError::MaxConcurrent { .. } => StatusCode::CONFLICT,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, e.to_string())
})?;
Ok(Json(serde_json::json!({
"status": "triggered",
"routine_id": routine_id,
"run_id": run_id,
})))
}
async fn routines_runs_handler(
State(state): State<Arc<GatewayState>>,
Path(id): Path<String>,
@@ -2902,6 +2879,7 @@ mod tests {
scheduler: None,
chat_rate_limiter: RateLimiter::new(30, 60),
oauth_rate_limiter: RateLimiter::new(10, 60),
webhook_rate_limiter: RateLimiter::new(10, 60),
registry_entries: vec![],
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
@@ -3253,7 +3231,7 @@ mod tests {
secrets,
sse_sender: None,
gateway_token: None,
resource: None,
token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None,
created_at,
};
@@ -3321,7 +3299,7 @@ mod tests {
secrets,
sse_sender: Some(sender),
gateway_token: None,
resource: None,
token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None,
created_at,
};
@@ -3424,7 +3402,7 @@ mod tests {
secrets,
sse_sender: None,
gateway_token: None,
resource: None,
token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None,
// Expired — handler will reject after lookup (no network I/O)
created_at,
@@ -3476,6 +3454,85 @@ mod tests {
);
}
#[tokio::test]
async fn test_oauth_callback_accepts_versioned_hosted_state() {
use axum::body::Body;
use tower::ServiceExt;
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
TEST_GATEWAY_CRYPTO_KEY.to_string(),
))
.expect("crypto"),
)));
let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets.clone());
let Some(created_at) = expired_flow_created_at() else {
eprintln!("Skipping versioned OAuth state test: monotonic uptime below expiry window");
return;
};
let flow = crate::cli::oauth_defaults::PendingOAuthFlow {
extension_name: "test_tool".to_string(),
display_name: "Test Tool".to_string(),
token_url: "https://example.com/token".to_string(),
client_id: "client123".to_string(),
client_secret: None,
redirect_uri: "https://example.com/oauth/callback".to_string(),
code_verifier: None,
access_token_field: "access_token".to_string(),
secret_name: "test_token".to_string(),
provider: None,
validation_endpoint: None,
scopes: vec![],
user_id: "test".to_string(),
secrets,
sse_sender: None,
gateway_token: None,
token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None,
created_at,
};
ext_mgr
.pending_oauth_flows()
.write()
.await
.insert("test_nonce".to_string(), flow);
let state = test_gateway_state(Some(ext_mgr.clone()));
let app = test_oauth_router(state);
let versioned_state =
crate::cli::oauth_defaults::encode_hosted_oauth_state("test_nonce", Some("myinstance"));
let req = axum::http::Request::builder()
.uri(format!(
"/oauth/callback?code=fake_code&state={}",
urlencoding::encode(&versioned_state)
))
.body(Body::empty())
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
.await
.expect("body");
let html = String::from_utf8_lossy(&body);
assert!(html.contains("Authorization Failed"));
assert!(
ext_mgr
.pending_oauth_flows()
.read()
.await
.get("test_nonce")
.is_none()
);
}
// --- Slack relay OAuth CSRF tests ---
fn test_relay_oauth_router(state: Arc<GatewayState>) -> Router {
@@ -3533,7 +3590,7 @@ mod tests {
// Callback without state param should be rejected
let req = axum::http::Request::builder()
.uri("/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack")
.uri("/oauth/slack/callback?team_id=T123&provider=slack")
.body(Body::empty())
.expect("request");
@@ -3577,7 +3634,7 @@ mod tests {
// Callback with wrong state param
let req = axum::http::Request::builder()
.uri("/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack&state=wrong-nonce")
.uri("/oauth/slack/callback?team_id=T123&provider=slack&state=wrong-nonce")
.body(Body::empty())
.expect("request");
@@ -3625,7 +3682,7 @@ mod tests {
// we just verify it doesn't return a CSRF error.
let req = axum::http::Request::builder()
.uri(format!(
"/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack&state={}",
"/oauth/slack/callback?team_id=T123&provider=slack&state={}",
nonce
))
.body(Body::empty())
+123 -8
View File
@@ -1,5 +1,69 @@
// IronClaw Web Gateway - Client
// --- Theme Management (dark / light / system) ---
// Icon switching is handled by pure CSS via data-theme-mode on <html>.
function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
const VALID_THEME_MODES = { dark: true, light: true, system: true };
function getThemeMode() {
const stored = localStorage.getItem('ironclaw-theme');
return (stored && VALID_THEME_MODES[stored]) ? stored : 'system';
}
function resolveTheme(mode) {
return mode === 'system' ? getSystemTheme() : mode;
}
function applyTheme(mode) {
const resolved = resolveTheme(mode);
document.documentElement.setAttribute('data-theme', resolved);
document.documentElement.setAttribute('data-theme-mode', mode);
const titleKeys = { dark: 'theme.tooltipDark', light: 'theme.tooltipLight', system: 'theme.tooltipSystem' };
const btn = document.getElementById('theme-toggle');
if (btn) btn.title = (typeof I18n !== 'undefined' && titleKeys[mode]) ? I18n.t(titleKeys[mode]) : ('Theme: ' + mode);
const announce = document.getElementById('theme-announce');
if (announce) announce.textContent = (typeof I18n !== 'undefined') ? I18n.t('theme.announce', { mode: mode }) : ('Theme: ' + mode);
}
function toggleTheme() {
const cycle = { dark: 'light', light: 'system', system: 'dark' };
const current = getThemeMode();
const next = cycle[current] || 'dark';
localStorage.setItem('ironclaw-theme', next);
applyTheme(next);
}
// Apply theme immediately (FOUC prevention is done via inline script in <head>,
// but we call again here to ensure tooltip is set after DOM is ready).
applyTheme(getThemeMode());
// Delay enabling theme transition to avoid flash on initial load.
requestAnimationFrame(function() {
requestAnimationFrame(function() {
document.body.classList.add('theme-transition');
});
});
// Listen for OS theme changes — only re-apply when in 'system' mode.
const mql = window.matchMedia('(prefers-color-scheme: light)');
const onSchemeChange = function() {
if (getThemeMode() === 'system') {
applyTheme('system');
}
};
if (mql.addEventListener) {
mql.addEventListener('change', onSchemeChange);
} else if (mql.addListener) {
mql.addListener(onSchemeChange);
}
// Bind theme toggle button (CSP-compliant — no inline onclick).
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
let token = '';
let eventSource = null;
let logEventSource = null;
@@ -100,6 +164,30 @@ 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());
// Auto-authenticate from URL param or saved session
(function autoAuth() {
const params = new URLSearchParams(window.location.search);
@@ -1138,18 +1226,19 @@ function showApproval(data) {
approveBtn.textContent = I18n.t('approval.approve');
approveBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'approve'));
const alwaysBtn = document.createElement('button');
alwaysBtn.className = 'always';
alwaysBtn.textContent = I18n.t('approval.always');
alwaysBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'always'));
const denyBtn = document.createElement('button');
denyBtn.className = 'deny';
denyBtn.textContent = I18n.t('approval.deny');
denyBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'deny'));
actions.appendChild(approveBtn);
actions.appendChild(alwaysBtn);
if (data.allow_always !== false) {
const alwaysBtn = document.createElement('button');
alwaysBtn.className = 'always';
alwaysBtn.textContent = I18n.t('approval.always');
alwaysBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'always'));
actions.appendChild(alwaysBtn);
}
actions.appendChild(denyBtn);
card.appendChild(actions);
@@ -3853,7 +3942,6 @@ function renderRoutineDetail(routine) {
+ '<pre class="action-json">' + escapeHtml(JSON.stringify(routine.trigger, null, 2)) + '</pre></div>';
}
// Action config
html += '<div class="job-description"><h3>Action</h3>'
+ '<pre class="action-json">' + escapeHtml(JSON.stringify(routine.action, null, 2)) + '</pre></div>';
@@ -4872,7 +4960,14 @@ function renderStructuredSettingsRow(def, value, activeValue) {
inputWrap.style.gap = '8px';
var ariaLabel = I18n.t(def.label) + (def.description ? '. ' + I18n.t(def.description) : '');
var placeholderText = activeValue ? I18n.t('settings.envValue', { value: activeValue }) : (def.placeholder || I18n.t('settings.envDefault'));
function formatSettingValue(raw) {
if (Array.isArray(raw)) return raw.join(', ');
if (raw === null || raw === undefined) return '';
return String(raw);
}
var activeValueText = formatSettingValue(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');
@@ -4944,6 +5039,26 @@ function renderStructuredSettingsRow(def, value, activeValue) {
};
})(def.key, numInp));
inputWrap.appendChild(numInp);
} else if (def.type === 'list') {
var listInp = document.createElement('input');
listInp.type = 'text';
listInp.className = 'settings-input';
listInp.setAttribute('aria-label', ariaLabel);
var listValue = '';
if (Array.isArray(value)) listValue = value.join(', ');
else if (typeof value === 'string') listValue = value;
listInp.value = listValue;
if (!listValue) listInp.placeholder = placeholderText;
listInp.addEventListener('change', (function(k, el) {
return function() {
if (el.value.trim() === '') return saveSetting(k, null);
var items = el.value.split(/[\n,]/).map(function(item) {
return item.trim();
}).filter(Boolean);
saveSetting(k, items);
};
})(def.key, listInp));
inputWrap.appendChild(listInp);
} else {
var textInp = document.createElement('input');
textInp.type = 'text';
+6
View File
@@ -24,6 +24,12 @@ I18n.register('en', {
'restart.progressSubtitle': 'Please wait for the process to restart...',
'restart.checkLogs': 'Check the Logs tab for details after restart completes.',
// Theme
'theme.tooltipDark': 'Theme: Dark (click for Light)',
'theme.tooltipLight': 'Theme: Light (click for System)',
'theme.tooltipSystem': 'Theme: System (click for Dark)',
'theme.announce': 'Theme: {mode}',
// Tabs
'tab.chat': 'Chat',
'tab.memory': 'Memory',
+6
View File
@@ -24,6 +24,12 @@ I18n.register('zh-CN', {
'restart.progressSubtitle': '请等待进程重启...',
'restart.checkLogs': '重启完成后,请查看日志标签页了解详情。',
// 主题
'theme.tooltipDark': '主题:深色(点击切换浅色)',
'theme.tooltipLight': '主题:浅色(点击切换跟随系统)',
'theme.tooltipSystem': '主题:跟随系统(点击切换深色)',
'theme.announce': '主题:{mode}',
// 标签页
'tab.chat': '聊天',
'tab.memory': '记忆',
+18 -7
View File
@@ -25,6 +25,7 @@
integrity="sha384-pN9zSKOnTZwXRtYZAu0PBPEgR2B7DOC1aeLxQ33oJ0oy5iN1we6gm57xldM2irDG"
crossorigin="anonymous"
></script>
<script src="/theme-init.js"></script>
</head>
<body>
<!-- Auth Screen -->
@@ -109,6 +110,18 @@
</div>
<button class="status-logs-btn" data-tab="logs" data-i18n="tab.logs" title="Logs">Logs</button>
<button class="theme-toggle-btn" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme">
<svg class="theme-icon icon-dark" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
<svg class="theme-icon icon-light" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
<svg class="theme-icon icon-system" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
</svg>
</button>
<span id="theme-announce" class="sr-only" aria-live="polite"></span>
<div class="tee-shield" id="tee-shield" style="display:none" title="Running in a Trusted Execution Environment">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
@@ -135,19 +148,17 @@
<!-- Chat Tab -->
<div class="tab-panel active" id="tab-chat">
<div class="thread-sidebar" id="thread-sidebar">
<div class="thread-sidebar-header">
<button class="thread-new-btn" id="thread-new-btn" data-i18n="chat.newThread" data-i18n-attr="title"
title="New thread (Ctrl/Cmd+N)">+</button>
<div class="spacer"></div>
<button class="thread-toggle-btn" id="thread-toggle-btn" data-i18n="chat.toggleSidebar"
data-i18n-attr="title" title="Toggle sidebar">&laquo;</button>
</div>
<div class="assistant-item" id="assistant-thread">
<span class="assistant-label" id="assistant-label" data-i18n="chat.assistant">Assistant</span>
<span class="assistant-meta" id="assistant-meta"></span>
</div>
<div class="threads-section-header">
<span data-i18n="chat.conversations">Conversations</span>
<div class="spacer"></div>
<button class="thread-new-btn" id="thread-new-btn" data-i18n="chat.newThread" data-i18n-attr="title"
title="New thread (Ctrl/Cmd+N)">+</button>
<button class="thread-toggle-btn" id="thread-toggle-btn" data-i18n="chat.toggleSidebar"
data-i18n-attr="title" title="Toggle sidebar">&laquo;</button>
</div>
<div class="thread-list" id="thread-list"></div>
</div>
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
// Prevent FOUC: apply saved theme before first paint.
// This script must be loaded synchronously in <head> (no defer/async).
(function() {
const stored = localStorage.getItem('ironclaw-theme');
const mode = (stored === 'dark' || stored === 'light' || stored === 'system') ? stored : 'system';
let resolved = mode;
if (mode === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
document.documentElement.setAttribute('data-theme', resolved);
document.documentElement.setAttribute('data-theme-mode', mode);
})();
+1
View File
@@ -83,6 +83,7 @@ impl TestGatewayBuilder {
scheduler: None,
chat_rate_limiter: RateLimiter::new(30, 60),
oauth_rate_limiter: RateLimiter::new(10, 60),
webhook_rate_limiter: RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
+31
View File
@@ -177,6 +177,8 @@ pub enum SseEvent {
parameters: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
/// Whether the "always" auto-approve option should be shown.
allow_always: bool,
},
#[serde(rename = "auth_required")]
AuthRequired {
@@ -230,6 +232,8 @@ pub enum SseEvent {
status: String,
#[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
fallback_deliverable: Option<serde_json::Value>,
},
/// An image was generated by a tool.
@@ -298,12 +302,30 @@ pub struct MemoryReadResponse {
pub struct MemoryWriteRequest {
pub path: String,
pub content: String,
/// Optional layer to write to. When present, uses `write_to_layer()`
/// which enables privacy classification and redirect.
pub layer: Option<String>,
/// When true and a layer is specified, appends to existing content
/// instead of replacing it.
#[serde(default)]
pub append: bool,
/// Skip privacy classification and write directly to the specified layer.
#[serde(default)]
pub force: bool,
}
#[derive(Debug, Serialize)]
pub struct MemoryWriteResponse {
pub path: String,
pub status: &'static str,
/// Whether the write was redirected to a different layer (e.g., sensitive
/// content redirected from shared to private).
#[serde(skip_serializing_if = "Option::is_none")]
pub redirected: Option<bool>,
/// The layer the content was actually written to (may differ from requested
/// layer if privacy redirect occurred).
#[serde(skip_serializing_if = "Option::is_none")]
pub actual_layer: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -810,6 +832,14 @@ impl RoutineInfo {
String::new(),
format!("event: {}.{}", source, event_type),
),
crate::agent::routine::Trigger::Webhook { path, .. } => {
let p = path.as_deref().unwrap_or("default");
(
"webhook".to_string(),
String::new(),
format!("webhook: /api/webhooks/{}", p),
)
}
crate::agent::routine::Trigger::Manual => (
"manual".to_string(),
String::new(),
@@ -1080,6 +1110,7 @@ mod tests {
description: "Run ls".to_string(),
parameters: "{}".to_string(),
thread_id: Some("t1".to_string()),
allow_always: true,
};
let ws = WsServerMessage::from_sse_event(&sse);
match ws {
+1
View File
@@ -517,6 +517,7 @@ mod tests {
skill_catalog: None,
chat_rate_limiter: crate::channels::web::server::RateLimiter::new(30, 60),
oauth_rate_limiter: crate::channels::web::server::RateLimiter::new(10, 60),
webhook_rate_limiter: crate::channels::web::server::RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
+59 -3
View File
@@ -33,7 +33,7 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
check(
"NEAR AI session",
check_nearai_session().await,
check_nearai_session(&settings).await,
&mut passed,
&mut failed,
&mut skipped,
@@ -215,7 +215,22 @@ fn check_settings_file() -> CheckResult {
// ── NEAR AI session ─────────────────────────────────────────
async fn check_nearai_session() -> CheckResult {
async fn check_nearai_session(settings: &Settings) -> CheckResult {
// Skip entirely when the configured backend is not NEAR AI.
let llm_config = match crate::config::LlmConfig::resolve(settings) {
Ok(config) => config,
Err(e) => {
// check_llm_config will report the full error; just skip here.
return CheckResult::Skip(format!("LLM config error: {e}"));
}
};
if llm_config.backend != "nearai" {
return CheckResult::Skip(format!(
"not using NEAR AI backend (backend={})",
llm_config.backend
));
}
// Check if session file exists
let session_path = crate::config::llm::default_session_path();
if !session_path.exists() {
@@ -620,12 +635,53 @@ mod tests {
#[tokio::test]
async fn check_nearai_session_does_not_panic() {
let result = check_nearai_session().await;
let settings = Settings::default();
let result = check_nearai_session(&settings).await;
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[test]
fn check_nearai_session_skips_for_non_nearai_backend() {
struct EnvGuard(&'static str, Option<String>);
impl Drop for EnvGuard {
fn drop(&mut self) {
// SAFETY: Under ENV_MUTEX.
unsafe {
match &self.1 {
Some(val) => std::env::set_var(self.0, val),
None => std::env::remove_var(self.0),
}
}
}
}
let _mutex = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
let prev = std::env::var("LLM_BACKEND").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::set_var("LLM_BACKEND", "anthropic");
}
let _env_guard = EnvGuard("LLM_BACKEND", prev);
let settings = Settings::default();
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
let result = rt.block_on(check_nearai_session(&settings));
match result {
CheckResult::Skip(msg) => {
assert!(
msg.contains("backend=anthropic"),
"expected backend name in skip message, got: {msg}"
);
}
other => panic!(
"expected Skip for non-nearai backend, got: {}",
format_result(&other)
),
}
}
#[test]
fn check_settings_file_handles_missing() {
// Settings::default_path() might or might not exist, but must not panic
+5 -3
View File
@@ -7,17 +7,18 @@ use std::sync::Arc;
use clap::Subcommand;
use crate::workspace::{EmbeddingProvider, SearchConfig, Workspace};
use crate::workspace::{EmbeddingCacheConfig, EmbeddingProvider, SearchConfig, Workspace};
/// Run a memory command using the Database trait (works with any backend).
pub async fn run_memory_command_with_db(
cmd: MemoryCommand,
db: std::sync::Arc<dyn crate::db::Database>,
embeddings: Option<Arc<dyn EmbeddingProvider>>,
cache_config: EmbeddingCacheConfig,
) -> anyhow::Result<()> {
let mut workspace = Workspace::new_with_db("default", db);
if let Some(emb) = embeddings {
workspace = workspace.with_embeddings(emb);
workspace = workspace.with_embeddings_cached(emb, cache_config);
}
match cmd {
@@ -85,10 +86,11 @@ pub async fn run_memory_command(
cmd: MemoryCommand,
pool: deadpool_postgres::Pool,
embeddings: Option<Arc<dyn EmbeddingProvider>>,
cache_config: EmbeddingCacheConfig,
) -> anyhow::Result<()> {
let mut workspace = Workspace::new("default", pool);
if let Some(emb) = embeddings {
workspace = workspace.with_embeddings(emb);
workspace = workspace.with_embeddings_cached(emb, cache_config);
}
match cmd {
+15 -1
View File
@@ -239,6 +239,17 @@ pub enum Command {
)]
Import(ImportCommand),
/// Authenticate with a provider (re-login)
#[command(
about = "Authenticate with a provider",
long_about = "Re-authenticate with an LLM provider.\nExample: ironclaw login --openai-codex"
)]
Login {
/// Authenticate with OpenAI Codex (ChatGPT subscription)
#[arg(long)]
openai_codex: bool,
},
/// Run as a sandboxed worker inside a Docker container (internal use).
/// This is invoked automatically by the orchestrator, not by users directly.
#[command(hide = true)]
@@ -336,7 +347,10 @@ pub async fn run_memory_command(mem_cmd: &MemoryCommand) -> anyhow::Result<()> {
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
run_memory_command_with_db(mem_cmd.clone(), db, embeddings).await
let cache_config = crate::workspace::EmbeddingCacheConfig {
max_entries: config.embeddings.cache_size,
};
run_memory_command_with_db(mem_cmd.clone(), db, embeddings, cache_config).await
}
#[cfg(test)]
+264 -74
View File
@@ -5,17 +5,10 @@
//!
//! # Built-in Credentials
//!
//! Many CLI tools (gcloud, rclone, gdrive) ship with default OAuth credentials
//! so users don't need to register their own OAuth app. Google explicitly
//! documents that client_secret for "Desktop App" / "Installed App" types
//! is NOT actually secret.
//!
//! Default credentials are hardcoded below. They can be overridden at:
//!
//! - **Compile time**: Set IRONCLAW_GOOGLE_CLIENT_ID / IRONCLAW_GOOGLE_CLIENT_SECRET
//! env vars before building to replace the hardcoded defaults.
//! - **Runtime**: Users can set GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET
//! env vars, which take priority over built-in defaults.
//! Some providers ship with built-in OAuth credentials so users don't need to
//! register their own OAuth app just to get started. Today this module only
//! includes built-in defaults for Google-family tools, and those defaults can
//! be overridden by provider-specific environment variables when needed.
use std::collections::HashMap;
use std::sync::Arc;
@@ -23,6 +16,7 @@ use std::time::Duration;
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tokio::sync::RwLock;
@@ -60,6 +54,14 @@ pub fn builtin_credentials(secret_name: &str) -> Option<OAuthCredentials> {
}
}
/// Returns the compile-time override env var name, if this provider supports one.
pub fn builtin_client_id_override_env(secret_name: &str) -> Option<&'static str> {
match secret_name {
"google_oauth_token" => Some("IRONCLAW_GOOGLE_CLIENT_ID"),
_ => None,
}
}
// ── Shared callback server ──────────────────────────────────────────────
// Core OAuth callback infrastructure is defined in `crate::llm::oauth_helpers`
@@ -173,9 +175,8 @@ pub async fn exchange_oauth_code(
code_verifier: Option<&str>,
access_token_field: &str,
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
// Delegates to exchange_oauth_code_with_resource with resource=None.
// Non-MCP OAuth flows don't need the RFC 8707 resource parameter.
exchange_oauth_code_with_resource(
let extra_token_params = HashMap::new();
exchange_oauth_code_with_params(
token_url,
client_id,
client_secret,
@@ -183,16 +184,14 @@ pub async fn exchange_oauth_code(
redirect_uri,
code_verifier,
access_token_field,
None,
&extra_token_params,
)
.await
}
/// Exchange an OAuth authorization code for tokens, with optional RFC 8707 `resource` parameter.
///
/// The `resource` parameter scopes the issued token to a specific server (used by MCP OAuth).
/// Exchange an OAuth authorization code for tokens with generic extra form parameters.
#[allow(clippy::too_many_arguments)]
pub async fn exchange_oauth_code_with_resource(
pub async fn exchange_oauth_code_with_params(
token_url: &str,
client_id: &str,
client_secret: Option<&str>,
@@ -200,7 +199,7 @@ pub async fn exchange_oauth_code_with_resource(
redirect_uri: &str,
code_verifier: Option<&str>,
access_token_field: &str,
resource: Option<&str>,
extra_token_params: &HashMap<String, String>,
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
let client = reqwest::Client::new();
let mut token_params = vec![
@@ -213,10 +212,8 @@ pub async fn exchange_oauth_code_with_resource(
token_params.push(("code_verifier", verifier.to_string()));
}
// RFC 8707: include the `resource` parameter so the authorization server
// scopes the issued token to the specific MCP server (protected resource).
if let Some(resource) = resource {
token_params.push(("resource", resource.to_string()));
for (key, value) in extra_token_params {
token_params.push((key.as_str(), value.clone()));
}
let mut request = client.post(token_url);
@@ -276,6 +273,37 @@ pub async fn exchange_oauth_code_with_resource(
})
}
/// Exchange an OAuth authorization code for tokens, with optional RFC 8707 `resource` parameter.
///
/// The `resource` parameter scopes the issued token to a specific server (used by MCP OAuth).
#[allow(clippy::too_many_arguments)]
pub async fn exchange_oauth_code_with_resource(
token_url: &str,
client_id: &str,
client_secret: Option<&str>,
code: &str,
redirect_uri: &str,
code_verifier: Option<&str>,
access_token_field: &str,
resource: Option<&str>,
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
let mut extra_token_params = HashMap::new();
if let Some(resource) = resource {
extra_token_params.insert("resource".to_string(), resource.to_string());
}
exchange_oauth_code_with_params(
token_url,
client_id,
client_secret,
code,
redirect_uri,
code_verifier,
access_token_field,
&extra_token_params,
)
.await
}
/// Store OAuth tokens (access + refresh) in the secrets store.
///
/// Also stores the granted scopes as `{secret_name}_scopes` so that scope
@@ -423,9 +451,9 @@ pub struct PendingOAuthFlow {
pub sse_sender: Option<tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>>,
/// Gateway auth token for authenticating with the platform token exchange proxy.
pub gateway_token: Option<String>,
/// RFC 8707 resource parameter (MCP OAuth only).
/// Sent during token exchange to scope the token to a specific MCP server.
pub resource: Option<String>,
/// Additional form params for the token exchange request.
/// Used for provider-specific requirements such as RFC 8707 `resource`.
pub token_exchange_extra_params: HashMap<String, String>,
/// Secret name for persisting the client ID (MCP OAuth only).
/// Needed so token refresh can find the client_id after the session ends.
pub client_id_secret_name: Option<String>,
@@ -459,9 +487,7 @@ pub fn new_pending_oauth_registry() -> PendingOAuthRegistry {
/// URL, meaning the user's browser will redirect to a hosted gateway rather than
/// localhost.
pub fn use_gateway_callback() -> bool {
std::env::var("IRONCLAW_OAUTH_CALLBACK_URL")
.ok()
.filter(|v| !v.is_empty())
crate::config::helpers::env_or_override("IRONCLAW_OAUTH_CALLBACK_URL")
.map(|raw| {
url::Url::parse(&raw)
.ok()
@@ -472,6 +498,13 @@ pub fn use_gateway_callback() -> bool {
.unwrap_or(false)
}
/// Returns the configured OAuth token-exchange proxy URL, if any.
pub fn exchange_proxy_url() -> Option<String> {
crate::config::helpers::env_or_override("IRONCLAW_OAUTH_EXCHANGE_URL")
.map(|url| url.trim().to_string())
.filter(|url| !url.is_empty())
}
/// Maximum age for pending OAuth flows (5 minutes, matching TCP listener timeout).
pub const OAUTH_FLOW_EXPIRY: Duration = Duration::from_secs(300);
@@ -486,23 +519,117 @@ pub async fn sweep_expired_flows(registry: &PendingOAuthRegistry) {
// ── Platform routing helpers ────────────────────────────────────────
/// Prepend instance name to CSRF state for platform routing.
const HOSTED_STATE_PREFIX: &str = "ic2";
const HOSTED_STATE_CHECKSUM_BYTES: usize = 12;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecodedHostedOAuthState {
pub flow_id: String,
pub instance_name: Option<String>,
pub is_legacy: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct HostedOAuthStatePayload {
flow_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
instance_name: Option<String>,
issued_at: u64,
}
fn current_instance_name() -> Option<String> {
crate::config::helpers::env_or_override("IRONCLAW_INSTANCE_NAME")
.or_else(|| crate::config::helpers::env_or_override("OPENCLAW_INSTANCE_NAME"))
.filter(|v| !v.is_empty())
}
fn hosted_state_checksum(payload_bytes: &[u8]) -> String {
let digest = Sha256::digest(payload_bytes);
URL_SAFE_NO_PAD.encode(&digest[..HOSTED_STATE_CHECKSUM_BYTES])
}
/// Build a versioned hosted OAuth state envelope.
///
/// The NEAR AI platform nginx proxy at `auth.DOMAIN` parses the instance name
/// from the `state` query parameter (format: `instance:nonce`) to route the
/// OAuth callback to the correct container.
///
/// Returns the nonce unchanged when `IRONCLAW_INSTANCE_NAME` is not set
/// (local/non-platform mode).
pub fn build_platform_state(nonce: &str) -> String {
let instance = std::env::var("IRONCLAW_INSTANCE_NAME")
.or_else(|_| std::env::var("OPENCLAW_INSTANCE_NAME"))
.ok()
.filter(|v| !v.is_empty());
match instance {
Some(name) => format!("{}:{}", name, nonce),
None => nonce.to_string(),
/// The encoded value is opaque to providers and can be decoded by both
/// IronClaw and the external auth proxy for routing and callback lookup.
pub fn encode_hosted_oauth_state(flow_id: &str, instance_name: Option<&str>) -> String {
let payload = HostedOAuthStatePayload {
flow_id: flow_id.to_string(),
instance_name: instance_name
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string),
issued_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
};
let payload_json = match serde_json::to_vec(&payload) {
Ok(payload_json) => payload_json,
Err(error) => {
tracing::warn!(%error, flow_id, "Failed to serialize hosted OAuth state payload");
return payload.flow_id;
}
};
let payload = URL_SAFE_NO_PAD.encode(&payload_json);
let checksum = hosted_state_checksum(&payload_json);
format!("{HOSTED_STATE_PREFIX}.{payload}.{checksum}")
}
/// Decode hosted OAuth state in either the new versioned format or the
/// legacy `instance:nonce`/`nonce` forms.
pub fn decode_hosted_oauth_state(state: &str) -> Result<DecodedHostedOAuthState, String> {
if let Some(rest) = state.strip_prefix(&format!("{HOSTED_STATE_PREFIX}."))
&& let Some((payload_b64, checksum)) = rest.rsplit_once('.')
&& let Ok(payload_json) = URL_SAFE_NO_PAD.decode(payload_b64)
{
let expected_checksum = hosted_state_checksum(&payload_json);
if checksum != expected_checksum {
return Err("Hosted OAuth state checksum mismatch".to_string());
}
if let Ok(payload) = serde_json::from_slice::<HostedOAuthStatePayload>(&payload_json)
&& !payload.flow_id.trim().is_empty()
{
return Ok(DecodedHostedOAuthState {
flow_id: payload.flow_id,
instance_name: payload.instance_name.filter(|v| !v.is_empty()),
is_legacy: false,
});
}
}
if let Some((instance_name, flow_id)) = state.split_once(':') {
if flow_id.is_empty() {
return Err("Hosted OAuth legacy state is missing flow_id".to_string());
}
return Ok(DecodedHostedOAuthState {
flow_id: flow_id.to_string(),
instance_name: if instance_name.is_empty() {
None
} else {
Some(instance_name.to_string())
},
is_legacy: true,
});
}
if state.is_empty() {
return Err("Hosted OAuth state is empty".to_string());
}
Ok(DecodedHostedOAuthState {
flow_id: state.to_string(),
instance_name: None,
is_legacy: true,
})
}
/// Build the hosted callback state used by the public OAuth callback endpoint.
///
/// New flows emit a versioned opaque envelope, while callback decoding accepts
/// both the envelope and the legacy `instance:nonce` contract.
pub fn build_platform_state(nonce: &str) -> String {
encode_hosted_oauth_state(nonce, current_instance_name().as_deref())
}
/// Strip the instance prefix from a state parameter to recover the lookup nonce.
@@ -517,43 +644,62 @@ pub fn strip_instance_prefix(state: &str) -> &str {
.unwrap_or(state)
}
pub struct ProxyTokenExchangeRequest<'a> {
pub proxy_url: &'a str,
pub gateway_token: &'a str,
pub token_url: &'a str,
pub client_id: &'a str,
pub client_secret: Option<&'a str>,
pub code: &'a str,
pub redirect_uri: &'a str,
pub code_verifier: Option<&'a str>,
pub access_token_field: &'a str,
pub extra_token_params: &'a HashMap<String, String>,
}
/// Exchange an OAuth authorization code via the platform's token exchange proxy.
///
/// The proxy holds `client_secret` server-side so the container never sees it.
/// Authenticated via the gateway auth token (Bearer header).
/// Authenticated via the gateway auth token (Bearer header). The caller may
/// either rely on proxy-side secret lookup or forward a `client_secret` when
/// the provider requires it.
///
/// The proxy expects form params `{code, redirect_uri, code_verifier}` and
/// returns a standard Google token response `{access_token, refresh_token, expires_in}`.
/// The proxy expects standard OAuth form params plus optional provider-specific
/// token params and returns a standard token response such as
/// `{access_token, refresh_token, expires_in}`.
pub async fn exchange_via_proxy(
proxy_url: &str,
gateway_token: &str,
code: &str,
redirect_uri: &str,
code_verifier: Option<&str>,
access_token_field: &str,
request: ProxyTokenExchangeRequest<'_>,
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
if gateway_token.is_empty() {
if request.gateway_token.is_empty() {
return Err(OAuthCallbackError::Io(
"Gateway auth token is required for proxy token exchange".to_string(),
));
}
let exchange_url = format!("{}/oauth/exchange", proxy_url.trim_end_matches('/'));
let exchange_url = format!("{}/oauth/exchange", request.proxy_url.trim_end_matches('/'));
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.build()
.map_err(|e| OAuthCallbackError::Io(format!("Failed to build HTTP client: {}", e)))?;
let mut params = vec![
("code", code.to_string()),
("redirect_uri", redirect_uri.to_string()),
("code", request.code.to_string()),
("redirect_uri", request.redirect_uri.to_string()),
("token_url", request.token_url.to_string()),
("client_id", request.client_id.to_string()),
("access_token_field", request.access_token_field.to_string()),
];
if let Some(verifier) = code_verifier {
if let Some(verifier) = request.code_verifier {
params.push(("code_verifier", verifier.to_string()));
}
if let Some(secret) = request.client_secret {
params.push(("client_secret", secret.to_string()));
}
for (key, value) in request.extra_token_params {
params.push((key.as_str(), value.clone()));
}
let response = client
.post(&exchange_url)
.bearer_auth(gateway_token)
.bearer_auth(request.gateway_token)
.form(&params)
.send()
.await
@@ -576,7 +722,7 @@ pub async fn exchange_via_proxy(
.map_err(|e| OAuthCallbackError::Io(format!("Failed to parse proxy response: {}", e)))?;
let access_token = token_data
.get(access_token_field)
.get(request.access_token_field)
.and_then(|v| v.as_str())
.ok_or_else(|| {
let fields: Vec<&str> = token_data
@@ -585,7 +731,7 @@ pub async fn exchange_via_proxy(
.unwrap_or_default();
OAuthCallbackError::Io(format!(
"No '{}' field in proxy response (fields present: {:?})",
access_token_field, fields
request.access_token_field, fields
))
})?
.to_string();
@@ -605,14 +751,10 @@ pub async fn exchange_via_proxy(
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use crate::cli::oauth_defaults::{
builtin_credentials, callback_host, callback_url, is_loopback_host, landing_html,
};
/// Serializes env-mutating tests to prevent parallel races.
static ENV_MUTEX: Mutex<()> = Mutex::new(());
use crate::config::helpers::ENV_MUTEX;
#[test]
fn test_is_loopback_host() {
@@ -935,7 +1077,7 @@ mod tests {
#[test]
fn test_build_platform_state_with_instance() {
use crate::cli::oauth_defaults::build_platform_state;
use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
@@ -943,7 +1085,11 @@ mod tests {
unsafe {
std::env::set_var("IRONCLAW_INSTANCE_NAME", "kind-deer");
}
assert_eq!(build_platform_state("abc123"), "kind-deer:abc123");
let encoded = build_platform_state("abc123");
let decoded = decode_hosted_oauth_state(&encoded).expect("decode hosted state");
assert_eq!(decoded.flow_id, "abc123");
assert_eq!(decoded.instance_name.as_deref(), Some("kind-deer"));
assert!(!decoded.is_legacy);
unsafe {
if let Some(val) = original {
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
@@ -955,7 +1101,7 @@ mod tests {
#[test]
fn test_build_platform_state_without_instance() {
use crate::cli::oauth_defaults::build_platform_state;
use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
@@ -965,7 +1111,11 @@ mod tests {
std::env::remove_var("IRONCLAW_INSTANCE_NAME");
std::env::remove_var("OPENCLAW_INSTANCE_NAME");
}
assert_eq!(build_platform_state("abc123"), "abc123");
let encoded = build_platform_state("abc123");
let decoded = decode_hosted_oauth_state(&encoded).expect("decode hosted state");
assert_eq!(decoded.flow_id, "abc123");
assert_eq!(decoded.instance_name, None);
assert!(!decoded.is_legacy);
unsafe {
if let Some(val) = original {
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
@@ -978,7 +1128,7 @@ mod tests {
#[test]
fn test_build_platform_state_with_openclaw_instance() {
use crate::cli::oauth_defaults::build_platform_state;
use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original_ic = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
@@ -988,7 +1138,11 @@ mod tests {
std::env::remove_var("IRONCLAW_INSTANCE_NAME");
std::env::set_var("OPENCLAW_INSTANCE_NAME", "quiet-lion");
}
assert_eq!(build_platform_state("xyz789"), "quiet-lion:xyz789");
let encoded = build_platform_state("xyz789");
let decoded = decode_hosted_oauth_state(&encoded).expect("decode hosted state");
assert_eq!(decoded.flow_id, "xyz789");
assert_eq!(decoded.instance_name.as_deref(), Some("quiet-lion"));
assert!(!decoded.is_legacy);
unsafe {
if let Some(val) = original_ic {
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
@@ -1017,6 +1171,42 @@ mod tests {
assert_eq!(strip_instance_prefix(""), "");
}
#[test]
fn test_decode_hosted_oauth_state_accepts_legacy_formats() {
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
let decoded = decode_hosted_oauth_state("kind-deer:abc123").expect("legacy prefixed");
assert_eq!(decoded.flow_id, "abc123");
assert_eq!(decoded.instance_name.as_deref(), Some("kind-deer"));
assert!(decoded.is_legacy);
let decoded = decode_hosted_oauth_state("abc123").expect("legacy raw");
assert_eq!(decoded.flow_id, "abc123");
assert_eq!(decoded.instance_name, None);
assert!(decoded.is_legacy);
}
#[test]
fn test_decode_hosted_oauth_state_falls_back_for_non_envelope_ic2_prefix() {
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
let decoded =
decode_hosted_oauth_state("ic2.provider-owned-state").expect("prefixed fallback");
assert_eq!(decoded.flow_id, "ic2.provider-owned-state");
assert_eq!(decoded.instance_name, None);
assert!(decoded.is_legacy);
}
#[test]
fn test_decode_hosted_oauth_state_rejects_tampered_checksum() {
use crate::cli::oauth_defaults::{decode_hosted_oauth_state, encode_hosted_oauth_state};
let encoded = encode_hosted_oauth_state("abc123", Some("kind-deer"));
let tampered = format!("{encoded}broken");
let err = decode_hosted_oauth_state(&tampered).expect_err("tampered state should fail");
assert!(err.contains("checksum"), "unexpected error: {err}");
}
/// Verify that `build_oauth_url` includes the RFC 8707 `resource` parameter
/// when passed through `extra_params`, which is how MCP OAuth gateway mode
/// scopes tokens to a specific MCP server.
@@ -24,6 +24,7 @@ Commands:
status Show system status
completion Generate completions
import Import from other AI systems
login Authenticate with a provider
help Print this message or the help of the given subcommand(s)
Options:
@@ -23,6 +23,7 @@ Commands:
logs View and manage gateway logs
status Show system status
completion Generate completions
login Authenticate with a provider
help Print this message or the help of the given subcommand(s)
Options:
@@ -27,6 +27,7 @@ Commands:
status Show system status
completion Generate completions
import Import from other AI systems
login Authenticate with a provider
help Print this message or the help of the given subcommand(s)
Options:
@@ -26,6 +26,7 @@ Commands:
logs View and manage gateway logs
status Show system status
completion Generate completions
login Authenticate with a provider
help Print this message or the help of the given subcommand(s)
Options:
+14 -7
View File
@@ -651,8 +651,8 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
// Check for OAuth configuration
if let Some(ref oauth) = auth.oauth {
// For providers with shared tokens (e.g., all Google tools share google_oauth_token),
// combine scopes from all installed tools so one auth covers everything.
// For providers with shared tokens, combine scopes from all installed
// tools so one auth covers everything.
let combined = combine_provider_scopes(&tools_dir, &auth.secret_name, oauth).await;
if combined.scopes.len() > oauth.scopes.len() {
let extra = combined.scopes.len() - oauth.scopes.len();
@@ -670,8 +670,8 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
}
/// Scan the tools directory for all capabilities files sharing the same secret_name
/// and combine their OAuth scopes. This way, authing any Google tool requests scopes
/// for ALL installed Google tools, so one login covers everything.
/// and combine their OAuth scopes so one authorization covers the full shared
/// credential set.
async fn combine_provider_scopes(
tools_dir: &Path,
secret_name: &str,
@@ -736,11 +736,18 @@ async fn auth_tool_oauth(
})
.or_else(|| builtin.as_ref().map(|c| c.client_id.to_string()))
.ok_or_else(|| {
anyhow::anyhow!(
let mut message = format!(
"OAuth client_id not configured.\n\
Set {} env var, or build with IRONCLAW_GOOGLE_CLIENT_ID.",
Set {} env var",
oauth.client_id_env.as_deref().unwrap_or("the client_id")
)
);
if let Some(override_env) =
oauth_defaults::builtin_client_id_override_env(&auth.secret_name)
{
message.push_str(&format!(", or build with {override_env}"));
}
message.push('.');
anyhow::anyhow!(message)
})?;
// Get client_secret: capabilities file > runtime env var > built-in defaults
+5 -1
View File
@@ -111,6 +111,10 @@ impl ChannelsConfig {
let gateway_enabled = parse_bool_env("GATEWAY_ENABLED", cs.gateway_enabled)?;
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());
Some(GatewayConfig {
host: optional_env("GATEWAY_HOST")?
.or_else(|| cs.gateway_host.clone())
@@ -121,7 +125,7 @@ impl ChannelsConfig {
)?,
auth_token: optional_env("GATEWAY_AUTH_TOKEN")?
.or_else(|| cs.gateway_auth_token.clone()),
user_id: owner_id.to_string(),
user_id,
})
} else {
None
+51 -11
View File
@@ -2,12 +2,15 @@ use std::sync::Arc;
use secrecy::{ExposeSecret, SecretString};
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env, validate_base_url};
use crate::error::ConfigError;
use crate::llm::SessionManager;
use crate::settings::Settings;
use crate::workspace::EmbeddingProvider;
/// Default maximum number of cached embeddings.
pub const DEFAULT_EMBEDDING_CACHE_SIZE: usize = 10_000;
/// Embeddings provider configuration.
#[derive(Debug, Clone)]
pub struct EmbeddingsConfig {
@@ -26,6 +29,12 @@ pub struct EmbeddingsConfig {
/// Custom base URL for OpenAI-compatible embedding providers.
/// When set, overrides the default `https://api.openai.com`.
pub openai_base_url: Option<String>,
/// Maximum entries in the embedding LRU cache (default 10,000).
///
/// Approximate raw embedding payload: `cache_size × dimension × 4 bytes`.
/// 10,000 × 1536 floats ≈ 58 MB (payload only; actual memory is higher
/// due to HashMap buckets, per-entry Vec/timestamp overhead).
pub cache_size: usize,
}
impl Default for EmbeddingsConfig {
@@ -40,6 +49,7 @@ impl Default for EmbeddingsConfig {
ollama_base_url: "http://localhost:11434".to_string(),
dimension,
openai_base_url: None,
cache_size: DEFAULT_EMBEDDING_CACHE_SIZE,
}
}
}
@@ -47,7 +57,7 @@ impl Default for EmbeddingsConfig {
/// Infer the embedding dimension from a well-known model name.
///
/// Falls back to 1536 (OpenAI text-embedding-3-small default) for unknown models.
fn default_dimension_for_model(model: &str) -> usize {
pub(crate) fn default_dimension_for_model(model: &str) -> usize {
match model {
"text-embedding-3-small" => 1536,
"text-embedding-3-large" => 3072,
@@ -80,6 +90,21 @@ impl EmbeddingsConfig {
let openai_base_url = optional_env("EMBEDDING_BASE_URL")?;
// Validate base URLs to prevent SSRF attacks (#1103).
validate_base_url(&ollama_base_url, "OLLAMA_BASE_URL")?;
if let Some(ref url) = openai_base_url {
validate_base_url(url, "EMBEDDING_BASE_URL")?;
}
let cache_size = parse_optional_env("EMBEDDING_CACHE_SIZE", DEFAULT_EMBEDDING_CACHE_SIZE)?;
if cache_size == 0 {
return Err(ConfigError::InvalidValue {
key: "EMBEDDING_CACHE_SIZE".to_string(),
message: "must be at least 1".to_string(),
});
}
Ok(Self {
enabled,
provider,
@@ -88,6 +113,7 @@ impl EmbeddingsConfig {
ollama_base_url,
dimension,
openai_base_url,
cache_size,
})
}
@@ -183,13 +209,13 @@ mod tests {
std::env::remove_var("EMBEDDING_MODEL");
std::env::remove_var("OPENAI_API_KEY");
std::env::remove_var("EMBEDDING_BASE_URL");
std::env::remove_var("EMBEDDING_CACHE_SIZE");
}
}
#[test]
fn embeddings_disabled_not_overridden_by_openai_key() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_embedding_env();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
@@ -240,7 +266,6 @@ mod tests {
#[test]
fn embeddings_env_override_takes_precedence() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_embedding_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
@@ -274,17 +299,12 @@ mod tests {
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::set_var("EMBEDDING_BASE_URL", "https://custom.example.com");
std::env::set_var("EMBEDDING_BASE_URL", "https://8.8.8.8");
}
let settings = Settings::default();
let config = EmbeddingsConfig::resolve(&settings).expect("resolve should succeed");
assert_eq!(
config.openai_base_url.as_deref(),
Some("https://custom.example.com"),
"EMBEDDING_BASE_URL env var should be parsed into openai_base_url"
);
assert_eq!(config.openai_base_url.as_deref(), Some("https://8.8.8.8"));
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("EMBEDDING_BASE_URL");
@@ -303,4 +323,24 @@ mod tests {
"openai_base_url should be None when EMBEDDING_BASE_URL is not set"
);
}
#[test]
fn cache_size_zero_rejected() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_embedding_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::set_var("EMBEDDING_CACHE_SIZE", "0");
}
let settings = Settings::default();
let result = EmbeddingsConfig::resolve(&settings);
assert!(result.is_err(), "cache_size=0 should be rejected");
let err = result.unwrap_err().to_string();
assert!(err.contains("at least 1"), "should mention minimum: {err}");
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("EMBEDDING_CACHE_SIZE");
}
}
}
+263
View File
@@ -176,6 +176,151 @@ pub(crate) fn parse_string_env(
Ok(optional_env(key)?.unwrap_or_else(|| default.into()))
}
/// Validate a user-configurable base URL to prevent SSRF attacks (#1103).
///
/// Rejects:
/// - Non-HTTP(S) schemes (file://, ftp://, etc.)
/// - HTTPS URLs pointing at private/loopback/link-local IPs
/// - HTTP URLs pointing at anything other than localhost/127.0.0.1/::1
///
/// This is intended for config-time validation of base URLs like
/// `OLLAMA_BASE_URL`, `EMBEDDING_BASE_URL`, `NEARAI_BASE_URL`, etc.
pub(crate) fn validate_base_url(url: &str, field_name: &str) -> Result<(), ConfigError> {
use std::net::{IpAddr, Ipv4Addr};
let parsed = reqwest::Url::parse(url).map_err(|e| ConfigError::InvalidValue {
key: field_name.to_string(),
message: format!("invalid URL '{}': {}", url, e),
})?;
let scheme = parsed.scheme();
if scheme != "http" && scheme != "https" {
return Err(ConfigError::InvalidValue {
key: field_name.to_string(),
message: format!("only http/https URLs are allowed, got '{}'", scheme),
});
}
let host = parsed.host_str().ok_or_else(|| ConfigError::InvalidValue {
key: field_name.to_string(),
message: "URL is missing a host".to_string(),
})?;
let host_lower = host.to_lowercase();
// For HTTP (non-TLS), only allow localhost — remote HTTP endpoints
// risk credential leakage (e.g. NEAR AI bearer tokens sent over plaintext).
if scheme == "http" {
let is_localhost = host_lower == "localhost"
|| host_lower == "127.0.0.1"
|| host_lower == "::1"
|| host_lower == "[::1]"
|| host_lower.ends_with(".localhost");
if !is_localhost {
return Err(ConfigError::InvalidValue {
key: field_name.to_string(),
message: format!(
"HTTP (non-TLS) is only allowed for localhost, got '{}'. \
Use HTTPS for remote endpoints.",
host
),
});
}
return Ok(());
}
// Check whether an IP is in a blocked range (private, loopback,
// link-local, multicast, metadata, CGN, ULA).
let is_dangerous_ip = |ip: &IpAddr| -> bool {
match ip {
IpAddr::V4(v4) => {
v4.is_private()
|| v4.is_loopback()
|| v4.is_link_local()
|| v4.is_multicast()
|| v4.is_unspecified()
|| *v4 == Ipv4Addr::new(169, 254, 169, 254)
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64) // CGN
}
IpAddr::V6(v6) => {
if let Some(v4) = v6.to_ipv4_mapped() {
v4.is_private()
|| v4.is_loopback()
|| v4.is_link_local()
|| v4.is_multicast()
|| v4.is_unspecified()
|| v4 == Ipv4Addr::new(169, 254, 169, 254)
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64) // CGN
} else {
v6.is_loopback()
|| v6.is_unspecified()
|| (v6.octets()[0] & 0xfe) == 0xfc // ULA (fc00::/7)
|| (v6.segments()[0] & 0xffc0) == 0xfe80 // link-local (fe80::/10)
|| v6.octets()[0] == 0xff // multicast (ff00::/8)
}
}
}
};
// For HTTPS, reject private/loopback/link-local/metadata IPs.
// Check both IP literals and resolved hostnames to prevent DNS-based SSRF.
if let Ok(ip) = host.parse::<IpAddr>() {
if is_dangerous_ip(&ip) {
return Err(ConfigError::InvalidValue {
key: field_name.to_string(),
message: format!(
"URL points to a private/internal IP '{}'. \
This is blocked to prevent SSRF attacks.",
ip
),
});
}
} else {
// Hostname — resolve and check all resulting IPs as defense-in-depth.
// NOTE: This does NOT fully prevent DNS rebinding attacks (the hostname
// could resolve to a different IP at request time). Full protection
// would require pinning the resolved IP in the HTTP client's connector.
// This validation catches the common case of misconfigured or malicious URLs.
//
// NOTE: `to_socket_addrs()` performs blocking DNS resolution. This is
// acceptable because `validate_base_url` runs at config-load time only,
// before the async runtime is fully driving I/O. If this ever moves to
// a hot path, wrap in `tokio::task::spawn_blocking` or use
// `tokio::net::lookup_host`.
use std::net::ToSocketAddrs;
let port = parsed.port().unwrap_or(443);
match (host, port).to_socket_addrs() {
Ok(addrs) => {
for addr in addrs {
if is_dangerous_ip(&addr.ip()) {
return Err(ConfigError::InvalidValue {
key: field_name.to_string(),
message: format!(
"hostname '{}' resolves to private/internal IP '{}'. \
This is blocked to prevent SSRF attacks.",
host,
addr.ip()
),
});
}
}
}
Err(e) => {
return Err(ConfigError::InvalidValue {
key: field_name.to_string(),
message: format!(
"failed to resolve hostname '{}': {}. \
Base URLs must be resolvable at config time.",
host, e
),
});
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -226,4 +371,122 @@ mod tests {
// Now the runtime override is visible again
assert_eq!(env_or_override(key), Some("override_value".to_string()));
}
// --- validate_base_url tests (regression for #1103) ---
#[test]
fn validate_base_url_allows_https() {
// Use IP literals to avoid DNS resolution in sandboxed test environments.
assert!(validate_base_url("https://8.8.8.8", "TEST").is_ok());
assert!(validate_base_url("https://8.8.8.8/v1", "TEST").is_ok());
}
#[test]
fn validate_base_url_allows_http_localhost() {
assert!(validate_base_url("http://localhost:11434", "TEST").is_ok());
assert!(validate_base_url("http://127.0.0.1:11434", "TEST").is_ok());
assert!(validate_base_url("http://[::1]:11434", "TEST").is_ok());
}
#[test]
fn validate_base_url_rejects_http_remote() {
assert!(validate_base_url("http://evil.example.com", "TEST").is_err());
assert!(validate_base_url("http://192.168.1.1", "TEST").is_err());
}
#[test]
fn validate_base_url_rejects_non_http_schemes() {
assert!(validate_base_url("file:///etc/passwd", "TEST").is_err());
assert!(validate_base_url("ftp://evil.com", "TEST").is_err());
}
#[test]
fn validate_base_url_rejects_cloud_metadata() {
assert!(validate_base_url("https://169.254.169.254", "TEST").is_err());
}
#[test]
fn validate_base_url_rejects_private_ips() {
assert!(validate_base_url("https://10.0.0.1", "TEST").is_err());
assert!(validate_base_url("https://192.168.1.1", "TEST").is_err());
assert!(validate_base_url("https://172.16.0.1", "TEST").is_err());
}
#[test]
fn validate_base_url_rejects_cgn_range() {
// Carrier-grade NAT: 100.64.0.0/10
assert!(validate_base_url("https://100.64.0.1", "TEST").is_err());
assert!(validate_base_url("https://100.127.255.254", "TEST").is_err());
}
#[test]
fn validate_base_url_rejects_ipv4_mapped_ipv6() {
// ::ffff:10.0.0.1 is an IPv4-mapped IPv6 address pointing to private IP
assert!(validate_base_url("https://[::ffff:10.0.0.1]", "TEST").is_err());
assert!(validate_base_url("https://[::ffff:169.254.169.254]", "TEST").is_err());
}
#[test]
fn validate_base_url_rejects_ula_ipv6() {
// fc00::/7 — unique local addresses
assert!(validate_base_url("https://[fc00::1]", "TEST").is_err());
assert!(validate_base_url("https://[fd12:3456:789a::1]", "TEST").is_err());
}
#[test]
fn validate_base_url_handles_url_with_credentials() {
// URLs with embedded credentials — validate_base_url checks the host,
// not the credentials. Use IP literal to avoid DNS in sandboxed envs.
let result = validate_base_url("https://user:[email protected]", "TEST");
assert!(result.is_ok());
}
#[test]
fn validate_base_url_rejects_empty_and_invalid() {
assert!(validate_base_url("", "TEST").is_err());
assert!(validate_base_url("not-a-url", "TEST").is_err());
assert!(validate_base_url("://missing-scheme", "TEST").is_err());
}
#[test]
fn validate_base_url_rejects_unspecified_ipv4() {
assert!(validate_base_url("https://0.0.0.0", "TEST").is_err());
}
#[test]
fn validate_base_url_rejects_ipv6_loopback_https() {
// IPv6 loopback is allowed over HTTP (localhost equivalent),
// but must be rejected over HTTPS as a dangerous IP.
assert!(validate_base_url("https://[::1]", "TEST").is_err());
}
#[test]
fn validate_base_url_rejects_ipv6_link_local() {
// fe80::/10 — link-local addresses
assert!(validate_base_url("https://[fe80::1]", "TEST").is_err());
}
#[test]
fn validate_base_url_rejects_ipv6_multicast() {
// ff00::/8 — multicast addresses
assert!(validate_base_url("https://[ff02::1]", "TEST").is_err());
}
#[test]
fn validate_base_url_rejects_ipv6_unspecified() {
// :: — unspecified address
assert!(validate_base_url("https://[::]", "TEST").is_err());
}
#[test]
fn validate_base_url_rejects_dns_failure() {
// .invalid TLD is guaranteed to never resolve (RFC 6761)
let result = validate_base_url("https://ssrf-test.invalid", "TEST");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("failed to resolve"),
"Expected DNS resolution failure, got: {err}"
);
}
}
+324 -18
View File
@@ -3,7 +3,7 @@ use std::path::PathBuf;
use secrecy::SecretString;
use crate::bootstrap::ironclaw_base_dir;
use crate::config::helpers::{optional_env, parse_optional_env};
use crate::config::helpers::{optional_env, parse_optional_env, validate_base_url};
use crate::error::ConfigError;
use crate::llm::config::*;
use crate::llm::registry::{ProviderProtocol, ProviderRegistry};
@@ -37,6 +37,7 @@ impl LlmConfig {
},
provider: None,
bedrock: None,
openai_codex: None,
request_timeout_secs: 120,
cheap_model: None,
smart_routing_cascade: false,
@@ -72,8 +73,12 @@ 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_openai_codex = backend_lower == "openai_codex"
|| backend_lower == "openai-codex"
|| backend_lower == "codex";
if !is_nearai && !is_bedrock && registry.find(&backend_lower).is_none() {
if !is_nearai && !is_bedrock && !is_openai_codex && registry.find(&backend_lower).is_none()
{
tracing::warn!(
"Unknown LLM backend '{}'. Will attempt as openai_compatible fallback.",
backend
@@ -81,9 +86,11 @@ impl LlmConfig {
}
// Session config (used by NearAI provider for OAuth/session-token auth)
let nearai_auth_url = optional_env("NEARAI_AUTH_URL")?
.unwrap_or_else(|| "https://private.near.ai".to_string());
validate_base_url(&nearai_auth_url, "NEARAI_AUTH_URL")?;
let session = SessionConfig {
auth_base_url: optional_env("NEARAI_AUTH_URL")?
.unwrap_or_else(|| "https://private.near.ai".to_string()),
auth_base_url: nearai_auth_url,
session_path: optional_env("NEARAI_SESSION_PATH")?
.map(PathBuf::from)
.unwrap_or_else(default_session_path),
@@ -92,15 +99,19 @@ impl LlmConfig {
// Always resolve NEAR AI config (used for embeddings even when not the primary backend)
let nearai_api_key = optional_env("NEARAI_API_KEY")?.map(SecretString::from);
let nearai = NearAiConfig {
model: Self::resolve_model("NEARAI_MODEL", settings, "zai-org/GLM-latest")?,
model: Self::resolve_model("NEARAI_MODEL", settings, crate::llm::DEFAULT_MODEL)?,
cheap_model: optional_env("NEARAI_CHEAP_MODEL")?,
base_url: optional_env("NEARAI_BASE_URL")?.unwrap_or_else(|| {
if nearai_api_key.is_some() {
"https://cloud-api.near.ai".to_string()
} else {
"https://private.near.ai".to_string()
}
}),
base_url: {
let url = optional_env("NEARAI_BASE_URL")?.unwrap_or_else(|| {
if nearai_api_key.is_some() {
"https://cloud-api.near.ai".to_string()
} else {
"https://private.near.ai".to_string()
}
});
validate_base_url(&url, "NEARAI_BASE_URL")?;
url
},
api_key: nearai_api_key,
fallback_model: optional_env("NEARAI_FALLBACK_MODEL")?,
max_retries: parse_optional_env("NEARAI_MAX_RETRIES", 3)?,
@@ -120,8 +131,8 @@ impl LlmConfig {
smart_routing_cascade: parse_optional_env("SMART_ROUTING_CASCADE", true)?,
};
// Resolve registry provider config (for non-NearAI, non-Bedrock backends)
let provider = if is_nearai || is_bedrock {
// Resolve registry provider config (for non-NearAI, non-Bedrock, non-Codex backends)
let provider = if is_nearai || is_bedrock || is_openai_codex {
None
} else {
Some(Self::resolve_registry_provider(
@@ -168,6 +179,38 @@ impl LlmConfig {
None
};
// Resolve OpenAI Codex config
let openai_codex = if is_openai_codex {
// Model: OPENAI_CODEX_MODEL > OPENAI_MODEL > settings.selected_model > default
let model = optional_env("OPENAI_CODEX_MODEL")?
.or(optional_env("OPENAI_MODEL")?)
.or_else(|| settings.selected_model.clone())
.unwrap_or_else(|| "gpt-5.3-codex".to_string());
let auth_endpoint = optional_env("OPENAI_CODEX_AUTH_URL")?
.unwrap_or_else(|| "https://auth.openai.com".to_string());
validate_base_url(&auth_endpoint, "OPENAI_CODEX_AUTH_URL")?;
let api_base_url = optional_env("OPENAI_CODEX_API_URL")?
.unwrap_or_else(|| "https://chatgpt.com/backend-api/codex".to_string());
validate_base_url(&api_base_url, "OPENAI_CODEX_API_URL")?;
let client_id = optional_env("OPENAI_CODEX_CLIENT_ID")?
.unwrap_or_else(|| "app_EMoamEEZ73f0CkXaXp7hrann".to_string());
let session_path = optional_env("OPENAI_CODEX_SESSION_PATH")?
.map(PathBuf::from)
.unwrap_or_else(|| ironclaw_base_dir().join("openai_codex_session.json"));
let token_refresh_margin_secs =
parse_optional_env("OPENAI_CODEX_REFRESH_MARGIN_SECS", 300)?;
Some(OpenAiCodexConfig {
model,
auth_endpoint,
api_base_url,
client_id,
session_path,
token_refresh_margin_secs,
})
} else {
None
};
let request_timeout_secs = parse_optional_env("LLM_REQUEST_TIMEOUT_SECS", 120)?;
// Generic cheap model (works with any backend).
@@ -183,6 +226,8 @@ impl LlmConfig {
"nearai".to_string()
} else if is_bedrock {
"bedrock".to_string()
} else if is_openai_codex {
"openai_codex".to_string()
} else if let Some(ref p) = provider {
p.provider_id.clone()
} else {
@@ -192,6 +237,7 @@ impl LlmConfig {
nearai,
provider,
bedrock,
openai_codex,
request_timeout_secs,
cheap_model,
smart_routing_cascade,
@@ -325,6 +371,12 @@ impl LlmConfig {
});
}
// Validate base URL to prevent SSRF (#1103).
if !base_url.is_empty() {
let field = base_url_env.unwrap_or("LLM_BASE_URL");
validate_base_url(&base_url, field)?;
}
// Resolve model
let model = Self::resolve_model(model_env, settings, default_model)?;
@@ -337,6 +389,14 @@ impl LlmConfig {
} else {
Vec::new()
};
let extra_headers = if canonical_id == "github_copilot" {
merge_extra_headers(
crate::llm::github_copilot_auth::default_headers(),
extra_headers,
)
} else {
extra_headers
};
// Resolve OAuth token (Anthropic-specific: `claude login` flow).
// Only check for OAuth token when the provider is actually Anthropic.
@@ -421,6 +481,26 @@ fn parse_extra_headers(val: &str) -> Result<Vec<(String, String)>, ConfigError>
Ok(headers)
}
fn merge_extra_headers(
defaults: Vec<(String, String)>,
overrides: Vec<(String, String)>,
) -> Vec<(String, String)> {
let mut merged = Vec::new();
let mut positions = std::collections::HashMap::<String, usize>::new();
for (key, value) in defaults.into_iter().chain(overrides) {
let normalized = key.to_ascii_lowercase();
if let Some(existing_index) = positions.get(&normalized).copied() {
merged[existing_index] = (key, value);
} else {
positions.insert(normalized, merged.len());
merged.push((key, value));
}
}
merged
}
/// Get the default session file path (~/.ironclaw/session.json).
pub fn default_session_path() -> PathBuf {
ironclaw_base_dir().join("session.json")
@@ -552,6 +632,29 @@ mod tests {
);
}
#[test]
fn merge_extra_headers_prefers_overrides_case_insensitively() {
let merged = merge_extra_headers(
vec![
("User-Agent".to_string(), "default-agent".to_string()),
("X-Test".to_string(), "default".to_string()),
],
vec![
("user-agent".to_string(), "override-agent".to_string()),
("X-Extra".to_string(), "present".to_string()),
],
);
assert_eq!(
merged,
vec![
("user-agent".to_string(), "override-agent".to_string()),
("X-Test".to_string(), "default".to_string()),
("X-Extra".to_string(), "present".to_string()),
]
);
}
/// Clear all ollama-related env vars.
fn clear_ollama_env() {
// SAFETY: Only called under ENV_MUTEX in tests.
@@ -704,6 +807,54 @@ mod tests {
assert_eq!(provider.protocol, ProviderProtocol::OpenAiCompletions);
}
#[test]
fn registry_provider_resolves_github_copilot_alias() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::set_var("LLM_BACKEND", "github-copilot");
std::env::set_var("GITHUB_COPILOT_TOKEN", "gho_test_token");
std::env::set_var(
"GITHUB_COPILOT_EXTRA_HEADERS",
"Copilot-Integration-Id:custom-chat,X-Test:enabled",
);
}
let settings = Settings::default();
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
assert_eq!(cfg.backend, "github_copilot");
let provider = cfg.provider.expect("provider config should be present");
assert_eq!(provider.provider_id, "github_copilot");
assert_eq!(provider.base_url, "https://api.githubcopilot.com");
assert_eq!(provider.model, "gpt-4o");
assert!(
provider
.extra_headers
.iter()
.any(|(key, value)| { key == "Copilot-Integration-Id" && value == "custom-chat" })
);
assert!(
provider
.extra_headers
.iter()
.any(|(key, value)| key == "User-Agent" && value == "GitHubCopilotChat/0.26.7")
);
assert!(
provider
.extra_headers
.iter()
.any(|(key, value)| key == "X-Test" && value == "enabled")
);
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("LLM_BACKEND");
std::env::remove_var("GITHUB_COPILOT_TOKEN");
std::env::remove_var("GITHUB_COPILOT_EXTRA_HEADERS");
}
}
#[test]
fn nearai_backend_has_no_registry_provider() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
@@ -803,19 +954,19 @@ mod tests {
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::set_var("LLM_BACKEND", "openai_compatible");
std::env::set_var("LLM_BASE_URL", "http://env-url/v1");
std::env::set_var("LLM_BASE_URL", "http://localhost:8000/v1");
}
let settings = Settings {
llm_backend: Some("openai_compatible".to_string()),
openai_compatible_base_url: Some("http://settings-url/v1".to_string()),
openai_compatible_base_url: Some("http://localhost:9000/v1".to_string()),
..Default::default()
};
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
let provider = cfg.provider.expect("should have provider config");
assert_eq!(
provider.base_url, "http://env-url/v1",
provider.base_url, "http://localhost:8000/v1",
"env var should take priority over settings"
);
@@ -827,7 +978,7 @@ mod tests {
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
let provider = cfg.provider.expect("should have provider config");
assert_eq!(
provider.base_url, "http://settings-url/v1",
provider.base_url, "http://localhost:9000/v1",
"settings should take priority over registry default"
);
@@ -1057,4 +1208,159 @@ mod tests {
std::env::remove_var("LLM_REQUEST_TIMEOUT_SECS");
}
}
// ── OpenAI Codex tests ──────────────────────────────────────────
/// Clear all openai-codex-related env vars.
fn clear_openai_codex_env() {
// SAFETY: Only called under ENV_MUTEX in tests.
unsafe {
std::env::remove_var("LLM_BACKEND");
std::env::remove_var("OPENAI_CODEX_MODEL");
std::env::remove_var("OPENAI_MODEL");
}
}
#[test]
fn openai_codex_resolves_config() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_openai_codex_env();
let settings = Settings {
llm_backend: Some("openai_codex".to_string()),
..Default::default()
};
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
assert_eq!(cfg.backend, "openai_codex");
let codex = cfg.openai_codex.expect("codex config should be present");
assert_eq!(codex.model, "gpt-5.3-codex"); // default
assert!(
cfg.provider.is_none(),
"codex should not use registry provider"
);
}
#[test]
fn openai_codex_model_env_resolution() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_openai_codex_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::set_var("OPENAI_CODEX_MODEL", "o3-pro");
}
let settings = Settings {
llm_backend: Some("openai_codex".to_string()),
..Default::default()
};
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
let codex = cfg.openai_codex.expect("codex config should be present");
assert_eq!(codex.model, "o3-pro");
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("OPENAI_CODEX_MODEL");
}
}
#[test]
fn openai_codex_falls_back_to_openai_model() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_openai_codex_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::set_var("OPENAI_MODEL", "gpt-4o");
}
let settings = Settings {
llm_backend: Some("openai_codex".to_string()),
..Default::default()
};
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
let codex = cfg.openai_codex.expect("codex config should be present");
assert_eq!(codex.model, "gpt-4o");
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("OPENAI_MODEL");
}
}
#[test]
fn openai_codex_falls_back_to_selected_model() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_openai_codex_env();
let settings = Settings {
llm_backend: Some("openai_codex".to_string()),
selected_model: Some("gpt-4o-mini".to_string()),
..Default::default()
};
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
let codex = cfg.openai_codex.expect("codex config should be present");
assert_eq!(codex.model, "gpt-4o-mini");
}
/// 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");
clear_openai_codex_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::set_var(
"OPENAI_CODEX_API_URL",
"http://169.254.169.254/latest/meta-data",
);
}
let settings = Settings {
llm_backend: Some("openai_codex".to_string()),
..Default::default()
};
let err = LlmConfig::resolve(&settings).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("OPENAI_CODEX_API_URL"),
"error should reference the field name: {msg}"
);
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("OPENAI_CODEX_API_URL");
}
}
/// 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");
clear_openai_codex_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::set_var("OPENAI_CODEX_AUTH_URL", "http://10.0.0.1");
}
let settings = Settings {
llm_backend: Some("openai_codex".to_string()),
..Default::default()
};
let err = LlmConfig::resolve(&settings).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("OPENAI_CODEX_AUTH_URL"),
"error should reference the field name: {msg}"
);
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("OPENAI_CODEX_AUTH_URL");
}
}
}
+21 -6
View File
@@ -9,7 +9,7 @@ mod agent;
mod builder;
mod channels;
mod database;
mod embeddings;
pub(crate) mod embeddings;
mod heartbeat;
pub(crate) mod helpers;
mod hygiene;
@@ -24,6 +24,7 @@ mod skills;
mod transcription;
mod tunnel;
mod wasm;
mod workspace;
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex, Once};
@@ -38,7 +39,7 @@ pub use self::channels::{
ChannelsConfig, CliConfig, DEFAULT_GATEWAY_PORT, GatewayConfig, HttpConfig, SignalConfig,
};
pub use self::database::{DatabaseBackend, DatabaseConfig, SslMode, default_libsql_path};
pub use self::embeddings::EmbeddingsConfig;
pub use self::embeddings::{DEFAULT_EMBEDDING_CACHE_SIZE, EmbeddingsConfig};
pub use self::heartbeat::HeartbeatConfig;
pub use self::hygiene::HygieneConfig;
pub use self::llm::default_session_path;
@@ -53,8 +54,9 @@ pub use self::skills::SkillsConfig;
pub use self::transcription::TranscriptionConfig;
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,
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER, OpenAiCodexConfig,
RegistryProviderConfig,
};
pub use crate::llm::session::SessionConfig;
@@ -98,6 +100,7 @@ pub struct Config {
pub skills: SkillsConfig,
pub transcription: TranscriptionConfig,
pub search: WorkspaceSearchConfig,
pub workspace: WorkspaceConfig,
pub observability: crate::observability::ObservabilityConfig,
/// Channel-relay integration (Slack via external relay service).
/// Present only when both `CHANNEL_RELAY_URL` and `CHANNEL_RELAY_API_KEY` are set.
@@ -175,6 +178,9 @@ impl Config {
},
transcription: TranscriptionConfig::default(),
search: WorkspaceSearchConfig::default(),
workspace: WorkspaceConfig {
memory_layers: vec![],
},
observability: crate::observability::ObservabilityConfig::default(),
relay: None,
}
@@ -305,13 +311,21 @@ impl Config {
async fn build(settings: &Settings) -> Result<Self, ConfigError> {
let owner_id = resolve_owner_id(settings)?;
let tunnel = TunnelConfig::resolve(settings)?;
let channels = ChannelsConfig::resolve(settings, &owner_id)?;
let workspace_user_id = channels
.gateway
.as_ref()
.map(|gw| gw.user_id.clone())
.unwrap_or_else(|| "default".to_string());
Ok(Self {
owner_id: owner_id.clone(),
database: DatabaseConfig::resolve()?,
llm: LlmConfig::resolve(settings)?,
embeddings: EmbeddingsConfig::resolve(settings)?,
tunnel: TunnelConfig::resolve(settings)?,
channels: ChannelsConfig::resolve(settings, &owner_id)?,
tunnel,
channels,
agent: AgentConfig::resolve(settings)?,
safety: resolve_safety_config(settings)?,
wasm: WasmConfig::resolve(settings)?,
@@ -325,6 +339,7 @@ impl Config {
skills: SkillsConfig::resolve()?,
transcription: TranscriptionConfig::resolve(settings)?,
search: WorkspaceSearchConfig::resolve()?,
workspace: WorkspaceConfig::resolve(&workspace_user_id)?,
observability: crate::observability::ObservabilityConfig {
backend: std::env::var("OBSERVABILITY_BACKEND").unwrap_or_else(|_| "none".into()),
},
@@ -377,7 +392,7 @@ pub(crate) fn resolve_owner_id(settings: &Settings) -> Result<String, ConfigErro
/// are read by `optional_env()` before falling back to `std::env::var()`,
/// so explicit env vars always win.
///
/// Also loads tokens from OS credential stores (macOS Keychain, Linux
/// Also loads tokens from OS credential stores (macOS Keychain / Linux
/// credentials files) which don't require the secrets DB.
pub async fn inject_llm_keys_from_secrets(
secrets: &dyn crate::secrets::SecretsStore,
+29 -35
View File
@@ -7,7 +7,7 @@ use secrecy::SecretString;
pub struct RelayConfig {
/// Base URL of the channel-relay service (e.g., `http://localhost:3001`).
pub url: String,
/// API key for authenticated channel-relay endpoints.
/// Bearer token for authenticated channel-relay endpoints (`sk-agent-*`).
pub api_key: SecretString,
/// Override for the OAuth callback URL (e.g., a tunnel URL).
pub callback_url: Option<String>,
@@ -15,12 +15,8 @@ pub struct RelayConfig {
pub instance_id: Option<String>,
/// HTTP request timeout in seconds (default: 30).
pub request_timeout_secs: u64,
/// SSE stream long-poll timeout in seconds (default: 86400 = 24 h).
pub stream_timeout_secs: u64,
/// Initial exponential backoff in milliseconds (default: 1000).
pub backoff_initial_ms: u64,
/// Maximum exponential backoff in milliseconds (default: 60000).
pub backoff_max_ms: u64,
/// Path for the webhook callback endpoint (default: `/relay/events`).
pub webhook_path: String,
}
impl std::fmt::Debug for RelayConfig {
@@ -31,9 +27,7 @@ impl std::fmt::Debug for RelayConfig {
.field("callback_url", &self.callback_url)
.field("instance_id", &self.instance_id)
.field("request_timeout_secs", &self.request_timeout_secs)
.field("stream_timeout_secs", &self.stream_timeout_secs)
.field("backoff_initial_ms", &self.backoff_initial_ms)
.field("backoff_max_ms", &self.backoff_max_ms)
.field("webhook_path", &self.webhook_path)
.finish()
}
}
@@ -41,8 +35,10 @@ impl std::fmt::Debug for RelayConfig {
impl RelayConfig {
/// Load relay config from environment variables.
///
/// Returns `None` if either `CHANNEL_RELAY_URL` or `CHANNEL_RELAY_API_KEY`
/// is not set, making the relay integration opt-in.
/// Returns `None` if either of the required env vars (`CHANNEL_RELAY_URL`,
/// `CHANNEL_RELAY_API_KEY`) is not set, making the relay integration opt-in.
/// The signing secret is fetched from channel-relay at activation time via
/// the authenticated `/relay/signing-secret` endpoint — no env var required.
pub fn from_env() -> Option<Self> {
Self::from_env_reader(|key| std::env::var(key).ok())
}
@@ -55,9 +51,7 @@ impl RelayConfig {
callback_url: None,
instance_id: None,
request_timeout_secs: 30,
stream_timeout_secs: 86400,
backoff_initial_ms: 1000,
backoff_max_ms: 60000,
webhook_path: "/relay/events".into(),
}
}
@@ -73,15 +67,7 @@ impl RelayConfig {
request_timeout_secs: env("RELAY_REQUEST_TIMEOUT_SECS")
.and_then(|v| v.parse().ok())
.unwrap_or(30),
stream_timeout_secs: env("RELAY_STREAM_TIMEOUT_SECS")
.and_then(|v| v.parse().ok())
.unwrap_or(86400),
backoff_initial_ms: env("RELAY_BACKOFF_INITIAL_MS")
.and_then(|v| v.parse().ok())
.unwrap_or(1000),
backoff_max_ms: env("RELAY_BACKOFF_MAX_MS")
.and_then(|v| v.parse().ok())
.unwrap_or(60000),
webhook_path: env("RELAY_WEBHOOK_PATH").unwrap_or_else(|| "/relay/events".into()),
})
}
}
@@ -97,7 +83,21 @@ mod tests {
}
#[test]
fn from_env_reader_loads_defaults() {
fn from_env_reader_requires_only_url_and_api_key() {
// Signing secret is fetched at activation time — only URL + API key needed.
let config = RelayConfig::from_env_reader(|key| match key {
"CHANNEL_RELAY_URL" => Some("http://localhost:3001".into()),
"CHANNEL_RELAY_API_KEY" => Some("test-key".into()),
_ => None,
});
assert!(
config.is_some(),
"relay config should load with just URL + API key"
);
}
#[test]
fn from_env_reader_loads_all_required() {
let config = RelayConfig::from_env_reader(|key| match key {
"CHANNEL_RELAY_URL" => Some("http://localhost:3001".into()),
"CHANNEL_RELAY_API_KEY" => Some("test-key".into()),
@@ -107,9 +107,7 @@ mod tests {
assert_eq!(config.url, "http://localhost:3001");
assert_eq!(config.request_timeout_secs, 30);
assert_eq!(config.stream_timeout_secs, 86400);
assert_eq!(config.backoff_initial_ms, 1000);
assert_eq!(config.backoff_max_ms, 60000);
assert_eq!(config.webhook_path, "/relay/events");
assert!(config.callback_url.is_none());
assert!(config.instance_id.is_none());
}
@@ -122,9 +120,7 @@ mod tests {
"IRONCLAW_OAUTH_CALLBACK_URL" => Some("https://tunnel.example.com".into()),
"IRONCLAW_INSTANCE_ID" => Some("my-instance".into()),
"RELAY_REQUEST_TIMEOUT_SECS" => Some("60".into()),
"RELAY_STREAM_TIMEOUT_SECS" => Some("43200".into()),
"RELAY_BACKOFF_INITIAL_MS" => Some("2000".into()),
"RELAY_BACKOFF_MAX_MS" => Some("120000".into()),
"RELAY_WEBHOOK_PATH" => Some("/custom/events".into()),
_ => None,
})
.expect("config should be Some");
@@ -135,9 +131,7 @@ mod tests {
);
assert_eq!(config.instance_id.as_deref(), Some("my-instance"));
assert_eq!(config.request_timeout_secs, 60);
assert_eq!(config.stream_timeout_secs, 43200);
assert_eq!(config.backoff_initial_ms, 2000);
assert_eq!(config.backoff_max_ms, 120000);
assert_eq!(config.webhook_path, "/custom/events");
}
#[test]
@@ -148,7 +142,7 @@ mod tests {
}
#[test]
fn debug_redacts_api_key() {
fn debug_redacts_secrets() {
let config = RelayConfig::from_values("http://localhost:3001", "super-secret");
let debug = format!("{:?}", config);
assert!(debug.contains("[REDACTED]"));
+6 -1
View File
@@ -1,6 +1,6 @@
use secrecy::SecretString;
use crate::config::helpers::{optional_env, parse_bool_env};
use crate::config::helpers::{optional_env, parse_bool_env, validate_base_url};
use crate::error::ConfigError;
use crate::settings::Settings;
@@ -60,6 +60,11 @@ impl TranscriptionConfig {
let base_url = optional_env("TRANSCRIPTION_BASE_URL")?;
// Validate base URL to prevent SSRF (#1103).
if let Some(ref url) = base_url {
validate_base_url(url, "TRANSCRIPTION_BASE_URL")?;
}
Ok(Self {
enabled,
provider,
+208
View File
@@ -0,0 +1,208 @@
use crate::config::helpers::optional_env;
use crate::error::ConfigError;
use crate::workspace::layer::MemoryLayer;
/// Workspace memory configuration.
///
/// 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)]
pub struct WorkspaceConfig {
pub memory_layers: Vec<MemoryLayer>,
}
impl WorkspaceConfig {
pub(crate) fn resolve(user_id: &str) -> Result<Self, ConfigError> {
let memory_layers: Vec<MemoryLayer> = match optional_env("MEMORY_LAYERS")? {
Some(json_str) => {
serde_json::from_str(&json_str).map_err(|e| ConfigError::InvalidValue {
key: "MEMORY_LAYERS".to_string(),
message: format!("must be valid JSON array of layer objects: {e}"),
})?
}
None => MemoryLayer::default_for_user(user_id),
};
// Validate layer names and scopes
for layer in &memory_layers {
if layer.name.trim().is_empty() {
return Err(ConfigError::InvalidValue {
key: "MEMORY_LAYERS".to_string(),
message: "layer name must not be empty".to_string(),
});
}
if layer.name.len() > 64 {
return Err(ConfigError::InvalidValue {
key: "MEMORY_LAYERS".to_string(),
message: format!("layer name '{}' exceeds 64 characters", layer.name),
});
}
if !layer
.name
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
{
return Err(ConfigError::InvalidValue {
key: "MEMORY_LAYERS".to_string(),
message: format!(
"layer name '{}' contains invalid characters (only alphanumeric, _, - allowed)",
layer.name
),
});
}
if layer.scope.trim().is_empty() {
return Err(ConfigError::InvalidValue {
key: "MEMORY_LAYERS".to_string(),
message: format!("layer '{}' has an empty scope", layer.name),
});
}
}
// Check for duplicate layer names
{
let mut seen = std::collections::HashSet::new();
for layer in &memory_layers {
if !seen.insert(&layer.name) {
return Err(ConfigError::InvalidValue {
key: "MEMORY_LAYERS".to_string(),
message: format!("duplicate layer name '{}'", layer.name),
});
}
}
}
Ok(Self { memory_layers })
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
// Serialize env-var-dependent tests to avoid races.
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn with_env(key: &str, val: Option<&str>, f: impl FnOnce()) {
let _guard = ENV_LOCK.lock().unwrap();
let prev = std::env::var(key).ok();
match val {
Some(v) => unsafe { std::env::set_var(key, v) },
None => unsafe { std::env::remove_var(key) },
}
f();
match prev {
Some(v) => unsafe { std::env::set_var(key, v) },
None => unsafe { std::env::remove_var(key) },
}
}
#[test]
fn valid_json_parses_correctly() {
let json = r#"[{"name":"private","scope":"alice","writable":true,"sensitivity":"private"},{"name":"shared","scope":"shared","writable":true,"sensitivity":"shared"}]"#;
with_env("MEMORY_LAYERS", Some(json), || {
let config = WorkspaceConfig::resolve("alice").expect("should parse");
assert_eq!(config.memory_layers.len(), 2);
assert_eq!(config.memory_layers[0].name, "private");
assert_eq!(config.memory_layers[1].name, "shared");
});
}
#[test]
fn invalid_json_returns_error() {
with_env("MEMORY_LAYERS", Some("not json"), || {
let result = WorkspaceConfig::resolve("alice");
assert!(result.is_err(), "invalid JSON should fail");
let err = result.unwrap_err().to_string();
assert!(
err.contains("valid JSON"),
"error should mention JSON: {err}"
);
});
}
#[test]
fn empty_layer_name_returns_error() {
let json = r#"[{"name":"","scope":"alice"}]"#;
with_env("MEMORY_LAYERS", Some(json), || {
let result = WorkspaceConfig::resolve("alice");
assert!(result.is_err(), "empty layer name should fail");
let err = result.unwrap_err().to_string();
assert!(err.contains("empty"), "error should mention empty: {err}");
});
}
#[test]
fn layer_name_exceeding_64_chars_returns_error() {
let long_name = "a".repeat(65);
let json = format!(r#"[{{"name":"{long_name}","scope":"alice"}}]"#);
with_env("MEMORY_LAYERS", Some(&json), || {
let result = WorkspaceConfig::resolve("alice");
assert!(result.is_err(), "long layer name should fail");
let err = result.unwrap_err().to_string();
assert!(
err.contains("exceeds 64"),
"error should mention 64 chars: {err}"
);
});
}
#[test]
fn layer_name_with_invalid_chars_returns_error() {
for bad_name in ["has space", "has@at", "has.dot", "has/slash"] {
let json = format!(r#"[{{"name":"{bad_name}","scope":"alice"}}]"#);
with_env("MEMORY_LAYERS", Some(&json), || {
let result = WorkspaceConfig::resolve("alice");
assert!(
result.is_err(),
"layer name '{bad_name}' should fail validation"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("invalid characters"),
"error for '{bad_name}' should mention invalid characters: {err}"
);
});
}
}
#[test]
fn empty_scope_returns_error() {
let json = r#"[{"name":"private","scope":""}]"#;
with_env("MEMORY_LAYERS", Some(json), || {
let result = WorkspaceConfig::resolve("alice");
assert!(result.is_err(), "empty scope should fail");
let err = result.unwrap_err().to_string();
assert!(
err.contains("empty scope"),
"error should mention empty scope: {err}"
);
});
}
#[test]
fn duplicate_layer_names_returns_error() {
let json = r#"[{"name":"private","scope":"alice"},{"name":"private","scope":"bob"}]"#;
with_env("MEMORY_LAYERS", Some(json), || {
let result = WorkspaceConfig::resolve("alice");
assert!(result.is_err(), "duplicate names should fail");
let err = result.unwrap_err().to_string();
assert!(
err.contains("duplicate"),
"error should mention duplicate: {err}"
);
});
}
#[test]
fn missing_env_defaults_to_single_private_layer() {
with_env("MEMORY_LAYERS", None, || {
let config = WorkspaceConfig::resolve("alice").expect("should default");
assert_eq!(config.memory_layers.len(), 1);
assert_eq!(config.memory_layers[0].name, "private");
assert_eq!(config.memory_layers[0].scope, "alice");
assert!(config.memory_layers[0].writable);
});
}
}
+319
View File
@@ -0,0 +1,319 @@
//! Structured fallback deliverables for failed or stuck jobs.
//!
//! When a job fails or is detected as stuck, a [`FallbackDeliverable`] captures
//! what was accomplished before the failure: partial results, action statistics,
//! cost, and timing. This gives users visibility into terminal jobs instead of
//! just an error string.
//!
//! Fallback deliverables are stored in `JobContext.metadata["fallback_deliverable"]`
//! and surfaced through the `job_status` tool.
use serde::{Deserialize, Serialize};
use crate::context::memory::Memory;
use crate::context::state::JobContext;
/// Structured summary of a failed or stuck job.
///
/// Stored in `JobContext.metadata["fallback_deliverable"]` when a job fails
/// or is marked stuck. Surfaced through the `job_status` tool.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FallbackDeliverable {
/// True if at least one action succeeded before failure.
pub partial: bool,
/// Why the job failed.
pub failure_reason: String,
/// Last action taken before failure.
pub last_action: Option<LastAction>,
/// Aggregate action statistics.
pub action_stats: ActionStats,
/// Total tokens consumed.
pub tokens_used: u64,
/// Total cost incurred (decimal as string for JSON safety).
pub cost: String,
/// Wall-clock elapsed time in seconds.
pub elapsed_secs: f64,
/// Number of self-repair attempts.
pub repair_attempts: u32,
}
/// Summary of the last action taken before failure.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LastAction {
pub tool_name: String,
/// Truncated to 200 bytes (UTF-8 safe).
pub output_preview: String,
pub success: bool,
}
/// Aggregate action counts.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionStats {
pub total: u32,
pub successful: u32,
pub failed: u32,
}
impl FallbackDeliverable {
/// Build a fallback deliverable from a job context and its memory.
pub fn build(ctx: &JobContext, memory: &Memory, reason: &str) -> Self {
let successful = memory.successful_actions() as u32;
let failed = memory.failed_actions() as u32;
let total = memory.actions.len() as u32;
let last_action = memory.last_action().map(|a| {
// Use sanitized output to avoid leaking secrets through the fallback API surface.
// For failed actions (no sanitized output), fall back to the error message.
// Borrow the string slice directly when possible to avoid cloning
// potentially large outputs just for truncation.
let owned_fallback;
let preview_str: &str = if let Some(v) = a.output_sanitized.as_ref() {
match v {
serde_json::Value::String(s) => s.as_str(),
other => {
owned_fallback = serde_json::to_string(other).unwrap_or_default();
&owned_fallback
}
}
} else if let Some(ref err) = a.error {
err.as_str()
} else {
""
};
let preview = truncate_str(preview_str, 200);
LastAction {
tool_name: a.tool_name.clone(),
output_preview: preview.to_string(),
success: a.success,
}
});
let elapsed_secs = ctx.elapsed().map_or(0.0, |d| d.as_secs_f64());
Self {
partial: successful > 0,
failure_reason: truncate_str(reason, 1000).to_string(),
last_action,
action_stats: ActionStats {
total,
successful,
failed,
},
tokens_used: ctx.total_tokens_used,
cost: ctx.actual_cost.to_string(),
elapsed_secs,
repair_attempts: ctx.repair_attempts,
}
}
}
/// Truncate a string to at most `max_len` bytes on a char boundary.
fn truncate_str(s: &str, max_len: usize) -> &str {
&s[..crate::util::floor_char_boundary(s, max_len)]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::memory::Memory;
use crate::context::state::JobContext;
use chrono::{Duration, Utc};
use rust_decimal::Decimal;
use std::time::Duration as StdDuration;
#[test]
fn test_fallback_zero_actions() {
let ctx = JobContext::new("Test", "Empty job");
let memory = Memory::new(ctx.job_id);
let fb = FallbackDeliverable::build(&ctx, &memory, "timed out");
assert!(!fb.partial); // safety: test
assert_eq!(fb.failure_reason, "timed out"); // safety: test
assert!(fb.last_action.is_none()); // safety: test
assert_eq!(fb.action_stats.total, 0); // safety: test
assert_eq!(fb.action_stats.successful, 0); // safety: test
assert_eq!(fb.action_stats.failed, 0); // safety: test
assert_eq!(fb.tokens_used, 0); // safety: test
assert_eq!(fb.cost, "0"); // safety: test
assert_eq!(fb.repair_attempts, 0); // safety: test
}
#[test]
fn test_fallback_mixed_actions() {
let mut ctx = JobContext::new("Test", "Mixed job");
ctx.total_tokens_used = 5000;
ctx.actual_cost = Decimal::new(42, 2); // 0.42
ctx.repair_attempts = 1;
let mut memory = Memory::new(ctx.job_id);
// 3 successes
for _ in 0..3 {
let action = memory
.create_action("tool_a", serde_json::json!({}))
.succeed(
Some("output".to_string()),
serde_json::json!({}),
StdDuration::from_secs(1),
);
memory.record_action(action);
}
// 2 failures
for _ in 0..2 {
let action = memory
.create_action("tool_b", serde_json::json!({}))
.fail("broke", StdDuration::from_secs(1));
memory.record_action(action);
}
let fb = FallbackDeliverable::build(&ctx, &memory, "max iterations");
assert!(fb.partial); // safety: test
assert_eq!(fb.action_stats.total, 5); // safety: test
assert_eq!(fb.action_stats.successful, 3); // safety: test
assert_eq!(fb.action_stats.failed, 2); // safety: test
assert_eq!(fb.tokens_used, 5000); // safety: test
assert_eq!(fb.cost, "0.42"); // safety: test
assert_eq!(fb.repair_attempts, 1); // safety: test
assert!(fb.last_action.is_some()); // safety: test
let la = fb.last_action.unwrap(); // safety: test
assert_eq!(la.tool_name, "tool_b"); // safety: test
assert!(!la.success); // safety: test
// Failed actions should surface the error message as the output preview
assert_eq!(la.output_preview, "broke"); // safety: test
}
#[test]
fn test_fallback_failed_action_shows_error() {
let ctx = JobContext::new("Test", "Error preview");
let mut memory = Memory::new(ctx.job_id);
let action = memory
.create_action("broken_tool", serde_json::json!({}))
.fail("connection timed out after 30s", StdDuration::from_secs(30));
memory.record_action(action);
let fb = FallbackDeliverable::build(&ctx, &memory, "tool failure");
let la = fb.last_action.unwrap(); // safety: test
assert!(!la.success); // safety: test
assert_eq!(la.output_preview, "connection timed out after 30s"); // safety: test
}
#[test]
fn test_fallback_last_action_truncation() {
let ctx = JobContext::new("Test", "Truncation");
let mut memory = Memory::new(ctx.job_id);
let long_output = "x".repeat(500);
let action = memory
.create_action("tool_c", serde_json::json!({}))
.succeed(
Some(long_output.clone()),
serde_json::Value::String(long_output),
StdDuration::from_secs(1),
);
memory.record_action(action);
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
let la = fb.last_action.unwrap(); // safety: test
assert!(la.output_preview.len() <= 200); // safety: test
assert!(!la.output_preview.is_empty()); // safety: test
}
#[test]
fn test_fallback_uses_sanitized_output() {
let ctx = JobContext::new("Test", "Sanitized");
let mut memory = Memory::new(ctx.job_id);
let action = memory
.create_action("tool_d", serde_json::json!({}))
.succeed(
Some("[REDACTED]".to_string()),
serde_json::json!({"api_key": "sk-secret-key-12345"}),
StdDuration::from_secs(1),
);
memory.record_action(action);
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
let la = fb.last_action.unwrap(); // safety: test
// Must use sanitized output, not raw
assert!(!la.output_preview.contains("sk-secret")); // safety: test
assert!(la.output_preview.contains("REDACTED")); // safety: test
}
#[test]
fn test_fallback_elapsed_time() {
let mut ctx = JobContext::new("Test", "Timing");
let now = Utc::now();
ctx.started_at = Some(now - Duration::seconds(10));
ctx.completed_at = Some(now);
let memory = Memory::new(ctx.job_id);
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
// Should be approximately 10 seconds
assert!((fb.elapsed_secs - 10.0).abs() < 0.1); // safety: test
}
#[test]
fn test_fallback_no_started_at() {
let ctx = JobContext::new("Test", "Never started");
let memory = Memory::new(ctx.job_id);
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
assert!((fb.elapsed_secs - 0.0).abs() < 0.001); // safety: test
}
#[test]
fn test_fallback_elapsed_time_no_completed_at() {
let mut ctx = JobContext::new("Test", "Still running");
ctx.started_at = Some(Utc::now() - Duration::seconds(5));
// completed_at is None — should use Utc::now() as fallback
let memory = Memory::new(ctx.job_id);
let fb = FallbackDeliverable::build(&ctx, &memory, "stuck");
// Should be approximately 5 seconds (using now as end time)
assert!(fb.elapsed_secs >= 4.0 && fb.elapsed_secs <= 7.0); // safety: test
}
#[test]
fn test_fallback_failure_reason_truncation() {
let ctx = JobContext::new("Test", "Long reason");
let memory = Memory::new(ctx.job_id);
let long_reason = "x".repeat(5000);
let fb = FallbackDeliverable::build(&ctx, &memory, &long_reason);
assert!(fb.failure_reason.len() <= 1000); // safety: test
assert!(!fb.failure_reason.is_empty()); // safety: test
}
#[test]
fn test_truncate_str_ascii() {
assert_eq!(truncate_str("hello", 10), "hello"); // safety: test
assert_eq!(truncate_str("hello world", 5), "hello"); // safety: test
}
#[test]
fn test_truncate_str_unicode() {
// "é" is 2 bytes in UTF-8
let s = "café";
assert_eq!(truncate_str(s, 10), "café"); // safety: test
// Truncating at 4 would split "é", should back up to 3
assert_eq!(truncate_str(s, 4), "caf"); // safety: test
}
#[test]
fn test_fallback_serialization() {
let ctx = JobContext::new("Test", "Serialize");
let memory = Memory::new(ctx.job_id);
let fb = FallbackDeliverable::build(&ctx, &memory, "test error");
// Should serialize to JSON and back without error
let json = serde_json::to_value(&fb).unwrap(); // safety: test
let deserialized: FallbackDeliverable = serde_json::from_value(json).unwrap(); // safety: test
assert_eq!(deserialized.failure_reason, "test error"); // safety: test
}
}
+201 -11
View File
@@ -1,11 +1,12 @@
//! Context manager for handling multiple job contexts.
use std::collections::HashMap;
use std::time::Duration;
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::context::{JobContext, Memory};
use crate::context::{JobContext, JobState, Memory};
use crate::error::JobError;
/// Manages contexts for multiple concurrent jobs.
@@ -45,12 +46,41 @@ impl ContextManager {
title: impl Into<String>,
description: impl Into<String>,
) -> Result<Uuid, JobError> {
// Hold write lock for the entire check-insert to prevent TOCTOU races
// where two concurrent calls both pass the parallel_count check.
let context = JobContext::with_user(user_id, title, description);
let job_id = context.job_id;
self.insert_context(context).await?;
Ok(job_id)
}
/// Register a sandbox job with a pre-determined ID.
///
/// Unlike `create_job_for_user` (which generates its own UUID), this method
/// accepts an existing `job_id` — used by `execute_sandbox()` which creates
/// the UUID before the container so it can be shared with Docker labels and
/// DB persistence.
///
/// The job starts in `InProgress` state since the container is about to be
/// created. Counts against `max_jobs` like any other job.
pub async fn register_sandbox_job(
&self,
job_id: Uuid,
user_id: impl Into<String>,
title: impl Into<String>,
description: impl Into<String>,
) -> Result<(), JobError> {
let mut context = JobContext::with_user(user_id, title, description);
context.job_id = job_id;
context.state = JobState::InProgress;
context.started_at = Some(chrono::Utc::now());
self.insert_context(context).await
}
/// Check max_jobs limit, insert context, and allocate memory.
///
/// Holds the write lock for the entire check-insert to prevent TOCTOU
/// races where two concurrent calls both pass the parallel_count check.
async fn insert_context(&self, context: JobContext) -> Result<(), JobError> {
let mut contexts = self.contexts.write().await;
// Only count jobs that consume execution slots (Pending, InProgress, Stuck).
// Completed and Submitted jobs are no longer actively executing and shouldn't
// block new job creation.
let parallel_count = contexts
.values()
.filter(|c| c.state.is_parallel_blocking())
@@ -60,15 +90,16 @@ impl ContextManager {
return Err(JobError::MaxJobsExceeded { max: self.max_jobs });
}
let context = JobContext::with_user(user_id, title, description);
let job_id = context.job_id;
contexts.insert(job_id, context);
drop(contexts);
let memory = Memory::new(job_id);
self.memories.write().await.insert(job_id, memory);
self.memories
.write()
.await
.insert(job_id, Memory::new(job_id));
Ok(job_id)
Ok(())
}
/// Get a job context by ID.
@@ -205,12 +236,46 @@ impl ContextManager {
}
/// Find stuck jobs.
///
/// Returns jobs that are explicitly in `Stuck` state, plus `InProgress`
/// jobs that have been running longer than `elapsed_threshold` (if provided).
/// The threshold-based detection catches jobs that never transitioned to
/// `Stuck` (e.g., due to a deadlock or unhandled timeout).
pub async fn find_stuck_jobs(&self) -> Vec<Uuid> {
self.find_stuck_jobs_with_threshold(None).await
}
/// Find stuck jobs with an optional elapsed threshold for `InProgress` detection.
pub async fn find_stuck_jobs_with_threshold(
&self,
elapsed_threshold: Option<Duration>,
) -> Vec<Uuid> {
let now = chrono::Utc::now();
self.contexts
.read()
.await
.iter()
.filter(|(_, c)| c.state == crate::context::JobState::Stuck)
.filter(|(_, c)| {
// Always include explicitly Stuck jobs.
if c.state == crate::context::JobState::Stuck {
return true;
}
// Detect InProgress jobs that have been running beyond the elapsed threshold.
// NOTE: `started_at` is set on the first transition to InProgress and is
// NOT reset when a job recovers from Stuck back to InProgress. This means
// a recovered job may be re-detected on the next scan. A future improvement
// could track `in_progress_since` or use the most recent StateTransition
// with `to == InProgress` to avoid false positives on recovered jobs.
if c.state == crate::context::JobState::InProgress
&& let Some(threshold) = elapsed_threshold
&& let Some(started) = c.started_at
{
let elapsed = now.signed_duration_since(started);
let elapsed_secs = elapsed.num_seconds().max(0) as u64;
return elapsed_secs > threshold.as_secs();
}
false
})
.map(|(id, _)| *id)
.collect()
}
@@ -629,6 +694,48 @@ mod tests {
assert_eq!(stuck[0], id2);
}
/// Regression test for #1223: InProgress jobs exceeding the threshold
/// should be detected as stuck even if they never transitioned to Stuck.
#[tokio::test]
async fn find_stuck_jobs_with_threshold_detects_idle_in_progress() {
let manager = ContextManager::new(10);
let id1 = manager.create_job("Active job", "desc").await.unwrap();
let id2 = manager.create_job("Idle job", "desc").await.unwrap();
// Both transition to InProgress
for id in [id1, id2] {
manager
.update_context(id, |ctx| {
ctx.transition_to(crate::context::JobState::InProgress, None)
})
.await
.unwrap()
.unwrap();
}
// Backdate id2's started_at to simulate a long-running job
manager
.update_context(id2, |ctx| -> Result<(), crate::error::JobError> {
ctx.started_at = Some(chrono::Utc::now() - chrono::Duration::seconds(600));
Ok(())
})
.await
.unwrap()
.unwrap();
// With a 5-minute threshold, only id2 (10 min) should be detected
let stuck = manager
.find_stuck_jobs_with_threshold(Some(Duration::from_secs(300)))
.await;
assert_eq!(stuck.len(), 1);
assert_eq!(stuck[0], id2);
// Without threshold, neither InProgress job is detected (no explicit Stuck state)
let stuck_no_threshold = manager.find_stuck_jobs().await;
assert!(stuck_no_threshold.is_empty());
}
#[tokio::test]
async fn active_count_tracks_non_terminal_jobs() {
let manager = ContextManager::new(10);
@@ -1185,4 +1292,87 @@ mod tests {
}
}
}
// === Regression: sandbox jobs must be visible to query tools ===
// Before the fix, execute_sandbox() only persisted to DB but never
// registered in ContextManager, making sandbox jobs invisible to
// list_jobs, job_status, job_events, and resolve_job_id.
#[tokio::test]
async fn register_sandbox_job_visible_to_queries() {
let manager = ContextManager::new(5);
let job_id = Uuid::new_v4();
manager
.register_sandbox_job(
job_id,
"user-42",
"Run tests",
"Execute test suite in sandbox",
)
.await
.unwrap();
// Job should be retrievable by ID (used by job_status, job_events)
let ctx = manager.get_context(job_id).await.unwrap();
assert_eq!(ctx.job_id, job_id);
assert_eq!(ctx.user_id, "user-42");
assert_eq!(ctx.title, "Run tests");
assert_eq!(ctx.state, JobState::InProgress);
assert!(ctx.started_at.is_some());
// Job should appear in all_jobs (used by resolve_job_id prefix matching)
let all = manager.all_jobs().await;
assert!(all.contains(&job_id));
// Job should appear in user-scoped listing (used by list_jobs)
let user_jobs = manager.all_jobs_for("user-42").await;
assert!(user_jobs.contains(&job_id));
// Job should appear in active jobs listing
let active = manager.active_jobs_for("user-42").await;
assert!(active.contains(&job_id));
}
#[tokio::test]
async fn register_sandbox_job_respects_max_jobs() {
let manager = ContextManager::new(2);
// Fill up the slots with sandbox jobs
manager
.register_sandbox_job(Uuid::new_v4(), "user-1", "Job 1", "desc")
.await
.unwrap();
manager
.register_sandbox_job(Uuid::new_v4(), "user-1", "Job 2", "desc")
.await
.unwrap();
// Third should fail
let result = manager
.register_sandbox_job(Uuid::new_v4(), "user-1", "Job 3", "desc")
.await;
assert!(matches!(result, Err(JobError::MaxJobsExceeded { max: 2 })));
}
#[tokio::test]
async fn register_sandbox_job_transitions_correctly() {
let manager = ContextManager::new(5);
let job_id = Uuid::new_v4();
manager
.register_sandbox_job(job_id, "user-1", "Task", "desc")
.await
.unwrap();
// Should be able to transition InProgress -> Completed
manager
.update_context(job_id, |ctx| ctx.transition_to(JobState::Completed, None))
.await
.unwrap()
.unwrap();
let ctx = manager.get_context(job_id).await.unwrap();
assert_eq!(ctx.state, JobState::Completed);
}
}
+79 -70
View File
@@ -58,15 +58,19 @@ impl ActionRecord {
}
/// Mark the action as successful.
///
/// `output_sanitized` is the tool output after safety processing (string).
/// `output_raw` is the original tool result (JSON value, stored as a
/// pretty-printed JSON string in `ActionRecord.output_raw`).
pub fn succeed(
mut self,
output_raw: Option<String>,
output_sanitized: serde_json::Value,
output_sanitized: Option<String>,
output_raw: serde_json::Value,
duration: Duration,
) -> Self {
self.success = true;
self.output_raw = output_raw;
self.output_sanitized = Some(output_sanitized);
self.output_raw = Some(serde_json::to_string_pretty(&output_raw).unwrap_or_default());
self.output_sanitized = output_sanitized.map(serde_json::Value::String);
self.duration = duration;
self
}
@@ -248,15 +252,15 @@ mod tests {
#[test]
fn test_action_record() {
let action = ActionRecord::new(0, "test", serde_json::json!({"key": "value"}));
assert_eq!(action.sequence, 0);
assert!(!action.success);
assert_eq!(action.sequence, 0); // safety: test
assert!(!action.success); // safety: test
let action = action.succeed(
Some("raw".to_string()),
serde_json::json!({"result": "ok"}),
Duration::from_millis(100),
);
assert!(action.success);
assert!(action.success); // safety: test
}
#[test]
@@ -267,7 +271,7 @@ mod tests {
memory.add(ChatMessage::user("How are you?"));
memory.add(ChatMessage::assistant("Good!"));
assert_eq!(memory.len(), 3); // Oldest removed
assert_eq!(memory.len(), 3); // Oldest removed // safety: test
}
#[test]
@@ -286,9 +290,9 @@ mod tests {
.with_cost(Decimal::new(20, 1));
memory.record_action(action2);
assert_eq!(memory.total_cost(), Decimal::new(30, 1));
assert_eq!(memory.total_duration(), Duration::from_secs(3));
assert_eq!(memory.successful_actions(), 2);
assert_eq!(memory.total_cost(), Decimal::new(30, 1)); // safety: test
assert_eq!(memory.total_duration(), Duration::from_secs(3)); // safety: test
assert_eq!(memory.successful_actions(), 2); // safety: test
}
#[test]
@@ -296,11 +300,11 @@ mod tests {
let action = ActionRecord::new(1, "broken_tool", serde_json::json!({"x": 1}));
let action = action.fail("something went wrong", Duration::from_millis(50));
assert!(!action.success);
assert_eq!(action.error.as_deref(), Some("something went wrong"));
assert_eq!(action.duration, Duration::from_millis(50));
assert!(action.output_raw.is_none());
assert!(action.output_sanitized.is_none());
assert!(!action.success); // safety: test
assert_eq!(action.error.as_deref(), Some("something went wrong")); // safety: test
assert_eq!(action.duration, Duration::from_millis(50)); // safety: test
assert!(action.output_raw.is_none()); // safety: test
assert!(action.output_sanitized.is_none()); // safety: test
}
#[test]
@@ -308,9 +312,9 @@ mod tests {
let action = ActionRecord::new(0, "risky_tool", serde_json::json!({}));
let action = action.with_warnings(vec!["suspicious pattern".into(), "possible xss".into()]);
assert_eq!(action.sanitization_warnings.len(), 2);
assert_eq!(action.sanitization_warnings[0], "suspicious pattern");
assert_eq!(action.sanitization_warnings[1], "possible xss");
assert_eq!(action.sanitization_warnings.len(), 2); // safety: test
assert_eq!(action.sanitization_warnings[0], "suspicious pattern"); // safety: test
assert_eq!(action.sanitization_warnings[1], "possible xss"); // safety: test
}
#[test]
@@ -319,41 +323,46 @@ mod tests {
let cost = Decimal::new(42, 2); // 0.42
let action = action.with_cost(cost);
assert_eq!(action.cost, Some(Decimal::new(42, 2)));
assert_eq!(action.cost, Some(Decimal::new(42, 2))); // safety: test
}
#[test]
fn test_action_record_new_defaults() {
let action = ActionRecord::new(5, "my_tool", serde_json::json!({"key": "val"}));
assert_eq!(action.sequence, 5);
assert_eq!(action.tool_name, "my_tool");
assert_eq!(action.input, serde_json::json!({"key": "val"}));
assert!(!action.success);
assert!(action.output_raw.is_none());
assert!(action.output_sanitized.is_none());
assert!(action.sanitization_warnings.is_empty());
assert!(action.cost.is_none());
assert_eq!(action.duration, Duration::ZERO);
assert!(action.error.is_none());
assert_eq!(action.sequence, 5); // safety: test
assert_eq!(action.tool_name, "my_tool"); // safety: test
assert_eq!(action.input, serde_json::json!({"key": "val"})); // safety: test
assert!(!action.success); // safety: test
assert!(action.output_raw.is_none()); // safety: test
assert!(action.output_sanitized.is_none()); // safety: test
assert!(action.sanitization_warnings.is_empty()); // safety: test
assert!(action.cost.is_none()); // safety: test
assert_eq!(action.duration, Duration::ZERO); // safety: test
assert!(action.error.is_none()); // safety: test
}
#[test]
fn test_action_record_succeed_sets_fields() {
let action = ActionRecord::new(0, "tool", serde_json::json!({}));
let action = action.succeed(
Some("raw output here".into()),
Some("sanitized output".into()),
serde_json::json!({"clean": true}),
Duration::from_secs(7),
);
assert!(action.success);
assert_eq!(action.output_raw.as_deref(), Some("raw output here"));
assert!(action.success); // safety: test
// output_raw is the JSON value pretty-printed
let expected_raw =
serde_json::to_string_pretty(&serde_json::json!({"clean": true})).unwrap(); // safety: test
assert_eq!(action.output_raw.as_deref(), Some(expected_raw.as_str())); // safety: test
// output_sanitized wraps the string in a JSON string value
assert_eq!(
/* safety: test */
action.output_sanitized,
Some(serde_json::json!({"clean": true}))
Some(serde_json::json!("sanitized output"))
);
assert_eq!(action.duration, Duration::from_secs(7));
assert_eq!(action.duration, Duration::from_secs(7)); // safety: test
}
#[test]
@@ -361,13 +370,13 @@ mod tests {
let mut mem = ConversationMemory::new(10);
mem.add(ChatMessage::user("hello"));
mem.add(ChatMessage::assistant("hi"));
assert_eq!(mem.len(), 2);
assert!(!mem.is_empty());
assert_eq!(mem.len(), 2); // safety: test
assert!(!mem.is_empty()); // safety: test
mem.clear();
assert_eq!(mem.len(), 0);
assert!(mem.is_empty());
assert!(mem.messages().is_empty());
assert_eq!(mem.len(), 0); // safety: test
assert!(mem.is_empty()); // safety: test
assert!(mem.messages().is_empty()); // safety: test
}
#[test]
@@ -379,20 +388,20 @@ mod tests {
mem.add(ChatMessage::assistant("four"));
let last_2 = mem.last_n(2);
assert_eq!(last_2.len(), 2);
assert_eq!(last_2[0].content, "three");
assert_eq!(last_2[1].content, "four");
assert_eq!(last_2.len(), 2); // safety: test
assert_eq!(last_2[0].content, "three"); // safety: test
assert_eq!(last_2[1].content, "four"); // safety: test
// Requesting more than available returns all
let last_100 = mem.last_n(100);
assert_eq!(last_100.len(), 4);
assert_eq!(last_100.len(), 4); // safety: test
}
#[test]
fn test_conversation_memory_last_n_empty() {
let mem = ConversationMemory::new(10);
let result = mem.last_n(5);
assert!(result.is_empty());
assert!(result.is_empty()); // safety: test
}
#[test]
@@ -405,13 +414,13 @@ mod tests {
// At capacity (3). Adding one more should trim, but keep system.
mem.add(ChatMessage::user("msg3"));
assert_eq!(mem.len(), 3);
assert_eq!(mem.len(), 3); // safety: test
// System message must survive
assert_eq!(mem.messages()[0].role, crate::llm::Role::System);
assert_eq!(mem.messages()[0].content, "You are helpful");
assert_eq!(mem.messages()[0].role, crate::llm::Role::System); // safety: test
assert_eq!(mem.messages()[0].content, "You are helpful"); // safety: test
// Oldest non-system message (msg1) should be gone
assert_eq!(mem.messages()[1].content, "msg2");
assert_eq!(mem.messages()[2].content, "msg3");
assert_eq!(mem.messages()[1].content, "msg2"); // safety: test
assert_eq!(mem.messages()[2].content, "msg3"); // safety: test
}
#[test]
@@ -422,9 +431,9 @@ mod tests {
// Now at capacity. Add another.
mem.add(ChatMessage::user("b"));
assert_eq!(mem.len(), 2);
assert_eq!(mem.messages()[0].role, crate::llm::Role::System);
assert_eq!(mem.messages()[1].content, "b");
assert_eq!(mem.len(), 2); // safety: test
assert_eq!(mem.messages()[0].role, crate::llm::Role::System); // safety: test
assert_eq!(mem.messages()[1].content, "b"); // safety: test
}
#[test]
@@ -440,7 +449,7 @@ mod tests {
mem.add(ChatMessage::user("hello"));
// Should have broken out rather than looping forever.
// The system message is protected, so len may exceed max.
assert!(mem.len() <= 2);
assert!(mem.len() <= 2); // safety: test
}
#[test]
@@ -459,14 +468,14 @@ mod tests {
.fail("oops", Duration::from_millis(2));
memory.record_action(err);
assert_eq!(memory.successful_actions(), 1);
assert_eq!(memory.failed_actions(), 1);
assert_eq!(memory.successful_actions(), 1); // safety: test
assert_eq!(memory.failed_actions(), 1); // safety: test
}
#[test]
fn test_memory_last_action() {
let mut memory = Memory::new(Uuid::new_v4());
assert!(memory.last_action().is_none());
assert!(memory.last_action().is_none()); // safety: test
let a1 = memory
.create_action("first", serde_json::json!({}))
@@ -478,8 +487,8 @@ mod tests {
.fail("nope", Duration::ZERO);
memory.record_action(a2);
let last = memory.last_action().unwrap();
assert_eq!(last.tool_name, "second");
let last = memory.last_action().unwrap(); // safety: test
assert_eq!(last.tool_name, "second"); // safety: test
}
#[test]
@@ -499,9 +508,9 @@ mod tests {
);
memory.record_action(a);
assert_eq!(memory.actions_by_tool("shell").len(), 3);
assert_eq!(memory.actions_by_tool("http").len(), 1);
assert_eq!(memory.actions_by_tool("nonexistent").len(), 0);
assert_eq!(memory.actions_by_tool("shell").len(), 3); // safety: test
assert_eq!(memory.actions_by_tool("http").len(), 1); // safety: test
assert_eq!(memory.actions_by_tool("nonexistent").len(), 0); // safety: test
}
#[test]
@@ -509,25 +518,25 @@ mod tests {
let mut memory = Memory::new(Uuid::new_v4());
let a0 = memory.create_action("t", serde_json::json!({}));
assert_eq!(a0.sequence, 0);
assert_eq!(a0.sequence, 0); // safety: test
let a1 = memory.create_action("t", serde_json::json!({}));
assert_eq!(a1.sequence, 1);
assert_eq!(a1.sequence, 1); // safety: test
let a2 = memory.create_action("t", serde_json::json!({}));
assert_eq!(a2.sequence, 2);
assert_eq!(a2.sequence, 2); // safety: test
}
#[test]
fn test_memory_add_message_delegates_to_conversation() {
let mut memory = Memory::new(Uuid::new_v4());
assert!(memory.conversation.is_empty());
assert!(memory.conversation.is_empty()); // safety: test
memory.add_message(ChatMessage::user("hello"));
memory.add_message(ChatMessage::assistant("hi"));
assert_eq!(memory.conversation.len(), 2);
assert_eq!(memory.conversation.messages()[0].content, "hello");
assert_eq!(memory.conversation.len(), 2); // safety: test
assert_eq!(memory.conversation.messages()[0].content, "hello"); // safety: test
}
#[test]
@@ -540,7 +549,7 @@ mod tests {
.succeed(None, serde_json::json!({}), Duration::ZERO);
memory.record_action(a);
assert_eq!(memory.total_cost(), Decimal::ZERO);
assert_eq!(memory.total_cost(), Decimal::ZERO); // safety: test
}
#[test]
@@ -560,6 +569,6 @@ mod tests {
memory.record_action(a2);
// Both successful and failed actions contribute to total duration
assert_eq!(memory.total_duration(), Duration::from_millis(300));
assert_eq!(memory.total_duration(), Duration::from_millis(300)); // safety: test
}
}
+2
View File
@@ -6,10 +6,12 @@
//! - State machine
//! - Resource tracking
pub mod fallback;
mod manager;
mod memory;
mod state;
pub use fallback::FallbackDeliverable;
pub use manager::ContextManager;
pub use memory::{ActionRecord, ConversationMemory, Memory};
pub use state::{JobContext, JobState, StateTransition, TokenBudgetExceeded};
+3 -3
View File
@@ -75,7 +75,7 @@ The `Database` supertrait is composed of seven sub-traits. Leaf consumers can de
| Numeric/Decimal | `NUMERIC` | `TEXT` (preserves `rust_decimal` precision) |
| Arrays | `TEXT[]` | `TEXT` (JSON-encoded array) |
| Booleans | `BOOLEAN` | `INTEGER` (0/1) |
| Vector embeddings | `VECTOR` (any dim, V9 removed fixed 1536) | `F32_BLOB(1536)` via `libsql_vector_idx` |
| Vector embeddings | `VECTOR` (any dim, V9 removed fixed 1536) | `F32_BLOB(N)` via `libsql_vector_idx` (dimension set dynamically by `ensure_vector_index`) |
| Full-text search | `tsvector` + `ts_rank_cd` | FTS5 virtual table + sync triggers |
| JSON path update | `jsonb_set(col, '{key}', val)` | `json_patch(col, '{"key": val}')` |
| PL/pgSQL | Functions | Triggers (no stored procs in SQLite) |
@@ -90,7 +90,7 @@ The `Database` supertrait is composed of seven sub-traits. Leaf consumers can de
**Timestamp write format:** Always write timestamps with `fmt_ts(dt)` (RFC 3339, millisecond precision). Read with `get_ts()` / `get_opt_ts()` which handle legacy naive formats too.
**Vector dimension:** PostgreSQL V9 migration changed the column to unbounded `vector` (removing the HNSW index). libSQL still uses `F32_BLOB(1536)` — if you use a different-dimension embedding model, the libSQL schema needs updating too.
**Vector dimension:** PostgreSQL V9 migration changed the column to unbounded `vector` (removing the HNSW index). libSQL dynamically creates `F32_BLOB(N)` with the correct dimension via `ensure_vector_index()` during `run_migrations()`, reading `EMBEDDING_DIMENSION` / `EMBEDDING_MODEL` from env vars.
**Connection per operation:** `LibSqlBackend::connect()` creates a fresh connection for every operation, sets `PRAGMA busy_timeout = 5000`, and closes it when the `Connection` is dropped. This is intentional — the libSQL SDK does not offer a pool. Avoid holding connections open across `await` points.
@@ -134,7 +134,7 @@ The `Database` supertrait is composed of seven sub-traits. Leaf consumers can de
- **Settings reload**`Config::from_db` skipped (requires `Store`)
- **No incremental migrations** — schema is idempotent CREATE IF NOT EXISTS; no ALTER TABLE support; column additions require a new versioned approach
- **No encryption at rest** — only secrets (API tokens) are AES-256-GCM encrypted; all other data is plaintext SQLite
- **Hybrid search** — both FTS5 and vector search (`libsql_vector_idx`) are implemented; however, the vector index is fixed at `F32_BLOB(1536)` while PostgreSQL switched to unbounded `vector` in V9
- **Hybrid search** — both FTS5 and vector search (`libsql_vector_idx`) are implemented; `ensure_vector_index()` dynamically creates the index with the correct `F32_BLOB(N)` dimension from env vars during `run_migrations()`
- **Write serialization** — WAL mode allows concurrent readers but only one writer at a time; busy timeout is 5 s, which may cause timeouts under high write concurrency
## Running Locally with libSQL
+8
View File
@@ -341,6 +341,14 @@ impl Database for LibSqlBackend {
.map_err(|e| DatabaseError::Migration(format!("libSQL migration failed: {}", e)))?;
// Apply incremental migrations (V9+) tracked in _migrations table.
libsql_migrations::run_incremental(&conn).await?;
// Set up vector index if embeddings are configured.
// This dynamically creates a libsql_vector_idx on memory_chunks.embedding
// with the correct F32_BLOB(N) dimension inferred from env vars.
if let Some(dimension) = workspace::resolve_embedding_dimension() {
self.ensure_vector_index(dimension).await?;
}
Ok(())
}
}
+28
View File
@@ -477,6 +477,34 @@ impl RoutineStore for LibSqlBackend {
Ok(())
}
async fn get_webhook_routine_by_path(
&self,
path: &str,
) -> Result<Option<Routine>, DatabaseError> {
let conn = self.connect().await?;
let mut rows = conn
.query(
&format!(
"SELECT {} FROM routines WHERE enabled = 1 AND trigger_type = 'webhook' \
AND (json_extract(trigger_config, '$.path') = ?1 \
OR (json_extract(trigger_config, '$.path') IS NULL AND CAST(id AS TEXT) = ?1))",
ROUTINE_COLUMNS
),
params![path],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
match rows
.next()
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?
{
Some(row) => Ok(Some(row_to_routine_libsql(&row)?)),
None => Ok(None),
}
}
async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError> {
let conn = self.connect().await?;
let mut rows = conn
+474 -7
View File
@@ -11,7 +11,7 @@ use super::{
row_to_memory_document,
};
use crate::db::WorkspaceStore;
use crate::error::WorkspaceError;
use crate::error::{DatabaseError, WorkspaceError};
use crate::workspace::{
MemoryChunk, MemoryDocument, RankedResult, SearchConfig, SearchResult, WorkspaceEntry,
fuse_results,
@@ -19,6 +19,227 @@ use crate::workspace::{
use chrono::Utc;
/// Resolve the embedding dimension from environment variables.
///
/// Reads `EMBEDDING_ENABLED`, `EMBEDDING_DIMENSION`, and `EMBEDDING_MODEL`
/// from env vars. Returns `None` if embeddings are disabled.
///
/// Note: this only reads env vars, not persisted `Settings`, because it runs
/// during `run_migrations()` before the full config stack is available. Users
/// who configure embeddings via the settings UI must also set
/// `EMBEDDING_ENABLED=true` in their environment for the vector index to be
/// created. The model→dimension mapping is shared with `EmbeddingsConfig` via
/// `default_dimension_for_model()`.
pub(crate) fn resolve_embedding_dimension() -> Option<usize> {
let enabled = std::env::var("EMBEDDING_ENABLED")
.map(|v| v.eq_ignore_ascii_case("true") || v == "1")
.unwrap_or(false);
if !enabled {
tracing::info!("Vector index setup skipped (EMBEDDING_ENABLED not set in env)");
return None;
}
if let Ok(dim_str) = std::env::var("EMBEDDING_DIMENSION")
&& let Ok(dim) = dim_str.parse::<usize>()
&& dim > 0
{
return Some(dim);
}
let model =
std::env::var("EMBEDDING_MODEL").unwrap_or_else(|_| "text-embedding-3-small".to_string());
Some(crate::config::embeddings::default_dimension_for_model(
&model,
))
}
impl LibSqlBackend {
/// Ensure the `libsql_vector_idx` on `memory_chunks.embedding` matches the
/// configured embedding dimension.
///
/// The V9 migration dropped the vector index (and changed `F32_BLOB(1536)`
/// to `BLOB`) to support flexible dimensions. This method restores a
/// properly-typed `F32_BLOB(N)` column and creates the vector index.
///
/// Tracks the active dimension in `_migrations` version `0` — a reserved
/// metadata row where `name` stores the dimension as a string. Version 0
/// is never used by incremental migrations (which start at 9), so there
/// is no collision. If the stored dimension matches, this is a no-op.
///
/// **Precondition:** `run_migrations()` must have been called first so that
/// the `_migrations` table exists. This is guaranteed when called from
/// `Database::run_migrations()`, but callers using this directly must
/// ensure migrations have run.
pub async fn ensure_vector_index(&self, dimension: usize) -> Result<(), DatabaseError> {
if dimension == 0 || dimension > 65536 {
return Err(DatabaseError::Migration(format!(
"ensure_vector_index: dimension {dimension} out of valid range (1..=65536)"
)));
}
let conn = self.connect().await?;
// Check current dimension from _migrations version=0 (reserved metadata row).
// The block scope ensures `rows` is dropped before `conn.transaction()` —
// holding a result set open would cause "database table is locked" errors.
let current_dim = {
let mut rows = conn
.query("SELECT name FROM _migrations WHERE version = 0", ())
.await
.map_err(|e| {
DatabaseError::Migration(format!("Failed to check vector index metadata: {e}"))
})?;
rows.next().await.ok().flatten().and_then(|row| {
row.get::<String>(0)
.ok()
.and_then(|s| s.parse::<usize>().ok())
})
};
if current_dim == Some(dimension) {
tracing::debug!(
dimension,
"Vector index already matches configured dimension"
);
return Ok(());
}
tracing::info!(
old_dimension = ?current_dim,
new_dimension = dimension,
"Rebuilding memory_chunks table for vector index"
);
let tx = conn.transaction().await.map_err(|e| {
DatabaseError::Migration(format!(
"ensure_vector_index: failed to start transaction: {e}"
))
})?;
// 1. Drop FTS triggers that reference the old table
tx.execute_batch(
"DROP TRIGGER IF EXISTS memory_chunks_fts_insert;
DROP TRIGGER IF EXISTS memory_chunks_fts_delete;
DROP TRIGGER IF EXISTS memory_chunks_fts_update;",
)
.await
.map_err(|e| DatabaseError::Migration(format!("Failed to drop FTS triggers: {e}")))?;
// 2. Drop old vector index
tx.execute_batch("DROP INDEX IF EXISTS idx_memory_chunks_embedding;")
.await
.map_err(|e| {
DatabaseError::Migration(format!("Failed to drop old vector index: {e}"))
})?;
// 3. Drop stale temp table (if a previous attempt crashed) and create fresh
tx.execute_batch("DROP TABLE IF EXISTS memory_chunks_new;")
.await
.map_err(|e| {
DatabaseError::Migration(format!("Failed to drop stale memory_chunks_new: {e}"))
})?;
let create_sql = format!(
"CREATE TABLE memory_chunks_new (
_rowid INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
document_id TEXT NOT NULL REFERENCES memory_documents(id) ON DELETE CASCADE,
chunk_index INTEGER NOT NULL,
content TEXT NOT NULL,
embedding F32_BLOB({dimension}),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE (document_id, chunk_index)
)"
);
tx.execute_batch(&create_sql).await.map_err(|e| {
DatabaseError::Migration(format!(
"Failed to create memory_chunks_new with F32_BLOB({dimension}): {e}"
))
})?;
// 4. Copy data — embeddings with wrong byte length get NULLed
// (they will be re-embedded on next background pass).
// _rowid is explicitly preserved so the FTS5 content table
// (memory_chunks_fts, content_rowid='_rowid') stays in sync.
let expected_bytes = dimension * 4;
let copy_sql = format!(
"INSERT INTO memory_chunks_new
(_rowid, id, document_id, chunk_index, content, embedding, created_at)
SELECT _rowid, id, document_id, chunk_index, content,
CASE WHEN length(embedding) = {expected_bytes} THEN embedding ELSE NULL END,
created_at
FROM memory_chunks"
);
tx.execute_batch(&copy_sql).await.map_err(|e| {
DatabaseError::Migration(format!("Failed to copy data to memory_chunks_new: {e}"))
})?;
// 5. Swap tables
tx.execute_batch(
"DROP TABLE memory_chunks;
ALTER TABLE memory_chunks_new RENAME TO memory_chunks;",
)
.await
.map_err(|e| {
DatabaseError::Migration(format!("Failed to swap memory_chunks tables: {e}"))
})?;
// 6. Recreate document index + vector index
tx.execute_batch(
"CREATE INDEX IF NOT EXISTS idx_memory_chunks_document ON memory_chunks(document_id);
CREATE INDEX IF NOT EXISTS idx_memory_chunks_embedding ON memory_chunks(libsql_vector_idx(embedding));",
)
.await
.map_err(|e| {
DatabaseError::Migration(format!("Failed to create indexes: {e}"))
})?;
// 7. Recreate FTS triggers
tx.execute_batch(
"CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_insert AFTER INSERT ON memory_chunks BEGIN
INSERT INTO memory_chunks_fts(rowid, content) VALUES (new._rowid, new.content);
END;
CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_delete AFTER DELETE ON memory_chunks BEGIN
INSERT INTO memory_chunks_fts(memory_chunks_fts, rowid, content)
VALUES ('delete', old._rowid, old.content);
END;
CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_update AFTER UPDATE ON memory_chunks BEGIN
INSERT INTO memory_chunks_fts(memory_chunks_fts, rowid, content)
VALUES ('delete', old._rowid, old.content);
INSERT INTO memory_chunks_fts(rowid, content) VALUES (new._rowid, new.content);
END;",
)
.await
.map_err(|e| {
DatabaseError::Migration(format!("Failed to recreate FTS triggers: {e}"))
})?;
// 8. Upsert dimension into _migrations(version=0)
tx.execute(
"INSERT INTO _migrations (version, name) VALUES (0, ?1)
ON CONFLICT(version) DO UPDATE SET name = ?1,
applied_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
params![dimension.to_string()],
)
.await
.map_err(|e| {
DatabaseError::Migration(format!("Failed to record vector index dimension: {e}"))
})?;
tx.commit().await.map_err(|e| {
DatabaseError::Migration(format!("ensure_vector_index: commit failed: {e}"))
})?;
tracing::info!(dimension, "Vector index created successfully");
Ok(())
}
}
#[async_trait]
impl WorkspaceStore for LibSqlBackend {
async fn get_document_by_path(
@@ -395,6 +616,9 @@ impl WorkspaceStore for LibSqlBackend {
reason: e.to_string(),
})?;
let id = Uuid::new_v4();
// Note: embedding dimension is not validated here — the F32_BLOB(N)
// column type created by ensure_vector_index() enforces byte length at
// the libSQL level and will reject mismatched dimensions.
let embedding_blob = embedding.map(|e| {
let bytes: Vec<u8> = e.iter().flat_map(|f| f.to_le_bytes()).collect();
bytes
@@ -561,9 +785,9 @@ impl WorkspaceStore for LibSqlBackend {
.join(",")
);
// vector_top_k requires a libsql_vector_idx index. After the V9
// migration the index is dropped (to support flexible embedding
// dimensions), so this query may fail. Fall back to FTS-only.
// vector_top_k requires a libsql_vector_idx index created by
// ensure_vector_index(). If the index is missing (embeddings not
// configured or dimension mismatch), fall back to FTS-only.
match conn
.query(
r#"
@@ -597,9 +821,9 @@ impl WorkspaceStore for LibSqlBackend {
results
}
Err(e) => {
tracing::debug!(
"Vector index query failed (expected after V9 migration), \
falling back to FTS-only: {e}"
tracing::warn!(
"Vector index query failed (ensure_vector_index may not have run \
or dimension mismatch), falling back to FTS-only: {e}"
);
Vec::new()
}
@@ -617,3 +841,246 @@ impl WorkspaceStore for LibSqlBackend {
Ok(fuse_results(fts_results, vector_results, config))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
/// Helper: create a file-backed backend with migrations applied.
async fn setup_backend() -> (LibSqlBackend, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("test_vector.db");
let backend = LibSqlBackend::new_local(&db_path).await.expect("new_local");
backend.run_migrations().await.expect("migrations");
(backend, dir)
}
/// Helper: insert a document and chunk with an optional embedding.
async fn insert_test_chunk(
backend: &LibSqlBackend,
user_id: &str,
path: &str,
content: &str,
embedding: Option<&[f32]>,
) -> (Uuid, Uuid) {
let conn = backend.connect().await.expect("connect");
let doc_id = Uuid::new_v4();
let now = super::fmt_ts(&Utc::now());
conn.execute(
"INSERT INTO memory_documents (id, user_id, path, content, created_at, updated_at, metadata)
VALUES (?1, ?2, ?3, '', ?4, ?4, '{}')",
params![doc_id.to_string(), user_id, path, now],
)
.await
.expect("insert doc");
let chunk_id = backend
.insert_chunk(doc_id, 0, content, embedding)
.await
.expect("insert chunk");
(doc_id, chunk_id)
}
#[tokio::test]
async fn test_ensure_vector_index_enables_vector_search() {
let (backend, _dir) = setup_backend().await;
// Create vector index with dim=4
backend.ensure_vector_index(4).await.expect("ensure dim=4");
// Insert a chunk with a 4-dim embedding
let embedding = [1.0_f32, 0.0, 0.0, 0.0];
let (_doc_id, _chunk_id) = insert_test_chunk(
&backend,
"test",
"notes.md",
"hello world",
Some(&embedding),
)
.await;
// Query using vector_top_k — should find the chunk
let conn = backend.connect().await.expect("connect");
let mut rows = conn
.query(
r#"SELECT c.id
FROM vector_top_k('idx_memory_chunks_embedding', vector('[1,0,0,0]'), 5) AS top_k
JOIN memory_chunks c ON c._rowid = top_k.id"#,
(),
)
.await
.expect("vector_top_k query");
let row = rows
.next()
.await
.expect("row fetch")
.expect("expected a result row");
let id: String = row.get(0).expect("get id");
assert!(!id.is_empty(), "vector search should return the chunk");
}
#[tokio::test]
async fn test_ensure_vector_index_dimension_change() {
let (backend, _dir) = setup_backend().await;
// Create with dim=4 and insert data
backend.ensure_vector_index(4).await.expect("ensure dim=4");
let embedding_4d = [1.0_f32, 2.0, 3.0, 4.0];
insert_test_chunk(&backend, "test", "a.md", "content a", Some(&embedding_4d)).await;
// Recreate with dim=8 — old 4-dim embeddings should be NULLed
backend.ensure_vector_index(8).await.expect("ensure dim=8");
// Verify metadata updated
let conn = backend.connect().await.expect("connect");
let mut rows = conn
.query("SELECT name FROM _migrations WHERE version = 0", ())
.await
.expect("query metadata");
let row = rows.next().await.expect("fetch").expect("metadata row");
let dim_str: String = row.get(0).expect("get name");
assert_eq!(dim_str, "8");
// Verify old embedding was NULLed (wrong byte length for dim=8)
let mut rows = conn
.query("SELECT embedding IS NULL FROM memory_chunks LIMIT 1", ())
.await
.expect("query embedding");
let row = rows.next().await.expect("fetch").expect("chunk row");
let is_null: i64 = row.get(0).expect("get is_null");
assert_eq!(
is_null, 1,
"old 4-dim embedding should be NULLed after dim change to 8"
);
}
#[tokio::test]
async fn test_ensure_vector_index_noop_when_unchanged() {
let (backend, _dir) = setup_backend().await;
// Create with dim=4 and insert data
backend.ensure_vector_index(4).await.expect("ensure dim=4");
let embedding = [1.0_f32, 0.0, 0.0, 0.0];
insert_test_chunk(&backend, "test", "b.md", "content b", Some(&embedding)).await;
// Run again with same dimension — should be a no-op
backend
.ensure_vector_index(4)
.await
.expect("ensure dim=4 again");
// Verify data is untouched (embedding not NULLed)
let conn = backend.connect().await.expect("connect");
let mut rows = conn
.query(
"SELECT embedding IS NOT NULL FROM memory_chunks LIMIT 1",
(),
)
.await
.expect("query embedding");
let row = rows.next().await.expect("fetch").expect("chunk row");
let has_embedding: i64 = row.get(0).expect("get");
assert_eq!(
has_embedding, 1,
"embedding should be preserved on no-op call"
);
}
#[tokio::test]
async fn test_hybrid_search_returns_vector_results() {
let (backend, _dir) = setup_backend().await;
// Create vector index with dim=4
backend.ensure_vector_index(4).await.expect("ensure dim=4");
// Insert chunk with embedding and searchable content
let embedding = [0.5_f32, 0.5, 0.0, 0.0];
insert_test_chunk(
&backend,
"user1",
"notes.md",
"quantum computing research",
Some(&embedding),
)
.await;
// Search via the WorkspaceStore trait with vector enabled
let query_emb = [0.5_f32, 0.5, 0.0, 0.0];
let config = SearchConfig::default().with_limit(5);
let results = backend
.hybrid_search("user1", None, "quantum", Some(&query_emb), &config)
.await
.expect("hybrid_search");
assert!(!results.is_empty(), "hybrid search should return results");
let first = &results[0];
assert!(
first.vector_rank.is_some(),
"result should have a vector_rank"
);
assert_eq!(first.content, "quantum computing research");
}
mod resolve_dimension {
use super::*;
use crate::config::helpers::ENV_MUTEX;
fn clear_embedding_env() {
// SAFETY: called under ENV_MUTEX
unsafe {
std::env::remove_var("EMBEDDING_ENABLED");
std::env::remove_var("EMBEDDING_DIMENSION");
std::env::remove_var("EMBEDDING_MODEL");
}
}
#[test]
fn returns_none_when_disabled() {
let _guard = ENV_MUTEX.lock().expect("env mutex");
clear_embedding_env();
assert!(resolve_embedding_dimension().is_none());
}
#[test]
fn returns_explicit_dimension() {
let _guard = ENV_MUTEX.lock().expect("env mutex");
clear_embedding_env();
// SAFETY: under ENV_MUTEX
unsafe {
std::env::set_var("EMBEDDING_ENABLED", "true");
std::env::set_var("EMBEDDING_DIMENSION", "768");
}
assert_eq!(resolve_embedding_dimension(), Some(768));
unsafe {
std::env::remove_var("EMBEDDING_ENABLED");
std::env::remove_var("EMBEDDING_DIMENSION");
}
}
#[test]
fn infers_from_model() {
let _guard = ENV_MUTEX.lock().expect("env mutex");
clear_embedding_env();
// SAFETY: under ENV_MUTEX
unsafe {
std::env::set_var("EMBEDDING_ENABLED", "1");
std::env::set_var("EMBEDDING_MODEL", "all-minilm");
}
assert_eq!(resolve_embedding_dimension(), Some(384));
unsafe {
std::env::remove_var("EMBEDDING_ENABLED");
std::env::remove_var("EMBEDDING_MODEL");
}
}
#[test]
fn defaults_to_1536_for_unknown_model() {
let _guard = ENV_MUTEX.lock().expect("env mutex");
clear_embedding_env();
// SAFETY: under ENV_MUTEX
unsafe {
std::env::set_var("EMBEDDING_ENABLED", "true");
std::env::set_var("EMBEDDING_MODEL", "some-unknown-model");
}
assert_eq!(resolve_embedding_dimension(), Some(1536));
unsafe {
std::env::remove_var("EMBEDDING_ENABLED");
std::env::remove_var("EMBEDDING_MODEL");
}
}
}
}
+6 -7
View File
@@ -240,9 +240,9 @@ CREATE TABLE IF NOT EXISTS memory_chunks (
CREATE INDEX IF NOT EXISTS idx_memory_chunks_document ON memory_chunks(document_id);
-- No vector index: BLOB column accepts any embedding dimension.
-- Vector search uses brute-force cosine distance (fast enough for
-- personal assistant workspaces). Matches PostgreSQL after V9 migration.
-- No vector index in base schema: BLOB column accepts any embedding dimension.
-- Vector index is created dynamically by ensure_vector_index() during
-- run_migrations() when embeddings are configured (EMBEDDING_ENABLED=true).
-- FTS5 virtual table for full-text search
CREATE VIRTUAL TABLE IF NOT EXISTS memory_chunks_fts USING fts5(
@@ -593,10 +593,9 @@ pub const INCREMENTAL_MIGRATIONS: &[(i64, &str, &str)] = &[
// constraint so any embedding dimension works. Existing embeddings
// are preserved; users only need to re-embed if they change models.
//
// The vector index (libsql_vector_idx) requires a fixed-dimension
// F32_BLOB(N), so we drop it entirely. Vector search falls back to
// brute-force cosine distance which is fast enough for personal
// assistant workspaces. This matches PostgreSQL after its V9 migration.
// The vector index is dropped here; ensure_vector_index() recreates
// it with the correct F32_BLOB(N) dimension during run_migrations()
// when embeddings are configured.
//
// SQLite cannot ALTER COLUMN types, so we recreate the table.
r#"
+5
View File
@@ -525,6 +525,11 @@ pub trait RoutineStore: Send + Sync {
run_id: Uuid,
job_id: Uuid,
) -> Result<(), DatabaseError>;
async fn get_webhook_routine_by_path(
&self,
path: &str,
) -> Result<Option<Routine>, DatabaseError>;
/// List routine runs that were dispatched as full_job but have not yet
/// been finalized (status='running' with a linked job_id).
async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError>;
+7
View File
@@ -504,6 +504,13 @@ impl RoutineStore for PgBackend {
self.store.link_routine_run_to_job(run_id, job_id).await
}
async fn get_webhook_routine_by_path(
&self,
path: &str,
) -> Result<Option<Routine>, DatabaseError> {
self.store.get_webhook_routine_by_path(path).await
}
async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError> {
self.store.list_dispatched_routine_runs().await
}
+21
View File
@@ -168,6 +168,9 @@ pub enum ToolError {
#[error("Tool {name} requires authentication")]
AuthRequired { name: String },
#[error("Tool {name} is not available for autonomous execution: {reason}")]
AutonomousUnavailable { name: String, reason: String },
#[error("Tool {name} is rate limited, retry after {retry_after:?}")]
RateLimited {
name: String,
@@ -300,6 +303,21 @@ 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 },
#[error("Layer '{name}' is read-only")]
LayerReadOnly { name: String },
#[error("Cannot write sensitive content: no private layer available for redirect")]
PrivacyRedirectFailed,
#[error("Write rejected for '{path}': prompt injection detected ({reason})")]
InjectionRejected { path: String, reason: String },
}
/// Orchestrator errors (internal API, container management).
@@ -370,6 +388,9 @@ pub enum RoutineError {
#[error("Not authorized to trigger routine {id}")]
NotAuthorized { id: Uuid },
#[error("Routine {name} is in cooldown period")]
Cooldown { name: String },
#[error("Routine {name} at max concurrent runs")]
MaxConcurrent { name: String },
+572 -220
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -1105,6 +1105,22 @@ impl Store {
rows.iter().map(row_to_routine).collect()
}
/// Find an enabled webhook routine by its configured path (or fallback to ID).
pub async fn get_webhook_routine_by_path(
&self,
path: &str,
) -> Result<Option<Routine>, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_opt(
"SELECT * FROM routines WHERE enabled AND trigger_type = 'webhook' \
AND (trigger_config->>'path' = $1 OR (trigger_config->>'path' IS NULL AND id::text = $1))",
&[&path],
)
.await?;
row.as_ref().map(row_to_routine).transpose()
}
/// List all enabled cron routines whose next_fire_at <= now.
pub async fn list_due_cron_routines(&self) -> Result<Vec<Routine>, DatabaseError> {
let conn = self.conn().await?;
+1
View File
@@ -60,6 +60,7 @@ pub mod llm;
pub mod observability;
pub mod orchestrator;
pub mod pairing;
pub mod profile;
pub mod registry;
pub mod safety;
pub mod sandbox;
+45 -1
View File
@@ -13,6 +13,9 @@ Multi-provider LLM integration with circuit breaker, retry, failover, and respon
| `nearai_chat.rs` | NEAR AI Chat Completions provider (dual auth: session token or API key) |
| `codex_auth.rs` | Reads Codex CLI `auth.json`, extracts tokens, refreshes ChatGPT OAuth access tokens |
| `codex_chatgpt.rs` | Custom Responses API provider for Codex ChatGPT backend (`/backend-api/codex`) |
| `openai_codex_provider.rs` | OpenAI Codex Responses API client (SSE streaming, JWT auth, subscription billing) |
| `openai_codex_session.rs` | OAuth 2.0 session manager for OpenAI Codex (device code flow, token persistence) |
| `token_refreshing.rs` | Token-refreshing `LlmProvider` decorator for OpenAI Codex (pre-emptive refresh, zero-cost billing) |
| `reasoning.rs` | `Reasoning` struct, `ReasoningContext`, `RespondResult`, `ActionPlan`, `ToolSelection`; thinking-tag stripping; `SILENT_REPLY_TOKEN` |
| `session.rs` | NEAR AI session token management with disk + DB persistence, OAuth login flow |
| `circuit_breaker.rs` | Circuit breaker: Closed → Open → HalfOpen state machine |
@@ -34,10 +37,12 @@ Set via `LLM_BACKEND` env var:
| `nearai` (default) | NEAR AI Chat Completions | `NEARAI_SESSION_TOKEN` or `NEARAI_API_KEY` |
| `openai` | OpenAI | `OPENAI_API_KEY` |
| `anthropic` | Anthropic | `ANTHROPIC_API_KEY` |
| `github_copilot` | GitHub Copilot Chat API | `GITHUB_COPILOT_TOKEN`, `GITHUB_COPILOT_MODEL` |
| `ollama` | Ollama local | `OLLAMA_BASE_URL` |
| `openai_compatible` | Any OpenAI-compatible endpoint | `LLM_BASE_URL`, `LLM_API_KEY`, `LLM_MODEL` |
| `tinfoil` | Tinfoil TEE inference | `TINFOIL_API_KEY`, `TINFOIL_MODEL` |
| `bedrock` | AWS Bedrock (requires `--features bedrock`) | `BEDROCK_REGION`, `BEDROCK_MODEL`, `AWS_PROFILE` |
| `openai_codex` | OpenAI Codex (ChatGPT subscription) | `OPENAI_CODEX_MODEL`, `OPENAI_CODEX_CLIENT_ID` |
Codex auth reuse:
- Set `LLM_USE_CODEX_AUTH=true` to load credentials from `~/.codex/auth.json` (override with `CODEX_AUTH_PATH`).
@@ -56,6 +61,27 @@ Uses the native Converse API via `aws-sdk-bedrockruntime` (`bedrock.rs`). Requir
- `BEDROCK_MODEL` — Required model ID (e.g., `anthropic.claude-opus-4-6-v1`)
- `BEDROCK_CROSS_REGION` — Optional cross-region inference prefix (`us`, `eu`, `apac`, `global`)
## GitHub Copilot Provider Notes
`github_copilot` uses a dedicated `GithubCopilotProvider` (`github_copilot.rs`) with
direct HTTP via `reqwest::Client`. It cannot use `RigAdapter` because the Copilot API
requires a two-step authentication flow: a long-lived GitHub OAuth token is exchanged
for a short-lived Copilot session token via `api.github.com/copilot_internal/v2/token`.
The session token is cached and auto-refreshed before expiry by `CopilotTokenManager`
in `github_copilot_auth.rs`.
The API endpoint is `https://api.githubcopilot.com/chat/completions` (OpenAI Chat
Completions format). Token source: `GITHUB_COPILOT_TOKEN` env var, or the
`oauth_token` from your IDE sign-in flow (`~/.config/github-copilot/apps.json`).
The setup wizard supports GitHub device login or manual token paste.
**Known risk:** The device login flow uses the VS Code Copilot OAuth client ID
(`Iv1.b507a08c87ecfe98`) and injects VS Code identity headers (`User-Agent`,
`Editor-Version`, `Editor-Plugin-Version`, `Copilot-Integration-Id`). GitHub could
rotate this client ID at any time. If GitHub publishes an official third-party client
ID, migrate to it immediately. Advanced users can override headers via
`GITHUB_COPILOT_EXTRA_HEADERS`.
## NEAR AI Provider Gotchas
**Dual auth modes:**
@@ -148,9 +174,27 @@ To add a new provider:
Set `LLM_EXTRA_HEADERS=Key:Value,Key2:Value2` to inject headers into every request. Useful for OpenRouter attribution (`HTTP-Referer`, `X-Title`). Invalid header names/values are skipped with a warning (not a fatal error).
## OpenAI Codex Provider
Uses the Responses API at `chatgpt.com/backend-api/codex/responses` with ChatGPT subscription OAuth tokens (zero API cost — billing through subscription).
**Auth flow:** Device code OAuth via `auth.openai.com/api/accounts/deviceauth/*` endpoints. On first run, displays a code for the user to enter at a URL. Tokens are persisted to `~/.ironclaw/openai_codex_session.json` (mode 0600) and auto-refreshed before expiry.
**Provider chain:** `OpenAiCodexProvider``TokenRefreshingProvider` (pre-emptive refresh + retry on 401) → standard decorator chain. The `TokenRefreshingProvider` intercepts `AuthFailed`/`SessionExpired` errors, refreshes the OAuth token, and retries once.
**Key differences from other providers:**
- Uses Responses API (not Chat Completions) — SSE streaming with different event types
- System messages are sent as `instructions` field, not in `input` array
- Tool schemas are normalized via `normalize_schema_strict()` for OpenAI strict mode
- `cost_per_token()` returns `(0, 0)` — subscription-based billing
- `set_model()` returns error — model is fixed at construction time
- Image attachments are silently dropped with a warning log
**Env vars:** `OPENAI_CODEX_MODEL` (default: `gpt-5.3-codex`), `OPENAI_CODEX_CLIENT_ID`, `OPENAI_CODEX_AUTH_URL`, `OPENAI_CODEX_API_URL`.
## Provider Chain Construction
`build_provider_chain()` in `mod.rs` is the single source of truth for assembling decorators. The chain is:
`build_provider_chain()` in `mod.rs` is the single source of truth for assembling decorators. It creates the base provider (dispatching to `create_openai_codex_provider()` for codex, `create_llm_provider()` for everything else), then applies all decorators inline:
```
Raw provider
+3 -91
View File
@@ -22,8 +22,6 @@ use crate::llm::provider::{
ToolCompletionRequest, ToolCompletionResponse, strip_unsupported_completion_params,
strip_unsupported_tool_params,
};
use crate::llm::retry::cap_retry_after;
const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
/// OAuth beta requires 2023-06-01; the 2024-10-22 version is not valid with the beta flag.
const ANTHROPIC_API_VERSION: &str = "2023-06-01";
@@ -144,15 +142,9 @@ impl AnthropicOAuthProvider {
if !status.is_success() {
// Parse Retry-After header before consuming the body.
// Falls back to 60s if header is missing or unparseable (prevents "retry after None" errors).
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)));
let retry_after = Some(crate::llm::retry::parse_retry_after(
response.headers().get("retry-after"),
));
let response_text = response
.text()
@@ -709,84 +701,4 @@ mod tests {
// Subsequent reads see the updated token
assert_eq!(token.read().unwrap().expose_secret(), "new_token");
}
// -- Retry-After header parsing tests (regression for rate limit "None" bug) --
#[test]
fn test_retry_after_parsing_delay_seconds() {
// Verify delay-seconds format is parsed correctly
let header_value = "45";
let duration = parse_retry_after_anthropic_for_test(header_value);
assert_eq!(
duration,
Some(std::time::Duration::from_secs(45)),
"Should parse delay-seconds format"
);
}
#[test]
fn test_retry_after_fallback_missing_header() {
// Regression test: When Retry-After header is missing,
// should fall back to 60s instead of None
let duration = parse_retry_after_anthropic_for_test("");
assert_eq!(
duration,
Some(std::time::Duration::from_secs(60)),
"Missing header should fallback to 60s"
);
}
#[test]
fn test_retry_after_fallback_invalid_format() {
// Regression test: When Retry-After header is in unexpected format,
// should fall back to 60s instead of None
let invalid_formats = vec![
"invalid",
"not-a-number",
"30.5", // float instead of int
"abc123",
"Mon, 02 Mar 2026 18:00:00 GMT", // RFC2822 not supported in anthropic version
];
for format in invalid_formats {
let duration = parse_retry_after_anthropic_for_test(format);
assert_eq!(
duration,
Some(std::time::Duration::from_secs(60)),
"Invalid format '{}' should fallback to 60s",
format
);
}
}
#[test]
fn test_retry_after_zero_seconds_accepted() {
// Verify zero seconds is a valid retry delay
let duration = parse_retry_after_anthropic_for_test("0");
assert_eq!(duration, Some(std::time::Duration::ZERO));
}
#[test]
fn test_retry_after_large_number() {
// Verify large numbers are capped to the safe maximum
let duration = parse_retry_after_anthropic_for_test("7200"); // 2 hours
assert_eq!(
duration,
Some(std::time::Duration::from_secs(
crate::llm::retry::MAX_RETRY_AFTER_SECS
))
);
}
/// Helper function to test Retry-After header parsing logic for Anthropic
/// (simulates the parsing done in send_request without actual HTTP, including fallback)
fn parse_retry_after_anthropic_for_test(header_value: &str) -> Option<std::time::Duration> {
header_value
.trim()
.parse::<u64>()
.ok()
.map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)))
}
}
+34
View File
@@ -0,0 +1,34 @@
//! Shared test helpers for OpenAI Codex provider tests.
#![cfg(test)]
use crate::config::OpenAiCodexConfig;
/// Build a minimal JWT for testing (header.payload.signature).
pub(crate) fn make_test_jwt(account_id: &str) -> String {
use base64::Engine;
let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
let header = engine.encode(b"{\"alg\":\"RS256\",\"typ\":\"JWT\"}");
let payload_json = serde_json::json!({
"sub": "user123",
"https://api.openai.com/auth": {
"chatgpt_account_id": account_id,
},
});
let payload = engine.encode(payload_json.to_string().as_bytes());
let sig = engine.encode(b"fake-signature");
format!("{header}.{payload}.{sig}")
}
/// Build a test `OpenAiCodexConfig` with a given session path.
pub(crate) fn test_codex_config(session_path: std::path::PathBuf) -> OpenAiCodexConfig {
OpenAiCodexConfig {
model: "gpt-5.3-codex".to_string(),
auth_endpoint: "https://auth.openai.com".to_string(),
api_base_url: "https://chatgpt.com/backend-api/codex".to_string(),
client_id: "test_client_id".to_string(),
session_path,
token_refresh_margin_secs: 300,
}
}
+36 -4
View File
@@ -9,6 +9,7 @@ use std::path::PathBuf;
use secrecy::SecretString;
use crate::bootstrap::ironclaw_base_dir;
use crate::llm::registry::ProviderProtocol;
use crate::llm::session::SessionConfig;
@@ -102,6 +103,36 @@ pub struct RegistryProviderConfig {
pub unsupported_params: Vec<String>,
}
/// Configuration for OpenAI Codex (ChatGPT subscription OAuth).
#[derive(Debug, Clone)]
pub struct OpenAiCodexConfig {
/// Model to use (default: "gpt-5.3-codex").
pub model: String,
/// OAuth authorization server (default: "https://auth.openai.com").
pub auth_endpoint: String,
/// Responses API base URL (default: "https://chatgpt.com/backend-api/codex").
pub api_base_url: String,
/// OAuth client ID (default: OpenAI's public Codex client).
pub client_id: String,
/// Path to session file (default: ~/.ironclaw/openai_codex_session.json).
pub session_path: PathBuf,
/// Seconds before expiry to proactively refresh (default: 300).
pub token_refresh_margin_secs: u64,
}
impl Default for OpenAiCodexConfig {
fn default() -> Self {
Self {
model: "gpt-5.3-codex".to_string(),
auth_endpoint: "https://auth.openai.com".to_string(),
api_base_url: "https://chatgpt.com/backend-api/codex".to_string(),
client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
session_path: ironclaw_base_dir().join("openai_codex_session.json"),
token_refresh_margin_secs: 300,
}
}
}
/// Configuration for AWS Bedrock (native Converse API).
#[derive(Debug, Clone)]
pub struct BedrockConfig {
@@ -134,6 +165,8 @@ pub struct LlmConfig {
pub provider: Option<RegistryProviderConfig>,
/// AWS Bedrock config (populated when backend=bedrock, requires --features bedrock).
pub bedrock: Option<BedrockConfig>,
/// OpenAI Codex config (populated when backend=openai_codex).
pub openai_codex: Option<OpenAiCodexConfig>,
/// HTTP request timeout in seconds for LLM API calls.
/// Default: 120. Increase for local LLMs (Ollama, vLLM, LM Studio) that
/// need more time for prompt evaluation on consumer hardware.
@@ -204,8 +237,7 @@ impl NearAiConfig {
/// appropriate base URL (cloud-api when API key is present,
/// private.near.ai for session-token auth).
pub(crate) fn for_model_discovery() -> Self {
let api_key = std::env::var("NEARAI_API_KEY")
.ok()
let api_key = crate::config::helpers::env_or_override("NEARAI_API_KEY")
.filter(|k| !k.is_empty())
.map(SecretString::from);
@@ -214,8 +246,8 @@ impl NearAiConfig {
} else {
"https://private.near.ai"
};
let base_url =
std::env::var("NEARAI_BASE_URL").unwrap_or_else(|_| default_base.to_string());
let base_url = crate::config::helpers::env_or_override("NEARAI_BASE_URL")
.unwrap_or_else(|| default_base.to_string());
Self {
model: String::new(),
+712
View File
@@ -0,0 +1,712 @@
//! GitHub Copilot provider (direct HTTP with token exchange).
//!
//! The GitHub Copilot API at `api.githubcopilot.com` speaks OpenAI Chat
//! Completions format but requires a two-step authentication flow:
//! 1. A long-lived GitHub OAuth token (from device login or IDE sign-in)
//! 2. A short-lived Copilot session token (exchanged via GitHub API)
//!
//! The standard OpenAI rig-core client sends `Authorization: Bearer <token>`
//! with the raw OAuth token, which gets rejected with "Authorization header
//! is badly formatted". This provider handles the token exchange transparently.
use std::collections::HashSet;
use std::sync::Arc;
use async_trait::async_trait;
use reqwest::Client;
use rust_decimal::Decimal;
use secrecy::ExposeSecret;
use serde::{Deserialize, Serialize};
use crate::llm::config::RegistryProviderConfig;
use crate::llm::costs;
use crate::llm::error::LlmError;
use crate::llm::github_copilot_auth::CopilotTokenManager;
use crate::llm::provider::{
ChatMessage, CompletionRequest, CompletionResponse, ContentPart, FinishReason, LlmProvider,
Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse,
strip_unsupported_completion_params, strip_unsupported_tool_params,
};
/// GitHub Copilot provider with automatic token exchange.
pub struct GithubCopilotProvider {
client: Client,
token_manager: Arc<CopilotTokenManager>,
model: String,
base_url: String,
active_model: std::sync::RwLock<String>,
extra_headers: Vec<(String, String)>,
/// Parameter names that this provider does not support.
unsupported_params: HashSet<String>,
}
impl GithubCopilotProvider {
pub fn new(
config: &RegistryProviderConfig,
request_timeout_secs: u64,
) -> Result<Self, LlmError> {
let oauth_token = config
.api_key
.as_ref()
.map(|k| k.expose_secret().to_string())
.ok_or_else(|| {
tracing::error!("No API key configured for github_copilot — check GITHUB_COPILOT_TOKEN env var or secrets store");
LlmError::AuthFailed {
provider: "github_copilot".to_string(),
}
})?;
let client = Client::builder()
.timeout(std::time::Duration::from_secs(request_timeout_secs))
.build()
.map_err(|e| LlmError::RequestFailed {
provider: "github_copilot".to_string(),
reason: format!("Failed to build HTTP client: {e}"),
})?;
let token_manager = Arc::new(CopilotTokenManager::new(client.clone(), oauth_token));
let base_url = if config.base_url.is_empty() {
"https://api.githubcopilot.com".to_string()
} else {
config.base_url.clone()
};
let active_model = std::sync::RwLock::new(config.model.clone());
let unsupported_params: HashSet<String> =
config.unsupported_params.iter().cloned().collect();
Ok(Self {
client,
token_manager,
model: config.model.clone(),
base_url,
active_model,
extra_headers: config.extra_headers.clone(),
unsupported_params,
})
}
fn api_url(&self) -> String {
let base = self.base_url.trim_end_matches('/');
format!("{base}/chat/completions")
}
/// Strip unsupported fields from a `CompletionRequest` in place.
fn strip_unsupported_completion_params(&self, req: &mut CompletionRequest) {
strip_unsupported_completion_params(&self.unsupported_params, req);
}
/// Strip unsupported fields from a `ToolCompletionRequest` in place.
fn strip_unsupported_tool_params(&self, req: &mut ToolCompletionRequest) {
strip_unsupported_tool_params(&self.unsupported_params, req);
}
async fn send_request<R: for<'de> Deserialize<'de>>(
&self,
body: &impl Serialize,
) -> Result<R, LlmError> {
let url = self.api_url();
// Map token exchange failures to RequestFailed (retryable) rather than
// AuthFailed (non-retryable), since transient network errors during
// exchange should be retried by RetryProvider.
let token = self.token_manager.get_token().await.map_err(|e| {
tracing::warn!(error = %e, "Copilot: token exchange failed");
LlmError::RequestFailed {
provider: "github_copilot".to_string(),
reason: format!("Token exchange failed: {e}"),
}
})?;
let mut request = self
.client
.post(&url)
.bearer_auth(token.expose_secret())
.header("Content-Type", "application/json");
// Inject Copilot identity headers
for (key, value) in &self.extra_headers {
request = request.header(key.as_str(), value.as_str());
}
let response = request.json(body).send().await.map_err(|e| {
tracing::warn!(error = %e, "Copilot: HTTP request failed");
LlmError::RequestFailed {
provider: "github_copilot".to_string(),
reason: e.to_string(),
}
})?;
let status = response.status();
if !status.is_success() {
// Use shared retry-after parser (supports HTTP-date, default 60s)
let retry_after = Some(crate::llm::retry::parse_retry_after(
response.headers().get(reqwest::header::RETRY_AFTER),
));
let response_text = response
.text()
.await
.unwrap_or_else(|e| format!("(failed to read error body: {e})"));
tracing::warn!(
status = %status,
body = %crate::agent::truncate_for_preview(&response_text, 256),
"Copilot: API error response"
);
if status.as_u16() == 401 {
// Invalidate the cached session token and retry once with a
// fresh exchange — stale tokens are the most common 401 cause.
tracing::warn!("Copilot: 401 Unauthorized — invalidating session token, retrying");
self.token_manager.invalidate().await;
let fresh = self.token_manager.get_token().await.map_err(|e| {
tracing::warn!(error = %e, "Copilot: re-exchange after 401 failed");
LlmError::RequestFailed {
provider: "github_copilot".to_string(),
reason: format!("Token re-exchange after 401 failed: {e}"),
}
})?;
let mut retry_req = self
.client
.post(&url)
.bearer_auth(fresh.expose_secret())
.header("Content-Type", "application/json");
for (key, value) in &self.extra_headers {
retry_req = retry_req.header(key.as_str(), value.as_str());
}
let retry =
retry_req
.json(body)
.send()
.await
.map_err(|e| LlmError::RequestFailed {
provider: "github_copilot".to_string(),
reason: format!("Retry after 401 failed: {e}"),
})?;
if retry.status().is_success() {
let text = retry.text().await.map_err(|e| LlmError::RequestFailed {
provider: "github_copilot".to_string(),
reason: format!("Failed to read retry response body: {e}"),
})?;
return serde_json::from_str(&text).map_err(|e| {
let truncated = crate::agent::truncate_for_preview(&text, 512);
LlmError::InvalidResponse {
provider: "github_copilot".to_string(),
reason: format!("JSON parse error: {e}. Raw: {truncated}"),
}
});
}
let retry_status = retry.status();
tracing::warn!(
status = %retry_status,
"Copilot: 401 retry also failed"
);
return Err(LlmError::AuthFailed {
provider: "github_copilot".to_string(),
});
}
if status.as_u16() == 429 {
tracing::warn!(retry_after = ?retry_after, "Copilot: rate limited");
return Err(LlmError::RateLimited {
provider: "github_copilot".to_string(),
retry_after,
});
}
let truncated = crate::agent::truncate_for_preview(&response_text, 512);
return Err(LlmError::RequestFailed {
provider: "github_copilot".to_string(),
reason: format!("HTTP {status}: {truncated}"),
});
}
let response_text = response.text().await.map_err(|e| LlmError::RequestFailed {
provider: "github_copilot".to_string(),
reason: format!("Failed to read response body: {e}"),
})?;
serde_json::from_str(&response_text).map_err(|e| {
let truncated = crate::agent::truncate_for_preview(&response_text, 512);
tracing::warn!(
error = %e,
body = %truncated,
"Copilot: failed to parse response JSON"
);
LlmError::InvalidResponse {
provider: "github_copilot".to_string(),
reason: format!("JSON parse error: {e}. Raw: {truncated}"),
}
})
}
}
#[async_trait]
impl LlmProvider for GithubCopilotProvider {
async fn complete(&self, mut req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
let model = req.model.take().unwrap_or_else(|| self.active_model_name());
self.strip_unsupported_completion_params(&mut req);
let messages = convert_messages(req.messages);
let request = OpenAiRequest {
model,
messages,
max_tokens: req.max_tokens,
temperature: req.temperature,
stop: req.stop_sequences,
tools: None,
tool_choice: None,
};
let response: OpenAiResponse = self.send_request(&request).await?;
let choice =
response
.choices
.into_iter()
.next()
.ok_or_else(|| LlmError::InvalidResponse {
provider: "github_copilot".to_string(),
reason: "No choices in response".to_string(),
})?;
let (content, _tool_calls) = extract_choice_content(&choice);
let finish_reason = match choice.finish_reason.as_deref() {
Some("stop") => FinishReason::Stop,
Some("length") => FinishReason::Length,
Some("tool_calls") => FinishReason::ToolUse,
Some("content_filter") => FinishReason::ContentFilter,
_ => FinishReason::Unknown,
};
Ok(CompletionResponse {
content: content.unwrap_or_default(),
finish_reason,
input_tokens: response
.usage
.as_ref()
.map(|u| u.prompt_tokens)
.unwrap_or(0),
output_tokens: response
.usage
.as_ref()
.map(|u| u.completion_tokens)
.unwrap_or(0),
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
})
}
async fn complete_with_tools(
&self,
mut req: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> {
let model = req.model.take().unwrap_or_else(|| self.active_model_name());
self.strip_unsupported_tool_params(&mut req);
let messages = convert_messages(req.messages);
let tools: Vec<OpenAiTool> = req
.tools
.into_iter()
.map(|t| OpenAiTool {
tool_type: "function".to_string(),
function: OpenAiFunction {
name: t.name,
description: t.description,
parameters: t.parameters,
},
})
.collect();
let tool_choice = req.tool_choice.map(|tc| match tc.as_str() {
"auto" | "required" | "none" => serde_json::Value::String(tc),
specific => serde_json::json!({
"type": "function",
"function": {"name": specific}
}),
});
let request = OpenAiRequest {
model,
messages,
max_tokens: req.max_tokens,
temperature: req.temperature,
stop: req.stop_sequences,
tools: if tools.is_empty() { None } else { Some(tools) },
tool_choice,
};
let response: OpenAiResponse = self.send_request(&request).await?;
let choice =
response
.choices
.into_iter()
.next()
.ok_or_else(|| LlmError::InvalidResponse {
provider: "github_copilot".to_string(),
reason: "No choices in response".to_string(),
})?;
let (content, tool_calls) = extract_choice_content(&choice);
let finish_reason = match choice.finish_reason.as_deref() {
Some("stop") => FinishReason::Stop,
Some("length") => FinishReason::Length,
Some("tool_calls") => FinishReason::ToolUse,
Some("content_filter") => FinishReason::ContentFilter,
_ => {
if !tool_calls.is_empty() {
FinishReason::ToolUse
} else {
FinishReason::Unknown
}
}
};
Ok(ToolCompletionResponse {
content,
tool_calls,
finish_reason,
input_tokens: response
.usage
.as_ref()
.map(|u| u.prompt_tokens)
.unwrap_or(0),
output_tokens: response
.usage
.as_ref()
.map(|u| u.completion_tokens)
.unwrap_or(0),
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
})
}
fn model_name(&self) -> &str {
&self.model
}
fn cost_per_token(&self) -> (Decimal, Decimal) {
let model = self.active_model_name();
costs::model_cost(&model).unwrap_or_else(costs::default_cost)
}
fn active_model_name(&self) -> String {
match self.active_model.read() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
fn set_model(&self, model: &str) -> Result<(), LlmError> {
match self.active_model.write() {
Ok(mut guard) => {
*guard = model.to_string();
}
Err(poisoned) => {
*poisoned.into_inner() = model.to_string();
}
}
Ok(())
}
}
// --- OpenAI Chat Completions API types ---
#[derive(Debug, Serialize)]
struct OpenAiRequest {
model: String,
messages: Vec<OpenAiMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
stop: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<OpenAiTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<serde_json::Value>,
}
#[derive(Debug, Serialize)]
struct OpenAiMessage {
role: String,
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<OpenAiContent>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_calls: Option<Vec<OpenAiToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
}
/// OpenAI content can be a plain string or an array of parts (for multimodal).
#[derive(Debug, Serialize)]
#[serde(untagged)]
enum OpenAiContent {
Text(String),
Parts(Vec<OpenAiContentPart>),
}
#[derive(Debug, Serialize)]
#[serde(tag = "type")]
enum OpenAiContentPart {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "image_url")]
ImageUrl { image_url: OpenAiImageUrl },
}
#[derive(Debug, Serialize)]
struct OpenAiImageUrl {
url: String,
}
#[derive(Debug, Serialize)]
struct OpenAiToolCall {
id: String,
#[serde(rename = "type")]
call_type: String,
function: OpenAiToolCallFunction,
}
#[derive(Debug, Serialize)]
struct OpenAiToolCallFunction {
name: String,
arguments: String,
}
#[derive(Debug, Serialize)]
struct OpenAiTool {
#[serde(rename = "type")]
tool_type: String,
function: OpenAiFunction,
}
#[derive(Debug, Serialize)]
struct OpenAiFunction {
name: String,
description: String,
parameters: serde_json::Value,
}
#[derive(Debug, Deserialize)]
struct OpenAiResponse {
choices: Vec<OpenAiChoice>,
#[serde(default)]
usage: Option<OpenAiUsage>,
}
#[derive(Debug, Deserialize)]
struct OpenAiChoice {
message: OpenAiResponseMessage,
#[serde(default)]
finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
struct OpenAiResponseMessage {
#[serde(default)]
content: Option<String>,
#[serde(default)]
tool_calls: Option<Vec<OpenAiResponseToolCall>>,
}
#[derive(Debug, Deserialize)]
struct OpenAiResponseToolCall {
id: String,
function: OpenAiResponseFunction,
}
#[derive(Debug, Deserialize)]
struct OpenAiResponseFunction {
name: String,
arguments: String,
}
#[derive(Debug, Deserialize)]
struct OpenAiUsage {
#[serde(default)]
prompt_tokens: u32,
#[serde(default)]
completion_tokens: u32,
}
/// Convert IronClaw messages to OpenAI Chat Completions format.
fn convert_messages(messages: Vec<ChatMessage>) -> Vec<OpenAiMessage> {
messages
.into_iter()
.map(|msg| match msg.role {
Role::System => OpenAiMessage {
role: "system".to_string(),
content: Some(OpenAiContent::Text(msg.content)),
tool_calls: None,
tool_call_id: None,
name: None,
},
Role::User => {
let content = if msg.content_parts.is_empty() {
Some(OpenAiContent::Text(msg.content))
} else {
let mut parts = Vec::with_capacity(1 + msg.content_parts.len());
if !msg.content.is_empty() {
parts.push(OpenAiContentPart::Text { text: msg.content });
}
for part in msg.content_parts {
match part {
ContentPart::Text { text } => {
parts.push(OpenAiContentPart::Text { text });
}
ContentPart::ImageUrl { image_url } => {
parts.push(OpenAiContentPart::ImageUrl {
image_url: OpenAiImageUrl { url: image_url.url },
});
}
}
}
Some(OpenAiContent::Parts(parts))
};
OpenAiMessage {
role: "user".to_string(),
content,
tool_calls: None,
tool_call_id: None,
name: None,
}
}
Role::Assistant => {
let tool_calls = msg.tool_calls.map(|calls| {
calls
.into_iter()
.map(|tc| OpenAiToolCall {
id: tc.id,
call_type: "function".to_string(),
function: OpenAiToolCallFunction {
name: tc.name,
arguments: tc.arguments.to_string(),
},
})
.collect()
});
let content = if msg.content.is_empty() {
None
} else {
Some(OpenAiContent::Text(msg.content))
};
OpenAiMessage {
role: "assistant".to_string(),
content,
tool_calls,
tool_call_id: None,
name: None,
}
}
Role::Tool => OpenAiMessage {
role: "tool".to_string(),
content: Some(OpenAiContent::Text(msg.content)),
tool_calls: None,
tool_call_id: msg.tool_call_id,
name: msg.name,
},
})
.collect()
}
/// Extract text and tool calls from an OpenAI response choice.
fn extract_choice_content(choice: &OpenAiChoice) -> (Option<String>, Vec<ToolCall>) {
let content = choice.message.content.clone();
let tool_calls = choice
.message
.tool_calls
.as_ref()
.map(|calls| {
calls
.iter()
.map(|tc| ToolCall {
id: tc.id.clone(),
name: tc.function.name.clone(),
arguments: serde_json::from_str(&tc.function.arguments)
.unwrap_or(serde_json::Value::Object(serde_json::Map::new())),
})
.collect()
})
.unwrap_or_default();
(content, tool_calls)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_convert_messages_basic() {
let messages = vec![
ChatMessage::system("You are helpful."),
ChatMessage::user("Hello"),
ChatMessage::assistant("Hi there!"),
];
let converted = convert_messages(messages);
assert_eq!(converted.len(), 3);
assert_eq!(converted[0].role, "system");
assert_eq!(converted[1].role, "user");
assert_eq!(converted[2].role, "assistant");
}
#[test]
fn test_convert_messages_tool_calls() {
let tool_calls = vec![ToolCall {
id: "call_1".to_string(),
name: "search".to_string(),
arguments: serde_json::json!({"q": "test"}),
}];
let messages = vec![
ChatMessage::user("Search"),
ChatMessage::assistant_with_tool_calls(Some("Searching...".to_string()), tool_calls),
ChatMessage::tool_result("call_1", "search", "found it"),
];
let converted = convert_messages(messages);
assert_eq!(converted.len(), 3);
assert!(converted[1].tool_calls.is_some());
assert_eq!(converted[2].role, "tool");
assert_eq!(converted[2].tool_call_id, Some("call_1".to_string()));
}
#[test]
fn test_extract_choice_text_only() {
let choice = OpenAiChoice {
message: OpenAiResponseMessage {
content: Some("Hello!".to_string()),
tool_calls: None,
},
finish_reason: Some("stop".to_string()),
};
let (content, tool_calls) = extract_choice_content(&choice);
assert_eq!(content, Some("Hello!".to_string()));
assert!(tool_calls.is_empty());
}
#[test]
fn test_extract_choice_with_tool_calls() {
let choice = OpenAiChoice {
message: OpenAiResponseMessage {
content: Some("Let me search.".to_string()),
tool_calls: Some(vec![OpenAiResponseToolCall {
id: "call_1".to_string(),
function: OpenAiResponseFunction {
name: "search".to_string(),
arguments: r#"{"q":"test"}"#.to_string(),
},
}]),
},
finish_reason: Some("tool_calls".to_string()),
};
let (content, tool_calls) = extract_choice_content(&choice);
assert_eq!(content, Some("Let me search.".to_string()));
assert_eq!(tool_calls.len(), 1);
assert_eq!(tool_calls[0].name, "search");
assert_eq!(tool_calls[0].arguments["q"], "test");
}
}
+740
View File
@@ -0,0 +1,740 @@
use std::time::Duration;
use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize;
use tokio::sync::RwLock;
// ─── Risk: hardcoded VS Code Copilot identity ───────────────────────────────
//
// The client ID and editor identity headers below are extracted from the
// VS Code Copilot Chat extension. This is the *only* publicly documented
// way to access the Copilot completions API with a personal GitHub token.
//
// **Known risks:**
// • GitHub may rotate or revoke this client ID at any time, which would
// break authentication for all IronClaw users until the constant is
// updated and a new release is shipped.
// • Using another product's client ID may violate GitHub's Terms of
// Service. Maintainers should seek explicit guidance from GitHub
// before shipping this to a wide audience.
// • The editor version strings (`vscode/1.99.3`, `copilot-chat/0.26.7`)
// will become stale and could eventually be rejected by the API.
//
// **Mitigation:** If GitHub publishes an official Copilot API client ID or
// an OAuth app registration flow for third-party tools, migrate to it
// immediately.
// ─────────────────────────────────────────────────────────────────────────────
pub const GITHUB_COPILOT_CLIENT_ID: &str = "Iv1.b507a08c87ecfe98";
pub const GITHUB_COPILOT_SCOPE: &str = "read:user";
pub const GITHUB_COPILOT_DEVICE_CODE_URL: &str = "https://github.com/login/device/code";
pub const GITHUB_COPILOT_ACCESS_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
pub const GITHUB_COPILOT_MODELS_URL: &str = "https://api.githubcopilot.com/models";
pub const GITHUB_COPILOT_TOKEN_URL: &str = "https://api.github.com/copilot_internal/v2/token";
pub const GITHUB_COPILOT_USER_AGENT: &str = "GitHubCopilotChat/0.26.7";
pub const GITHUB_COPILOT_EDITOR_VERSION: &str = "vscode/1.99.3";
pub const GITHUB_COPILOT_EDITOR_PLUGIN_VERSION: &str = "copilot-chat/0.26.7";
pub const GITHUB_COPILOT_INTEGRATION_ID: &str = "vscode-chat";
/// Buffer before token expiry to trigger a refresh (5 minutes).
const TOKEN_REFRESH_BUFFER_SECS: u64 = 300;
#[derive(Debug, Clone, Deserialize)]
pub struct DeviceCodeResponse {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub expires_in: u64,
#[serde(default = "default_poll_interval_secs")]
pub interval: u64,
}
#[derive(Debug, Clone, Deserialize)]
struct AccessTokenResponse {
access_token: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum GithubCopilotAuthError {
#[error("failed to start device login: {0}")]
DeviceCodeRequest(String),
#[error("failed to poll device login: {0}")]
TokenPolling(String),
#[error("device login was denied")]
AccessDenied,
#[error("device login expired before authorization completed")]
Expired,
#[error("github copilot token validation failed: {0}")]
Validation(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DevicePollingStatus {
Pending,
SlowDown,
Authorized(String),
}
pub fn default_headers() -> Vec<(String, String)> {
vec![
(
"User-Agent".to_string(),
GITHUB_COPILOT_USER_AGENT.to_string(),
),
(
"Editor-Version".to_string(),
GITHUB_COPILOT_EDITOR_VERSION.to_string(),
),
(
"Editor-Plugin-Version".to_string(),
GITHUB_COPILOT_EDITOR_PLUGIN_VERSION.to_string(),
),
(
"Copilot-Integration-Id".to_string(),
GITHUB_COPILOT_INTEGRATION_ID.to_string(),
),
]
}
pub fn default_poll_interval_secs() -> u64 {
5
}
pub async fn request_device_code(
client: &reqwest::Client,
) -> Result<DeviceCodeResponse, GithubCopilotAuthError> {
let response = client
.post(GITHUB_COPILOT_DEVICE_CODE_URL)
.header(reqwest::header::ACCEPT, "application/json")
.header(reqwest::header::USER_AGENT, GITHUB_COPILOT_USER_AGENT)
.form(&[
("client_id", GITHUB_COPILOT_CLIENT_ID),
("scope", GITHUB_COPILOT_SCOPE),
])
.send()
.await
.map_err(|e| {
tracing::warn!(
error = %e,
is_timeout = e.is_timeout(),
is_connect = e.is_connect(),
url = %GITHUB_COPILOT_DEVICE_CODE_URL,
"Copilot: device code request failed"
);
GithubCopilotAuthError::DeviceCodeRequest(format_reqwest_error(&e))
})?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
tracing::warn!(
status = %status,
body = %truncate_for_error(&body),
"Copilot: device code endpoint returned error"
);
return Err(GithubCopilotAuthError::DeviceCodeRequest(format!(
"HTTP {status}: {}",
truncate_for_error(&body)
)));
}
let device = response
.json::<DeviceCodeResponse>()
.await
.map_err(|e| GithubCopilotAuthError::DeviceCodeRequest(e.to_string()))?;
Ok(device)
}
pub async fn poll_for_access_token(
client: &reqwest::Client,
device_code: &str,
) -> Result<DevicePollingStatus, GithubCopilotAuthError> {
let response = client
.post(GITHUB_COPILOT_ACCESS_TOKEN_URL)
.header(reqwest::header::ACCEPT, "application/json")
.header(reqwest::header::USER_AGENT, GITHUB_COPILOT_USER_AGENT)
.form(&[
("client_id", GITHUB_COPILOT_CLIENT_ID),
("device_code", device_code),
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
])
.send()
.await
.map_err(|e| {
tracing::warn!(
error = %e,
is_timeout = e.is_timeout(),
is_connect = e.is_connect(),
url = %GITHUB_COPILOT_ACCESS_TOKEN_URL,
"Copilot: poll request failed"
);
GithubCopilotAuthError::TokenPolling(format_reqwest_error(&e))
})?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
tracing::warn!(
status = %status,
body = %truncate_for_error(&body),
"Copilot: poll endpoint returned error"
);
return Err(GithubCopilotAuthError::TokenPolling(format!(
"HTTP {status}: {}",
truncate_for_error(&body)
)));
}
let body = response
.json::<AccessTokenResponse>()
.await
.map_err(|e| GithubCopilotAuthError::TokenPolling(e.to_string()))?;
if let Some(token) = body.access_token {
return Ok(DevicePollingStatus::Authorized(token));
}
match body.error.as_deref() {
Some("authorization_pending") | None => Ok(DevicePollingStatus::Pending),
Some("slow_down") => {
tracing::debug!("Copilot: GitHub requested slow_down, increasing poll interval");
Ok(DevicePollingStatus::SlowDown)
}
Some("access_denied") => {
tracing::warn!("Copilot: device login was denied by user");
Err(GithubCopilotAuthError::AccessDenied)
}
Some("expired_token") => {
tracing::warn!("Copilot: device code expired before authorization");
Err(GithubCopilotAuthError::Expired)
}
Some(other) => {
let desc = body
.error_description
.filter(|description| !description.is_empty())
.unwrap_or_else(|| other.to_string());
tracing::warn!(error = %other, description = %desc, "Copilot: unexpected poll error");
Err(GithubCopilotAuthError::TokenPolling(desc))
}
}
}
/// Maximum consecutive transient poll failures before giving up.
const MAX_POLL_FAILURES: u32 = 5;
pub async fn wait_for_device_login(
client: &reqwest::Client,
device: &DeviceCodeResponse,
) -> Result<String, GithubCopilotAuthError> {
let expires_at = std::time::Instant::now()
.checked_add(Duration::from_secs(device.expires_in))
.ok_or(GithubCopilotAuthError::Expired)?;
let mut poll_interval = device.interval.max(1);
let mut consecutive_failures: u32 = 0;
loop {
if std::time::Instant::now() >= expires_at {
tracing::warn!("Copilot: device login expired");
return Err(GithubCopilotAuthError::Expired);
}
tokio::time::sleep(Duration::from_secs(poll_interval)).await;
match poll_for_access_token(client, &device.device_code).await {
Ok(DevicePollingStatus::Pending) => {
consecutive_failures = 0;
}
Ok(DevicePollingStatus::SlowDown) => {
consecutive_failures = 0;
poll_interval = poll_interval.saturating_add(5);
}
Ok(DevicePollingStatus::Authorized(token)) => {
return Ok(token);
}
// Definitive failures — propagate immediately
Err(GithubCopilotAuthError::AccessDenied) => {
return Err(GithubCopilotAuthError::AccessDenied);
}
Err(GithubCopilotAuthError::Expired) => {
return Err(GithubCopilotAuthError::Expired);
}
// Transient failures — retry with backoff
Err(e) => {
consecutive_failures += 1;
tracing::warn!(
error = %e,
attempt = consecutive_failures,
max = MAX_POLL_FAILURES,
"Copilot: transient poll failure, will retry"
);
if consecutive_failures >= MAX_POLL_FAILURES {
tracing::error!(
error = %e,
"Copilot: too many consecutive poll failures, giving up"
);
return Err(e);
}
// Back off on transient errors
poll_interval = (poll_interval + 2).min(30);
}
}
}
}
/// Validate a GitHub OAuth token by performing the Copilot token exchange.
///
/// This exchanges the raw OAuth token for a Copilot session token (proving the
/// token is valid and the user has Copilot access), then verifies the session
/// token works against the models endpoint.
pub async fn validate_token(
client: &reqwest::Client,
token: &str,
) -> Result<(), GithubCopilotAuthError> {
// Step 1: Exchange the OAuth token for a Copilot session token.
// This validates both that the OAuth token is valid and that the user
// has an active Copilot subscription.
let session = exchange_copilot_token(client, token).await?;
// Step 2: Verify the session token works against the models endpoint.
let mut request = client
.get(GITHUB_COPILOT_MODELS_URL)
.bearer_auth(&session.token)
.timeout(Duration::from_secs(15));
for (key, value) in default_headers() {
request = request.header(&key, value);
}
let response = request.send().await.map_err(|e| {
tracing::warn!(
error = %e,
is_timeout = e.is_timeout(),
is_connect = e.is_connect(),
"Copilot: models endpoint request failed"
);
GithubCopilotAuthError::Validation(format_reqwest_error(&e))
})?;
if response.status().is_success() {
return Ok(());
}
let status = response.status();
let body = response.text().await.unwrap_or_default();
tracing::warn!(
status = %status,
body = %truncate_for_error(&body),
"Copilot: models endpoint returned error during validation"
);
Err(GithubCopilotAuthError::Validation(format!(
"HTTP {status}: {}",
truncate_for_error(&body)
)))
}
/// Response from the Copilot token exchange endpoint.
///
/// The `token` field is an HMAC-signed session token (not a JWT) used as
/// `Authorization: Bearer <token>` for requests to `api.githubcopilot.com`.
#[derive(Debug, Clone, Deserialize)]
pub struct CopilotTokenResponse {
/// The Copilot session token (HMAC-signed, not a JWT).
pub token: String,
/// Unix timestamp (seconds) when this token expires.
pub expires_at: u64,
}
/// Exchange a GitHub OAuth token for a Copilot API session token.
///
/// Calls `GET https://api.github.com/copilot_internal/v2/token` with the
/// GitHub OAuth token in `Authorization: token <oauth_token>` format.
/// Returns a short-lived session token for `api.githubcopilot.com`.
pub async fn exchange_copilot_token(
client: &reqwest::Client,
oauth_token: &str,
) -> Result<CopilotTokenResponse, GithubCopilotAuthError> {
let token_trimmed = oauth_token.trim();
let mut request = client
.get(GITHUB_COPILOT_TOKEN_URL)
.header(reqwest::header::ACCEPT, "application/json")
// GitHub Copilot uses `token` auth scheme, not `Bearer`
.header(
reqwest::header::AUTHORIZATION,
format!("token {token_trimmed}"),
)
.timeout(Duration::from_secs(15));
for (key, value) in default_headers() {
request = request.header(&key, value);
}
let response = request.send().await.map_err(|e| {
tracing::warn!(
error = %e,
is_timeout = e.is_timeout(),
is_connect = e.is_connect(),
"Copilot: token exchange HTTP request failed"
);
GithubCopilotAuthError::Validation(format_reqwest_error(&e))
})?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
tracing::warn!(
status = %status,
body = %truncate_for_error(&body),
"Copilot: token exchange endpoint returned error"
);
return Err(GithubCopilotAuthError::Validation(format!(
"Copilot token exchange failed: HTTP {status}: {}",
truncate_for_error(&body)
)));
}
let token_response = response.json::<CopilotTokenResponse>().await.map_err(|e| {
tracing::warn!(error = %e, "Copilot: failed to parse token exchange response");
GithubCopilotAuthError::Validation(e.to_string())
})?;
Ok(token_response)
}
/// Manages a cached Copilot API session token with automatic refresh.
///
/// The GitHub Copilot API requires a two-step authentication:
/// 1. A long-lived GitHub OAuth token (from device login or IDE sign-in)
/// 2. A short-lived Copilot session token (exchanged via `/copilot_internal/v2/token`)
///
/// This manager caches the session token and refreshes it automatically
/// before it expires (with a 5-minute buffer).
pub struct CopilotTokenManager {
client: reqwest::Client,
oauth_token: SecretString,
cached: RwLock<Option<CachedCopilotToken>>,
}
#[derive(Clone)]
struct CachedCopilotToken {
token: SecretString,
expires_at: u64,
}
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
impl CopilotTokenManager {
/// Create a new token manager with the given GitHub OAuth token.
pub fn new(client: reqwest::Client, oauth_token: String) -> Self {
Self {
client,
oauth_token: SecretString::from(oauth_token),
cached: RwLock::new(None),
}
}
/// Get a valid Copilot session token, refreshing if needed.
///
/// Returns the cached token if it has more than 5 minutes remaining,
/// otherwise exchanges the OAuth token for a fresh session token.
pub async fn get_token(&self) -> Result<SecretString, GithubCopilotAuthError> {
// Fast path: check if cached token is still valid under read lock.
{
let guard = self.cached.read().await;
if let Some(ref cached) = *guard {
let now = unix_now();
if cached.expires_at > now + TOKEN_REFRESH_BUFFER_SECS {
return Ok(cached.token.clone());
}
tracing::debug!(
expires_at = cached.expires_at,
now = now,
"Copilot: cached session token expired or expiring soon, refreshing"
);
}
}
// Slow path: acquire write lock and re-check (another caller may have
// already refreshed while we waited for the lock).
let mut guard = self.cached.write().await;
if let Some(ref cached) = *guard {
let now = unix_now();
if cached.expires_at > now + TOKEN_REFRESH_BUFFER_SECS {
return Ok(cached.token.clone());
}
}
let response =
exchange_copilot_token(&self.client, self.oauth_token.expose_secret()).await?;
let token = SecretString::from(response.token);
let expires_at = response.expires_at;
*guard = Some(CachedCopilotToken {
token: token.clone(),
expires_at,
});
tracing::debug!(expires_at = expires_at, "Copilot session token refreshed");
Ok(token)
}
/// Invalidate the cached session token.
///
/// Called when the API returns 401, so the next `get_token()` call
/// will perform a fresh token exchange instead of reusing the stale token.
pub async fn invalidate(&self) {
let mut guard = self.cached.write().await;
*guard = None;
tracing::debug!("Copilot session token invalidated");
}
}
fn truncate_for_error(body: &str) -> String {
const LIMIT: usize = 200;
if body.len() <= LIMIT {
return body.to_string();
}
let end = crate::util::floor_char_boundary(body, LIMIT);
format!("{}...", &body[..end])
}
/// Format a reqwest error with its full causal chain for debugging.
///
/// `reqwest::Error::to_string()` often just says "error sending request"
/// without the underlying cause (timeout, DNS, TLS, connection refused).
/// This walks the `source()` chain to surface the real problem.
fn format_reqwest_error(e: &reqwest::Error) -> String {
use std::error::Error;
let mut msg = e.to_string();
let mut source = e.source();
while let Some(cause) = source {
msg.push_str(&format!(": {cause}"));
source = cause.source();
}
msg
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_headers_include_required_identity_headers() {
let headers = default_headers();
assert!(headers.iter().any(|(key, value)| {
key == "Copilot-Integration-Id" && value == GITHUB_COPILOT_INTEGRATION_ID
}));
assert!(
headers
.iter()
.any(|(key, value)| key == "Editor-Version"
&& value == GITHUB_COPILOT_EDITOR_VERSION)
);
assert!(
headers
.iter()
.any(|(key, value)| key == "User-Agent" && value == GITHUB_COPILOT_USER_AGENT)
);
}
#[test]
fn truncate_for_error_preserves_utf8_boundaries() {
let long = "日本語".repeat(100);
let truncated = truncate_for_error(&long);
assert!(truncated.ends_with("..."));
assert!(truncated.is_char_boundary(truncated.len() - 3));
}
#[test]
fn truncate_for_error_short_strings_unchanged() {
let short = "hello";
assert_eq!(truncate_for_error(short), "hello");
}
// --- poll_for_access_token response parsing ---
fn parse_access_token_body(json: &str) -> AccessTokenResponse {
serde_json::from_str(json).expect("valid JSON")
}
#[test]
fn parse_authorization_pending_response() {
let body: AccessTokenResponse =
parse_access_token_body(r#"{"error": "authorization_pending"}"#);
assert!(body.access_token.is_none());
assert_eq!(body.error.as_deref(), Some("authorization_pending"));
}
#[test]
fn parse_slow_down_response() {
let body: AccessTokenResponse = parse_access_token_body(r#"{"error": "slow_down"}"#);
assert_eq!(body.error.as_deref(), Some("slow_down"));
}
#[test]
fn parse_access_denied_response() {
let body: AccessTokenResponse = parse_access_token_body(r#"{"error": "access_denied"}"#);
assert_eq!(body.error.as_deref(), Some("access_denied"));
}
#[test]
fn parse_expired_token_response() {
let body: AccessTokenResponse = parse_access_token_body(r#"{"error": "expired_token"}"#);
assert_eq!(body.error.as_deref(), Some("expired_token"));
}
#[test]
fn parse_successful_token_response() {
let body: AccessTokenResponse =
parse_access_token_body(r#"{"access_token": "ghu_abc123"}"#);
assert_eq!(body.access_token.as_deref(), Some("ghu_abc123"));
assert!(body.error.is_none());
}
#[test]
fn parse_error_with_description() {
let body: AccessTokenResponse = parse_access_token_body(
r#"{"error": "bad_verification_code", "error_description": "The code has expired"}"#,
);
assert_eq!(body.error.as_deref(), Some("bad_verification_code"));
assert_eq!(
body.error_description.as_deref(),
Some("The code has expired")
);
}
#[test]
fn parse_device_code_response_with_defaults() {
let json = r#"{
"device_code": "dc_123",
"user_code": "ABCD-1234",
"verification_uri": "https://github.com/login/device",
"expires_in": 900
}"#;
let resp: DeviceCodeResponse = serde_json::from_str(json).expect("valid JSON");
assert_eq!(resp.device_code, "dc_123");
assert_eq!(resp.user_code, "ABCD-1234");
assert_eq!(resp.interval, 5); // default_poll_interval_secs
assert_eq!(resp.expires_in, 900);
}
#[test]
fn parse_device_code_response_with_custom_interval() {
let json = r#"{
"device_code": "dc_456",
"user_code": "EFGH-5678",
"verification_uri": "https://github.com/login/device",
"expires_in": 600,
"interval": 10
}"#;
let resp: DeviceCodeResponse = serde_json::from_str(json).expect("valid JSON");
assert_eq!(resp.interval, 10);
}
// --- CopilotTokenManager ---
#[tokio::test]
async fn token_manager_caches_token_and_returns_same_value() {
// Pre-populate the cache with a token that expires far in the future.
let client = reqwest::Client::new();
let manager = CopilotTokenManager::new(client, "unused_oauth".to_string());
let far_future = unix_now() + 3600;
{
let mut guard = manager.cached.write().await;
*guard = Some(CachedCopilotToken {
token: SecretString::from("cached_session_token".to_string()),
expires_at: far_future,
});
}
let token = manager.get_token().await.expect("should return cached");
assert_eq!(token.expose_secret(), "cached_session_token");
// A second call should return the same cached token.
let token2 = manager.get_token().await.expect("should return cached");
assert_eq!(token2.expose_secret(), "cached_session_token");
}
#[tokio::test]
async fn token_manager_invalidation_clears_cache() {
let client = reqwest::Client::new();
let manager = CopilotTokenManager::new(client, "unused_oauth".to_string());
let far_future = unix_now() + 3600;
{
let mut guard = manager.cached.write().await;
*guard = Some(CachedCopilotToken {
token: SecretString::from("old_token".to_string()),
expires_at: far_future,
});
}
manager.invalidate().await;
let guard = manager.cached.read().await;
assert!(guard.is_none(), "cache should be empty after invalidation");
}
#[tokio::test]
async fn token_manager_expired_token_triggers_refresh_path() {
let client = reqwest::Client::new();
let manager = CopilotTokenManager::new(client, "unused_oauth".to_string());
// Set a token that is already expired (expires_at in the past).
{
let mut guard = manager.cached.write().await;
*guard = Some(CachedCopilotToken {
token: SecretString::from("stale_token".to_string()),
expires_at: 1, // way in the past
});
}
// get_token will try the slow path (token exchange) which will fail
// because we have no real server, but this proves the cached stale
// token is NOT returned.
let result = manager.get_token().await;
assert!(
result.is_err(),
"expired cached token should trigger exchange, which fails without a server"
);
}
#[tokio::test]
async fn token_manager_within_buffer_triggers_refresh() {
let client = reqwest::Client::new();
let manager = CopilotTokenManager::new(client, "unused_oauth".to_string());
// Set a token that expires within the refresh buffer window.
let expires_soon = unix_now() + TOKEN_REFRESH_BUFFER_SECS - 10;
{
let mut guard = manager.cached.write().await;
*guard = Some(CachedCopilotToken {
token: SecretString::from("expiring_soon".to_string()),
expires_at: expires_soon,
});
}
let result = manager.get_token().await;
assert!(
result.is_err(),
"token within buffer should trigger exchange"
);
}
// --- CopilotTokenResponse parsing ---
#[test]
fn parse_copilot_token_response() {
let json = r#"{"token": "tid=abc;exp=999;sku=123;sig=xyz", "expires_at": 1700000000}"#;
let resp: CopilotTokenResponse = serde_json::from_str(json).expect("valid JSON");
assert!(resp.token.starts_with("tid="));
assert_eq!(resp.expires_at, 1700000000);
}
}
+80 -3
View File
@@ -18,8 +18,12 @@ pub mod config;
pub mod costs;
pub mod error;
pub mod failover;
mod github_copilot;
pub(crate) mod github_copilot_auth;
mod nearai_chat;
pub mod oauth_helpers;
pub mod openai_codex_provider;
pub mod openai_codex_session;
mod provider;
mod reasoning;
pub mod recording;
@@ -29,6 +33,10 @@ pub mod retry;
mod rig_adapter;
pub mod session;
pub mod smart_routing;
mod token_refreshing;
#[cfg(test)]
mod codex_test_helpers;
pub mod image_models;
pub mod models;
@@ -37,12 +45,14 @@ pub mod vision_models;
pub use circuit_breaker::{CircuitBreakerConfig, CircuitBreakerProvider};
pub use config::{
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER,
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER, OpenAiCodexConfig,
RegistryProviderConfig,
};
pub use error::LlmError;
pub use failover::{CooldownConfig, FailoverProvider};
pub use nearai_chat::{ModelInfo, NearAiChatProvider};
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,
@@ -59,6 +69,7 @@ pub use retry::{RetryConfig, RetryProvider};
pub use rig_adapter::RigAdapter;
pub use session::{SessionConfig, SessionManager, create_session_manager};
pub use smart_routing::{SmartRoutingConfig, SmartRoutingProvider, TaskComplexity};
pub use token_refreshing::TokenRefreshingProvider;
use std::sync::Arc;
@@ -97,6 +108,15 @@ pub async fn create_llm_provider(
}
}
if config.backend == "openai_codex" {
return Err(LlmError::RequestFailed {
provider: "openai_codex".to_string(),
reason:
"OpenAI Codex uses a dedicated factory path. Use build_provider_chain() instead of create_llm_provider()."
.to_string(),
});
}
let reg_config = config
.provider
.as_ref()
@@ -153,6 +173,17 @@ fn create_registry_provider(
ProviderProtocol::OpenAiCompletions => create_openai_compat_from_registry(config),
ProviderProtocol::Anthropic => create_anthropic_from_registry(config),
ProviderProtocol::Ollama => create_ollama_from_registry(config),
ProviderProtocol::GithubCopilot => {
let provider =
github_copilot::GithubCopilotProvider::new(config, request_timeout_secs)?;
tracing::debug!(
provider = %config.provider_id,
model = %config.model,
base_url = %config.base_url,
"Using GitHub Copilot provider (token exchange)"
);
Ok(Arc::new(provider))
}
}
}
@@ -374,6 +405,47 @@ fn create_ollama_from_registry(
Ok(Arc::new(adapter))
}
/// Create an OpenAI Codex provider with OAuth authentication.
///
/// This is async because it needs to ensure authentication before
/// creating the provider (which requires a valid Bearer token).
///
/// Uses the Responses API (`chatgpt.com/backend-api/codex/responses`)
/// instead of the Chat Completions API, matching OpenClaw's approach.
async fn create_openai_codex_provider(
config: &LlmConfig,
) -> Result<Arc<dyn LlmProvider>, LlmError> {
let codex = config
.openai_codex
.as_ref()
.ok_or_else(|| LlmError::AuthFailed {
provider: "openai_codex".to_string(),
})?;
let session_mgr = Arc::new(OpenAiCodexSessionManager::new(codex.clone())?);
session_mgr.ensure_authenticated().await?;
let token = session_mgr.get_access_token().await?;
let provider = Arc::new(OpenAiCodexProvider::new(
&codex.model,
&codex.api_base_url,
token.expose_secret(),
config.request_timeout_secs,
)?);
tracing::info!(
"Using OpenAI Codex (Responses API, model: {}, base: {})",
codex.model,
codex.api_base_url,
);
Ok(Arc::new(TokenRefreshingProvider::new(
provider,
session_mgr,
)))
}
/// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation).
///
/// Resolution order:
@@ -460,7 +532,11 @@ pub async fn build_provider_chain(
),
LlmError,
> {
let llm = create_llm_provider(config, session.clone()).await?;
let llm: Arc<dyn LlmProvider> = if config.backend == "openai_codex" {
create_openai_codex_provider(config).await?
} else {
create_llm_provider(config, session.clone()).await?
};
tracing::debug!("LLM provider initialized: {}", llm.model_name());
// 1. Retry
@@ -632,6 +708,7 @@ mod tests {
request_timeout_secs: 120,
cheap_model: None,
smart_routing_cascade: true,
openai_codex: None,
}
}
+3 -2
View File
@@ -332,8 +332,8 @@ pub(crate) async fn fetch_openai_compatible_models(
/// Uses [`NearAiConfig::for_model_discovery()`] to construct a minimal NEAR AI
/// config, then wraps it in an `LlmConfig` with session config for auth.
pub(crate) fn build_nearai_model_fetch_config() -> crate::config::LlmConfig {
let auth_base_url =
std::env::var("NEARAI_AUTH_URL").unwrap_or_else(|_| "https://private.near.ai".to_string());
let auth_base_url = crate::config::helpers::env_or_override("NEARAI_AUTH_URL")
.unwrap_or_else(|| "https://private.near.ai".to_string());
crate::config::LlmConfig {
backend: "nearai".to_string(),
@@ -347,5 +347,6 @@ pub(crate) fn build_nearai_model_fetch_config() -> crate::config::LlmConfig {
request_timeout_secs: 120,
cheap_model: None,
smart_routing_cascade: false,
openai_codex: None,
}
}
+19 -144
View File
@@ -22,7 +22,7 @@ use crate::llm::provider::{
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall,
ToolCompletionRequest, ToolCompletionResponse,
};
use crate::llm::{costs, retry::cap_retry_after, session::SessionManager};
use crate::llm::{costs, session::SessionManager};
/// Information about an available model from NEAR AI API.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -35,6 +35,21 @@ pub struct ModelInfo {
pub provider: Option<String>,
}
/// Default NEAR AI model used when no model is configured.
pub const DEFAULT_MODEL: &str = "Qwen/Qwen3.5-122B-A10B";
/// Fallback model list used by the setup wizard when the `/models` API is
/// unreachable. Returns `(model_id, display_label)` pairs.
pub fn default_models() -> Vec<(String, String)> {
vec![
(DEFAULT_MODEL.into(), "Qwen 3.5 122B (default)".into()),
(
"Qwen/Qwen3-32B".into(),
"Qwen 3 32B (smaller, faster)".into(),
),
]
}
/// NEAR AI provider (Chat Completions API, dual auth).
pub struct NearAiChatProvider {
client: Client,
@@ -243,30 +258,9 @@ impl NearAiChatProvider {
let status = response.status();
// Extract Retry-After header before consuming the response body.
// Supports both delay-seconds (RFC 7231 §7.1.3) and HTTP-date formats.
// Falls back to 60s if header is missing or unparseable (prevents "retry after None" errors).
let retry_after_header = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| {
// Try delay-seconds first (most common from API providers)
if let Ok(secs) = v.trim().parse::<u64>() {
return Some(cap_retry_after(std::time::Duration::from_secs(secs)));
}
// Try HTTP-date (e.g. "Mon, 02 Mar 2026 18:00:00 GMT")
if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(v.trim()) {
let now = chrono::Utc::now();
let delta = dt.signed_duration_since(now);
// Use max(0) so past/present dates yield Duration::ZERO
// rather than None (which would cause an immediate retry).
return Some(cap_retry_after(std::time::Duration::from_secs(
delta.num_seconds().max(0) as u64,
)));
}
None
})
.or(Some(std::time::Duration::from_secs(60)));
let retry_after_header = Some(crate::llm::retry::parse_retry_after(
response.headers().get("retry-after"),
));
let response_text = response.text().await.map_err(|e| LlmError::RequestFailed {
provider: "nearai_chat".to_string(),
reason: format!("Failed to read response body: {}", e),
@@ -2218,123 +2212,4 @@ mod tests {
"http://example.com/api/proxy/v1/chat/completions"
);
}
// -- Retry-After header parsing tests (regression for rate limit "None" bug) --
#[test]
fn test_retry_after_parsing_delay_seconds() {
// Verify delay-seconds format (most common) is parsed correctly
let header_value = "30";
let duration = parse_retry_after_for_test(header_value);
assert_eq!(duration, Some(std::time::Duration::from_secs(30)));
}
#[test]
fn test_retry_after_parsing_rfc2822_date() {
// Verify HTTP-date (RFC 2822) format is parsed correctly
// Use a date 60 seconds in the future
let now = chrono::Utc::now();
let future = now + chrono::Duration::seconds(60);
let date_str = future.to_rfc2822();
let duration = parse_retry_after_for_test(&date_str);
assert!(duration.is_some());
let d = duration.unwrap();
// Allow ±5 seconds of drift due to processing time
assert!(
d.as_secs() >= 55 && d.as_secs() <= 65,
"Expected ~60s, got {}s",
d.as_secs()
);
}
#[test]
fn test_retry_after_fallback_missing_header() {
// Regression test: When Retry-After header is missing,
// should fall back to 60s instead of None
let duration = parse_retry_after_for_test("");
assert_eq!(
duration,
Some(std::time::Duration::from_secs(60)),
"Missing header should fallback to 60s"
);
}
#[test]
fn test_retry_after_fallback_invalid_format() {
// Regression test: When Retry-After header is in unexpected format,
// should fall back to 60s instead of None
let invalid_formats = vec![
"invalid",
"not-a-number",
"30.5", // float instead of int
"abc123",
];
for format in invalid_formats {
let duration = parse_retry_after_for_test(format);
assert_eq!(
duration,
Some(std::time::Duration::from_secs(60)),
"Invalid format '{}' should fallback to 60s",
format
);
}
}
#[test]
fn test_retry_after_past_date_returns_zero() {
// When HTTP-date is in the past, should return Duration::ZERO
// (not None, which would trigger immediate retry)
let past = chrono::Utc::now() - chrono::Duration::seconds(60);
let past_date_str = past.to_rfc2822();
let duration = parse_retry_after_for_test(&past_date_str);
assert_eq!(
duration,
Some(std::time::Duration::ZERO),
"Past date should return Duration::ZERO, not None"
);
}
#[test]
fn test_retry_after_zero_seconds_accepted() {
// Verify zero seconds is a valid retry delay
let duration = parse_retry_after_for_test("0");
assert_eq!(duration, Some(std::time::Duration::ZERO));
}
#[test]
fn test_retry_after_large_number() {
// Verify large numbers are capped to the safe maximum
let duration = parse_retry_after_for_test("3600"); // 1 hour
assert_eq!(duration, Some(std::time::Duration::from_secs(3600)));
let huge = parse_retry_after_for_test("18446744073709551615");
assert_eq!(
huge,
Some(std::time::Duration::from_secs(
crate::llm::retry::MAX_RETRY_AFTER_SECS
))
);
}
/// Helper function to test Retry-After header parsing logic
/// (simulates the parsing done in send_request without actual HTTP, including fallback)
fn parse_retry_after_for_test(header_value: &str) -> Option<std::time::Duration> {
let trimmed = header_value.trim();
let parsed = if let Ok(secs) = trimmed.parse::<u64>() {
Some(cap_retry_after(std::time::Duration::from_secs(secs)))
} else if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(trimmed) {
let now = chrono::Utc::now();
let delta = dt.signed_duration_since(now);
Some(cap_retry_after(std::time::Duration::from_secs(
delta.num_seconds().max(0) as u64,
)))
} else {
None
};
// Apply fallback to 60s if parsing failed (matches actual code behavior)
parsed.or(Some(std::time::Duration::from_secs(60)))
}
}
+28 -8
View File
@@ -39,9 +39,7 @@ pub enum OAuthCallbackError {
/// deployments where `127.0.0.1` is unreachable from the user's browser),
/// then falls back to `http://{callback_host()}:{OAUTH_CALLBACK_PORT}`.
pub fn callback_url() -> String {
std::env::var("IRONCLAW_OAUTH_CALLBACK_URL")
.ok()
.filter(|v| !v.is_empty())
crate::config::helpers::env_or_override("IRONCLAW_OAUTH_CALLBACK_URL")
.unwrap_or_else(|| format!("http://{}:{}", callback_host(), OAUTH_CALLBACK_PORT))
}
@@ -57,7 +55,8 @@ pub fn callback_url() -> String {
/// Note: this transmits the session token over plain HTTP — prefer SSH port
/// forwarding (`ssh -L 9876:127.0.0.1:9876 user@host`) when possible.
pub fn callback_host() -> String {
std::env::var("OAUTH_CALLBACK_HOST").unwrap_or_else(|_| "127.0.0.1".to_string())
crate::config::helpers::env_or_override("OAUTH_CALLBACK_HOST")
.unwrap_or_else(|| "127.0.0.1".to_string())
}
/// Returns `true` if `host` is a loopback address that only accepts local connections.
@@ -362,6 +361,7 @@ pub fn landing_html(provider_name: &str, success: bool) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::helpers::ENV_MUTEX;
#[test]
fn loopback_detection() {
@@ -386,12 +386,22 @@ mod tests {
assert!(!is_wildcard_host("localhost"));
}
// Lock held across await to serialize env-var mutation; the awaited op is a quick local TCP bind.
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn bind_rejects_wildcard_ipv4() {
// SAFETY: test is single-threaded; env var is restored immediately after.
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
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") };
let result = bind_callback_listener().await;
unsafe { std::env::remove_var("OAUTH_CALLBACK_HOST") };
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
match &original {
Some(v) => std::env::set_var("OAUTH_CALLBACK_HOST", v),
None => std::env::remove_var("OAUTH_CALLBACK_HOST"),
}
}
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
@@ -400,12 +410,22 @@ mod tests {
);
}
// Lock held across await to serialize env-var mutation; the awaited op is a quick local TCP bind.
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn bind_rejects_wildcard_ipv6() {
// SAFETY: test is single-threaded; env var is restored immediately after.
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
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", "::") };
let result = bind_callback_listener().await;
unsafe { std::env::remove_var("OAUTH_CALLBACK_HOST") };
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
match &original {
Some(v) => std::env::set_var("OAUTH_CALLBACK_HOST", v),
None => std::env::remove_var("OAUTH_CALLBACK_HOST"),
}
}
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
File diff suppressed because it is too large Load Diff
+731
View File
@@ -0,0 +1,731 @@
//! OAuth 2.0 session manager for OpenAI Codex (ChatGPT subscription).
//!
//! Supports two auth flows:
//! - **Device Code** (primary): Works on headless servers, no browser needed.
//! - **Browser PKCE** (fallback): Standard OAuth for local machines.
//!
//! Tokens are persisted to `~/.ironclaw/openai_codex_session.json` and
//! auto-refreshed before expiry.
use chrono::{DateTime, Utc};
use reqwest::Client;
use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT};
use secrecy::SecretString;
use serde::{Deserialize, Serialize};
use tokio::sync::{Mutex, RwLock};
use crate::config::OpenAiCodexConfig;
use crate::error::LlmError;
/// Persisted OAuth session data.
///
/// Note: `Debug` is manually implemented to redact tokens.
#[derive(Serialize, Deserialize)]
pub struct OpenAiCodexSession {
pub(crate) access_token: String,
pub(crate) refresh_token: String,
pub(crate) expires_at: DateTime<Utc>,
pub(crate) created_at: DateTime<Utc>,
}
impl std::fmt::Debug for OpenAiCodexSession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OpenAiCodexSession")
.field("access_token", &"[REDACTED]")
.field("refresh_token", &"[REDACTED]")
.field("expires_at", &self.expires_at)
.field("created_at", &self.created_at)
.finish()
}
}
/// Request body for the device code usercode endpoint.
#[derive(Debug, Serialize)]
struct UserCodeRequest {
client_id: String,
}
/// Response from the device code usercode endpoint.
#[derive(Debug, Deserialize)]
struct UserCodeResponse {
/// Unique ID for this device auth session.
device_auth_id: String,
/// Code the user enters in their browser.
user_code: String,
/// URL where the user enters the code (may not be present).
#[serde(default = "default_verification_uri")]
verification_uri: String,
/// Polling interval in seconds (OpenAI sends this as a string).
#[serde(
default = "default_interval",
deserialize_with = "deserialize_string_or_u64"
)]
interval: u64,
/// Expiry timestamp (OpenAI sends `expires_at` as ISO-8601).
#[serde(default)]
expires_at: Option<String>,
/// Seconds until the device code expires (standard field, may not be present).
#[serde(default)]
expires_in: Option<u64>,
}
fn default_verification_uri() -> String {
"https://auth.openai.com/codex/device".to_string()
}
fn default_interval() -> u64 {
5
}
/// Deserialize a value that may be either a string or a number as u64.
fn deserialize_string_or_u64<'de, D>(deserializer: D) -> Result<u64, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de;
struct StringOrU64;
impl<'de> de::Visitor<'de> for StringOrU64 {
type Value = u64;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a string or integer")
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<u64, E> {
Ok(v)
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<u64, E> {
v.parse().map_err(de::Error::custom)
}
}
deserializer.deserialize_any(StringOrU64)
}
impl UserCodeResponse {
/// Get the expiry duration in seconds, from either `expires_in` or `expires_at`.
fn expires_in_secs(&self) -> u64 {
if let Some(secs) = self.expires_in {
return secs;
}
if let Some(ref ts) = self.expires_at
&& let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts)
{
let remaining = dt.signed_duration_since(Utc::now()).num_seconds();
return remaining.max(0) as u64;
}
900 // default 15 minutes
}
}
/// Request body for polling the device auth token endpoint.
#[derive(Debug, Serialize)]
struct DeviceTokenPollRequest {
device_auth_id: String,
user_code: String,
}
/// Successful response from the device auth token endpoint.
/// Returns an authorization code + PKCE pair for the final token exchange.
#[derive(Debug, Deserialize)]
struct DeviceAuthCodeResponse {
authorization_code: String,
#[allow(dead_code)]
code_challenge: String,
code_verifier: String,
}
/// Response from the final OAuth token exchange.
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
#[serde(default)]
refresh_token: String,
#[serde(default)]
expires_in: u64,
#[serde(default)]
#[allow(dead_code)]
token_type: String,
}
/// Manages OpenAI Codex OAuth sessions with persistence and auto-refresh.
pub struct OpenAiCodexSessionManager {
config: OpenAiCodexConfig,
client: Client,
session: RwLock<Option<OpenAiCodexSession>>,
renewal_lock: Mutex<()>,
}
impl OpenAiCodexSessionManager {
/// Create a new session manager. Tries to load existing session from disk.
///
/// # Errors
///
/// Returns `LlmError` if the HTTP client cannot be constructed.
pub fn new(config: OpenAiCodexConfig) -> Result<Self, LlmError> {
let mut headers = HeaderMap::new();
headers.insert(
USER_AGENT,
HeaderValue::from_static(concat!("ironclaw/", env!("CARGO_PKG_VERSION"))),
);
let client = Client::builder()
.default_headers(headers)
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| LlmError::RequestFailed {
provider: "openai_codex".into(),
reason: format!("HTTP client build failed: {e}"),
})?;
let mgr = Self {
config,
client,
session: RwLock::new(None),
renewal_lock: Mutex::new(()),
};
// Try synchronous load from disk during construction
if let Ok(data) = std::fs::read_to_string(&mgr.config.session_path)
&& let Ok(session) = serde_json::from_str::<OpenAiCodexSession>(&data)
&& let Ok(mut guard) = mgr.session.try_write()
{
*guard = Some(session);
tracing::info!(
"Loaded OpenAI Codex session from {}",
mgr.config.session_path.display()
);
}
Ok(mgr)
}
/// Check if we have a session (may be expired).
pub async fn has_session(&self) -> bool {
self.session.read().await.is_some()
}
/// Check if the current access token needs refreshing.
pub async fn needs_refresh(&self) -> bool {
let guard = self.session.read().await;
match guard.as_ref() {
None => true,
Some(s) => {
let margin =
chrono::Duration::seconds(self.config.token_refresh_margin_secs as i64);
Utc::now() + margin >= s.expires_at
}
}
}
/// Get the current access token, refreshing if needed.
///
/// If the token is within the refresh margin, silently refreshes first.
/// If no session exists, returns an AuthFailed error.
pub async fn get_access_token(&self) -> Result<SecretString, LlmError> {
if self.needs_refresh().await {
let has_refresh = self
.session
.read()
.await
.as_ref()
.map(|s| !s.refresh_token.is_empty())
.unwrap_or(false);
if has_refresh {
self.refresh_tokens().await?;
} else {
return Err(LlmError::AuthFailed {
provider: "openai_codex".to_string(),
});
}
}
let guard = self.session.read().await;
guard
.as_ref()
.map(|s| SecretString::from(s.access_token.clone()))
.ok_or_else(|| LlmError::AuthFailed {
provider: "openai_codex".to_string(),
})
}
/// Ensure we have a valid session. Loads from disk, refreshes, or prompts login.
pub async fn ensure_authenticated(&self) -> Result<(), LlmError> {
// Try loading from disk if we don't have a session
if !self.has_session().await {
let _ = self.load_session().await;
}
if !self.has_session().await {
// No session at all -- need to authenticate
return self.device_code_login().await;
}
if self.needs_refresh().await {
// Try refresh; if it fails, re-authenticate
match self.refresh_tokens().await {
Ok(()) => Ok(()),
Err(e) => {
tracing::info!("Token refresh failed ({}), re-authenticating...", e);
self.device_code_login().await
}
}
} else {
Ok(())
}
}
/// Run OpenAI's device code auth flow.
///
/// Uses OpenAI's custom `/api/accounts/deviceauth/*` endpoints (not the standard
/// Auth0 `/oauth/device/code` which is behind Cloudflare managed challenge).
///
/// Flow:
/// 1. POST `/api/accounts/deviceauth/usercode` → get device_auth_id + user_code
/// 2. Poll POST `/api/accounts/deviceauth/token` → get authorization_code + PKCE
/// 3. Exchange via POST `/oauth/token` → get access_token + refresh_token
pub async fn device_code_login(&self) -> Result<(), LlmError> {
let _guard = self.renewal_lock.lock().await;
let auth_base = format!("{}/api/accounts", self.config.auth_endpoint);
// Step 1: Request device code
let usercode_url = format!("{}/deviceauth/usercode", auth_base);
let resp = self
.client
.post(&usercode_url)
.json(&UserCodeRequest {
client_id: self.config.client_id.clone(),
})
.send()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Device code request failed: {}", e),
})?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Device code request failed: HTTP {} -- {}", status, body),
});
}
let body_text = resp
.text()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Failed to read device code response: {}", e),
})?;
tracing::debug!("Device code response received ({} bytes)", body_text.len());
let device: UserCodeResponse =
serde_json::from_str(&body_text).map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!(
"Failed to parse device code response: {} ({} bytes)",
e,
body_text.len()
),
})?;
// Step 2: Display code to user
println!();
println!("===========================================================");
println!(" OpenAI Codex Authentication ");
println!("===========================================================");
println!();
println!(" 1. Open this URL in any browser:");
println!(" {}", device.verification_uri);
println!();
println!(" 2. Enter this code:");
println!();
println!(" [ {} ]", device.user_code);
println!();
let expires_secs = device.expires_in_secs();
println!(
" Waiting for authorization... (expires in {} min)",
expires_secs / 60
);
println!("===========================================================");
println!();
// Step 3: Poll for authorization code
let poll_url = format!("{}/deviceauth/token", auth_base);
let mut interval = std::time::Duration::from_secs(device.interval.max(5));
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(expires_secs);
let auth_code = loop {
tokio::time::sleep(interval).await;
if tokio::time::Instant::now() >= deadline {
return Err(LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: "Device code authorization timed out".to_string(),
});
}
let resp = self
.client
.post(&poll_url)
.json(&DeviceTokenPollRequest {
device_auth_id: device.device_auth_id.clone(),
user_code: device.user_code.clone(),
})
.send()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Token poll request failed: {}", e),
})?;
let status = resp.status();
if status.is_success() {
let code_resp: DeviceAuthCodeResponse =
resp.json()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Failed to parse auth code response: {}", e),
})?;
break code_resp;
}
// 403 = authorization_pending, keep polling
// 404 = device code not found / not enabled
if status == reqwest::StatusCode::FORBIDDEN {
continue;
}
if status == reqwest::StatusCode::NOT_FOUND {
return Err(LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: "Device code login is not enabled. Please check your OpenAI account settings.".to_string(),
});
}
// Slow down on 429, cap at 60s to avoid unbounded growth
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
interval = (interval + std::time::Duration::from_secs(5))
.min(std::time::Duration::from_secs(60));
continue;
}
let body = resp.text().await.unwrap_or_default();
return Err(LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Device auth poll failed: HTTP {} -- {}", status, body),
});
};
// Step 4: Exchange authorization code for tokens (form-encoded, per Auth0 spec)
let token_url = format!("{}/oauth/token", self.config.auth_endpoint);
let resp = self
.client
.post(&token_url)
.form(&[
("grant_type", "authorization_code"),
("code", &auth_code.authorization_code),
("code_verifier", &auth_code.code_verifier),
("client_id", &self.config.client_id),
(
"redirect_uri",
&format!("{}/deviceauth/callback", self.config.auth_endpoint),
),
])
.send()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Token exchange failed: {}", e),
})?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Token exchange failed: HTTP {} -- {}", status, body),
});
}
let token_resp: TokenResponse =
resp.json()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Failed to parse token response: {}", e),
})?;
let session = OpenAiCodexSession {
access_token: token_resp.access_token,
refresh_token: token_resp.refresh_token,
expires_at: Utc::now()
+ chrono::Duration::seconds(if token_resp.expires_in > 0 {
token_resp.expires_in
} else {
tracing::warn!("Token response has expires_in=0, defaulting to 3600s");
3600
} as i64),
created_at: Utc::now(),
};
self.save_session(&session).await?;
self.set_session(session).await;
println!();
println!("Authentication successful!");
println!();
Ok(())
}
/// Refresh the access token using the refresh token.
pub async fn refresh_tokens(&self) -> Result<(), LlmError> {
let _guard = self.renewal_lock.lock().await;
// Double-check: another task may have refreshed while we waited on the lock
if !self.needs_refresh().await {
return Ok(());
}
let refresh_token = {
let guard = self.session.read().await;
guard
.as_ref()
.map(|s| s.refresh_token.clone())
.ok_or_else(|| LlmError::AuthFailed {
provider: "openai_codex".to_string(),
})?
};
let token_url = format!("{}/oauth/token", self.config.auth_endpoint);
let resp = self
.client
.post(&token_url)
.form(&[
("grant_type", "refresh_token"),
("refresh_token", refresh_token.as_str()),
("client_id", self.config.client_id.as_str()),
])
.send()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Token refresh request failed: {}", e),
})?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Token refresh failed: HTTP {} -- {}", status, body),
});
}
let token_resp: TokenResponse =
resp.json()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Failed to parse refresh response: {}", e),
})?;
let session = OpenAiCodexSession {
access_token: token_resp.access_token,
refresh_token: token_resp.refresh_token,
expires_at: Utc::now()
+ chrono::Duration::seconds(if token_resp.expires_in > 0 {
token_resp.expires_in
} else {
tracing::warn!("Token response has expires_in=0, defaulting to 3600s");
3600
} as i64),
created_at: Utc::now(),
};
self.save_session(&session).await?;
self.set_session(session).await;
tracing::debug!("OpenAI Codex token refreshed successfully");
Ok(())
}
/// Save session data to disk with restrictive permissions.
pub async fn save_session(&self, session: &OpenAiCodexSession) -> Result<(), LlmError> {
if let Some(parent) = self.config.session_path.parent() {
tokio::fs::create_dir_all(parent).await.map_err(|e| {
LlmError::Io(std::io::Error::new(
e.kind(),
format!("Failed to create session directory: {}", e),
))
})?;
}
let json =
serde_json::to_string_pretty(session).map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Failed to serialize session: {}", e),
})?;
tokio::fs::write(&self.config.session_path, &json)
.await
.map_err(|e| {
LlmError::Io(std::io::Error::new(
e.kind(),
format!("Failed to write session file: {}", e),
))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
tokio::fs::set_permissions(&self.config.session_path, perms)
.await
.map_err(|e| {
LlmError::Io(std::io::Error::new(
e.kind(),
format!("Failed to set permissions: {}", e),
))
})?;
}
Ok(())
}
/// Load session from disk.
pub async fn load_session(&self) -> Result<(), LlmError> {
let data = tokio::fs::read_to_string(&self.config.session_path)
.await
.map_err(|e| {
LlmError::Io(std::io::Error::new(
e.kind(),
format!("Failed to read session file: {}", e),
))
})?;
let session: OpenAiCodexSession =
serde_json::from_str(&data).map_err(|e| LlmError::SessionRenewalFailed {
provider: "openai_codex".to_string(),
reason: format!("Failed to parse session file: {}", e),
})?;
let mut guard = self.session.write().await;
*guard = Some(session);
tracing::info!(
"Loaded OpenAI Codex session from {}",
self.config.session_path.display()
);
Ok(())
}
/// Set session directly (for testing or after auth).
pub async fn set_session(&self, session: OpenAiCodexSession) {
let mut guard = self.session.write().await;
*guard = Some(session);
}
/// Handle a 401 response by refreshing, or re-authenticating.
pub async fn handle_auth_failure(&self) -> Result<(), LlmError> {
match self.refresh_tokens().await {
Ok(()) => Ok(()),
Err(_) => self.device_code_login().await,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::codex_test_helpers::test_codex_config as test_config;
use tempfile::tempdir;
#[tokio::test]
async fn test_save_and_load_session() {
let dir = tempdir().unwrap();
let path = dir.path().join("session.json");
let config = test_config(path.clone());
let mgr = OpenAiCodexSessionManager::new(config).unwrap();
// No session initially
assert!(!mgr.has_session().await);
// Save a session
let session = OpenAiCodexSession {
access_token: "access_abc".to_string(),
refresh_token: "refresh_xyz".to_string(),
expires_at: chrono::Utc::now() + chrono::Duration::hours(1),
created_at: chrono::Utc::now(),
};
mgr.save_session(&session).await.unwrap();
mgr.set_session(session).await;
assert!(mgr.has_session().await);
// Load from disk in a new manager
let config2 = test_config(path);
let mgr2 = OpenAiCodexSessionManager::new(config2).unwrap();
mgr2.load_session().await.unwrap();
assert!(mgr2.has_session().await);
}
#[tokio::test]
async fn test_needs_refresh_when_near_expiry() {
let dir = tempdir().unwrap();
let config = test_config(dir.path().join("session.json"));
let mgr = OpenAiCodexSessionManager::new(config).unwrap();
// Token expiring in 2 minutes (margin is 300s = 5 min)
let session = OpenAiCodexSession {
access_token: "access_abc".to_string(),
refresh_token: "refresh_xyz".to_string(),
expires_at: chrono::Utc::now() + chrono::Duration::minutes(2),
created_at: chrono::Utc::now(),
};
mgr.set_session(session).await;
assert!(mgr.needs_refresh().await);
}
#[test]
fn device_code_parse_error_redacts_body() {
// Regression: the parse error used to include raw body_text which could
// contain sensitive auth data. Now it only shows byte count.
let body_text = r#"{"secret_token":"sk-12345","error":"unexpected"}"#;
let err: Result<UserCodeResponse, _> = serde_json::from_str(body_text);
assert!(err.is_err());
let e = err.unwrap_err();
let error_msg = format!(
"Failed to parse device code response: {} ({} bytes)",
e,
body_text.len()
);
assert!(
!error_msg.contains("sk-12345"),
"error message must not contain raw body: {error_msg}"
);
assert!(
error_msg.contains("bytes"),
"error message should show byte count"
);
}
#[tokio::test]
async fn test_no_refresh_when_fresh() {
let dir = tempdir().unwrap();
let config = test_config(dir.path().join("session.json"));
let mgr = OpenAiCodexSessionManager::new(config).unwrap();
// Token expiring in 30 minutes (margin is 300s = 5 min)
let session = OpenAiCodexSession {
access_token: "access_abc".to_string(),
refresh_token: "refresh_xyz".to_string(),
expires_at: chrono::Utc::now() + chrono::Duration::minutes(30),
created_at: chrono::Utc::now(),
};
mgr.set_session(session).await;
assert!(!mgr.needs_refresh().await);
}
}
+2
View File
@@ -37,6 +37,8 @@ pub enum ProviderProtocol {
Anthropic,
/// Ollama API (OpenAI-ish, no API key required).
Ollama,
/// GitHub Copilot API (OpenAI-compatible with token exchange).
GithubCopilot,
}
/// How the setup wizard should collect credentials for this provider.
+76
View File
@@ -78,6 +78,33 @@ pub(crate) fn cap_retry_after(duration: Duration) -> Duration {
duration.min(Duration::from_secs(MAX_RETRY_AFTER_SECS))
}
/// Parse a `Retry-After` header value into a capped `Duration`.
///
/// Supports both delay-seconds (RFC 7231 §7.1.3) and HTTP-date formats (RFC 7231
/// §7.1.1 / IMF-fixdate). The implementation uses `chrono::DateTime::parse_from_rfc2822`,
/// which also accepts RFC 2822-style dates.
/// Returns `DEFAULT_RETRY_AFTER` (60 s) if the header is missing or unparseable.
pub(crate) fn parse_retry_after(header: Option<&reqwest::header::HeaderValue>) -> Duration {
header
.and_then(|v| v.to_str().ok())
.and_then(|v| {
if let Ok(secs) = v.trim().parse::<u64>() {
return Some(cap_retry_after(Duration::from_secs(secs)));
}
if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(v.trim()) {
let now = chrono::Utc::now();
let delta = dt.signed_duration_since(now);
return Some(cap_retry_after(Duration::from_secs(
delta.num_seconds().max(0) as u64,
)));
}
None
})
.unwrap_or(Duration::from_secs(DEFAULT_RETRY_AFTER_SECS))
}
const DEFAULT_RETRY_AFTER_SECS: u64 = 60;
/// Configuration for the retry decorator.
#[derive(Debug, Clone)]
pub struct RetryConfig {
@@ -444,4 +471,53 @@ mod tests {
Duration::from_secs(0)
);
}
#[test]
fn parse_retry_after_delay_seconds() {
let val = reqwest::header::HeaderValue::from_static("30");
assert_eq!(parse_retry_after(Some(&val)), Duration::from_secs(30));
}
#[test]
fn parse_retry_after_missing_header() {
assert_eq!(
parse_retry_after(None),
Duration::from_secs(DEFAULT_RETRY_AFTER_SECS)
);
}
#[test]
fn parse_retry_after_unparseable() {
let val = reqwest::header::HeaderValue::from_static("not-a-number");
assert_eq!(
parse_retry_after(Some(&val)),
Duration::from_secs(DEFAULT_RETRY_AFTER_SECS)
);
}
#[test]
fn parse_retry_after_clamps_large_value() {
let val = reqwest::header::HeaderValue::from_static("999999");
assert_eq!(
parse_retry_after(Some(&val)),
Duration::from_secs(MAX_RETRY_AFTER_SECS)
);
}
#[test]
fn parse_retry_after_http_date() {
let future = chrono::Utc::now() + chrono::Duration::seconds(30);
let date_str = future.to_rfc2822();
let val = reqwest::header::HeaderValue::from_str(&date_str).unwrap();
let parsed = parse_retry_after(Some(&val));
let diff = if parsed > Duration::from_secs(30) {
parsed - Duration::from_secs(30)
} else {
Duration::from_secs(30) - parsed
};
assert!(
diff <= Duration::from_secs(2),
"expected ~30s, got {parsed:?} (diff {diff:?}) from header {date_str:?}"
);
}
}

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