mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-31 08:39:24 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78b3b327c1 | ||
|
|
32c5a86dd3 | ||
|
|
6f4050dafa | ||
|
|
9ac03c3e62 | ||
|
|
76431159c8 | ||
|
|
b394c0cfdd | ||
|
|
485d1568c4 | ||
|
|
acb590214a | ||
|
|
d9358b0fa9 | ||
|
|
8f6999a074 | ||
|
|
4d7501a968 | ||
|
|
abba083147 | ||
|
|
7034e910c4 | ||
|
|
3e73dbe615 | ||
|
|
969b559e2a | ||
|
|
3aa36c8f55 | ||
|
|
fbce9a5fe3 | ||
|
|
1a62febe67 | ||
|
|
a09c023642 | ||
|
|
8638895879 |
+18
-1
@@ -4,7 +4,7 @@ DATABASE_POOL_SIZE=10
|
|||||||
|
|
||||||
# LLM Provider
|
# LLM Provider
|
||||||
# LLM_BACKEND=nearai # default
|
# LLM_BACKEND=nearai # default
|
||||||
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, github_copilot, tinfoil, openai_codex
|
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, github_copilot, tinfoil, openai_codex, gemini_oauth
|
||||||
# LLM_REQUEST_TIMEOUT_SECS=120 # Increase for local LLMs (Ollama, vLLM, LM Studio)
|
# LLM_REQUEST_TIMEOUT_SECS=120 # Increase for local LLMs (Ollama, vLLM, LM Studio)
|
||||||
|
|
||||||
# === Anthropic Direct ===
|
# === Anthropic Direct ===
|
||||||
@@ -110,6 +110,23 @@ NEARAI_AUTH_URL=https://private.near.ai
|
|||||||
# OPENAI_CODEX_AUTH_URL=https://auth.openai.com # override (rare)
|
# OPENAI_CODEX_AUTH_URL=https://auth.openai.com # override (rare)
|
||||||
# OPENAI_CODEX_API_URL=https://chatgpt.com/backend-api/codex # override (rare)
|
# OPENAI_CODEX_API_URL=https://chatgpt.com/backend-api/codex # override (rare)
|
||||||
|
|
||||||
|
# === Google Gemini (OAuth, Gemini CLI compatible) ===
|
||||||
|
# LLM_BACKEND=gemini_oauth
|
||||||
|
# GEMINI_MODEL=gemini-2.5-flash # default
|
||||||
|
# GEMINI_CREDENTIALS_PATH=~/.gemini/oauth_creds.json # default
|
||||||
|
# GEMINI_API_KEY=... # optional: use API key instead of OAuth
|
||||||
|
# GEMINI_API_KEY_AUTH_MECHANISM=query # "query" (default) or "header"
|
||||||
|
# GEMINI_SAFETY_BLOCK_NONE=true # disable safety filters (default: false)
|
||||||
|
# GEMINI_CLI_CUSTOM_HEADERS=Key:Value,Key2:Value2
|
||||||
|
# GEMINI_TOP_P=0.95
|
||||||
|
# GEMINI_TOP_K=40
|
||||||
|
# GEMINI_SEED=42
|
||||||
|
# GEMINI_PRESENCE_PENALTY=0.0
|
||||||
|
# GEMINI_FREQUENCY_PENALTY=0.0
|
||||||
|
# GEMINI_RESPONSE_MIME_TYPE=application/json
|
||||||
|
# GEMINI_RESPONSE_JSON_SCHEMA={"type":"object"}
|
||||||
|
# GEMINI_CACHED_CONTENT=cachedContents/abc123
|
||||||
|
|
||||||
# For full provider setup guide see docs/LLM_PROVIDERS.md
|
# For full provider setup guide see docs/LLM_PROVIDERS.md
|
||||||
|
|
||||||
# Channel Configuration
|
# Channel Configuration
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ jobs:
|
|||||||
- group: features
|
- group: features
|
||||||
files: "tests/e2e/scenarios/test_skills.py tests/e2e/scenarios/test_tool_approval.py tests/e2e/scenarios/test_webhook.py"
|
files: "tests/e2e/scenarios/test_skills.py tests/e2e/scenarios/test_tool_approval.py tests/e2e/scenarios/test_webhook.py"
|
||||||
- group: extensions
|
- group: extensions
|
||||||
files: "tests/e2e/scenarios/test_extensions.py tests/e2e/scenarios/test_extension_oauth.py tests/e2e/scenarios/test_telegram_token_validation.py tests/e2e/scenarios/test_telegram_hot_activation.py tests/e2e/scenarios/test_wasm_lifecycle.py tests/e2e/scenarios/test_tool_execution.py tests/e2e/scenarios/test_pairing.py tests/e2e/scenarios/test_mcp_auth_flow.py tests/e2e/scenarios/test_oauth_credential_fallback.py tests/e2e/scenarios/test_routine_oauth_credential_injection.py"
|
files: "tests/e2e/scenarios/test_extensions.py tests/e2e/scenarios/test_extension_oauth.py tests/e2e/scenarios/test_oauth_url_parameters.py tests/e2e/scenarios/test_telegram_token_validation.py tests/e2e/scenarios/test_telegram_hot_activation.py tests/e2e/scenarios/test_wasm_lifecycle.py tests/e2e/scenarios/test_tool_execution.py tests/e2e/scenarios/test_pairing.py tests/e2e/scenarios/test_mcp_auth_flow.py tests/e2e/scenarios/test_oauth_credential_fallback.py tests/e2e/scenarios/test_routine_oauth_credential_injection.py"
|
||||||
- group: routines
|
- group: routines
|
||||||
files: "tests/e2e/scenarios/test_owner_scope.py tests/e2e/scenarios/test_routine_event_batch.py"
|
files: "tests/e2e/scenarios/test_owner_scope.py tests/e2e/scenarios/test_routine_event_batch.py"
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Whole-function context: detect edits inside existing test functions.
|
# Whole-function context: detect edits inside existing test functions.
|
||||||
|
# Uses -W (whole function) which works when git recognises function boundaries.
|
||||||
if git diff "${BASE_REF}...${HEAD_REF}" -W -- '*.rs' | awk '
|
if git diff "${BASE_REF}...${HEAD_REF}" -W -- '*.rs' | awk '
|
||||||
/^@@/ { if (has_test && has_add) { found=1; exit } has_test=0; has_add=0 }
|
/^@@/ { if (has_test && has_add) { found=1; exit } has_test=0; has_add=0 }
|
||||||
/^ .*#\[test\]/ || /^ .*#\[tokio::test\]/ || /^ .*#\[cfg\(test\)\]/ || /^ .*mod tests/ { has_test=1 }
|
/^ .*#\[test\]/ || /^ .*#\[tokio::test\]/ || /^ .*#\[cfg\(test\)\]/ || /^ .*mod tests/ { has_test=1 }
|
||||||
@@ -132,6 +133,40 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Line-level check: detect changes inside #[cfg(test)] mod blocks.
|
||||||
|
# git -W relies on function boundary detection which misses Rust mod blocks,
|
||||||
|
# so this fallback checks whether changed line numbers fall within test modules.
|
||||||
|
# We specifically match #[cfg(test)] that is followed by `mod` (same or next
|
||||||
|
# line) to avoid false positives from standalone #[cfg(test)] items like
|
||||||
|
# individual statics or functions.
|
||||||
|
CHANGED_RS=$(echo "$CHANGED_FILES" | grep '\.rs$' || true)
|
||||||
|
if [ -n "$CHANGED_RS" ]; then
|
||||||
|
while IFS= read -r rs_file; do
|
||||||
|
[ -f "$rs_file" ] || continue
|
||||||
|
|
||||||
|
# Find the line where #[cfg(test)] precedes a `mod` declaration.
|
||||||
|
# Handles both `#[cfg(test)] mod tests` (same line) and the two-line form.
|
||||||
|
TEST_MOD_START=$(awk '
|
||||||
|
/^[[:space:]]*#\[cfg\(test\)\].*mod / { print NR; exit }
|
||||||
|
/^[[:space:]]*#\[cfg\(test\)\][[:space:]]*$/ { pending=NR; next }
|
||||||
|
pending && /^[[:space:]]*mod / { print pending; exit }
|
||||||
|
{ pending=0 }
|
||||||
|
' "$rs_file")
|
||||||
|
[ -n "$TEST_MOD_START" ] || continue
|
||||||
|
|
||||||
|
# Get changed line numbers in this file from the diff hunk headers.
|
||||||
|
# Each @@ line looks like: @@ -old,count +new,count @@
|
||||||
|
while IFS= read -r hunk_line; do
|
||||||
|
line_no=$(echo "$hunk_line" | sed -E 's/^@@ -[0-9,]+ \+([0-9]+).*/\1/')
|
||||||
|
[ -n "$line_no" ] || continue
|
||||||
|
if [ "$line_no" -ge "$TEST_MOD_START" ]; then
|
||||||
|
echo "Test changes found: $rs_file has changes at line $line_no inside #[cfg(test)] mod block (starts at line $TEST_MOD_START)."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
done < <(git diff "${BASE_REF}...${HEAD_REF}" -U0 -- "$rs_file" | grep -E '^@@')
|
||||||
|
done <<< "$CHANGED_RS"
|
||||||
|
fi
|
||||||
|
|
||||||
if grep -qE '^tests/' <<< "$CHANGED_FILES"; then
|
if grep -qE '^tests/' <<< "$CHANGED_FILES"; then
|
||||||
echo "Test file changes found under tests/."
|
echo "Test file changes found under tests/."
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
Generated
+13
-29
@@ -157,7 +157,7 @@ version = "1.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -168,7 +168,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"once_cell_polyfill",
|
"once_cell_polyfill",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1510,7 +1510,7 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "980c2afde4af43d6a05c5be738f9eae595cff86dce1f38f88b95058a98c027f3"
|
checksum = "980c2afde4af43d6a05c5be738f9eae595cff86dce1f38f88b95058a98c027f3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossterm 0.29.0",
|
"crossterm",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1731,7 +1731,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "04a63daf06a168535c74ab97cdba3ed4fa5d4f32cb36e437dcceb83d66854b7c"
|
checksum = "04a63daf06a168535c74ab97cdba3ed4fa5d4f32cb36e437dcceb83d66854b7c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crokey-proc_macros",
|
"crokey-proc_macros",
|
||||||
"crossterm 0.29.0",
|
"crossterm",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"serde",
|
"serde",
|
||||||
"strict",
|
"strict",
|
||||||
@@ -1743,7 +1743,7 @@ version = "1.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "847f11a14855fc490bd5d059821895c53e77eeb3c2b73ee3dded7ce77c93b231"
|
checksum = "847f11a14855fc490bd5d059821895c53e77eeb3c2b73ee3dded7ce77c93b231"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossterm 0.29.0",
|
"crossterm",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"strict",
|
"strict",
|
||||||
@@ -1817,22 +1817,6 @@ version = "0.8.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "crossterm"
|
|
||||||
version = "0.28.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.11.0",
|
|
||||||
"crossterm_winapi",
|
|
||||||
"mio",
|
|
||||||
"parking_lot",
|
|
||||||
"rustix 0.38.44",
|
|
||||||
"signal-hook",
|
|
||||||
"signal-hook-mio",
|
|
||||||
"winapi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossterm"
|
name = "crossterm"
|
||||||
version = "0.29.0"
|
version = "0.29.0"
|
||||||
@@ -2339,7 +2323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3426,7 +3410,7 @@ dependencies = [
|
|||||||
"clap_complete",
|
"clap_complete",
|
||||||
"criterion",
|
"criterion",
|
||||||
"cron",
|
"cron",
|
||||||
"crossterm 0.28.1",
|
"crossterm",
|
||||||
"deadpool-postgres",
|
"deadpool-postgres",
|
||||||
"dirs 6.0.0",
|
"dirs 6.0.0",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
@@ -5488,7 +5472,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.12.1",
|
"linux-raw-sys 0.12.1",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6170,7 +6154,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6370,9 +6354,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tar"
|
name = "tar"
|
||||||
version = "0.4.44"
|
version = "0.4.45"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
|
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"filetime",
|
"filetime",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -6395,7 +6379,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix 1.1.4",
|
"rustix 1.1.4",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7195,7 +7179,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"memoffset",
|
"memoffset",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+1
-1
@@ -88,7 +88,7 @@ async-trait = "0.1"
|
|||||||
clap = { version = "4", features = ["derive", "env"] }
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
|
|
||||||
# Terminal
|
# Terminal
|
||||||
crossterm = "0.28"
|
crossterm = "0.29"
|
||||||
rustyline = { version = "17", features = ["custom-bindings", "derive", "with-file-history"] }
|
rustyline = { version = "17", features = ["custom-bindings", "derive", "with-file-history"] }
|
||||||
termimad = "0.34"
|
termimad = "0.34"
|
||||||
|
|
||||||
|
|||||||
+15
-6
@@ -3,6 +3,7 @@
|
|||||||
This document tracks feature parity between IronClaw (Rust implementation) and OpenClaw (TypeScript reference implementation). Use this to coordinate work across developers.
|
This document tracks feature parity between IronClaw (Rust implementation) and OpenClaw (TypeScript reference implementation). Use this to coordinate work across developers.
|
||||||
|
|
||||||
**Legend:**
|
**Legend:**
|
||||||
|
|
||||||
- ✅ Implemented
|
- ✅ Implemented
|
||||||
- 🚧 Partial (in progress or incomplete)
|
- 🚧 Partial (in progress or incomplete)
|
||||||
- ❌ Not implemented
|
- ❌ Not implemented
|
||||||
@@ -160,7 +161,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| `config` | ✅ | ✅ | - | Read/write config plus validate/path helpers |
|
| `config` | ✅ | ✅ | - | Read/write config plus validate/path helpers |
|
||||||
| `backup` | ✅ | ❌ | P3 | Create/verify local backup archives |
|
| `backup` | ✅ | ❌ | P3 | Create/verify local backup archives |
|
||||||
| `channels` | ✅ | 🚧 | P2 | `list` implemented; `enable`/`disable`/`status` deferred pending config source unification |
|
| `channels` | ✅ | 🚧 | P2 | `list` implemented; `enable`/`disable`/`status` deferred pending config source unification |
|
||||||
| `models` | ✅ | 🚧 | - | Model selector in TUI |
|
| `models` | ✅ | 🚧 | P1 | `models list [<provider>]` (`--verbose`, `--json`; fetches live model list when provider specified), `models status` (`--json`), `models set <model>`, `models set-provider <provider> [--model model]` (alias normalization, config.toml + .env persistence). Remaining: `set` doesn't validate model against live list. |
|
||||||
| `status` | ✅ | ✅ | - | System status (enriched session details) |
|
| `status` | ✅ | ✅ | - | System status (enriched session details) |
|
||||||
| `agents` | ✅ | ❌ | P3 | Multi-agent management |
|
| `agents` | ✅ | ❌ | P3 | Multi-agent management |
|
||||||
| `sessions` | ✅ | ❌ | P3 | Session listing (shows subagent models) |
|
| `sessions` | ✅ | ❌ | P3 | Session listing (shows subagent models) |
|
||||||
@@ -204,7 +205,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| Skills (modular capabilities) | ✅ | ✅ | Prompt-based skills with trust gating, attenuation, activation criteria, catalog, selector |
|
| Skills (modular capabilities) | ✅ | ✅ | Prompt-based skills with trust gating, attenuation, activation criteria, catalog, selector |
|
||||||
| Skill routing blocks | ✅ | 🚧 | ActivationCriteria (keywords, patterns, tags) but no "Use when / Don't use when" blocks |
|
| Skill routing blocks | ✅ | 🚧 | ActivationCriteria (keywords, patterns, tags) but no "Use when / Don't use when" blocks |
|
||||||
| Skill path compaction | ✅ | ❌ | ~ prefix to reduce prompt tokens |
|
| Skill path compaction | ✅ | ❌ | ~ prefix to reduce prompt tokens |
|
||||||
| Thinking modes (off/minimal/low/medium/high/xhigh/adaptive) | ✅ | ❌ | Configurable reasoning depth |
|
| Thinking modes (off/minimal/low/medium/high/xhigh/adaptive) | ✅ | 🚧 | thinkingConfig for Gemini models (thinkingBudget/thinkingLevel); no per-level control yet |
|
||||||
| Per-model thinkingDefault override | ✅ | ❌ | Override thinking level per model; Anthropic Claude 4.6 defaults to adaptive |
|
| Per-model thinkingDefault override | ✅ | ❌ | Override thinking level per model; Anthropic Claude 4.6 defaults to adaptive |
|
||||||
| Block-level streaming | ✅ | ❌ | |
|
| Block-level streaming | ✅ | ❌ | |
|
||||||
| Tool-level streaming | ✅ | ❌ | |
|
| Tool-level streaming | ✅ | ❌ | |
|
||||||
@@ -236,9 +237,13 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| NEAR AI | ✅ | ✅ | - | Primary provider |
|
| NEAR AI | ✅ | ✅ | - | Primary provider |
|
||||||
| Anthropic (Claude) | ✅ | 🚧 | - | Via NEAR AI proxy; Opus 4.5, Sonnet 4, Sonnet 4.6, adaptive thinking default |
|
| Anthropic (Claude) | ✅ | 🚧 | - | Via NEAR AI proxy; Opus 4.5, Sonnet 4, Sonnet 4.6, adaptive thinking default |
|
||||||
| OpenAI | ✅ | 🚧 | - | Via NEAR AI proxy; GPT-5.4 + Codex OAuth |
|
| OpenAI | ✅ | 🚧 | - | Via NEAR AI proxy; GPT-5.4 + Codex OAuth |
|
||||||
| AWS Bedrock | ✅ | ❌ | P3 | |
|
| AWS Bedrock | ✅ | ✅ | - | Native Converse API via aws-sdk-bedrockruntime (requires `--features bedrock`) |
|
||||||
| Google Gemini | ✅ | ❌ | P3 | |
|
| Google Gemini | ✅ | ✅ | - | OAuth (PKCE + S256), function calling, thinkingConfig, generationConfig |
|
||||||
| NVIDIA API | ✅ | ❌ | P3 | New provider |
|
| io.net | ✅ | ✅ | P3 | Via `ionet` adapter |
|
||||||
|
| Mistral | ✅ | ✅ | P3 | Via `mistral` adapter |
|
||||||
|
| Yandex AI Studio | ✅ | ✅ | P3 | Via `yandex` adapter |
|
||||||
|
| Cloudflare Workers AI | ✅ | ✅ | P3 | Via `cloudflare` adapter |
|
||||||
|
| NVIDIA API | ✅ | ✅ | P3 | Via `nvidia` adapter and `providers.json` |
|
||||||
| OpenRouter | ✅ | ✅ | - | Via OpenAI-compatible provider (RigAdapter) |
|
| OpenRouter | ✅ | ✅ | - | Via OpenAI-compatible provider (RigAdapter) |
|
||||||
| Tinfoil | ❌ | ✅ | - | Private inference provider (IronClaw-only) |
|
| Tinfoil | ❌ | ✅ | - | Private inference provider (IronClaw-only) |
|
||||||
| OpenAI-compatible | ❌ | ✅ | - | Generic OpenAI-compatible endpoint (RigAdapter) |
|
| OpenAI-compatible | ❌ | ✅ | - | Generic OpenAI-compatible endpoint (RigAdapter) |
|
||||||
@@ -466,7 +471,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| Device pairing | ✅ | ❌ | |
|
| Device pairing | ✅ | ❌ | |
|
||||||
| Tailscale identity | ✅ | ❌ | |
|
| Tailscale identity | ✅ | ❌ | |
|
||||||
| Trusted-proxy auth | ✅ | ❌ | Header-based reverse proxy auth |
|
| Trusted-proxy auth | ✅ | ❌ | Header-based reverse proxy auth |
|
||||||
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth plus hosted extension/MCP OAuth broker; external auth-proxy rollout still pending |
|
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth + Gemini OAuth (PKCE, S256) + hosted extension/MCP OAuth broker; external auth-proxy rollout still pending |
|
||||||
| DM pairing verification | ✅ | ✅ | ironclaw pairing approve, host APIs |
|
| DM pairing verification | ✅ | ✅ | ironclaw pairing approve, host APIs |
|
||||||
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
|
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
|
||||||
| Per-group tool policies | ✅ | ❌ | |
|
| Per-group tool policies | ✅ | ❌ | |
|
||||||
@@ -523,6 +528,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
## Implementation Priorities
|
## Implementation Priorities
|
||||||
|
|
||||||
### P0 - Core (Already Done)
|
### P0 - Core (Already Done)
|
||||||
|
|
||||||
- ✅ TUI channel with approval overlays
|
- ✅ TUI channel with approval overlays
|
||||||
- ✅ HTTP webhook channel
|
- ✅ HTTP webhook channel
|
||||||
- ✅ DM pairing (ironclaw pairing list/approve, host APIs)
|
- ✅ DM pairing (ironclaw pairing list/approve, host APIs)
|
||||||
@@ -550,6 +556,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
- ✅ OpenAI-compatible / OpenRouter provider support
|
- ✅ OpenAI-compatible / OpenRouter provider support
|
||||||
|
|
||||||
### P1 - High Priority
|
### P1 - High Priority
|
||||||
|
|
||||||
- ❌ Slack channel (real implementation)
|
- ❌ Slack channel (real implementation)
|
||||||
- ✅ Telegram channel (WASM, DM pairing, caption, /start)
|
- ✅ Telegram channel (WASM, DM pairing, caption, /start)
|
||||||
- ❌ WhatsApp channel
|
- ❌ WhatsApp channel
|
||||||
@@ -557,6 +564,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
- ✅ Hooks system (core lifecycle hooks + bundled/plugin/workspace hooks + outbound webhooks)
|
- ✅ Hooks system (core lifecycle hooks + bundled/plugin/workspace hooks + outbound webhooks)
|
||||||
|
|
||||||
### P2 - Medium Priority
|
### P2 - Medium Priority
|
||||||
|
|
||||||
- ❌ Media handling (images, PDFs)
|
- ❌ Media handling (images, PDFs)
|
||||||
- ✅ Ollama/local model support (via rig::providers::ollama)
|
- ✅ Ollama/local model support (via rig::providers::ollama)
|
||||||
- ❌ Configuration hot-reload
|
- ❌ Configuration hot-reload
|
||||||
@@ -565,6 +573,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
- ❌ Partial output preservation on abort
|
- ❌ Partial output preservation on abort
|
||||||
|
|
||||||
### P3 - Lower Priority
|
### P3 - Lower Priority
|
||||||
|
|
||||||
- ❌ Discord channel
|
- ❌ Discord channel
|
||||||
- ❌ Matrix channel
|
- ❌ Matrix channel
|
||||||
- ❌ Other messaging platforms
|
- ❌ Other messaging platforms
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
<a href="#license"><img src="https://img.shields.io/badge/license-MIT%20OR%20Apache%202.0-blue.svg" alt="License: MIT OR Apache-2.0" /></a>
|
<a href="#license"><img src="https://img.shields.io/badge/license-MIT%20OR%20Apache%202.0-blue.svg" alt="License: MIT OR Apache-2.0" /></a>
|
||||||
<a href="https://t.me/ironclawAI"><img src="https://img.shields.io/badge/Telegram-%40ironclawAI-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @ironclawAI" /></a>
|
<a href="https://t.me/ironclawAI"><img src="https://img.shields.io/badge/Telegram-%40ironclawAI-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @ironclawAI" /></a>
|
||||||
<a href="https://www.reddit.com/r/ironclawAI/"><img src="https://img.shields.io/badge/Reddit-r%2FironclawAI-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/ironclawAI" /></a>
|
<a href="https://www.reddit.com/r/ironclawAI/"><img src="https://img.shields.io/badge/Reddit-r%2FironclawAI-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/ironclawAI" /></a>
|
||||||
|
<a href="https://gitcgr.com/nearai/ironclaw">
|
||||||
|
<img src="https://gitcgr.com/badge/nearai/ironclaw.svg" alt="gitcgr" />
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
"wit_version": "0.3.0",
|
"wit_version": "0.3.0",
|
||||||
"type": "channel",
|
"type": "channel",
|
||||||
"name": "feishu",
|
"name": "feishu",
|
||||||
"description": "Feishu/Lark Bot channel for receiving and responding to Feishu messages",
|
"description": "Feishu/Lark Bot channel for receiving and responding to Feishu messages via Event Subscription webhooks",
|
||||||
"auth": {
|
"auth": {
|
||||||
"secret_name": "feishu_app_id",
|
"secret_name": "feishu_app_id",
|
||||||
"display_name": "Feishu / Lark",
|
"display_name": "Feishu / Lark",
|
||||||
"instructions": "Create a bot at https://open.feishu.cn/app (Feishu) or https://open.larksuite.com/app (Lark). You need the App ID and App Secret.",
|
"instructions": "Create a bot at https://open.feishu.cn/app (Feishu) or https://open.larksuite.com/app (Lark). You need the App ID and App Secret. Note: IronClaw supports Event Subscription webhook delivery, but not Feishu's long-connection websocket mode.",
|
||||||
"setup_url": "https://open.feishu.cn/app",
|
"setup_url": "https://open.feishu.cn/app",
|
||||||
"token_hint": "App ID looks like cli_XXXX, App Secret is a long alphanumeric string",
|
"token_hint": "App ID looks like cli_XXXX, App Secret is a long alphanumeric string",
|
||||||
"env_var": "FEISHU_APP_ID"
|
"env_var": "FEISHU_APP_ID"
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"required_secrets": [
|
"required_secrets": [
|
||||||
{
|
{
|
||||||
"name": "feishu_app_id",
|
"name": "feishu_app_id",
|
||||||
"prompt": "Enter your Feishu/Lark App ID (from https://open.feishu.cn/app)",
|
"prompt": "Enter your Feishu/Lark App ID (from https://open.feishu.cn/app). Use webhook-based Event Subscription, not long-connection websocket mode.",
|
||||||
"optional": false
|
"optional": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "feishu_verification_token",
|
"name": "feishu_verification_token",
|
||||||
"prompt": "Enter your Feishu/Lark Verification Token (from Event Subscription settings)",
|
"prompt": "Enter your Feishu/Lark Verification Token (from Event Subscription webhook settings)",
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
//!
|
//!
|
||||||
//! This WASM component implements the channel interface for handling Feishu
|
//! This WASM component implements the channel interface for handling Feishu
|
||||||
//! webhooks (Event Subscription v2.0) and sending messages back via the
|
//! webhooks (Event Subscription v2.0) and sending messages back via the
|
||||||
//! Feishu/Lark Bot API.
|
//! Feishu/Lark Bot API. IronClaw currently does not connect to Feishu's
|
||||||
|
//! long-connection websocket subscription mode; use Event Subscription
|
||||||
|
//! webhooks for this channel.
|
||||||
//!
|
//!
|
||||||
//! # Features
|
//! # Features
|
||||||
//!
|
//!
|
||||||
|
|||||||
+48
-3
@@ -1,8 +1,8 @@
|
|||||||
# LLM Provider Configuration
|
# LLM Provider Configuration
|
||||||
|
|
||||||
IronClaw defaults to NEAR AI for model access, but supports any OpenAI-compatible
|
IronClaw defaults to NEAR AI for model access, but supports any OpenAI-compatible
|
||||||
endpoint as well as Anthropic and Ollama directly. This guide covers the most common
|
endpoint as well as Anthropic, Ollama, and Google Gemini directly. This guide covers
|
||||||
configurations.
|
the most common configurations.
|
||||||
|
|
||||||
## Provider Overview
|
## Provider Overview
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ configurations.
|
|||||||
| NEAR AI | `nearai` | OAuth (browser) | Default; multi-model |
|
| NEAR AI | `nearai` | OAuth (browser) | Default; multi-model |
|
||||||
| Anthropic | `anthropic` | `ANTHROPIC_API_KEY` | Claude models |
|
| Anthropic | `anthropic` | `ANTHROPIC_API_KEY` | Claude models |
|
||||||
| OpenAI | `openai` | `OPENAI_API_KEY` | GPT models |
|
| OpenAI | `openai` | `OPENAI_API_KEY` | GPT models |
|
||||||
| Google Gemini | `gemini` | `GEMINI_API_KEY` | Gemini models |
|
| Google Gemini | `gemini_oauth` | OAuth (browser) | Gemini models; function calling |
|
||||||
| io.net | `ionet` | `IONET_API_KEY` | Intelligence API |
|
| io.net | `ionet` | `IONET_API_KEY` | Intelligence API |
|
||||||
| Mistral | `mistral` | `MISTRAL_API_KEY` | Mistral models |
|
| Mistral | `mistral` | `MISTRAL_API_KEY` | Mistral models |
|
||||||
| Yandex AI Studio | `yandex` | `YANDEX_API_KEY` | YandexGPT models |
|
| Yandex AI Studio | `yandex` | `YANDEX_API_KEY` | YandexGPT models |
|
||||||
@@ -62,6 +62,51 @@ Popular models: `gpt-4o`, `gpt-4o-mini`, `o3-mini`
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Google Gemini (OAuth)
|
||||||
|
|
||||||
|
Uses Google OAuth with PKCE (S256) for authentication — no API key required.
|
||||||
|
On first run, a browser opens for Google account login. Credentials (including
|
||||||
|
refresh token) are saved to `~/.gemini/oauth_creds.json` with `0600` permissions.
|
||||||
|
|
||||||
|
```env
|
||||||
|
LLM_BACKEND=gemini_oauth
|
||||||
|
GEMINI_MODEL=gemini-2.5-flash
|
||||||
|
```
|
||||||
|
|
||||||
|
### Supported features
|
||||||
|
|
||||||
|
| Feature | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Function calling | ✅ | `functionDeclarations` / `functionCall` / `functionResponse` |
|
||||||
|
| `generationConfig` | ✅ | `temperature`, `maxOutputTokens` passed from request |
|
||||||
|
| `thinkingConfig` | ✅ | `thinkingBudget`/`thinkingLevel` for thinking-capable models (does NOT set `includeThoughts`) |
|
||||||
|
| `toolConfig` | ✅ | `functionCallingConfig.mode`: `AUTO`/`ANY`/`NONE` |
|
||||||
|
| SSE streaming | ✅ | Cloud Code API with `streamGenerateContent?alt=sse` |
|
||||||
|
| Token refresh | ✅ | Automatic via refresh token |
|
||||||
|
|
||||||
|
### Popular models
|
||||||
|
|
||||||
|
| Model | ID | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Gemini 3.1 Pro | `gemini-3.1-pro-preview` | Latest, strongest reasoning |
|
||||||
|
| Gemini 3.1 Pro Custom Tools | `gemini-3.1-pro-preview-customtools` | Enhanced tool use |
|
||||||
|
| Gemini 3 Pro | `gemini-3-pro-preview` | Preview |
|
||||||
|
| Gemini 3 Flash | `gemini-3-flash-preview` | Fast preview with thinking |
|
||||||
|
| Gemini 3.1 Flash Lite | `gemini-3.1-flash-lite-preview` | Preview, lightweight |
|
||||||
|
| Gemini 2.5 Pro | `gemini-2.5-pro` | Stable, strong reasoning |
|
||||||
|
| Gemini 2.5 Flash | `gemini-2.5-flash` | Fast, good quality |
|
||||||
|
| Gemini 2.5 Flash Lite | `gemini-2.5-flash-lite` | Fastest, lightweight |
|
||||||
|
|
||||||
|
### Cloud Code API vs standard API
|
||||||
|
|
||||||
|
Models containing `-preview` (with hyphen) or `gemini-3` in the name, as well
|
||||||
|
as any `gemini-` model with major version >= 2, route through the Cloud Code
|
||||||
|
API (`cloudcode-pa.googleapis.com`) which supports SSE streaming
|
||||||
|
and project-scoped access. Other models use the standard Generative Language
|
||||||
|
API (`generativelanguage.googleapis.com`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## GitHub Copilot
|
## GitHub Copilot
|
||||||
|
|
||||||
GitHub Copilot exposes chat endpoint at
|
GitHub Copilot exposes chat endpoint at
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ pub struct AgentDeps {
|
|||||||
/// HTTP interceptor for trace recording/replay.
|
/// HTTP interceptor for trace recording/replay.
|
||||||
pub http_interceptor: Option<Arc<dyn crate::llm::recording::HttpInterceptor>>,
|
pub http_interceptor: Option<Arc<dyn crate::llm::recording::HttpInterceptor>>,
|
||||||
/// Audio transcription middleware for voice messages.
|
/// Audio transcription middleware for voice messages.
|
||||||
pub transcription: Option<Arc<crate::transcription::TranscriptionMiddleware>>,
|
pub transcription: Option<Arc<crate::llm::transcription::TranscriptionMiddleware>>,
|
||||||
/// Document text extraction middleware for PDF, DOCX, PPTX, etc.
|
/// Document text extraction middleware for PDF, DOCX, PPTX, etc.
|
||||||
pub document_extraction: Option<Arc<crate::document_extraction::DocumentExtractionMiddleware>>,
|
pub document_extraction: Option<Arc<crate::document_extraction::DocumentExtractionMiddleware>>,
|
||||||
/// Sandbox readiness state for full-job routine dispatch.
|
/// Sandbox readiness state for full-job routine dispatch.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//! via the `LoopDelegate` trait.
|
//! via the `LoopDelegate` trait.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
use crate::agent::session::PendingApproval;
|
use crate::agent::session::PendingApproval;
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
@@ -235,12 +236,12 @@ pub async fn run_agentic_loop(
|
|||||||
///
|
///
|
||||||
/// `max` is a byte budget. The result is truncated at the last valid char
|
/// `max` is a byte budget. The result is truncated at the last valid char
|
||||||
/// boundary at or before `max` bytes, so it is always valid UTF-8.
|
/// boundary at or before `max` bytes, so it is always valid UTF-8.
|
||||||
pub fn truncate_for_preview(s: &str, max: usize) -> String {
|
pub fn truncate_for_preview(s: &str, max: usize) -> Cow<'_, str> {
|
||||||
if s.len() <= max {
|
if s.len() <= max {
|
||||||
s.to_string()
|
Cow::Borrowed(s)
|
||||||
} else {
|
} else {
|
||||||
let end = crate::util::floor_char_boundary(s, max);
|
let end = crate::util::floor_char_boundary(s, max);
|
||||||
format!("{}...", &s[..end])
|
Cow::Owned(format!("{}...", &s[..end]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,12 +598,24 @@ mod tests {
|
|||||||
assert_eq!(truncate_for_preview("hello", 10), "hello");
|
assert_eq!(truncate_for_preview("hello", 10), "hello");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_truncate_short_string_borrows() {
|
||||||
|
let result = truncate_for_preview("hello", 10);
|
||||||
|
assert!(matches!(result, Cow::Borrowed("hello")));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_truncate_long_string_adds_ellipsis() {
|
fn test_truncate_long_string_adds_ellipsis() {
|
||||||
let result = truncate_for_preview("hello world", 5);
|
let result = truncate_for_preview("hello world", 5);
|
||||||
assert_eq!(result, "hello...");
|
assert_eq!(result, "hello...");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_truncate_long_string_owns() {
|
||||||
|
let result = truncate_for_preview("hello world", 5);
|
||||||
|
assert!(matches!(result, Cow::Owned(_)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_truncate_multibyte_safe() {
|
fn test_truncate_multibyte_safe() {
|
||||||
let result = truncate_for_preview("café", 4);
|
let result = truncate_for_preview("café", 4);
|
||||||
|
|||||||
+36
-5
@@ -317,7 +317,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
|||||||
.channels
|
.channels
|
||||||
.send_status(
|
.send_status(
|
||||||
&self.message.channel,
|
&self.message.channel,
|
||||||
StatusUpdate::Thinking("Calling LLM...".into()),
|
StatusUpdate::Thinking(format!("Thinking (step {iteration})...")),
|
||||||
&self.message.metadata,
|
&self.message.metadata,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -435,7 +435,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
|||||||
.channels
|
.channels
|
||||||
.send_status(
|
.send_status(
|
||||||
&self.message.channel,
|
&self.message.channel,
|
||||||
StatusUpdate::Thinking(format!("Executing {} tool(s)...", tool_calls.len())),
|
StatusUpdate::Thinking(contextual_tool_message(&tool_calls)),
|
||||||
&self.message.metadata,
|
&self.message.metadata,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -915,7 +915,14 @@ pub(super) async fn execute_chat_tool_standalone(
|
|||||||
params: &serde_json::Value,
|
params: &serde_json::Value,
|
||||||
job_ctx: &crate::context::JobContext,
|
job_ctx: &crate::context::JobContext,
|
||||||
) -> Result<String, Error> {
|
) -> Result<String, Error> {
|
||||||
crate::tools::execute::execute_tool_with_safety(tools, safety, tool_name, params, job_ctx).await
|
crate::tools::execute::execute_tool_with_safety(
|
||||||
|
tools,
|
||||||
|
safety,
|
||||||
|
tool_name,
|
||||||
|
params.clone(),
|
||||||
|
job_ctx,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parsed auth result fields for emitting StatusUpdate::AuthRequired.
|
/// Parsed auth result fields for emitting StatusUpdate::AuthRequired.
|
||||||
@@ -969,6 +976,30 @@ pub(super) fn check_auth_required(
|
|||||||
Some((name, instructions))
|
Some((name, instructions))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a contextual thinking message based on tool names.
|
||||||
|
///
|
||||||
|
/// Instead of a generic "Executing 2 tool(s)..." this returns messages like
|
||||||
|
/// "Running command..." or "Fetching page..." for single-tool calls, falling
|
||||||
|
/// back to "Executing N tool(s)..." for multi-tool calls.
|
||||||
|
fn contextual_tool_message(tool_calls: &[crate::llm::ToolCall]) -> String {
|
||||||
|
if tool_calls.len() == 1 {
|
||||||
|
match tool_calls[0].name.as_str() {
|
||||||
|
"shell" => "Running command...".into(),
|
||||||
|
"web_fetch" => "Fetching page...".into(),
|
||||||
|
"memory_search" => "Searching memory...".into(),
|
||||||
|
"memory_write" => "Writing to memory...".into(),
|
||||||
|
"memory_read" => "Reading memory...".into(),
|
||||||
|
"http_request" => "Making HTTP request...".into(),
|
||||||
|
"file_read" => "Reading file...".into(),
|
||||||
|
"file_write" => "Writing file...".into(),
|
||||||
|
"json_transform" => "Transforming data...".into(),
|
||||||
|
name => format!("Running {name}..."),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
format!("Executing {} tool(s)...", tool_calls.len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Compact messages for retry after a context-length-exceeded error.
|
/// Compact messages for retry after a context-length-exceeded error.
|
||||||
///
|
///
|
||||||
/// Keeps all `System` messages (which carry the system prompt and instructions),
|
/// Keeps all `System` messages (which carry the system prompt and instructions),
|
||||||
@@ -1869,7 +1900,7 @@ mod tests {
|
|||||||
Ok(ToolCompletionResponse {
|
Ok(ToolCompletionResponse {
|
||||||
content: None,
|
content: None,
|
||||||
tool_calls: vec![ToolCall {
|
tool_calls: vec![ToolCall {
|
||||||
id: format!("call_{}", uuid::Uuid::new_v4()),
|
id: crate::llm::generate_tool_call_id(0, 0),
|
||||||
name: "echo".to_string(),
|
name: "echo".to_string(),
|
||||||
arguments: serde_json::json!({"message": "looping"}),
|
arguments: serde_json::json!({"message": "looping"}),
|
||||||
}],
|
}],
|
||||||
@@ -2022,7 +2053,7 @@ mod tests {
|
|||||||
Ok(ToolCompletionResponse {
|
Ok(ToolCompletionResponse {
|
||||||
content: None,
|
content: None,
|
||||||
tool_calls: vec![ToolCall {
|
tool_calls: vec![ToolCall {
|
||||||
id: format!("call_{}", uuid::Uuid::new_v4()),
|
id: crate::llm::generate_tool_call_id(0, 0),
|
||||||
name: "nonexistent_tool".to_string(),
|
name: "nonexistent_tool".to_string(),
|
||||||
arguments: serde_json::json!({}),
|
arguments: serde_json::json!({}),
|
||||||
}],
|
}],
|
||||||
|
|||||||
+22
-12
@@ -44,7 +44,7 @@ pub struct JobMonitorRoute {
|
|||||||
/// the main agent's context window).
|
/// the main agent's context window).
|
||||||
pub fn spawn_job_monitor(
|
pub fn spawn_job_monitor(
|
||||||
job_id: Uuid,
|
job_id: Uuid,
|
||||||
event_rx: broadcast::Receiver<(Uuid, SseEvent)>,
|
event_rx: broadcast::Receiver<(Uuid, String, SseEvent)>,
|
||||||
inject_tx: mpsc::Sender<IncomingMessage>,
|
inject_tx: mpsc::Sender<IncomingMessage>,
|
||||||
route: JobMonitorRoute,
|
route: JobMonitorRoute,
|
||||||
) -> JoinHandle<()> {
|
) -> JoinHandle<()> {
|
||||||
@@ -56,7 +56,7 @@ pub fn spawn_job_monitor(
|
|||||||
/// jobs don't stay `InProgress` forever in the `ContextManager`.
|
/// jobs don't stay `InProgress` forever in the `ContextManager`.
|
||||||
pub fn spawn_job_monitor_with_context(
|
pub fn spawn_job_monitor_with_context(
|
||||||
job_id: Uuid,
|
job_id: Uuid,
|
||||||
mut event_rx: broadcast::Receiver<(Uuid, SseEvent)>,
|
mut event_rx: broadcast::Receiver<(Uuid, String, SseEvent)>,
|
||||||
inject_tx: mpsc::Sender<IncomingMessage>,
|
inject_tx: mpsc::Sender<IncomingMessage>,
|
||||||
route: JobMonitorRoute,
|
route: JobMonitorRoute,
|
||||||
context_manager: Option<Arc<ContextManager>>,
|
context_manager: Option<Arc<ContextManager>>,
|
||||||
@@ -68,7 +68,7 @@ pub fn spawn_job_monitor_with_context(
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
match event_rx.recv().await {
|
match event_rx.recv().await {
|
||||||
Ok((ev_job_id, event)) => {
|
Ok((ev_job_id, _user_id, event)) => {
|
||||||
if ev_job_id != job_id {
|
if ev_job_id != job_id {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -162,7 +162,7 @@ pub fn spawn_job_monitor_with_context(
|
|||||||
/// inject messages into) but we still need to free the `max_jobs` slot.
|
/// inject messages into) but we still need to free the `max_jobs` slot.
|
||||||
pub fn spawn_completion_watcher(
|
pub fn spawn_completion_watcher(
|
||||||
job_id: Uuid,
|
job_id: Uuid,
|
||||||
mut event_rx: broadcast::Receiver<(Uuid, SseEvent)>,
|
mut event_rx: broadcast::Receiver<(Uuid, String, SseEvent)>,
|
||||||
context_manager: Arc<ContextManager>,
|
context_manager: Arc<ContextManager>,
|
||||||
) -> JoinHandle<()> {
|
) -> JoinHandle<()> {
|
||||||
let short_id = job_id.to_string()[..8].to_string();
|
let short_id = job_id.to_string()[..8].to_string();
|
||||||
@@ -170,7 +170,9 @@ pub fn spawn_completion_watcher(
|
|||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
match event_rx.recv().await {
|
match event_rx.recv().await {
|
||||||
Ok((ev_job_id, SseEvent::JobResult { status, .. })) if ev_job_id == job_id => {
|
Ok((ev_job_id, _user_id, SseEvent::JobResult { status, .. }))
|
||||||
|
if ev_job_id == job_id =>
|
||||||
|
{
|
||||||
let target = if status == "completed" {
|
let target = if status == "completed" {
|
||||||
JobState::Completed
|
JobState::Completed
|
||||||
} else {
|
} else {
|
||||||
@@ -227,7 +229,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_monitor_forwards_assistant_messages() {
|
async fn test_monitor_forwards_assistant_messages() {
|
||||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
let (event_tx, _) = broadcast::channel::<(Uuid, String, SseEvent)>(16);
|
||||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||||
|
|
||||||
let job_id = Uuid::new_v4();
|
let job_id = Uuid::new_v4();
|
||||||
@@ -237,6 +239,7 @@ mod tests {
|
|||||||
event_tx
|
event_tx
|
||||||
.send((
|
.send((
|
||||||
job_id,
|
job_id,
|
||||||
|
"test-user".to_string(),
|
||||||
SseEvent::JobMessage {
|
SseEvent::JobMessage {
|
||||||
job_id: job_id.to_string(),
|
job_id: job_id.to_string(),
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
@@ -259,7 +262,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_monitor_ignores_other_jobs() {
|
async fn test_monitor_ignores_other_jobs() {
|
||||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
let (event_tx, _) = broadcast::channel::<(Uuid, String, SseEvent)>(16);
|
||||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||||
|
|
||||||
let job_id = Uuid::new_v4();
|
let job_id = Uuid::new_v4();
|
||||||
@@ -270,6 +273,7 @@ mod tests {
|
|||||||
event_tx
|
event_tx
|
||||||
.send((
|
.send((
|
||||||
other_job_id,
|
other_job_id,
|
||||||
|
"test-user".to_string(),
|
||||||
SseEvent::JobMessage {
|
SseEvent::JobMessage {
|
||||||
job_id: other_job_id.to_string(),
|
job_id: other_job_id.to_string(),
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
@@ -289,7 +293,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_monitor_exits_on_job_result() {
|
async fn test_monitor_exits_on_job_result() {
|
||||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
let (event_tx, _) = broadcast::channel::<(Uuid, String, SseEvent)>(16);
|
||||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||||
|
|
||||||
let job_id = Uuid::new_v4();
|
let job_id = Uuid::new_v4();
|
||||||
@@ -299,6 +303,7 @@ mod tests {
|
|||||||
event_tx
|
event_tx
|
||||||
.send((
|
.send((
|
||||||
job_id,
|
job_id,
|
||||||
|
"test-user".to_string(),
|
||||||
SseEvent::JobResult {
|
SseEvent::JobResult {
|
||||||
job_id: job_id.to_string(),
|
job_id: job_id.to_string(),
|
||||||
status: "completed".to_string(),
|
status: "completed".to_string(),
|
||||||
@@ -324,7 +329,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_monitor_skips_tool_events() {
|
async fn test_monitor_skips_tool_events() {
|
||||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
let (event_tx, _) = broadcast::channel::<(Uuid, String, SseEvent)>(16);
|
||||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||||
|
|
||||||
let job_id = Uuid::new_v4();
|
let job_id = Uuid::new_v4();
|
||||||
@@ -334,6 +339,7 @@ mod tests {
|
|||||||
event_tx
|
event_tx
|
||||||
.send((
|
.send((
|
||||||
job_id,
|
job_id,
|
||||||
|
"test-user".to_string(),
|
||||||
SseEvent::JobToolUse {
|
SseEvent::JobToolUse {
|
||||||
job_id: job_id.to_string(),
|
job_id: job_id.to_string(),
|
||||||
tool_name: "shell".to_string(),
|
tool_name: "shell".to_string(),
|
||||||
@@ -346,6 +352,7 @@ mod tests {
|
|||||||
event_tx
|
event_tx
|
||||||
.send((
|
.send((
|
||||||
job_id,
|
job_id,
|
||||||
|
"test-user".to_string(),
|
||||||
SseEvent::JobMessage {
|
SseEvent::JobMessage {
|
||||||
job_id: job_id.to_string(),
|
job_id: job_id.to_string(),
|
||||||
role: "user".to_string(),
|
role: "user".to_string(),
|
||||||
@@ -402,7 +409,7 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
let (event_tx, _) = broadcast::channel::<(Uuid, String, SseEvent)>(16);
|
||||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||||
|
|
||||||
let handle = spawn_job_monitor_with_context(
|
let handle = spawn_job_monitor_with_context(
|
||||||
@@ -417,6 +424,7 @@ mod tests {
|
|||||||
event_tx
|
event_tx
|
||||||
.send((
|
.send((
|
||||||
job_id,
|
job_id,
|
||||||
|
"test-user".to_string(),
|
||||||
SseEvent::JobResult {
|
SseEvent::JobResult {
|
||||||
job_id: job_id.to_string(),
|
job_id: job_id.to_string(),
|
||||||
status: "completed".to_string(),
|
status: "completed".to_string(),
|
||||||
@@ -450,7 +458,7 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
let (event_tx, _) = broadcast::channel::<(Uuid, String, SseEvent)>(16);
|
||||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||||
|
|
||||||
let handle = spawn_job_monitor_with_context(
|
let handle = spawn_job_monitor_with_context(
|
||||||
@@ -465,6 +473,7 @@ mod tests {
|
|||||||
event_tx
|
event_tx
|
||||||
.send((
|
.send((
|
||||||
job_id,
|
job_id,
|
||||||
|
"test-user".to_string(),
|
||||||
SseEvent::JobResult {
|
SseEvent::JobResult {
|
||||||
job_id: job_id.to_string(),
|
job_id: job_id.to_string(),
|
||||||
status: "failed".to_string(),
|
status: "failed".to_string(),
|
||||||
@@ -498,12 +507,13 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
let (event_tx, _) = broadcast::channel::<(Uuid, String, SseEvent)>(16);
|
||||||
let handle = spawn_completion_watcher(job_id, event_tx.subscribe(), Arc::clone(&cm));
|
let handle = spawn_completion_watcher(job_id, event_tx.subscribe(), Arc::clone(&cm));
|
||||||
|
|
||||||
event_tx
|
event_tx
|
||||||
.send((
|
.send((
|
||||||
job_id,
|
job_id,
|
||||||
|
"test-user".to_string(),
|
||||||
SseEvent::JobResult {
|
SseEvent::JobResult {
|
||||||
job_id: job_id.to_string(),
|
job_id: job_id.to_string(),
|
||||||
status: "completed".to_string(),
|
status: "completed".to_string(),
|
||||||
|
|||||||
@@ -529,8 +529,8 @@ pub fn normalize_cron_expression(schedule: &str) -> String {
|
|||||||
let trimmed = schedule.trim();
|
let trimmed = schedule.trim();
|
||||||
let fields: Vec<&str> = trimmed.split_whitespace().collect();
|
let fields: Vec<&str> = trimmed.split_whitespace().collect();
|
||||||
match fields.len() {
|
match fields.len() {
|
||||||
5 => format!("0 {} *", trimmed),
|
5 => format!("0 {} *", fields.join(" ")),
|
||||||
6 => format!("{} *", trimmed),
|
6 => format!("{} *", fields.join(" ")),
|
||||||
_ => trimmed.to_string(),
|
_ => trimmed.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -549,11 +549,7 @@ impl Scheduler {
|
|||||||
|
|
||||||
// Delegate to shared tool execution pipeline
|
// Delegate to shared tool execution pipeline
|
||||||
let output_str = crate::tools::execute::execute_tool_with_safety(
|
let output_str = crate::tools::execute::execute_tool_with_safety(
|
||||||
&tools,
|
&tools, &safety, tool_name, params, &job_ctx,
|
||||||
&safety,
|
|
||||||
tool_name,
|
|
||||||
&normalized_params,
|
|
||||||
&job_ctx,
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
+22
-8
@@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::channels::web::util::truncate_preview;
|
use crate::channels::web::util::truncate_preview;
|
||||||
use crate::llm::{ChatMessage, ToolCall};
|
use crate::llm::{ChatMessage, ToolCall, generate_tool_call_id};
|
||||||
|
|
||||||
/// A session containing one or more threads.
|
/// A session containing one or more threads.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -414,7 +414,12 @@ impl Thread {
|
|||||||
/// completed actions in subsequent turns.
|
/// completed actions in subsequent turns.
|
||||||
pub fn messages(&self) -> Vec<ChatMessage> {
|
pub fn messages(&self) -> Vec<ChatMessage> {
|
||||||
let mut messages = Vec::new();
|
let mut messages = Vec::new();
|
||||||
for turn in &self.turns {
|
// We use the enumeration index (`turn_idx`) rather than `turn.turn_number`
|
||||||
|
// intentionally: after `truncate_turns()`, the remaining turns are
|
||||||
|
// re-numbered starting from 0, so the enumeration index and turn_number
|
||||||
|
// are equivalent. Using the index avoids coupling to the field and keeps
|
||||||
|
// tool-call ID generation deterministic for the current message window.
|
||||||
|
for (turn_idx, turn) in self.turns.iter().enumerate() {
|
||||||
if turn.image_content_parts.is_empty() {
|
if turn.image_content_parts.is_empty() {
|
||||||
messages.push(ChatMessage::user(&turn.user_input));
|
messages.push(ChatMessage::user(&turn.user_input));
|
||||||
} else {
|
} else {
|
||||||
@@ -425,13 +430,23 @@ impl Thread {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !turn.tool_calls.is_empty() {
|
if !turn.tool_calls.is_empty() {
|
||||||
// Build ToolCall objects with synthetic stable IDs
|
// Assign synthetic call IDs for this turn's tool calls, so that
|
||||||
let tool_calls: Vec<ToolCall> = turn
|
// declarations and results can be consistently correlated.
|
||||||
|
let tool_calls_with_ids: Vec<(String, &_)> = turn
|
||||||
.tool_calls
|
.tool_calls
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, tc)| ToolCall {
|
.map(|(tc_idx, tc)| {
|
||||||
id: format!("turn{}_{}", turn.turn_number, i),
|
// Use provider-compatible tool call IDs derived from turn/tool indices.
|
||||||
|
(generate_tool_call_id(turn_idx, tc_idx), tc)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Build ToolCall objects using the synthetic call IDs.
|
||||||
|
let tool_calls: Vec<ToolCall> = tool_calls_with_ids
|
||||||
|
.iter()
|
||||||
|
.map(|(call_id, tc)| ToolCall {
|
||||||
|
id: call_id.clone(),
|
||||||
name: tc.name.clone(),
|
name: tc.name.clone(),
|
||||||
arguments: tc.parameters.clone(),
|
arguments: tc.parameters.clone(),
|
||||||
})
|
})
|
||||||
@@ -441,8 +456,7 @@ impl Thread {
|
|||||||
messages.push(ChatMessage::assistant_with_tool_calls(None, tool_calls));
|
messages.push(ChatMessage::assistant_with_tool_calls(None, tool_calls));
|
||||||
|
|
||||||
// Individual tool result messages, truncated to limit context size.
|
// Individual tool result messages, truncated to limit context size.
|
||||||
for (i, tc) in turn.tool_calls.iter().enumerate() {
|
for (call_id, tc) in tool_calls_with_ids {
|
||||||
let call_id = format!("turn{}_{}", turn.turn_number, i);
|
|
||||||
let content = if let Some(ref err) = tc.error {
|
let content = if let Some(ref err) = tc.error {
|
||||||
// .error already contains the full error text;
|
// .error already contains the full error text;
|
||||||
// pass through without wrapping to avoid double-prefix.
|
// pass through without wrapping to avoid double-prefix.
|
||||||
|
|||||||
+28
-1
@@ -556,6 +556,33 @@ impl Agent {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit per-turn cost summary
|
||||||
|
{
|
||||||
|
let usage = self.cost_guard().model_usage().await;
|
||||||
|
let (total_in, total_out, total_cost) =
|
||||||
|
usage
|
||||||
|
.values()
|
||||||
|
.fold((0u64, 0u64, rust_decimal::Decimal::ZERO), |acc, m| {
|
||||||
|
(
|
||||||
|
acc.0 + m.input_tokens,
|
||||||
|
acc.1 + m.output_tokens,
|
||||||
|
acc.2 + m.cost,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let _ = self
|
||||||
|
.channels
|
||||||
|
.send_status(
|
||||||
|
&message.channel,
|
||||||
|
StatusUpdate::TurnCost {
|
||||||
|
input_tokens: total_in,
|
||||||
|
output_tokens: total_out,
|
||||||
|
cost_usd: format!("${:.4}", total_cost),
|
||||||
|
},
|
||||||
|
&message.metadata,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(SubmissionResult::response(response))
|
Ok(SubmissionResult::response(response))
|
||||||
}
|
}
|
||||||
Ok(AgenticLoopResult::NeedApproval { pending }) => {
|
Ok(AgenticLoopResult::NeedApproval { pending }) => {
|
||||||
@@ -1619,7 +1646,7 @@ impl Agent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match ext_mgr
|
match ext_mgr
|
||||||
.configure_token(&pending.extension_name, token)
|
.configure_token(&pending.extension_name, token, &message.user_id)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(result) if result.activated => {
|
Ok(result) if result.activated => {
|
||||||
|
|||||||
+50
-10
@@ -325,12 +325,52 @@ impl AppBuilder {
|
|||||||
};
|
};
|
||||||
let mut ws = Workspace::new_with_db(workspace_user_id, db.clone())
|
let mut ws = Workspace::new_with_db(workspace_user_id, db.clone())
|
||||||
.with_search_config(&self.config.search);
|
.with_search_config(&self.config.search);
|
||||||
|
|
||||||
if let Some(ref emb) = embeddings {
|
if let Some(ref emb) = embeddings {
|
||||||
ws = ws.with_embeddings_cached(emb.clone(), emb_cache_config);
|
ws = ws.with_embeddings_cached(emb.clone(), emb_cache_config.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire workspace-level settings (read scopes, memory layers)
|
||||||
|
if !self.config.workspace.read_scopes.is_empty() {
|
||||||
|
ws = ws.with_additional_read_scopes(self.config.workspace.read_scopes.clone());
|
||||||
|
tracing::info!(
|
||||||
|
user_id = workspace_user_id,
|
||||||
|
read_scopes = ?ws.read_user_ids(),
|
||||||
|
"Workspace configured with multi-scope reads"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
ws = ws.with_memory_layers(self.config.workspace.memory_layers.clone());
|
ws = ws.with_memory_layers(self.config.workspace.memory_layers.clone());
|
||||||
let ws = Arc::new(ws);
|
let ws = Arc::new(ws);
|
||||||
tools.register_memory_tools(Arc::clone(&ws));
|
|
||||||
|
// Detect multi-tenant mode: when GATEWAY_USER_TOKENS is configured,
|
||||||
|
// each authenticated user needs their own workspace scope. Use
|
||||||
|
// PerUserWorkspaceResolver to create per-user workspaces on demand
|
||||||
|
// instead of sharing the startup workspace across all users.
|
||||||
|
let is_multi_tenant = self
|
||||||
|
.config
|
||||||
|
.channels
|
||||||
|
.gateway
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|gw| gw.user_tokens.is_some());
|
||||||
|
|
||||||
|
if is_multi_tenant {
|
||||||
|
let resolver = Arc::new(
|
||||||
|
crate::tools::builtin::memory::PerUserWorkspaceResolver::new(
|
||||||
|
Arc::clone(db),
|
||||||
|
embeddings.clone(),
|
||||||
|
emb_cache_config,
|
||||||
|
self.config.search.clone(),
|
||||||
|
self.config.workspace.clone(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
tools.register_memory_tools_with_resolver(resolver);
|
||||||
|
tracing::info!(
|
||||||
|
"Memory tools configured with per-user workspace resolver (multi-tenant mode)"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tools.register_memory_tools(Arc::clone(&ws));
|
||||||
|
}
|
||||||
|
|
||||||
Some(ws)
|
Some(ws)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -386,7 +426,7 @@ impl AppBuilder {
|
|||||||
let b = tools
|
let b = tools
|
||||||
.register_builder_tool(llm.clone(), Some(self.config.builder.to_builder_config()))
|
.register_builder_tool(llm.clone(), Some(self.config.builder.to_builder_config()))
|
||||||
.await;
|
.await;
|
||||||
tracing::info!("Builder mode enabled");
|
tracing::debug!("Builder mode enabled");
|
||||||
Some(b)
|
Some(b)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -729,13 +769,13 @@ impl AppBuilder {
|
|||||||
self.init_database().await?;
|
self.init_database().await?;
|
||||||
self.init_secrets().await?;
|
self.init_secrets().await?;
|
||||||
|
|
||||||
// Post-init validation: if a non-nearai backend was selected but
|
// Post-init validation: backends with dedicated config (nearai, gemini_oauth,
|
||||||
// credentials were never resolved (deferred resolution found no keys),
|
// bedrock, openai_codex) handle their own credential resolution. For registry-based
|
||||||
// fail early with a clear error instead of a confusing runtime failure.
|
// backends, fail early if no provider config was resolved.
|
||||||
if self.config.llm.backend != "nearai"
|
if !matches!(
|
||||||
&& self.config.llm.backend != "bedrock"
|
self.config.llm.backend.as_str(),
|
||||||
&& self.config.llm.backend != "openai_codex"
|
"nearai" | "gemini_oauth" | "bedrock" | "openai_codex"
|
||||||
&& self.config.llm.provider.is_none()
|
) && self.config.llm.provider.is_none()
|
||||||
{
|
{
|
||||||
let backend = &self.config.llm.backend;
|
let backend = &self.config.llm.backend;
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
|
|||||||
+188
-93
@@ -1,8 +1,11 @@
|
|||||||
//! Boot screen displayed after all initialization completes.
|
//! Boot screen displayed after all initialization completes.
|
||||||
//!
|
//!
|
||||||
//! Shows a polished ANSI-styled status panel summarizing the agent's runtime
|
//! Shows a compact ANSI-styled status panel with three tiers:
|
||||||
//! state: model, database, tool count, enabled features, active channels,
|
//! - **Tier 1 (always):** Name + version, model + backend.
|
||||||
//! and the gateway URL.
|
//! - **Tier 2 (conditional):** Gateway URL, tunnel URL, non-default channels.
|
||||||
|
//! - **Tier 3 (removed):** Database, tool count, features → use `ironclaw status`.
|
||||||
|
|
||||||
|
use crate::cli::fmt;
|
||||||
|
|
||||||
/// All displayable fields for the boot screen.
|
/// All displayable fields for the boot screen.
|
||||||
pub struct BootInfo {
|
pub struct BootInfo {
|
||||||
@@ -29,112 +32,76 @@ pub struct BootInfo {
|
|||||||
pub tunnel_url: Option<String>,
|
pub tunnel_url: Option<String>,
|
||||||
/// Provider name for the managed tunnel (e.g., "ngrok").
|
/// Provider name for the managed tunnel (e.g., "ngrok").
|
||||||
pub tunnel_provider: Option<String>,
|
pub tunnel_provider: Option<String>,
|
||||||
|
/// Time elapsed during startup. Shown at the bottom when present.
|
||||||
|
pub startup_elapsed: Option<std::time::Duration>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Print the boot screen to stdout.
|
const KW: usize = 10;
|
||||||
pub fn print_boot_screen(info: &BootInfo) {
|
|
||||||
// ANSI codes matching existing REPL palette
|
|
||||||
let bold = "\x1b[1m";
|
|
||||||
let cyan = "\x1b[36m";
|
|
||||||
let dim = "\x1b[90m";
|
|
||||||
let yellow = "\x1b[33m";
|
|
||||||
let yellow_underline = "\x1b[33;4m";
|
|
||||||
let reset = "\x1b[0m";
|
|
||||||
|
|
||||||
let border = format!(" {dim}{}{reset}", "\u{2576}".repeat(58));
|
/// Print the boot screen to stdout.
|
||||||
|
///
|
||||||
|
/// **Tier 1 (always):** Name + version, model + backend.
|
||||||
|
/// **Tier 2 (conditional):** Gateway URL, tunnel URL, non-default channels.
|
||||||
|
/// **Tier 3 (removed):** Database, tool count, features — use `ironclaw status`.
|
||||||
|
pub fn print_boot_screen(info: &BootInfo) {
|
||||||
|
let border = format!(" {}", fmt::separator(58));
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!("{border}");
|
println!("{border}");
|
||||||
println!();
|
println!();
|
||||||
println!(" {bold}{}{reset} v{}", info.agent_name, info.version);
|
|
||||||
|
// ── Tier 1: always shown ──────────────────────────────────────────
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {}{}{} v{}",
|
||||||
|
fmt::bold(),
|
||||||
|
info.agent_name,
|
||||||
|
fmt::reset(),
|
||||||
|
info.version
|
||||||
|
);
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
// Model line
|
// Model line
|
||||||
let model_display = if let Some(ref cheap) = info.cheap_model {
|
let model_display = if let Some(ref cheap) = info.cheap_model {
|
||||||
format!(
|
format!(
|
||||||
"{cyan}{}{reset} {dim}cheap{reset} {cyan}{}{reset}",
|
"{}{}{} {}cheap{} {}{}{}",
|
||||||
info.llm_model, cheap
|
fmt::accent(),
|
||||||
|
info.llm_model,
|
||||||
|
fmt::reset(),
|
||||||
|
fmt::dim(),
|
||||||
|
fmt::reset(),
|
||||||
|
fmt::accent(),
|
||||||
|
cheap,
|
||||||
|
fmt::reset(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
format!("{cyan}{}{reset}", info.llm_model)
|
format!("{}{}{}", fmt::accent(), info.llm_model, fmt::reset())
|
||||||
};
|
};
|
||||||
println!(
|
println!(
|
||||||
" {dim}model{reset} {model_display} {dim}via {}{reset}",
|
" {}{:<width$}{} {model_display} {}via {}{}",
|
||||||
info.llm_backend
|
fmt::dim(),
|
||||||
|
"model",
|
||||||
|
fmt::reset(),
|
||||||
|
fmt::dim(),
|
||||||
|
info.llm_backend,
|
||||||
|
fmt::reset(),
|
||||||
|
width = KW,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Database line
|
// ── Tier 2: conditional ───────────────────────────────────────────
|
||||||
let db_status = if info.db_connected {
|
|
||||||
"connected"
|
|
||||||
} else {
|
|
||||||
"none"
|
|
||||||
};
|
|
||||||
println!(
|
|
||||||
" {dim}database{reset} {cyan}{}{reset} {dim}({db_status}){reset}",
|
|
||||||
info.db_backend
|
|
||||||
);
|
|
||||||
|
|
||||||
// Tools line
|
// Gateway URL
|
||||||
println!(
|
|
||||||
" {dim}tools{reset} {cyan}{}{reset} {dim}registered{reset}",
|
|
||||||
info.tool_count
|
|
||||||
);
|
|
||||||
|
|
||||||
// Features line
|
|
||||||
let mut features = Vec::new();
|
|
||||||
if info.embeddings_enabled {
|
|
||||||
if let Some(ref provider) = info.embeddings_provider {
|
|
||||||
features.push(format!("embeddings ({provider})"));
|
|
||||||
} else {
|
|
||||||
features.push("embeddings".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if info.heartbeat_enabled {
|
|
||||||
let mins = info.heartbeat_interval_secs / 60;
|
|
||||||
features.push(format!("heartbeat ({mins}m)"));
|
|
||||||
}
|
|
||||||
match info.docker_status {
|
|
||||||
crate::sandbox::detect::DockerStatus::Available => {
|
|
||||||
features.push("sandbox".to_string());
|
|
||||||
}
|
|
||||||
crate::sandbox::detect::DockerStatus::NotInstalled => {
|
|
||||||
features.push(format!("{yellow}sandbox (docker not installed){reset}"));
|
|
||||||
}
|
|
||||||
crate::sandbox::detect::DockerStatus::NotRunning => {
|
|
||||||
features.push(format!("{yellow}sandbox (docker not running){reset}"));
|
|
||||||
}
|
|
||||||
crate::sandbox::detect::DockerStatus::Disabled => {
|
|
||||||
// Don't show sandbox when disabled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if info.claude_code_enabled {
|
|
||||||
features.push("claude-code".to_string());
|
|
||||||
}
|
|
||||||
if info.routines_enabled {
|
|
||||||
features.push("routines".to_string());
|
|
||||||
}
|
|
||||||
if info.skills_enabled {
|
|
||||||
features.push("skills".to_string());
|
|
||||||
}
|
|
||||||
if !features.is_empty() {
|
|
||||||
println!(
|
|
||||||
" {dim}features{reset} {cyan}{}{reset}",
|
|
||||||
features.join(" ")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Channels line
|
|
||||||
if !info.channels.is_empty() {
|
|
||||||
println!(
|
|
||||||
" {dim}channels{reset} {cyan}{}{reset}",
|
|
||||||
info.channels.join(" ")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gateway URL (highlighted)
|
|
||||||
if let Some(ref url) = info.gateway_url {
|
if let Some(ref url) = info.gateway_url {
|
||||||
println!();
|
println!(
|
||||||
println!(" {dim}gateway{reset} {yellow_underline}{url}{reset}");
|
" {}{:<width$}{} {}{}{}",
|
||||||
|
fmt::dim(),
|
||||||
|
"gateway",
|
||||||
|
fmt::reset(),
|
||||||
|
fmt::link(),
|
||||||
|
url,
|
||||||
|
fmt::reset(),
|
||||||
|
width = KW,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tunnel URL
|
// Tunnel URL
|
||||||
@@ -142,15 +109,140 @@ pub fn print_boot_screen(info: &BootInfo) {
|
|||||||
let provider_tag = info
|
let provider_tag = info
|
||||||
.tunnel_provider
|
.tunnel_provider
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|p| format!(" {dim}({p}){reset}"))
|
.map(|p| format!(" {}({}){}", fmt::dim(), p, fmt::reset()))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
println!(" {dim}tunnel{reset} {yellow_underline}{url}{reset}{provider_tag}");
|
println!(
|
||||||
|
" {}{:<width$}{} {}{}{}{}",
|
||||||
|
fmt::dim(),
|
||||||
|
"tunnel",
|
||||||
|
fmt::reset(),
|
||||||
|
fmt::link(),
|
||||||
|
url,
|
||||||
|
fmt::reset(),
|
||||||
|
provider_tag,
|
||||||
|
width = KW,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Non-default channels (skip if only the default set)
|
||||||
|
let non_default: Vec<&str> = info
|
||||||
|
.channels
|
||||||
|
.iter()
|
||||||
|
.filter(|c| !matches!(c.as_str(), "repl" | "gateway"))
|
||||||
|
.map(|c| c.as_str())
|
||||||
|
.collect();
|
||||||
|
if !non_default.is_empty() {
|
||||||
|
println!(
|
||||||
|
" {}{:<width$}{} {}{}{}",
|
||||||
|
fmt::dim(),
|
||||||
|
"channels",
|
||||||
|
fmt::reset(),
|
||||||
|
fmt::accent(),
|
||||||
|
non_default.join(" "),
|
||||||
|
fmt::reset(),
|
||||||
|
width = KW,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tier 3: compact feature tags ──────────────────────────────────
|
||||||
|
|
||||||
|
let mut tags: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
// Database
|
||||||
|
if info.db_connected {
|
||||||
|
tags.push(format!("db:{}", info.db_backend));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool count
|
||||||
|
if info.tool_count > 0 {
|
||||||
|
tags.push(format!("tools:{}", info.tool_count));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routines
|
||||||
|
if info.routines_enabled {
|
||||||
|
tags.push("routines".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heartbeat with interval
|
||||||
|
if info.heartbeat_enabled {
|
||||||
|
let interval = if info.heartbeat_interval_secs >= 3600
|
||||||
|
&& info.heartbeat_interval_secs.is_multiple_of(3600)
|
||||||
|
{
|
||||||
|
format!("{}h", info.heartbeat_interval_secs / 3600)
|
||||||
|
} else if info.heartbeat_interval_secs >= 60
|
||||||
|
&& info.heartbeat_interval_secs.is_multiple_of(60)
|
||||||
|
{
|
||||||
|
format!("{}m", info.heartbeat_interval_secs / 60)
|
||||||
|
} else {
|
||||||
|
format!("{}s", info.heartbeat_interval_secs)
|
||||||
|
};
|
||||||
|
tags.push(format!("heartbeat:{interval}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skills
|
||||||
|
if info.skills_enabled {
|
||||||
|
tags.push("skills".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sandbox / Docker
|
||||||
|
if info.sandbox_enabled {
|
||||||
|
let suffix = match info.docker_status {
|
||||||
|
crate::sandbox::detect::DockerStatus::Available => "",
|
||||||
|
crate::sandbox::detect::DockerStatus::NotRunning => ":stopped",
|
||||||
|
_ => ":unavail",
|
||||||
|
};
|
||||||
|
tags.push(format!("sandbox{suffix}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embeddings
|
||||||
|
if info.embeddings_enabled {
|
||||||
|
if let Some(ref provider) = info.embeddings_provider {
|
||||||
|
tags.push(format!("embeddings:{provider}"));
|
||||||
|
} else {
|
||||||
|
tags.push("embeddings".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude Code bridge
|
||||||
|
if info.claude_code_enabled {
|
||||||
|
tags.push("claude-code".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tags.is_empty() {
|
||||||
|
println!(
|
||||||
|
" {}{:<width$}{} {}",
|
||||||
|
fmt::dim(),
|
||||||
|
"features",
|
||||||
|
fmt::reset(),
|
||||||
|
tags.join(" "),
|
||||||
|
width = KW,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Footer ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!("{border}");
|
println!("{border}");
|
||||||
println!();
|
|
||||||
println!(" /help for commands, /quit to exit");
|
// Startup elapsed
|
||||||
|
if let Some(elapsed) = info.startup_elapsed {
|
||||||
|
let millis = elapsed.as_millis();
|
||||||
|
let elapsed_str = if millis < 1000 {
|
||||||
|
format!("{millis}ms")
|
||||||
|
} else {
|
||||||
|
let secs = elapsed.as_secs_f64();
|
||||||
|
format!("{secs:.1}s")
|
||||||
|
};
|
||||||
|
println!(" {}ready in {}{}", fmt::dim(), elapsed_str, fmt::reset());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hint to run `ironclaw status` for full details
|
||||||
|
println!(
|
||||||
|
" {}Run `ironclaw status` for full system details.{}",
|
||||||
|
fmt::hint(),
|
||||||
|
fmt::reset()
|
||||||
|
);
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +279,7 @@ mod tests {
|
|||||||
],
|
],
|
||||||
tunnel_url: Some("https://abc123.ngrok.io".to_string()),
|
tunnel_url: Some("https://abc123.ngrok.io".to_string()),
|
||||||
tunnel_provider: Some("ngrok".to_string()),
|
tunnel_provider: Some("ngrok".to_string()),
|
||||||
|
startup_elapsed: None,
|
||||||
};
|
};
|
||||||
// Should not panic
|
// Should not panic
|
||||||
print_boot_screen(&info);
|
print_boot_screen(&info);
|
||||||
@@ -216,6 +309,7 @@ mod tests {
|
|||||||
channels: vec![],
|
channels: vec![],
|
||||||
tunnel_url: None,
|
tunnel_url: None,
|
||||||
tunnel_provider: None,
|
tunnel_provider: None,
|
||||||
|
startup_elapsed: None,
|
||||||
};
|
};
|
||||||
// Should not panic
|
// Should not panic
|
||||||
print_boot_screen(&info);
|
print_boot_screen(&info);
|
||||||
@@ -245,6 +339,7 @@ mod tests {
|
|||||||
channels: vec!["repl".to_string()],
|
channels: vec!["repl".to_string()],
|
||||||
tunnel_url: None,
|
tunnel_url: None,
|
||||||
tunnel_provider: None,
|
tunnel_provider: None,
|
||||||
|
startup_elapsed: None,
|
||||||
};
|
};
|
||||||
// Should not panic
|
// Should not panic
|
||||||
print_boot_screen(&info);
|
print_boot_screen(&info);
|
||||||
|
|||||||
+25
-12
@@ -568,14 +568,12 @@ impl Drop for PidLock {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::config::helpers::lock_env;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::sync::Mutex;
|
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
static ENV_MUTEX: Mutex<()> = Mutex::new(());
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_save_and_load_database_url() {
|
fn test_save_and_load_database_url() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
@@ -669,8 +667,23 @@ INJECTED="pwned"#;
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_ironclaw_env_path() {
|
fn test_ironclaw_env_path() {
|
||||||
let path = ironclaw_env_path();
|
// Use compute_ironclaw_base_dir() directly to avoid LazyLock caching,
|
||||||
assert!(path.ends_with(".ironclaw/.env"));
|
// which can be poisoned by whichever test initializes it first.
|
||||||
|
let _guard = lock_env();
|
||||||
|
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
|
||||||
|
// SAFETY: Under lock_env(), no concurrent env access.
|
||||||
|
unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") };
|
||||||
|
|
||||||
|
let path = compute_ironclaw_base_dir().join(".env");
|
||||||
|
assert!(
|
||||||
|
path.ends_with(".ironclaw/.env"),
|
||||||
|
"expected path ending with .ironclaw/.env, got: {}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(val) = old_val {
|
||||||
|
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -836,7 +849,7 @@ INJECTED="pwned"#;
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_libsql_autodetect_sets_backend_when_db_exists() {
|
fn test_libsql_autodetect_sets_backend_when_db_exists() {
|
||||||
let _guard = ENV_MUTEX.lock().unwrap();
|
let _guard = lock_env();
|
||||||
let old_val = std::env::var("DATABASE_BACKEND").ok();
|
let old_val = std::env::var("DATABASE_BACKEND").ok();
|
||||||
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
||||||
unsafe { std::env::remove_var("DATABASE_BACKEND") };
|
unsafe { std::env::remove_var("DATABASE_BACKEND") };
|
||||||
@@ -907,7 +920,7 @@ INJECTED="pwned"#;
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_libsql_autodetect_does_not_override_explicit_backend() {
|
fn test_libsql_autodetect_does_not_override_explicit_backend() {
|
||||||
let _guard = ENV_MUTEX.lock().unwrap();
|
let _guard = lock_env();
|
||||||
let old_val = std::env::var("DATABASE_BACKEND").ok();
|
let old_val = std::env::var("DATABASE_BACKEND").ok();
|
||||||
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
||||||
unsafe { std::env::set_var("DATABASE_BACKEND", "postgres") };
|
unsafe { std::env::set_var("DATABASE_BACKEND", "postgres") };
|
||||||
@@ -1034,7 +1047,7 @@ INJECTED="pwned"#;
|
|||||||
fn test_ironclaw_base_dir_default() {
|
fn test_ironclaw_base_dir_default() {
|
||||||
// This test must run first (or in isolation) before the LazyLock is initialized.
|
// This test must run first (or in isolation) before the LazyLock is initialized.
|
||||||
// It verifies that when IRONCLAW_BASE_DIR is not set, the default path is used.
|
// It verifies that when IRONCLAW_BASE_DIR is not set, the default path is used.
|
||||||
let _guard = ENV_MUTEX.lock().unwrap();
|
let _guard = lock_env();
|
||||||
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
|
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
|
||||||
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
||||||
unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") };
|
unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") };
|
||||||
@@ -1054,7 +1067,7 @@ INJECTED="pwned"#;
|
|||||||
fn test_ironclaw_base_dir_env_override() {
|
fn test_ironclaw_base_dir_env_override() {
|
||||||
// This test verifies that when IRONCLAW_BASE_DIR is set,
|
// This test verifies that when IRONCLAW_BASE_DIR is set,
|
||||||
// the custom path is used. Must run before LazyLock is initialized.
|
// the custom path is used. Must run before LazyLock is initialized.
|
||||||
let _guard = ENV_MUTEX.lock().unwrap();
|
let _guard = lock_env();
|
||||||
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
|
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
|
||||||
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
||||||
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/custom/ironclaw/path") };
|
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/custom/ironclaw/path") };
|
||||||
@@ -1076,7 +1089,7 @@ INJECTED="pwned"#;
|
|||||||
fn test_compute_base_dir_env_path_join() {
|
fn test_compute_base_dir_env_path_join() {
|
||||||
// Verifies that ironclaw_env_path correctly joins .env to the base dir.
|
// Verifies that ironclaw_env_path correctly joins .env to the base dir.
|
||||||
// Uses compute_ironclaw_base_dir directly to avoid LazyLock caching.
|
// Uses compute_ironclaw_base_dir directly to avoid LazyLock caching.
|
||||||
let _guard = ENV_MUTEX.lock().unwrap();
|
let _guard = lock_env();
|
||||||
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
|
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
|
||||||
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
||||||
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/my/custom/dir") };
|
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/my/custom/dir") };
|
||||||
@@ -1098,7 +1111,7 @@ INJECTED="pwned"#;
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_ironclaw_base_dir_empty_env() {
|
fn test_ironclaw_base_dir_empty_env() {
|
||||||
// Verifies that empty IRONCLAW_BASE_DIR falls back to default.
|
// Verifies that empty IRONCLAW_BASE_DIR falls back to default.
|
||||||
let _guard = ENV_MUTEX.lock().unwrap();
|
let _guard = lock_env();
|
||||||
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
|
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
|
||||||
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
||||||
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "") };
|
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "") };
|
||||||
@@ -1120,7 +1133,7 @@ INJECTED="pwned"#;
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_ironclaw_base_dir_special_chars() {
|
fn test_ironclaw_base_dir_special_chars() {
|
||||||
// Verifies that paths with special characters are handled correctly.
|
// Verifies that paths with special characters are handled correctly.
|
||||||
let _guard = ENV_MUTEX.lock().unwrap();
|
let _guard = lock_env();
|
||||||
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
|
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
|
||||||
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
||||||
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/tmp/test_with-special.chars") };
|
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/tmp/test_with-special.chars") };
|
||||||
|
|||||||
@@ -333,6 +333,12 @@ pub enum StatusUpdate {
|
|||||||
},
|
},
|
||||||
/// Suggested follow-up messages for the user.
|
/// Suggested follow-up messages for the user.
|
||||||
Suggestions { suggestions: Vec<String> },
|
Suggestions { suggestions: Vec<String> },
|
||||||
|
/// Per-turn token usage and cost summary (shown as subtle metadata).
|
||||||
|
TurnCost {
|
||||||
|
input_tokens: u64,
|
||||||
|
output_tokens: u64,
|
||||||
|
cost_usd: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StatusUpdate {
|
impl StatusUpdate {
|
||||||
|
|||||||
+338
-126
@@ -20,6 +20,7 @@
|
|||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::io::{self, IsTerminal, Write};
|
use std::io::{self, IsTerminal, Write};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::Mutex;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -40,6 +41,7 @@ use tokio_stream::wrappers::ReceiverStream;
|
|||||||
use crate::agent::truncate_for_preview;
|
use crate::agent::truncate_for_preview;
|
||||||
use crate::bootstrap::ironclaw_base_dir;
|
use crate::bootstrap::ironclaw_base_dir;
|
||||||
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||||
|
use crate::cli::fmt;
|
||||||
use crate::error::ChannelError;
|
use crate::error::ChannelError;
|
||||||
|
|
||||||
/// Max characters for tool result previews in the terminal.
|
/// Max characters for tool result previews in the terminal.
|
||||||
@@ -119,7 +121,7 @@ impl Hinter for ReplHelper {
|
|||||||
|
|
||||||
impl Highlighter for ReplHelper {
|
impl Highlighter for ReplHelper {
|
||||||
fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
|
fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
|
||||||
Cow::Owned(format!("\x1b[90m{hint}\x1b[0m"))
|
Cow::Owned(format!("{}{hint}{}", fmt::dim(), fmt::reset()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,55 +145,207 @@ impl ConditionalEventHandler for EscInterruptHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Approval action chosen by the interactive selector.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum ApprovalAction {
|
||||||
|
Approve,
|
||||||
|
Always,
|
||||||
|
Deny,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ApprovalAction {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Approve => write!(f, "Approve (y)"),
|
||||||
|
Self::Always => write!(f, "Always approve (a)"),
|
||||||
|
Self::Deny => write!(f, "Deny (n)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApprovalAction {
|
||||||
|
fn as_input(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Approve => "y",
|
||||||
|
Self::Always => "a",
|
||||||
|
Self::Deny => "n",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Interactive approval selector using crossterm raw mode.
|
||||||
|
/// Returns the approval action string ("y", "a", or "n").
|
||||||
|
fn run_approval_selector(allow_always: bool) -> Option<&'static str> {
|
||||||
|
use crossterm::{
|
||||||
|
cursor,
|
||||||
|
event::{self, Event as CtEvent, KeyCode as CtKeyCode, KeyEventKind},
|
||||||
|
execute,
|
||||||
|
terminal::{self, ClearType},
|
||||||
|
};
|
||||||
|
|
||||||
|
let options: Vec<ApprovalAction> = if allow_always {
|
||||||
|
vec![
|
||||||
|
ApprovalAction::Approve,
|
||||||
|
ApprovalAction::Always,
|
||||||
|
ApprovalAction::Deny,
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![ApprovalAction::Approve, ApprovalAction::Deny]
|
||||||
|
};
|
||||||
|
|
||||||
|
let num = options.len();
|
||||||
|
let mut sel: usize = 0;
|
||||||
|
// Total lines: options + hint line
|
||||||
|
let total_lines = (num + 1) as u16;
|
||||||
|
|
||||||
|
let render = |sel: usize| {
|
||||||
|
let mut w = io::stderr();
|
||||||
|
let pipe = format!("{}│{}", fmt::accent(), fmt::reset());
|
||||||
|
for (i, opt) in options.iter().enumerate() {
|
||||||
|
if i == sel {
|
||||||
|
let _ = write!(w, " {pipe} {}● {opt}{}\r\n", fmt::bold(), fmt::reset());
|
||||||
|
} else {
|
||||||
|
let _ = write!(w, " {pipe} {}○ {opt}{}\r\n", fmt::dim(), fmt::reset());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = write!(
|
||||||
|
w,
|
||||||
|
" {}└{} {}↑↓ enter to select{}\r\n",
|
||||||
|
fmt::accent(),
|
||||||
|
fmt::reset(),
|
||||||
|
fmt::dim(),
|
||||||
|
fmt::reset()
|
||||||
|
);
|
||||||
|
let _ = w.flush();
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = terminal::enable_raw_mode();
|
||||||
|
render(sel);
|
||||||
|
|
||||||
|
let result = loop {
|
||||||
|
let Ok(evt) = event::read() else { break None };
|
||||||
|
if let CtEvent::Key(key) = evt {
|
||||||
|
if key.kind != KeyEventKind::Press {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match key.code {
|
||||||
|
CtKeyCode::Up | CtKeyCode::Char('k') => {
|
||||||
|
sel = if sel == 0 { num - 1 } else { sel - 1 };
|
||||||
|
}
|
||||||
|
CtKeyCode::Down | CtKeyCode::Char('j') => {
|
||||||
|
sel = (sel + 1) % num;
|
||||||
|
}
|
||||||
|
CtKeyCode::Enter => break Some(options[sel].as_input()),
|
||||||
|
CtKeyCode::Char('y') | CtKeyCode::Char('Y') => break Some("y"),
|
||||||
|
CtKeyCode::Char('a') | CtKeyCode::Char('A') if allow_always => break Some("a"),
|
||||||
|
CtKeyCode::Char('n') | CtKeyCode::Char('N') => break Some("n"),
|
||||||
|
CtKeyCode::Esc => break None,
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
// Redraw: move up, clear, render
|
||||||
|
let mut w = io::stderr();
|
||||||
|
let _ = execute!(w, cursor::MoveUp(total_lines));
|
||||||
|
let _ = execute!(w, terminal::Clear(ClearType::FromCursorDown));
|
||||||
|
render(sel);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = terminal::disable_raw_mode();
|
||||||
|
|
||||||
|
// Overwrite selector with the confirmed choice
|
||||||
|
let mut w = io::stderr();
|
||||||
|
let _ = execute!(w, cursor::MoveUp(total_lines));
|
||||||
|
let _ = execute!(w, terminal::Clear(ClearType::FromCursorDown));
|
||||||
|
let (label, color) = if let Some(action) = result {
|
||||||
|
let l = options
|
||||||
|
.iter()
|
||||||
|
.find(|o| o.as_input() == action)
|
||||||
|
.unwrap_or(&options[0]);
|
||||||
|
let c = if action == "n" {
|
||||||
|
fmt::error()
|
||||||
|
} else {
|
||||||
|
fmt::success()
|
||||||
|
};
|
||||||
|
(l.to_string(), c)
|
||||||
|
} else {
|
||||||
|
(ApprovalAction::Deny.to_string(), fmt::error())
|
||||||
|
};
|
||||||
|
let _ = writeln!(
|
||||||
|
w,
|
||||||
|
" {}└{} {color}● {label}{}",
|
||||||
|
fmt::accent(),
|
||||||
|
fmt::reset(),
|
||||||
|
fmt::reset()
|
||||||
|
);
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
/// Build a termimad skin with our color scheme.
|
/// Build a termimad skin with our color scheme.
|
||||||
fn make_skin() -> MadSkin {
|
fn make_skin() -> MadSkin {
|
||||||
let mut skin = MadSkin::default();
|
let mut skin = MadSkin::default();
|
||||||
skin.set_headers_fg(termimad::crossterm::style::Color::Yellow);
|
skin.set_headers_fg(crossterm::style::Color::Yellow);
|
||||||
skin.bold.set_fg(termimad::crossterm::style::Color::White);
|
skin.bold.set_fg(crossterm::style::Color::White);
|
||||||
skin.italic
|
skin.italic.set_fg(crossterm::style::Color::Magenta);
|
||||||
.set_fg(termimad::crossterm::style::Color::Magenta);
|
skin.inline_code.set_fg(crossterm::style::Color::Green);
|
||||||
skin.inline_code
|
skin.code_block.set_fg(crossterm::style::Color::Green);
|
||||||
.set_fg(termimad::crossterm::style::Color::Green);
|
|
||||||
skin.code_block
|
|
||||||
.set_fg(termimad::crossterm::style::Color::Green);
|
|
||||||
skin.code_block.left_margin = 2;
|
skin.code_block.left_margin = 2;
|
||||||
skin
|
skin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Truncate a string to `max_chars` using character boundaries.
|
||||||
|
///
|
||||||
|
/// For strings longer than `max_chars`, shows the first half and last half
|
||||||
|
/// separated by `...` so both ends are visible.
|
||||||
|
fn smart_truncate(s: &str, max_chars: usize) -> Cow<'_, str> {
|
||||||
|
let char_count = s.chars().count();
|
||||||
|
if char_count <= max_chars {
|
||||||
|
return Cow::Borrowed(s);
|
||||||
|
}
|
||||||
|
// Account for the 3-char "..." separator
|
||||||
|
let budget = max_chars.saturating_sub(3);
|
||||||
|
let head_len = budget / 2;
|
||||||
|
let tail_len = budget - head_len;
|
||||||
|
let head: String = s.chars().take(head_len).collect();
|
||||||
|
let tail: String = s
|
||||||
|
.chars()
|
||||||
|
.skip(char_count.saturating_sub(tail_len))
|
||||||
|
.collect();
|
||||||
|
Cow::Owned(format!("{head}...{tail}"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Format JSON params as `key: value` lines for the approval card.
|
/// Format JSON params as `key: value` lines for the approval card.
|
||||||
fn format_json_params(params: &serde_json::Value, indent: &str) -> String {
|
fn format_json_params(params: &serde_json::Value, indent: &str) -> String {
|
||||||
|
let max_val_len = fmt::term_width().saturating_sub(8);
|
||||||
|
|
||||||
match params {
|
match params {
|
||||||
serde_json::Value::Object(map) => {
|
serde_json::Value::Object(map) => {
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
for (key, value) in map {
|
for (key, value) in map {
|
||||||
let val_str = match value {
|
let val_str = match value {
|
||||||
serde_json::Value::String(s) => {
|
serde_json::Value::String(s) => {
|
||||||
let display = if s.len() > 120 { &s[..120] } else { s };
|
let display = smart_truncate(s, max_val_len);
|
||||||
format!("\x1b[32m\"{display}\"\x1b[0m")
|
format!("{}\"{display}\"{}", fmt::success(), fmt::reset())
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
let rendered = other.to_string();
|
let rendered = other.to_string();
|
||||||
if rendered.len() > 120 {
|
smart_truncate(&rendered, max_val_len).into_owned()
|
||||||
format!("{}...", &rendered[..120])
|
|
||||||
} else {
|
|
||||||
rendered
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
lines.push(format!("{indent}\x1b[36m{key}\x1b[0m: {val_str}"));
|
lines.push(format!(
|
||||||
|
"{indent}{}{key}{}: {val_str}",
|
||||||
|
fmt::accent(),
|
||||||
|
fmt::reset()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
lines.join("\n")
|
lines.join("\n")
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
let pretty = serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string());
|
let pretty = serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string());
|
||||||
let truncated = if pretty.len() > 300 {
|
let truncated = smart_truncate(&pretty, 300);
|
||||||
format!("{}...", &pretty[..300])
|
|
||||||
} else {
|
|
||||||
pretty
|
|
||||||
};
|
|
||||||
truncated
|
truncated
|
||||||
.lines()
|
.lines()
|
||||||
.map(|l| format!("{indent}\x1b[90m{l}\x1b[0m"))
|
.map(|l| format!("{indent}{}{l}{}", fmt::dim(), fmt::reset()))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n")
|
.join("\n")
|
||||||
}
|
}
|
||||||
@@ -210,6 +364,12 @@ pub struct ReplChannel {
|
|||||||
is_streaming: Arc<AtomicBool>,
|
is_streaming: Arc<AtomicBool>,
|
||||||
/// When true, the one-liner startup banner is suppressed (boot screen shown instead).
|
/// When true, the one-liner startup banner is suppressed (boot screen shown instead).
|
||||||
suppress_banner: Arc<AtomicBool>,
|
suppress_banner: Arc<AtomicBool>,
|
||||||
|
/// Sender to inject messages into the agent loop (set after start()).
|
||||||
|
msg_tx: Arc<Mutex<Option<mpsc::Sender<IncomingMessage>>>>,
|
||||||
|
/// When true, the readline thread must yield stdin (approval selector or agent processing).
|
||||||
|
stdin_locked: Arc<AtomicBool>,
|
||||||
|
/// Number of transient status lines (Thinking) to erase on next output.
|
||||||
|
transient_lines: std::sync::atomic::AtomicU8,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ReplChannel {
|
impl ReplChannel {
|
||||||
@@ -226,6 +386,9 @@ impl ReplChannel {
|
|||||||
debug_mode: Arc::new(AtomicBool::new(false)),
|
debug_mode: Arc::new(AtomicBool::new(false)),
|
||||||
is_streaming: Arc::new(AtomicBool::new(false)),
|
is_streaming: Arc::new(AtomicBool::new(false)),
|
||||||
suppress_banner: Arc::new(AtomicBool::new(false)),
|
suppress_banner: Arc::new(AtomicBool::new(false)),
|
||||||
|
msg_tx: Arc::new(Mutex::new(None)),
|
||||||
|
stdin_locked: Arc::new(AtomicBool::new(false)),
|
||||||
|
transient_lines: std::sync::atomic::AtomicU8::new(0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +405,9 @@ impl ReplChannel {
|
|||||||
debug_mode: Arc::new(AtomicBool::new(false)),
|
debug_mode: Arc::new(AtomicBool::new(false)),
|
||||||
is_streaming: Arc::new(AtomicBool::new(false)),
|
is_streaming: Arc::new(AtomicBool::new(false)),
|
||||||
suppress_banner: Arc::new(AtomicBool::new(false)),
|
suppress_banner: Arc::new(AtomicBool::new(false)),
|
||||||
|
msg_tx: Arc::new(Mutex::new(None)),
|
||||||
|
stdin_locked: Arc::new(AtomicBool::new(false)),
|
||||||
|
transient_lines: std::sync::atomic::AtomicU8::new(0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +419,17 @@ impl ReplChannel {
|
|||||||
fn is_debug(&self) -> bool {
|
fn is_debug(&self) -> bool {
|
||||||
self.debug_mode.load(Ordering::Relaxed)
|
self.debug_mode.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Erase transient status lines (Thinking indicators) from the terminal.
|
||||||
|
fn clear_transient(&self) {
|
||||||
|
use crossterm::{cursor, execute, terminal};
|
||||||
|
let n = self.transient_lines.swap(0, Ordering::Relaxed);
|
||||||
|
if n > 0 {
|
||||||
|
let mut stderr = io::stderr();
|
||||||
|
let _ = execute!(stderr, cursor::MoveUp(n as u16));
|
||||||
|
let _ = execute!(stderr, terminal::Clear(terminal::ClearType::FromCursorDown));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ReplChannel {
|
impl Default for ReplChannel {
|
||||||
@@ -262,33 +439,30 @@ impl Default for ReplChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
// Bold white for section headers, bold cyan for commands, dim gray for descriptions
|
let h = fmt::bold();
|
||||||
let h = "\x1b[1m"; // bold (section headers)
|
let c = fmt::bold_accent();
|
||||||
let c = "\x1b[1;36m"; // bold cyan (commands)
|
let d = fmt::dim();
|
||||||
let d = "\x1b[90m"; // dim gray (descriptions)
|
let r = fmt::reset();
|
||||||
let r = "\x1b[0m"; // reset
|
let hi = fmt::hint();
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!(" {h}IronClaw REPL{r}");
|
println!(" {h}IronClaw REPL{r}");
|
||||||
println!();
|
println!();
|
||||||
println!(" {h}Commands{r}");
|
println!(" {h}Quick start{r}");
|
||||||
println!(" {c}/help{r} {d}show this help{r}");
|
println!(" {c}/new{r} {hi}Start a new thread{r}");
|
||||||
println!(" {c}/debug{r} {d}toggle verbose output{r}");
|
println!(" {c}/compact{r} {hi}Compress context window{r}");
|
||||||
println!(" {c}/quit{r} {c}/exit{r} {d}exit the repl{r}");
|
println!(" {c}/quit{r} {hi}Exit{r}");
|
||||||
println!();
|
println!();
|
||||||
println!(" {h}Conversation{r}");
|
println!(" {h}All commands{r}");
|
||||||
println!(" {c}/undo{r} {d}undo the last turn{r}");
|
println!(
|
||||||
println!(" {c}/redo{r} {d}redo an undone turn{r}");
|
" {d}Conversation{r} {c}/new{r} {c}/clear{r} {c}/compact{r} {c}/undo{r} {c}/redo{r} {c}/summarize{r} {c}/suggest{r}"
|
||||||
println!(" {c}/clear{r} {d}clear conversation{r}");
|
);
|
||||||
println!(" {c}/compact{r} {d}compact context window{r}");
|
println!(" {d}Threads{r} {c}/thread{r} {c}/resume{r} {c}/list{r}");
|
||||||
println!(" {c}/new{r} {d}new conversation thread{r}");
|
println!(" {d}Execution{r} {c}/interrupt{r} {d}(esc){r} {c}/cancel{r}");
|
||||||
println!(" {c}/interrupt{r} {d}stop current operation{r}");
|
println!(
|
||||||
println!(" {c}esc{r} {d}stop current operation{r}");
|
" {d}System{r} {c}/tools{r} {c}/model{r} {c}/version{r} {c}/status{r} {c}/debug{r} {c}/heartbeat{r}"
|
||||||
println!();
|
);
|
||||||
println!(" {h}Approval responses{r}");
|
println!(" {d}Session{r} {c}/help{r} {c}/quit{r}");
|
||||||
println!(" {c}yes{r} ({c}y{r}) {d}approve tool execution{r}");
|
|
||||||
println!(" {c}no{r} ({c}n{r}) {d}deny tool execution{r}");
|
|
||||||
println!(" {c}always{r} ({c}a{r}) {d}approve for this session{r}");
|
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,10 +479,15 @@ impl Channel for ReplChannel {
|
|||||||
|
|
||||||
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
||||||
let (tx, rx) = mpsc::channel(32);
|
let (tx, rx) = mpsc::channel(32);
|
||||||
|
// Store tx so send_status can inject approval responses directly
|
||||||
|
if let Ok(mut guard) = self.msg_tx.lock() {
|
||||||
|
*guard = Some(tx.clone());
|
||||||
|
}
|
||||||
let single_message = self.single_message.clone();
|
let single_message = self.single_message.clone();
|
||||||
let user_id = self.user_id.clone();
|
let user_id = self.user_id.clone();
|
||||||
let debug_mode = Arc::clone(&self.debug_mode);
|
let debug_mode = Arc::clone(&self.debug_mode);
|
||||||
let suppress_banner = Arc::clone(&self.suppress_banner);
|
let suppress_banner = Arc::clone(&self.suppress_banner);
|
||||||
|
let stdin_locked = Arc::clone(&self.stdin_locked);
|
||||||
let esc_interrupt_triggered_for_thread = Arc::new(AtomicBool::new(false));
|
let esc_interrupt_triggered_for_thread = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
@@ -357,18 +536,33 @@ impl Channel for ReplChannel {
|
|||||||
let _ = rl.load_history(&hist_path);
|
let _ = rl.load_history(&hist_path);
|
||||||
|
|
||||||
if !suppress_banner.load(Ordering::Relaxed) {
|
if !suppress_banner.load(Ordering::Relaxed) {
|
||||||
println!("\x1b[1mIronClaw\x1b[0m /help for commands, /quit to exit");
|
println!(
|
||||||
|
"{}IronClaw{} /help for commands, /quit to exit",
|
||||||
|
fmt::bold(),
|
||||||
|
fmt::reset()
|
||||||
|
);
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
// Yield stdin while approval selector or agent processing locks it
|
||||||
|
while stdin_locked.load(Ordering::Relaxed) {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
|
||||||
let prompt = if debug_mode.load(Ordering::Relaxed) {
|
let prompt = if debug_mode.load(Ordering::Relaxed) {
|
||||||
"\x1b[33m[debug]\x1b[0m \x1b[1;36m\u{203A}\x1b[0m "
|
format!(
|
||||||
|
"{}[debug]{} {}\u{203A}{} ",
|
||||||
|
fmt::warning(),
|
||||||
|
fmt::reset(),
|
||||||
|
fmt::bold_accent(),
|
||||||
|
fmt::reset()
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
"\x1b[1;36m\u{203A}\x1b[0m "
|
format!("{}\u{203A}{} ", fmt::bold_accent(), fmt::reset())
|
||||||
};
|
};
|
||||||
|
|
||||||
match rl.readline(prompt) {
|
match rl.readline(&prompt) {
|
||||||
Ok(line) => {
|
Ok(line) => {
|
||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
if line.is_empty() {
|
if line.is_empty() {
|
||||||
@@ -394,9 +588,9 @@ impl Channel for ReplChannel {
|
|||||||
let current = debug_mode.load(Ordering::Relaxed);
|
let current = debug_mode.load(Ordering::Relaxed);
|
||||||
debug_mode.store(!current, Ordering::Relaxed);
|
debug_mode.store(!current, Ordering::Relaxed);
|
||||||
if !current {
|
if !current {
|
||||||
println!("\x1b[90mdebug mode on\x1b[0m");
|
println!("{}debug mode on{}", fmt::dim(), fmt::reset());
|
||||||
} else {
|
} else {
|
||||||
println!("\x1b[90mdebug mode off\x1b[0m");
|
println!("{}debug mode off{}", fmt::dim(), fmt::reset());
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -405,7 +599,11 @@ impl Channel for ReplChannel {
|
|||||||
|
|
||||||
let msg =
|
let msg =
|
||||||
IncomingMessage::new("repl", &user_id, line).with_timezone(&sys_tz);
|
IncomingMessage::new("repl", &user_id, line).with_timezone(&sys_tz);
|
||||||
|
// Lock stdin before sending so readline doesn't restart
|
||||||
|
// while the agent is processing (approval selector needs stdin)
|
||||||
|
stdin_locked.store(true, Ordering::Relaxed);
|
||||||
if tx.blocking_send(msg).is_err() {
|
if tx.blocking_send(msg).is_err() {
|
||||||
|
stdin_locked.store(false, Ordering::Relaxed);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -456,21 +654,23 @@ impl Channel for ReplChannel {
|
|||||||
_msg: &IncomingMessage,
|
_msg: &IncomingMessage,
|
||||||
response: OutgoingResponse,
|
response: OutgoingResponse,
|
||||||
) -> Result<(), ChannelError> {
|
) -> Result<(), ChannelError> {
|
||||||
let width = crossterm::terminal::size()
|
let width = fmt::term_width();
|
||||||
.map(|(w, _)| w as usize)
|
|
||||||
.unwrap_or(80);
|
|
||||||
|
|
||||||
// If we were streaming, the content was already printed via StreamChunk.
|
// If we were streaming, the content was already printed via StreamChunk.
|
||||||
// Just finish the line and reset.
|
// Just finish the line and reset.
|
||||||
if self.is_streaming.swap(false, Ordering::Relaxed) {
|
if self.is_streaming.swap(false, Ordering::Relaxed) {
|
||||||
println!();
|
println!();
|
||||||
println!();
|
println!();
|
||||||
|
self.stdin_locked.store(false, Ordering::Relaxed);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear any leftover thinking indicators
|
||||||
|
self.clear_transient();
|
||||||
|
|
||||||
// Dim separator line before the response
|
// Dim separator line before the response
|
||||||
let sep_width = width.min(80);
|
let sep_width = width.min(80);
|
||||||
eprintln!("\x1b[90m{}\x1b[0m", "\u{2500}".repeat(sep_width));
|
eprintln!("{}", fmt::separator(sep_width));
|
||||||
|
|
||||||
// Render markdown
|
// Render markdown
|
||||||
let skin = make_skin();
|
let skin = make_skin();
|
||||||
@@ -478,6 +678,8 @@ impl Channel for ReplChannel {
|
|||||||
|
|
||||||
print!("{text}");
|
print!("{text}");
|
||||||
println!();
|
println!();
|
||||||
|
// Unlock stdin so readline can resume
|
||||||
|
self.stdin_locked.store(false, Ordering::Relaxed);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,31 +692,34 @@ impl Channel for ReplChannel {
|
|||||||
|
|
||||||
match status {
|
match status {
|
||||||
StatusUpdate::Thinking(msg) => {
|
StatusUpdate::Thinking(msg) => {
|
||||||
|
self.clear_transient();
|
||||||
let display = truncate_for_preview(&msg, CLI_STATUS_MAX);
|
let display = truncate_for_preview(&msg, CLI_STATUS_MAX);
|
||||||
eprintln!(" \x1b[90m\u{25CB} {display}\x1b[0m");
|
eprintln!(" {}\u{25CB} {display}{}", fmt::dim(), fmt::reset());
|
||||||
|
self.transient_lines.store(1, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
StatusUpdate::ToolStarted { name } => {
|
StatusUpdate::ToolStarted { name } => {
|
||||||
eprintln!(" \x1b[33m\u{25CB} {name}\x1b[0m");
|
self.clear_transient();
|
||||||
|
eprintln!(" {}\u{25CB} {name}{}", fmt::dim(), fmt::reset());
|
||||||
|
self.transient_lines.store(1, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
StatusUpdate::ToolCompleted { name, success, .. } => {
|
StatusUpdate::ToolCompleted { name, success, .. } => {
|
||||||
|
self.clear_transient();
|
||||||
if success {
|
if success {
|
||||||
eprintln!(" \x1b[32m\u{25CF} {name}\x1b[0m");
|
eprintln!(" {}\u{25CF} {name}{}", fmt::success(), fmt::reset());
|
||||||
} else {
|
} else {
|
||||||
eprintln!(" \x1b[31m\u{2717} {name} (failed)\x1b[0m");
|
eprintln!(" {}\u{2717} {name} (failed){}", fmt::error(), fmt::reset());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
StatusUpdate::ToolResult { name: _, preview } => {
|
StatusUpdate::ToolResult { name: _, preview } => {
|
||||||
let display = truncate_for_preview(&preview, CLI_TOOL_RESULT_MAX);
|
let display = truncate_for_preview(&preview, CLI_TOOL_RESULT_MAX);
|
||||||
eprintln!(" \x1b[90m{display}\x1b[0m");
|
eprintln!(" {}{display}{}", fmt::dim(), fmt::reset());
|
||||||
}
|
}
|
||||||
StatusUpdate::StreamChunk(chunk) => {
|
StatusUpdate::StreamChunk(chunk) => {
|
||||||
// Print separator on the false-to-true transition
|
// Print separator on the false-to-true transition
|
||||||
if !self.is_streaming.swap(true, Ordering::Relaxed) {
|
if !self.is_streaming.swap(true, Ordering::Relaxed) {
|
||||||
let width = crossterm::terminal::size()
|
self.clear_transient();
|
||||||
.map(|(w, _)| w as usize)
|
let sep_width = fmt::term_width().min(80);
|
||||||
.unwrap_or(80);
|
eprintln!("{}", fmt::separator(sep_width));
|
||||||
let sep_width = width.min(80);
|
|
||||||
eprintln!("\x1b[90m{}\x1b[0m", "\u{2500}".repeat(sep_width));
|
|
||||||
}
|
}
|
||||||
print!("{chunk}");
|
print!("{chunk}");
|
||||||
let _ = io::stdout().flush();
|
let _ = io::stdout().flush();
|
||||||
@@ -525,73 +730,67 @@ impl Channel for ReplChannel {
|
|||||||
browse_url,
|
browse_url,
|
||||||
} => {
|
} => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" \x1b[36m[job]\x1b[0m {title} \x1b[90m({job_id})\x1b[0m \x1b[4m{browse_url}\x1b[0m"
|
" {}[job]{} {title} {}({job_id}){} {}{browse_url}{}",
|
||||||
|
fmt::accent(),
|
||||||
|
fmt::reset(),
|
||||||
|
fmt::dim(),
|
||||||
|
fmt::reset(),
|
||||||
|
fmt::link(),
|
||||||
|
fmt::reset()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
StatusUpdate::Status(msg) => {
|
StatusUpdate::Status(msg) => {
|
||||||
if debug || msg.contains("approval") || msg.contains("Approval") {
|
if debug || msg.contains("approval") || msg.contains("Approval") {
|
||||||
let display = truncate_for_preview(&msg, CLI_STATUS_MAX);
|
let display = truncate_for_preview(&msg, CLI_STATUS_MAX);
|
||||||
eprintln!(" \x1b[90m{display}\x1b[0m");
|
eprintln!(" {}{display}{}", fmt::dim(), fmt::reset());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
StatusUpdate::ApprovalNeeded {
|
StatusUpdate::ApprovalNeeded {
|
||||||
request_id,
|
request_id: _,
|
||||||
tool_name,
|
tool_name,
|
||||||
description,
|
description: _,
|
||||||
parameters,
|
parameters,
|
||||||
allow_always,
|
allow_always,
|
||||||
} => {
|
} => {
|
||||||
let term_width = crossterm::terminal::size()
|
self.clear_transient();
|
||||||
.map(|(w, _)| w as usize)
|
let pipe = format!("{}│{}", fmt::accent(), fmt::reset());
|
||||||
.unwrap_or(80);
|
|
||||||
let box_width = (term_width.saturating_sub(4)).clamp(40, 60);
|
|
||||||
|
|
||||||
// Short request ID for the bottom border
|
// Header: ◆ tool requires approval
|
||||||
let short_id = if request_id.len() > 8 {
|
eprintln!();
|
||||||
&request_id[..8]
|
eprintln!(
|
||||||
} else {
|
" {}\u{25C6} {}{tool_name}{} requires approval",
|
||||||
&request_id
|
fmt::accent(),
|
||||||
};
|
fmt::bold(),
|
||||||
|
fmt::reset()
|
||||||
// Top border: ┌ tool_name requires approval ───
|
|
||||||
let top_label = format!(" {tool_name} requires approval ");
|
|
||||||
let top_fill = box_width.saturating_sub(top_label.len() + 1);
|
|
||||||
let top_border = format!(
|
|
||||||
"\u{250C}\x1b[33m{top_label}\x1b[0m{}",
|
|
||||||
"\u{2500}".repeat(top_fill)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Bottom border: └─ short_id ─────
|
// Params: │ key value
|
||||||
let bot_label = format!(" {short_id} ");
|
let param_lines = format_json_params(¶meters, &format!(" {pipe} "));
|
||||||
let bot_fill = box_width.saturating_sub(bot_label.len() + 2);
|
if !param_lines.is_empty() {
|
||||||
let bot_border = format!(
|
eprintln!(" {pipe}");
|
||||||
"\u{2514}\u{2500}\x1b[90m{bot_label}\x1b[0m{}",
|
for line in param_lines.lines() {
|
||||||
"\u{2500}".repeat(bot_fill)
|
eprintln!("{line}");
|
||||||
);
|
}
|
||||||
|
|
||||||
eprintln!();
|
|
||||||
eprintln!(" {top_border}");
|
|
||||||
eprintln!(" \u{2502} \x1b[90m{description}\x1b[0m");
|
|
||||||
eprintln!(" \u{2502}");
|
|
||||||
|
|
||||||
// Params
|
|
||||||
let param_lines = format_json_params(¶meters, " \u{2502} ");
|
|
||||||
// The format_json_params already includes the indent prefix
|
|
||||||
// but we need to handle the case where each line already starts with it
|
|
||||||
for line in param_lines.lines() {
|
|
||||||
eprintln!("{line}");
|
|
||||||
}
|
}
|
||||||
|
eprintln!(" {pipe}");
|
||||||
eprintln!(" \u{2502}");
|
// Run interactive selector directly from send_status
|
||||||
if allow_always {
|
// stdin is already locked by Thinking/ToolStarted, so the
|
||||||
eprintln!(
|
// readline thread is not competing for stdin.
|
||||||
" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[34malways\x1b[0m (a) / \x1b[31mno\x1b[0m (n)"
|
let msg_tx = Arc::clone(&self.msg_tx);
|
||||||
);
|
let user_id = self.user_id.clone();
|
||||||
} else {
|
let lock_flag = Arc::clone(&self.stdin_locked);
|
||||||
eprintln!(" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[31mno\x1b[0m (n)");
|
tokio::task::spawn_blocking(move || {
|
||||||
}
|
let action = run_approval_selector(allow_always).unwrap_or("n");
|
||||||
eprintln!(" {bot_border}");
|
// Unlock stdin so readline can resume after approval
|
||||||
eprintln!();
|
lock_flag.store(false, Ordering::Relaxed);
|
||||||
|
let Ok(guard) = msg_tx.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(tx) = guard.as_ref() {
|
||||||
|
let msg = IncomingMessage::new("repl", &user_id, action);
|
||||||
|
let _ = tx.blocking_send(msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
StatusUpdate::AuthRequired {
|
StatusUpdate::AuthRequired {
|
||||||
extension_name,
|
extension_name,
|
||||||
@@ -600,12 +799,16 @@ impl Channel for ReplChannel {
|
|||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("\x1b[33m Authentication required for {extension_name}\x1b[0m");
|
eprintln!(
|
||||||
|
"{} Authentication required for {extension_name}{}",
|
||||||
|
fmt::warning(),
|
||||||
|
fmt::reset()
|
||||||
|
);
|
||||||
if let Some(ref instr) = instructions {
|
if let Some(ref instr) = instructions {
|
||||||
eprintln!(" {instr}");
|
eprintln!(" {instr}");
|
||||||
}
|
}
|
||||||
if let Some(ref url) = setup_url {
|
if let Some(ref url) = setup_url {
|
||||||
eprintln!(" \x1b[4m{url}\x1b[0m");
|
eprintln!(" {}{url}{}", fmt::link(), fmt::reset());
|
||||||
}
|
}
|
||||||
eprintln!();
|
eprintln!();
|
||||||
}
|
}
|
||||||
@@ -615,21 +818,32 @@ impl Channel for ReplChannel {
|
|||||||
message,
|
message,
|
||||||
} => {
|
} => {
|
||||||
if success {
|
if success {
|
||||||
eprintln!("\x1b[32m {extension_name}: {message}\x1b[0m");
|
eprintln!(
|
||||||
|
"{} {extension_name}: {message}{}",
|
||||||
|
fmt::success(),
|
||||||
|
fmt::reset()
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
eprintln!("\x1b[31m {extension_name}: {message}\x1b[0m");
|
eprintln!(
|
||||||
|
"{} {extension_name}: {message}{}",
|
||||||
|
fmt::error(),
|
||||||
|
fmt::reset()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
StatusUpdate::ImageGenerated { path, .. } => {
|
StatusUpdate::ImageGenerated { path, .. } => {
|
||||||
if let Some(ref p) = path {
|
if let Some(ref p) = path {
|
||||||
eprintln!("\x1b[36m [image] {p}\x1b[0m");
|
eprintln!("{} [image] {p}{}", fmt::accent(), fmt::reset());
|
||||||
} else {
|
} else {
|
||||||
eprintln!("\x1b[36m [image generated]\x1b[0m");
|
eprintln!("{} [image generated]{}", fmt::accent(), fmt::reset());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
StatusUpdate::Suggestions { .. } => {
|
StatusUpdate::Suggestions { .. } => {
|
||||||
// Suggestions are only rendered by the web gateway
|
// Suggestions are only rendered by the web gateway
|
||||||
}
|
}
|
||||||
|
StatusUpdate::TurnCost { .. } => {
|
||||||
|
// Cost display is handled by the TUI channel
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -640,11 +854,9 @@ impl Channel for ReplChannel {
|
|||||||
response: OutgoingResponse,
|
response: OutgoingResponse,
|
||||||
) -> Result<(), ChannelError> {
|
) -> Result<(), ChannelError> {
|
||||||
let skin = make_skin();
|
let skin = make_skin();
|
||||||
let width = crossterm::terminal::size()
|
let width = fmt::term_width();
|
||||||
.map(|(w, _)| w as usize)
|
|
||||||
.unwrap_or(80);
|
|
||||||
|
|
||||||
eprintln!("\x1b[34m\u{25CF}\x1b[0m notification");
|
eprintln!("{}\u{25CF}{} notification", fmt::accent(), fmt::reset());
|
||||||
let text = termimad::FmtText::from(&skin, &response.content, Some(width));
|
let text = termimad::FmtText::from(&skin, &response.content, Some(width));
|
||||||
eprint!("{text}");
|
eprint!("{text}");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ async fn register_channel(
|
|||||||
wasm_router: &Arc<WasmChannelRouter>,
|
wasm_router: &Arc<WasmChannelRouter>,
|
||||||
) -> (String, Box<dyn crate::channels::Channel>) {
|
) -> (String, Box<dyn crate::channels::Channel>) {
|
||||||
let channel_name = loaded.name().to_string();
|
let channel_name = loaded.name().to_string();
|
||||||
tracing::info!("Loaded WASM channel: {}", channel_name);
|
tracing::debug!("Loaded WASM channel: {}", channel_name);
|
||||||
let owner_actor_id = config
|
let owner_actor_id = config
|
||||||
.channels
|
.channels
|
||||||
.wasm_channel_owner_ids
|
.wasm_channel_owner_ids
|
||||||
|
|||||||
@@ -3059,8 +3059,8 @@ fn status_to_wit(
|
|||||||
},
|
},
|
||||||
metadata_json,
|
metadata_json,
|
||||||
},
|
},
|
||||||
// Suggestions are web-gateway-only; skip for WASM channels
|
// Suggestions and turn cost are web-gateway-only; skip for WASM channels
|
||||||
StatusUpdate::Suggestions { .. } => return None,
|
StatusUpdate::Suggestions { .. } | StatusUpdate::TurnCost { .. } => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+383
-22
@@ -1,17 +1,133 @@
|
|||||||
//! Bearer token authentication middleware for the web gateway.
|
//! Bearer token authentication middleware for the web gateway.
|
||||||
|
//!
|
||||||
|
//! Supports multi-user mode: each token maps to a `UserIdentity` that carries
|
||||||
|
//! the user_id. The identity is inserted into request extensions so downstream
|
||||||
|
//! handlers can extract it via `AuthenticatedUser`.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Request, State},
|
extract::{FromRequestParts, Request, State},
|
||||||
http::{HeaderMap, Method, StatusCode},
|
http::{HeaderMap, Method, StatusCode, request::Parts},
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use subtle::ConstantTimeEq;
|
use subtle::ConstantTimeEq;
|
||||||
|
|
||||||
/// Shared auth state injected via axum middleware state.
|
/// Identity resolved from a bearer token.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct UserIdentity {
|
||||||
|
pub user_id: String,
|
||||||
|
/// Additional user scopes this identity can read from.
|
||||||
|
pub workspace_read_scopes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash a token with SHA-256 for constant-size, timing-safe storage.
|
||||||
|
fn hash_token(token: &str) -> [u8; 32] {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(token.as_bytes());
|
||||||
|
hasher.finalize().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Multi-user auth state: maps token hashes to user identities.
|
||||||
|
///
|
||||||
|
/// Tokens are SHA-256 hashed on construction so they are never stored in
|
||||||
|
/// plaintext. Authentication compares fixed-size (32-byte) digests using
|
||||||
|
/// constant-time comparison, eliminating both length-oracle timing leaks
|
||||||
|
/// and accidental token exposure in memory dumps.
|
||||||
|
///
|
||||||
|
/// In single-user mode (the default), contains exactly one entry.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AuthState {
|
pub struct MultiAuthState {
|
||||||
pub token: String,
|
/// Maps SHA-256(token) → identity. Tokens are never stored in cleartext.
|
||||||
|
hashed_tokens: Vec<([u8; 32], UserIdentity)>,
|
||||||
|
/// Original first token kept only for single-user startup printing.
|
||||||
|
/// Not used for authentication.
|
||||||
|
display_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MultiAuthState {
|
||||||
|
/// Create a single-user auth state (backwards compatible).
|
||||||
|
pub fn single(token: String, user_id: String) -> Self {
|
||||||
|
let hash = hash_token(&token);
|
||||||
|
Self {
|
||||||
|
hashed_tokens: vec![(
|
||||||
|
hash,
|
||||||
|
UserIdentity {
|
||||||
|
user_id,
|
||||||
|
workspace_read_scopes: Vec::new(),
|
||||||
|
},
|
||||||
|
)],
|
||||||
|
display_token: Some(token),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a multi-user auth state from a map of tokens to identities.
|
||||||
|
pub fn multi(tokens: HashMap<String, UserIdentity>) -> Self {
|
||||||
|
let hashed_tokens: Vec<([u8; 32], UserIdentity)> = tokens
|
||||||
|
.into_iter()
|
||||||
|
.map(|(tok, identity)| (hash_token(&tok), identity))
|
||||||
|
.collect();
|
||||||
|
Self {
|
||||||
|
hashed_tokens,
|
||||||
|
display_token: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticate a token, returning the associated identity if valid.
|
||||||
|
///
|
||||||
|
/// Uses SHA-256 hashing + constant-time comparison (`subtle::ConstantTimeEq`)
|
||||||
|
/// to prevent timing side-channels. Both the candidate and stored tokens are
|
||||||
|
/// hashed to 32-byte digests, eliminating length-oracle leaks. Iterates all
|
||||||
|
/// entries regardless of match to avoid early-exit timing differences.
|
||||||
|
/// O(n) in the number of configured users — negligible for typical
|
||||||
|
/// deployments (< 10 users).
|
||||||
|
pub fn authenticate(&self, candidate: &str) -> Option<&UserIdentity> {
|
||||||
|
let candidate_hash = hash_token(candidate);
|
||||||
|
let mut matched: Option<&UserIdentity> = None;
|
||||||
|
for (stored_hash, identity) in &self.hashed_tokens {
|
||||||
|
if bool::from(candidate_hash.ct_eq(stored_hash)) {
|
||||||
|
matched = Some(identity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matched
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the first token for backwards-compatible printing at startup.
|
||||||
|
///
|
||||||
|
/// Only available in single-user mode; returns `None` in multi-user mode
|
||||||
|
/// to avoid exposing tokens.
|
||||||
|
pub fn first_token(&self) -> Option<&str> {
|
||||||
|
self.display_token.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the first user identity (for single-user fallback).
|
||||||
|
pub fn first_identity(&self) -> Option<&UserIdentity> {
|
||||||
|
self.hashed_tokens.first().map(|(_, id)| id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Axum extractor that provides the authenticated user identity.
|
||||||
|
///
|
||||||
|
/// Only available on routes behind `auth_middleware`. Extracts the
|
||||||
|
/// `UserIdentity` that the middleware inserted into request extensions.
|
||||||
|
pub struct AuthenticatedUser(pub UserIdentity);
|
||||||
|
|
||||||
|
impl<S> FromRequestParts<S> for AuthenticatedUser
|
||||||
|
where
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = (StatusCode, &'static str);
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
parts
|
||||||
|
.extensions
|
||||||
|
.get::<UserIdentity>()
|
||||||
|
.cloned()
|
||||||
|
.map(AuthenticatedUser)
|
||||||
|
.ok_or((StatusCode::UNAUTHORIZED, "Not authenticated"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether query-string token auth is allowed for this request.
|
/// Whether query-string token auth is allowed for this request.
|
||||||
@@ -51,29 +167,34 @@ fn query_token(request: &Request) -> Option<String> {
|
|||||||
/// Auth middleware that validates bearer token from header or query param.
|
/// Auth middleware that validates bearer token from header or query param.
|
||||||
///
|
///
|
||||||
/// SSE connections can't set headers from `EventSource`, so we also accept
|
/// SSE connections can't set headers from `EventSource`, so we also accept
|
||||||
/// `?token=xxx` as a query parameter, but only on SSE endpoints.
|
/// `?token=xxx` as a query parameter, but only on SSE/WS endpoints.
|
||||||
|
///
|
||||||
|
/// On successful authentication, inserts the matching `UserIdentity` into
|
||||||
|
/// request extensions for downstream extraction via `AuthenticatedUser`.
|
||||||
pub async fn auth_middleware(
|
pub async fn auth_middleware(
|
||||||
State(auth): State<AuthState>,
|
State(auth): State<MultiAuthState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
request: Request,
|
mut request: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
// Try Authorization header first (constant-time comparison).
|
// Try Authorization header first.
|
||||||
// RFC 6750 Section 2.1: auth-scheme comparison is case-insensitive.
|
// RFC 6750 Section 2.1: auth-scheme comparison is case-insensitive.
|
||||||
if let Some(auth_header) = headers.get("authorization")
|
if let Some(auth_header) = headers.get("authorization")
|
||||||
&& let Ok(value) = auth_header.to_str()
|
&& let Ok(value) = auth_header.to_str()
|
||||||
&& value.len() > 7
|
&& value.len() > 7
|
||||||
&& value[..7].eq_ignore_ascii_case("Bearer ")
|
&& value[..7].eq_ignore_ascii_case("Bearer ")
|
||||||
&& bool::from(value.as_bytes()[7..].ct_eq(auth.token.as_bytes()))
|
&& let Some(identity) = auth.authenticate(&value[7..])
|
||||||
{
|
{
|
||||||
|
request.extensions_mut().insert(identity.clone());
|
||||||
return next.run(request).await;
|
return next.run(request).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to query parameter, but only for SSE endpoints (constant-time comparison).
|
// Fall back to query parameter, but only for SSE/WS endpoints.
|
||||||
if allows_query_token_auth(&request)
|
if allows_query_token_auth(&request)
|
||||||
&& let Some(token) = query_token(&request)
|
&& let Some(token) = query_token(&request)
|
||||||
&& bool::from(token.as_bytes().ct_eq(auth.token.as_bytes()))
|
&& let Some(identity) = auth.authenticate(&token)
|
||||||
{
|
{
|
||||||
|
request.extensions_mut().insert(identity.clone());
|
||||||
return next.run(request).await;
|
return next.run(request).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,15 +204,61 @@ pub async fn auth_middleware(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::testing::credentials::{TEST_AUTH_SECRET_TOKEN, TEST_BEARER_TOKEN};
|
use crate::testing::credentials::TEST_AUTH_SECRET_TOKEN;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_auth_state_clone() {
|
fn test_multi_auth_state_single() {
|
||||||
let state = AuthState {
|
let state = MultiAuthState::single("tok-123".to_string(), "alice".to_string());
|
||||||
token: TEST_BEARER_TOKEN.to_string(),
|
let identity = state.authenticate("tok-123");
|
||||||
};
|
assert!(identity.is_some());
|
||||||
let cloned = state.clone();
|
assert_eq!(identity.unwrap().user_id, "alice");
|
||||||
assert_eq!(cloned.token, TEST_BEARER_TOKEN);
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multi_auth_state_reject_wrong_token() {
|
||||||
|
let state = MultiAuthState::single("tok-123".to_string(), "alice".to_string());
|
||||||
|
assert!(state.authenticate("wrong-token").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multi_auth_state_multi_users() {
|
||||||
|
let mut tokens = HashMap::new();
|
||||||
|
tokens.insert(
|
||||||
|
"tok-alice".to_string(),
|
||||||
|
UserIdentity {
|
||||||
|
user_id: "alice".to_string(),
|
||||||
|
workspace_read_scopes: Vec::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
tokens.insert(
|
||||||
|
"tok-bob".to_string(),
|
||||||
|
UserIdentity {
|
||||||
|
user_id: "bob".to_string(),
|
||||||
|
workspace_read_scopes: Vec::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let state = MultiAuthState::multi(tokens);
|
||||||
|
|
||||||
|
let alice = state.authenticate("tok-alice").unwrap();
|
||||||
|
assert_eq!(alice.user_id, "alice");
|
||||||
|
|
||||||
|
let bob = state.authenticate("tok-bob").unwrap();
|
||||||
|
assert_eq!(bob.user_id, "bob");
|
||||||
|
|
||||||
|
assert!(state.authenticate("tok-charlie").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multi_auth_state_first_token() {
|
||||||
|
let state = MultiAuthState::single("my-token".to_string(), "user1".to_string());
|
||||||
|
assert_eq!(state.first_token(), Some("my-token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multi_auth_state_first_identity() {
|
||||||
|
let state = MultiAuthState::single("my-token".to_string(), "user1".to_string());
|
||||||
|
let identity = state.first_identity().unwrap();
|
||||||
|
assert_eq!(identity.user_id, "user1");
|
||||||
}
|
}
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
@@ -107,9 +274,7 @@ mod tests {
|
|||||||
/// Router with streaming endpoints (query auth allowed) and regular
|
/// Router with streaming endpoints (query auth allowed) and regular
|
||||||
/// endpoints (query auth rejected).
|
/// endpoints (query auth rejected).
|
||||||
fn test_app(token: &str) -> Router {
|
fn test_app(token: &str) -> Router {
|
||||||
let state = AuthState {
|
let state = MultiAuthState::single(token.to_string(), "test-user".to_string());
|
||||||
token: token.to_string(),
|
|
||||||
};
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/chat/events", get(dummy_handler))
|
.route("/api/chat/events", get(dummy_handler))
|
||||||
.route("/api/logs/events", get(dummy_handler))
|
.route("/api/logs/events", get(dummy_handler))
|
||||||
@@ -306,4 +471,200 @@ mod tests {
|
|||||||
let resp = app.oneshot(req).await.unwrap();
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Multi-tenant auth integration tests ---
|
||||||
|
|
||||||
|
/// Handler that extracts `AuthenticatedUser` and returns the resolved user_id.
|
||||||
|
async fn identity_handler(AuthenticatedUser(identity): AuthenticatedUser) -> String {
|
||||||
|
identity.user_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler that extracts `AuthenticatedUser` and returns workspace_read_scopes as JSON.
|
||||||
|
async fn scopes_handler(AuthenticatedUser(identity): AuthenticatedUser) -> String {
|
||||||
|
serde_json::to_string(&identity.workspace_read_scopes).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a multi-user router where each token maps to a distinct identity.
|
||||||
|
fn multi_user_app(tokens: HashMap<String, UserIdentity>) -> Router {
|
||||||
|
let state = MultiAuthState::multi(tokens);
|
||||||
|
Router::new()
|
||||||
|
.route("/api/chat/events", get(identity_handler))
|
||||||
|
.route("/api/chat/send", post(identity_handler))
|
||||||
|
.route("/api/scopes", get(scopes_handler))
|
||||||
|
.layer(middleware::from_fn_with_state(state, auth_middleware))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn two_user_tokens() -> HashMap<String, UserIdentity> {
|
||||||
|
let mut tokens = HashMap::new();
|
||||||
|
tokens.insert(
|
||||||
|
"tok-alice".to_string(),
|
||||||
|
UserIdentity {
|
||||||
|
user_id: "alice".to_string(),
|
||||||
|
workspace_read_scopes: vec!["shared".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
tokens.insert(
|
||||||
|
"tok-bob".to_string(),
|
||||||
|
UserIdentity {
|
||||||
|
user_id: "bob".to_string(),
|
||||||
|
workspace_read_scopes: vec!["shared".to_string(), "alice".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_multi_user_alice_token_resolves_to_alice() {
|
||||||
|
let app = multi_user_app(two_user_tokens());
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/api/chat/events")
|
||||||
|
.header("Authorization", "Bearer tok-alice")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
|
||||||
|
assert_eq!(body, "alice");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_multi_user_bob_token_resolves_to_bob() {
|
||||||
|
let app = multi_user_app(two_user_tokens());
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/api/chat/events")
|
||||||
|
.header("Authorization", "Bearer tok-bob")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
|
||||||
|
assert_eq!(body, "bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_multi_user_sequential_tokens_resolve_independently() {
|
||||||
|
// Send both alice and bob tokens sequentially and verify each gets
|
||||||
|
// the correct identity — guards against token map corruption.
|
||||||
|
let tokens = two_user_tokens();
|
||||||
|
|
||||||
|
let app1 = multi_user_app(tokens.clone());
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/api/chat/events")
|
||||||
|
.header("Authorization", "Bearer tok-alice")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app1.oneshot(req).await.unwrap();
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
|
||||||
|
assert_eq!(body, "alice");
|
||||||
|
|
||||||
|
let app2 = multi_user_app(tokens);
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/api/chat/events")
|
||||||
|
.header("Authorization", "Bearer tok-bob")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app2.oneshot(req).await.unwrap();
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
|
||||||
|
assert_eq!(body, "bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_multi_user_unknown_token_rejected() {
|
||||||
|
let app = multi_user_app(two_user_tokens());
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/api/chat/events")
|
||||||
|
.header("Authorization", "Bearer tok-charlie")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_multi_user_workspace_read_scopes_propagated() {
|
||||||
|
let app = multi_user_app(two_user_tokens());
|
||||||
|
|
||||||
|
// Alice has ["shared"]
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/api/scopes")
|
||||||
|
.header("Authorization", "Bearer tok-alice")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
|
||||||
|
let scopes: Vec<String> = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(scopes, vec!["shared"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_multi_user_bob_has_two_scopes() {
|
||||||
|
let app = multi_user_app(two_user_tokens());
|
||||||
|
|
||||||
|
// Bob has ["shared", "alice"]
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/api/scopes")
|
||||||
|
.header("Authorization", "Bearer tok-bob")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
|
||||||
|
let scopes: Vec<String> = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(scopes, vec!["shared", "alice"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_multi_user_query_param_resolves_correct_identity() {
|
||||||
|
let app = multi_user_app(two_user_tokens());
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/api/chat/events?token=tok-bob")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
|
||||||
|
assert_eq!(body, "bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_multi_user_post_with_bearer_resolves_identity() {
|
||||||
|
let app = multi_user_app(two_user_tokens());
|
||||||
|
let req = Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.uri("/api/chat/send")
|
||||||
|
.header("Authorization", "Bearer tok-alice")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
|
||||||
|
assert_eq!(body, "alice");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_multi_user_empty_scopes_for_single_user() {
|
||||||
|
// Single-user mode creates identity with empty workspace_read_scopes.
|
||||||
|
let state = MultiAuthState::single("tok-only".to_string(), "solo".to_string());
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/api/scopes", get(scopes_handler))
|
||||||
|
.layer(middleware::from_fn_with_state(state, auth_middleware));
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/api/scopes")
|
||||||
|
.header("Authorization", "Bearer tok-only")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
|
||||||
|
let scopes: Vec<String> = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(scopes.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_prefix_and_extension_tokens_rejected() {
|
||||||
|
// Verifies that prefix/suffix variants of valid tokens are rejected.
|
||||||
|
// Note: the constant-time property is enforced structurally by use of
|
||||||
|
// subtle::ConstantTimeEq and cannot be verified via outcome testing.
|
||||||
|
let state = MultiAuthState::single("long-secret-token".to_string(), "user".to_string());
|
||||||
|
assert!(state.authenticate("long-secret").is_none());
|
||||||
|
assert!(state.authenticate("long-secret-token-extra").is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,22 +12,24 @@ use serde::Deserialize;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::channels::IncomingMessage;
|
use crate::channels::IncomingMessage;
|
||||||
|
use crate::channels::web::auth::AuthenticatedUser;
|
||||||
use crate::channels::web::server::GatewayState;
|
use crate::channels::web::server::GatewayState;
|
||||||
use crate::channels::web::types::*;
|
use crate::channels::web::types::*;
|
||||||
use crate::channels::web::util::{build_turns_from_db_messages, truncate_preview};
|
use crate::channels::web::util::{build_turns_from_db_messages, truncate_preview};
|
||||||
|
|
||||||
pub async fn chat_send_handler(
|
pub async fn chat_send_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(identity): AuthenticatedUser,
|
||||||
Json(req): Json<SendMessageRequest>,
|
Json(req): Json<SendMessageRequest>,
|
||||||
) -> Result<(StatusCode, Json<SendMessageResponse>), (StatusCode, String)> {
|
) -> Result<(StatusCode, Json<SendMessageResponse>), (StatusCode, String)> {
|
||||||
if !state.chat_rate_limiter.check() {
|
if !state.chat_rate_limiter.check(&identity.user_id) {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::TOO_MANY_REQUESTS,
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
"Rate limit exceeded. Try again shortly.".to_string(),
|
"Rate limit exceeded. Try again shortly.".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut msg = IncomingMessage::new("gateway", &state.user_id, &req.content);
|
let mut msg = IncomingMessage::new("gateway", &identity.user_id, &req.content);
|
||||||
|
|
||||||
if let Some(ref thread_id) = req.thread_id {
|
if let Some(ref thread_id) = req.thread_id {
|
||||||
msg = msg.with_thread(thread_id);
|
msg = msg.with_thread(thread_id);
|
||||||
@@ -74,6 +76,7 @@ pub async fn chat_send_handler(
|
|||||||
|
|
||||||
pub async fn chat_approval_handler(
|
pub async fn chat_approval_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(identity): AuthenticatedUser,
|
||||||
Json(req): Json<ApprovalRequest>,
|
Json(req): Json<ApprovalRequest>,
|
||||||
) -> Result<(StatusCode, Json<SendMessageResponse>), (StatusCode, String)> {
|
) -> Result<(StatusCode, Json<SendMessageResponse>), (StatusCode, String)> {
|
||||||
let (approved, always) = match req.action.as_str() {
|
let (approved, always) = match req.action.as_str() {
|
||||||
@@ -109,7 +112,7 @@ pub async fn chat_approval_handler(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut msg = IncomingMessage::new("gateway", &state.user_id, content);
|
let mut msg = IncomingMessage::new("gateway", &identity.user_id, content);
|
||||||
|
|
||||||
if let Some(ref thread_id) = req.thread_id {
|
if let Some(ref thread_id) = req.thread_id {
|
||||||
msg = msg.with_thread(thread_id);
|
msg = msg.with_thread(thread_id);
|
||||||
@@ -150,6 +153,7 @@ pub async fn chat_approval_handler(
|
|||||||
/// The token never touches the LLM, chat history, or SSE stream.
|
/// The token never touches the LLM, chat history, or SSE stream.
|
||||||
pub async fn chat_auth_token_handler(
|
pub async fn chat_auth_token_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
Json(req): Json<AuthTokenRequest>,
|
Json(req): Json<AuthTokenRequest>,
|
||||||
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
||||||
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
||||||
@@ -158,7 +162,7 @@ pub async fn chat_auth_token_handler(
|
|||||||
))?;
|
))?;
|
||||||
|
|
||||||
match ext_mgr
|
match ext_mgr
|
||||||
.configure_token(&req.extension_name, &req.token)
|
.configure_token(&req.extension_name, &req.token, &user.user_id)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
@@ -169,20 +173,26 @@ pub async fn chat_auth_token_handler(
|
|||||||
resp.instructions = result.verification.as_ref().map(|v| v.instructions.clone());
|
resp.instructions = result.verification.as_ref().map(|v| v.instructions.clone());
|
||||||
|
|
||||||
if result.verification.is_some() {
|
if result.verification.is_some() {
|
||||||
state.sse.broadcast(SseEvent::AuthRequired {
|
state.sse.broadcast_for_user(
|
||||||
extension_name: req.extension_name.clone(),
|
&user.user_id,
|
||||||
instructions: Some(result.message),
|
SseEvent::AuthRequired {
|
||||||
auth_url: None,
|
extension_name: req.extension_name.clone(),
|
||||||
setup_url: None,
|
instructions: Some(result.message),
|
||||||
});
|
auth_url: None,
|
||||||
|
setup_url: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
clear_auth_mode(&state).await;
|
clear_auth_mode(&state, &user.user_id).await;
|
||||||
|
|
||||||
state.sse.broadcast(SseEvent::AuthCompleted {
|
state.sse.broadcast_for_user(
|
||||||
extension_name: req.extension_name.clone(),
|
&user.user_id,
|
||||||
success: true,
|
SseEvent::AuthCompleted {
|
||||||
message: result.message,
|
extension_name: req.extension_name.clone(),
|
||||||
});
|
success: true,
|
||||||
|
message: result.message,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(resp))
|
Ok(Json(resp))
|
||||||
@@ -190,12 +200,15 @@ pub async fn chat_auth_token_handler(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
let msg = e.to_string();
|
let msg = e.to_string();
|
||||||
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
|
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
|
||||||
state.sse.broadcast(SseEvent::AuthRequired {
|
state.sse.broadcast_for_user(
|
||||||
extension_name: req.extension_name.clone(),
|
&user.user_id,
|
||||||
instructions: Some(msg.clone()),
|
SseEvent::AuthRequired {
|
||||||
auth_url: None,
|
extension_name: req.extension_name.clone(),
|
||||||
setup_url: None,
|
instructions: Some(msg.clone()),
|
||||||
});
|
auth_url: None,
|
||||||
|
setup_url: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(Json(ActionResponse::fail(msg)))
|
Ok(Json(ActionResponse::fail(msg)))
|
||||||
}
|
}
|
||||||
@@ -205,16 +218,17 @@ pub async fn chat_auth_token_handler(
|
|||||||
/// Cancel an in-progress auth flow.
|
/// Cancel an in-progress auth flow.
|
||||||
pub async fn chat_auth_cancel_handler(
|
pub async fn chat_auth_cancel_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(identity): AuthenticatedUser,
|
||||||
Json(_req): Json<AuthCancelRequest>,
|
Json(_req): Json<AuthCancelRequest>,
|
||||||
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
||||||
clear_auth_mode(&state).await;
|
clear_auth_mode(&state, &identity.user_id).await;
|
||||||
Ok(Json(ActionResponse::ok("Auth cancelled")))
|
Ok(Json(ActionResponse::ok("Auth cancelled")))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear pending auth mode on the active thread.
|
/// Clear pending auth mode on the active thread.
|
||||||
pub async fn clear_auth_mode(state: &GatewayState) {
|
pub async fn clear_auth_mode(state: &GatewayState, user_id: &str) {
|
||||||
if let Some(ref sm) = state.session_manager {
|
if let Some(ref sm) = state.session_manager {
|
||||||
let session = sm.get_or_create_session(&state.user_id).await;
|
let session = sm.get_or_create_session(user_id).await;
|
||||||
let mut sess = session.lock().await;
|
let mut sess = session.lock().await;
|
||||||
if let Some(thread_id) = sess.active_thread
|
if let Some(thread_id) = sess.active_thread
|
||||||
&& let Some(thread) = sess.threads.get_mut(&thread_id)
|
&& let Some(thread) = sess.threads.get_mut(&thread_id)
|
||||||
@@ -226,8 +240,9 @@ pub async fn clear_auth_mode(state: &GatewayState) {
|
|||||||
|
|
||||||
pub async fn chat_events_handler(
|
pub async fn chat_events_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
state.sse.subscribe().ok_or((
|
state.sse.subscribe(Some(user.user_id)).ok_or((
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
"Too many connections".to_string(),
|
"Too many connections".to_string(),
|
||||||
))
|
))
|
||||||
@@ -237,6 +252,7 @@ pub async fn chat_ws_handler(
|
|||||||
headers: axum::http::HeaderMap,
|
headers: axum::http::HeaderMap,
|
||||||
ws: WebSocketUpgrade,
|
ws: WebSocketUpgrade,
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(identity): AuthenticatedUser,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
// Validate Origin header to prevent cross-site WebSocket hijacking.
|
// Validate Origin header to prevent cross-site WebSocket hijacking.
|
||||||
let origin = headers
|
let origin = headers
|
||||||
@@ -262,7 +278,9 @@ pub async fn chat_ws_handler(
|
|||||||
"WebSocket origin not allowed".to_string(),
|
"WebSocket origin not allowed".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(ws.on_upgrade(move |socket| crate::channels::web::ws::handle_ws_connection(socket, state)))
|
Ok(ws.on_upgrade(move |socket| {
|
||||||
|
crate::channels::web::ws::handle_ws_connection(socket, state, identity)
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -274,6 +292,7 @@ pub struct HistoryQuery {
|
|||||||
|
|
||||||
pub async fn chat_history_handler(
|
pub async fn chat_history_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(identity): AuthenticatedUser,
|
||||||
Query(query): Query<HistoryQuery>,
|
Query(query): Query<HistoryQuery>,
|
||||||
) -> Result<Json<HistoryResponse>, (StatusCode, String)> {
|
) -> Result<Json<HistoryResponse>, (StatusCode, String)> {
|
||||||
let session_manager = state.session_manager.as_ref().ok_or((
|
let session_manager = state.session_manager.as_ref().ok_or((
|
||||||
@@ -281,7 +300,9 @@ pub async fn chat_history_handler(
|
|||||||
"Session manager not available".to_string(),
|
"Session manager not available".to_string(),
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
let session = session_manager.get_or_create_session(&state.user_id).await;
|
let session = session_manager
|
||||||
|
.get_or_create_session(&identity.user_id)
|
||||||
|
.await;
|
||||||
|
|
||||||
let limit = query.limit.unwrap_or(50);
|
let limit = query.limit.unwrap_or(50);
|
||||||
let before_cursor = query
|
let before_cursor = query
|
||||||
@@ -314,7 +335,7 @@ pub async fn chat_history_handler(
|
|||||||
&& let Some(ref store) = state.store
|
&& let Some(ref store) = state.store
|
||||||
{
|
{
|
||||||
let owned = store
|
let owned = store
|
||||||
.conversation_belongs_to_user(thread_id, &state.user_id)
|
.conversation_belongs_to_user(thread_id, &identity.user_id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if !owned {
|
if !owned {
|
||||||
@@ -434,24 +455,27 @@ pub async fn chat_history_handler(
|
|||||||
|
|
||||||
pub async fn chat_threads_handler(
|
pub async fn chat_threads_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(identity): AuthenticatedUser,
|
||||||
) -> Result<Json<ThreadListResponse>, (StatusCode, String)> {
|
) -> Result<Json<ThreadListResponse>, (StatusCode, String)> {
|
||||||
let session_manager = state.session_manager.as_ref().ok_or((
|
let session_manager = state.session_manager.as_ref().ok_or((
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
"Session manager not available".to_string(),
|
"Session manager not available".to_string(),
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
let session = session_manager.get_or_create_session(&state.user_id).await;
|
let session = session_manager
|
||||||
|
.get_or_create_session(&identity.user_id)
|
||||||
|
.await;
|
||||||
|
|
||||||
// Try DB first for persistent thread list
|
// Try DB first for persistent thread list
|
||||||
if let Some(ref store) = state.store {
|
if let Some(ref store) = state.store {
|
||||||
// Auto-create assistant thread if it doesn't exist
|
// Auto-create assistant thread if it doesn't exist
|
||||||
let assistant_id = store
|
let assistant_id = store
|
||||||
.get_or_create_assistant_conversation(&state.user_id, "gateway")
|
.get_or_create_assistant_conversation(&identity.user_id, "gateway")
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if let Ok(summaries) = store
|
if let Ok(summaries) = store
|
||||||
.list_conversations_all_channels(&state.user_id, 50)
|
.list_conversations_all_channels(&identity.user_id, 50)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
let mut assistant_thread = None;
|
let mut assistant_thread = None;
|
||||||
@@ -534,13 +558,16 @@ pub async fn chat_threads_handler(
|
|||||||
|
|
||||||
pub async fn chat_new_thread_handler(
|
pub async fn chat_new_thread_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(identity): AuthenticatedUser,
|
||||||
) -> Result<Json<ThreadInfo>, (StatusCode, String)> {
|
) -> Result<Json<ThreadInfo>, (StatusCode, String)> {
|
||||||
let session_manager = state.session_manager.as_ref().ok_or((
|
let session_manager = state.session_manager.as_ref().ok_or((
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
"Session manager not available".to_string(),
|
"Session manager not available".to_string(),
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
let session = session_manager.get_or_create_session(&state.user_id).await;
|
let session = session_manager
|
||||||
|
.get_or_create_session(&identity.user_id)
|
||||||
|
.await;
|
||||||
let (thread_id, info) = {
|
let (thread_id, info) = {
|
||||||
let mut sess = session.lock().await;
|
let mut sess = session.lock().await;
|
||||||
let thread = sess.create_thread();
|
let thread = sess.create_thread();
|
||||||
@@ -562,12 +589,12 @@ pub async fn chat_new_thread_handler(
|
|||||||
// so that the subsequent loadThreads() call from the frontend sees it.
|
// so that the subsequent loadThreads() call from the frontend sees it.
|
||||||
if let Some(ref store) = state.store {
|
if let Some(ref store) = state.store {
|
||||||
match store
|
match store
|
||||||
.ensure_conversation(thread_id, "gateway", &state.user_id, None)
|
.ensure_conversation(thread_id, "gateway", &identity.user_id, None)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(true) => {}
|
Ok(true) => {}
|
||||||
Ok(false) => tracing::warn!(
|
Ok(false) => tracing::warn!(
|
||||||
user = %state.user_id,
|
user = %identity.user_id,
|
||||||
thread_id = %thread_id,
|
thread_id = %thread_id,
|
||||||
"Skipped persisting new thread due to ownership/channel conflict"
|
"Skipped persisting new thread due to ownership/channel conflict"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ use axum::{
|
|||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::channels::web::auth::AuthenticatedUser;
|
||||||
use crate::channels::web::server::GatewayState;
|
use crate::channels::web::server::GatewayState;
|
||||||
use crate::channels::web::types::*;
|
use crate::channels::web::types::*;
|
||||||
|
|
||||||
pub async fn extensions_list_handler(
|
pub async fn extensions_list_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
) -> Result<Json<ExtensionListResponse>, (StatusCode, String)> {
|
) -> Result<Json<ExtensionListResponse>, (StatusCode, String)> {
|
||||||
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
||||||
StatusCode::NOT_IMPLEMENTED,
|
StatusCode::NOT_IMPLEMENTED,
|
||||||
@@ -20,7 +22,7 @@ pub async fn extensions_list_handler(
|
|||||||
))?;
|
))?;
|
||||||
|
|
||||||
let installed = ext_mgr
|
let installed = ext_mgr
|
||||||
.list(None, false)
|
.list(None, false, &user.user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
@@ -80,6 +82,7 @@ pub async fn extensions_list_handler(
|
|||||||
|
|
||||||
pub async fn extensions_tools_handler(
|
pub async fn extensions_tools_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(_user): AuthenticatedUser,
|
||||||
) -> Result<Json<ToolListResponse>, (StatusCode, String)> {
|
) -> Result<Json<ToolListResponse>, (StatusCode, String)> {
|
||||||
let registry = state.tool_registry.as_ref().ok_or((
|
let registry = state.tool_registry.as_ref().ok_or((
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
@@ -100,6 +103,7 @@ pub async fn extensions_tools_handler(
|
|||||||
|
|
||||||
pub async fn extensions_install_handler(
|
pub async fn extensions_install_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
Json(req): Json<InstallExtensionRequest>,
|
Json(req): Json<InstallExtensionRequest>,
|
||||||
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
||||||
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
||||||
@@ -116,7 +120,7 @@ pub async fn extensions_install_handler(
|
|||||||
});
|
});
|
||||||
|
|
||||||
match ext_mgr
|
match ext_mgr
|
||||||
.install(&req.name, req.url.as_deref(), kind_hint)
|
.install(&req.name, req.url.as_deref(), kind_hint, &user.user_id)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(result) => Ok(Json(ActionResponse::ok(result.message))),
|
Ok(result) => Ok(Json(ActionResponse::ok(result.message))),
|
||||||
@@ -126,6 +130,7 @@ pub async fn extensions_install_handler(
|
|||||||
|
|
||||||
pub async fn extensions_remove_handler(
|
pub async fn extensions_remove_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
Path(name): Path<String>,
|
Path(name): Path<String>,
|
||||||
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
||||||
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
||||||
@@ -133,7 +138,7 @@ pub async fn extensions_remove_handler(
|
|||||||
"Extension manager not available (secrets store required)".to_string(),
|
"Extension manager not available (secrets store required)".to_string(),
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
match ext_mgr.remove(&name).await {
|
match ext_mgr.remove(&name, &user.user_id).await {
|
||||||
Ok(message) => Ok(Json(ActionResponse::ok(message))),
|
Ok(message) => Ok(Json(ActionResponse::ok(message))),
|
||||||
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
|
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
|
||||||
}
|
}
|
||||||
|
|||||||
+388
-277
@@ -11,11 +11,13 @@ use axum::{
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::channels::web::auth::AuthenticatedUser;
|
||||||
use crate::channels::web::server::GatewayState;
|
use crate::channels::web::server::GatewayState;
|
||||||
use crate::channels::web::types::*;
|
use crate::channels::web::types::*;
|
||||||
|
|
||||||
pub async fn jobs_list_handler(
|
pub async fn jobs_list_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
) -> Result<Json<JobListResponse>, (StatusCode, String)> {
|
) -> Result<Json<JobListResponse>, (StatusCode, String)> {
|
||||||
let store = state.store.as_ref().ok_or((
|
let store = state.store.as_ref().ok_or((
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
@@ -25,8 +27,8 @@ pub async fn jobs_list_handler(
|
|||||||
let mut jobs: Vec<JobInfo> = Vec::new();
|
let mut jobs: Vec<JobInfo> = Vec::new();
|
||||||
let mut seen_ids: HashSet<Uuid> = HashSet::new();
|
let mut seen_ids: HashSet<Uuid> = HashSet::new();
|
||||||
|
|
||||||
// Fetch sandbox jobs from database.
|
// Fetch sandbox jobs scoped to this user.
|
||||||
match store.list_sandbox_jobs().await {
|
match store.list_sandbox_jobs_for_user(&user.user_id).await {
|
||||||
Ok(sandbox_jobs) => {
|
Ok(sandbox_jobs) => {
|
||||||
for j in &sandbox_jobs {
|
for j in &sandbox_jobs {
|
||||||
let ui_state = match j.status.as_str() {
|
let ui_state = match j.status.as_str() {
|
||||||
@@ -50,8 +52,8 @@ pub async fn jobs_list_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch agent (non-sandbox) jobs from database, deduplicating by ID.
|
// Fetch agent (non-sandbox) jobs scoped to this user, deduplicating by ID.
|
||||||
match store.list_agent_jobs().await {
|
match store.list_agent_jobs_for_user(&user.user_id).await {
|
||||||
Ok(agent_jobs) => {
|
Ok(agent_jobs) => {
|
||||||
for j in &agent_jobs {
|
for j in &agent_jobs {
|
||||||
if seen_ids.contains(&j.id) {
|
if seen_ids.contains(&j.id) {
|
||||||
@@ -80,6 +82,7 @@ pub async fn jobs_list_handler(
|
|||||||
|
|
||||||
pub async fn jobs_summary_handler(
|
pub async fn jobs_summary_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
) -> Result<Json<JobSummaryResponse>, (StatusCode, String)> {
|
) -> Result<Json<JobSummaryResponse>, (StatusCode, String)> {
|
||||||
let store = state.store.as_ref().ok_or((
|
let store = state.store.as_ref().ok_or((
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
@@ -93,8 +96,8 @@ pub async fn jobs_summary_handler(
|
|||||||
let mut failed = 0;
|
let mut failed = 0;
|
||||||
let mut stuck = 0;
|
let mut stuck = 0;
|
||||||
|
|
||||||
// Sandbox job counts.
|
// Sandbox job counts scoped to this user.
|
||||||
match store.sandbox_job_summary().await {
|
match store.sandbox_job_summary_for_user(&user.user_id).await {
|
||||||
Ok(s) => {
|
Ok(s) => {
|
||||||
total += s.total;
|
total += s.total;
|
||||||
pending += s.creating;
|
pending += s.creating;
|
||||||
@@ -107,8 +110,8 @@ pub async fn jobs_summary_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agent job counts.
|
// Agent job counts scoped to this user.
|
||||||
match store.agent_job_summary().await {
|
match store.agent_job_summary_for_user(&user.user_id).await {
|
||||||
Ok(s) => {
|
Ok(s) => {
|
||||||
total += s.total;
|
total += s.total;
|
||||||
pending += s.pending;
|
pending += s.pending;
|
||||||
@@ -134,6 +137,7 @@ pub async fn jobs_summary_handler(
|
|||||||
|
|
||||||
pub async fn jobs_detail_handler(
|
pub async fn jobs_detail_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<JobDetailResponse>, (StatusCode, String)> {
|
) -> Result<Json<JobDetailResponse>, (StatusCode, String)> {
|
||||||
let store = state.store.as_ref().ok_or((
|
let store = state.store.as_ref().ok_or((
|
||||||
@@ -145,169 +149,213 @@ pub async fn jobs_detail_handler(
|
|||||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||||
|
|
||||||
// Try sandbox job from DB first.
|
// Try sandbox job from DB first.
|
||||||
if let Ok(Some(job)) = store.get_sandbox_job(job_id).await {
|
match store.get_sandbox_job(job_id).await {
|
||||||
let browse_id = std::path::Path::new(&job.project_dir)
|
Ok(Some(job)) => {
|
||||||
.file_name()
|
if job.user_id != user.user_id {
|
||||||
.map(|n| n.to_string_lossy().to_string())
|
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||||
.unwrap_or_else(|| job.id.to_string());
|
}
|
||||||
|
let browse_id = std::path::Path::new(&job.project_dir)
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| job.id.to_string());
|
||||||
|
|
||||||
let ui_state = match job.status.as_str() {
|
let ui_state = match job.status.as_str() {
|
||||||
"creating" => "pending",
|
"creating" => "pending",
|
||||||
"running" => "in_progress",
|
"running" => "in_progress",
|
||||||
s => s,
|
s => s,
|
||||||
};
|
};
|
||||||
|
|
||||||
let elapsed_secs = job.started_at.map(|start| {
|
let elapsed_secs = job.started_at.map(|start| {
|
||||||
let end = job.completed_at.unwrap_or_else(chrono::Utc::now);
|
let end = job.completed_at.unwrap_or_else(chrono::Utc::now);
|
||||||
(end - start).num_seconds().max(0) as u64
|
(end - start).num_seconds().max(0) as u64
|
||||||
});
|
|
||||||
|
|
||||||
// Synthesize transitions from timestamps.
|
|
||||||
let mut transitions = Vec::new();
|
|
||||||
if let Some(started) = job.started_at {
|
|
||||||
transitions.push(TransitionInfo {
|
|
||||||
from: "creating".to_string(),
|
|
||||||
to: "running".to_string(),
|
|
||||||
timestamp: started.to_rfc3339(),
|
|
||||||
reason: None,
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
if let Some(completed) = job.completed_at {
|
|
||||||
transitions.push(TransitionInfo {
|
|
||||||
from: "running".to_string(),
|
|
||||||
to: job.status.clone(),
|
|
||||||
timestamp: completed.to_rfc3339(),
|
|
||||||
reason: job.failure_reason.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let mode = store.get_sandbox_job_mode(job.id).await.ok().flatten();
|
// Synthesize transitions from timestamps.
|
||||||
let is_claude_code = mode.as_deref() == Some("claude_code");
|
let mut transitions = Vec::new();
|
||||||
|
if let Some(started) = job.started_at {
|
||||||
|
transitions.push(TransitionInfo {
|
||||||
|
from: "creating".to_string(),
|
||||||
|
to: "running".to_string(),
|
||||||
|
timestamp: started.to_rfc3339(),
|
||||||
|
reason: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(completed) = job.completed_at {
|
||||||
|
transitions.push(TransitionInfo {
|
||||||
|
from: "running".to_string(),
|
||||||
|
to: job.status.clone(),
|
||||||
|
timestamp: completed.to_rfc3339(),
|
||||||
|
reason: job.failure_reason.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(Json(JobDetailResponse {
|
let mode = store.get_sandbox_job_mode(job.id).await.ok().flatten();
|
||||||
id: job.id,
|
let is_claude_code = mode.as_deref() == Some("claude_code");
|
||||||
title: job.task.clone(),
|
|
||||||
description: String::new(),
|
return Ok(Json(JobDetailResponse {
|
||||||
state: ui_state.to_string(),
|
id: job.id,
|
||||||
user_id: job.user_id.clone(),
|
title: job.task.clone(),
|
||||||
created_at: job.created_at.to_rfc3339(),
|
description: String::new(),
|
||||||
started_at: job.started_at.map(|dt| dt.to_rfc3339()),
|
state: ui_state.to_string(),
|
||||||
completed_at: job.completed_at.map(|dt| dt.to_rfc3339()),
|
user_id: job.user_id.clone(),
|
||||||
elapsed_secs,
|
created_at: job.created_at.to_rfc3339(),
|
||||||
project_dir: Some(job.project_dir.clone()),
|
started_at: job.started_at.map(|dt| dt.to_rfc3339()),
|
||||||
browse_url: Some(format!("/projects/{}/", browse_id)),
|
completed_at: job.completed_at.map(|dt| dt.to_rfc3339()),
|
||||||
job_mode: mode.filter(|m| m != "worker"),
|
elapsed_secs,
|
||||||
transitions,
|
project_dir: Some(job.project_dir.clone()),
|
||||||
can_restart: state.job_manager.is_some(),
|
browse_url: Some(format!("/projects/{}/", browse_id)),
|
||||||
can_prompt: is_claude_code && state.prompt_queue.is_some(),
|
job_mode: mode.filter(|m| m != "worker"),
|
||||||
job_kind: Some("sandbox".to_string()),
|
transitions,
|
||||||
}));
|
can_restart: state.job_manager.is_some(),
|
||||||
|
can_prompt: is_claude_code && state.prompt_queue.is_some(),
|
||||||
|
job_kind: Some("sandbox".to_string()),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(e) => {
|
||||||
|
return Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Database error: {}", e),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to agent job from DB.
|
// Fall back to agent job from DB.
|
||||||
if let Ok(Some(ctx)) = store.get_job(job_id).await {
|
match store.get_job(job_id).await {
|
||||||
let elapsed_secs = ctx.started_at.map(|start| {
|
Ok(Some(ctx)) => {
|
||||||
let end = ctx.completed_at.unwrap_or_else(chrono::Utc::now);
|
if ctx.user_id != user.user_id {
|
||||||
(end - start).num_seconds().max(0) as u64
|
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||||
});
|
}
|
||||||
|
let elapsed_secs = ctx.started_at.map(|start| {
|
||||||
|
let end = ctx.completed_at.unwrap_or_else(chrono::Utc::now);
|
||||||
|
(end - start).num_seconds().max(0) as u64
|
||||||
|
});
|
||||||
|
|
||||||
// Only show prompt bar for jobs that have a running worker (Pending/InProgress).
|
// Only show prompt bar for jobs that have a running worker (Pending/InProgress).
|
||||||
// Stuck jobs have no active worker loop, so messages would be silently dropped.
|
// Stuck jobs have no active worker loop, so messages would be silently dropped.
|
||||||
let is_promptable = matches!(
|
let is_promptable = matches!(
|
||||||
ctx.state,
|
ctx.state,
|
||||||
crate::context::JobState::Pending | crate::context::JobState::InProgress
|
crate::context::JobState::Pending | crate::context::JobState::InProgress
|
||||||
);
|
);
|
||||||
return Ok(Json(JobDetailResponse {
|
Ok(Json(JobDetailResponse {
|
||||||
id: ctx.job_id,
|
id: ctx.job_id,
|
||||||
title: ctx.title.clone(),
|
title: ctx.title.clone(),
|
||||||
description: ctx.description.clone(),
|
description: ctx.description.clone(),
|
||||||
state: ctx.state.to_string(),
|
state: ctx.state.to_string(),
|
||||||
user_id: ctx.user_id.clone(),
|
user_id: ctx.user_id.clone(),
|
||||||
created_at: ctx.created_at.to_rfc3339(),
|
created_at: ctx.created_at.to_rfc3339(),
|
||||||
started_at: ctx.started_at.map(|dt| dt.to_rfc3339()),
|
started_at: ctx.started_at.map(|dt| dt.to_rfc3339()),
|
||||||
completed_at: ctx.completed_at.map(|dt| dt.to_rfc3339()),
|
completed_at: ctx.completed_at.map(|dt| dt.to_rfc3339()),
|
||||||
elapsed_secs,
|
elapsed_secs,
|
||||||
project_dir: None,
|
project_dir: None,
|
||||||
browse_url: None,
|
browse_url: None,
|
||||||
job_mode: None,
|
job_mode: None,
|
||||||
transitions: Vec::new(),
|
transitions: Vec::new(),
|
||||||
can_restart: state.scheduler.is_some(),
|
can_restart: state.scheduler.is_some(),
|
||||||
can_prompt: is_promptable && state.scheduler.is_some(),
|
can_prompt: is_promptable && state.scheduler.is_some(),
|
||||||
job_kind: Some("agent".to_string()),
|
job_kind: Some("agent".to_string()),
|
||||||
}));
|
}))
|
||||||
|
}
|
||||||
|
Ok(None) => Err((StatusCode::NOT_FOUND, "Job not found".to_string())),
|
||||||
|
Err(e) => Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Database error: {}", e),
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
|
|
||||||
Err((StatusCode::NOT_FOUND, "Job not found".to_string()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn jobs_cancel_handler(
|
pub async fn jobs_cancel_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
let job_id = Uuid::parse_str(&id)
|
let job_id = Uuid::parse_str(&id)
|
||||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||||
|
|
||||||
// Try sandbox job cancellation.
|
// Try sandbox job cancellation.
|
||||||
if let Some(ref store) = state.store
|
if let Some(ref store) = state.store {
|
||||||
&& let Ok(Some(job)) = store.get_sandbox_job(job_id).await
|
match store.get_sandbox_job(job_id).await {
|
||||||
{
|
Ok(Some(job)) => {
|
||||||
if job.status == "running" || job.status == "creating" {
|
if job.user_id != user.user_id {
|
||||||
// Stop the container if we have a job manager.
|
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||||
if let Some(ref jm) = state.job_manager
|
}
|
||||||
&& let Err(e) = jm.stop_job(job_id).await
|
if job.status == "running" || job.status == "creating" {
|
||||||
{
|
if let Some(ref jm) = state.job_manager
|
||||||
tracing::warn!(job_id = %job_id, error = %e, "Failed to stop container during cancellation");
|
&& let Err(e) = jm.stop_job(job_id).await
|
||||||
|
{
|
||||||
|
tracing::warn!(job_id = %job_id, error = %e, "Failed to stop container during cancellation");
|
||||||
|
}
|
||||||
|
store
|
||||||
|
.update_sandbox_job_status(
|
||||||
|
job_id,
|
||||||
|
"failed",
|
||||||
|
Some(false),
|
||||||
|
Some("Cancelled by user"),
|
||||||
|
None,
|
||||||
|
Some(chrono::Utc::now()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
}
|
||||||
|
return Ok(Json(serde_json::json!({
|
||||||
|
"status": "cancelled",
|
||||||
|
"job_id": job_id,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(e) => {
|
||||||
|
return Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Database error: {}", e),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
store
|
|
||||||
.update_sandbox_job_status(
|
|
||||||
job_id,
|
|
||||||
"failed",
|
|
||||||
Some(false),
|
|
||||||
Some("Cancelled by user"),
|
|
||||||
None,
|
|
||||||
Some(chrono::Utc::now()),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
}
|
}
|
||||||
return Ok(Json(serde_json::json!({
|
|
||||||
"status": "cancelled",
|
|
||||||
"job_id": job_id,
|
|
||||||
})));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to agent job cancellation: stop the worker via the scheduler
|
// Fall back to agent job cancellation: stop the worker via the scheduler
|
||||||
// (which updates the in-memory ContextManager AND aborts the task handle),
|
// (which updates the in-memory ContextManager AND aborts the task handle),
|
||||||
// then persist the status to the DB as a fallback.
|
// then persist the status to the DB as a fallback.
|
||||||
if let Some(ref store) = state.store
|
if let Some(ref store) = state.store {
|
||||||
&& let Ok(Some(job)) = store.get_job(job_id).await
|
match store.get_job(job_id).await {
|
||||||
{
|
Ok(Some(job)) => {
|
||||||
if job.state.is_active() {
|
if job.user_id != user.user_id {
|
||||||
// Try to stop via scheduler (aborts the worker task + updates
|
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||||
// in-memory ContextManager). This is best-effort — the job may
|
}
|
||||||
// not be in the scheduler map if it already finished.
|
if job.state.is_active() {
|
||||||
if let Some(ref slot) = state.scheduler
|
// Try to stop via scheduler (aborts the worker task + updates
|
||||||
&& let Some(ref scheduler) = *slot.read().await
|
// in-memory ContextManager). This is best-effort — the job may
|
||||||
{
|
// not be in the scheduler map if it already finished.
|
||||||
let _ = scheduler.stop(job_id).await;
|
if let Some(ref slot) = state.scheduler
|
||||||
}
|
&& let Some(ref scheduler) = *slot.read().await
|
||||||
|
{
|
||||||
|
let _ = scheduler.stop(job_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
// Always persist cancellation to the DB so the state is
|
// Always persist cancellation to the DB so the state is
|
||||||
// consistent even if the scheduler wasn't available or the
|
// consistent even if the scheduler wasn't available or the
|
||||||
// job wasn't in its in-memory map.
|
// job wasn't in its in-memory map.
|
||||||
store
|
store
|
||||||
.update_job_status(
|
.update_job_status(
|
||||||
job_id,
|
job_id,
|
||||||
crate::context::JobState::Cancelled,
|
crate::context::JobState::Cancelled,
|
||||||
Some("Cancelled by user"),
|
Some("Cancelled by user"),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
}
|
||||||
|
return Ok(Json(serde_json::json!({
|
||||||
|
"status": "cancelled",
|
||||||
|
"job_id": job_id,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(e) => {
|
||||||
|
return Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Database error: {}", e),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Ok(Json(serde_json::json!({
|
|
||||||
"status": "cancelled",
|
|
||||||
"job_id": job_id,
|
|
||||||
})));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Err((StatusCode::NOT_FOUND, "Job not found".to_string()))
|
Err((StatusCode::NOT_FOUND, "Job not found".to_string()))
|
||||||
@@ -315,6 +363,7 @@ pub async fn jobs_cancel_handler(
|
|||||||
|
|
||||||
pub async fn jobs_restart_handler(
|
pub async fn jobs_restart_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
let store = state.store.as_ref().ok_or((
|
let store = state.store.as_ref().ok_or((
|
||||||
@@ -326,146 +375,166 @@ pub async fn jobs_restart_handler(
|
|||||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||||
|
|
||||||
// Try sandbox job restart first.
|
// Try sandbox job restart first.
|
||||||
if let Ok(Some(old_job)) = store.get_sandbox_job(old_job_id).await {
|
match store.get_sandbox_job(old_job_id).await {
|
||||||
if old_job.status != "interrupted" && old_job.status != "failed" {
|
Ok(Some(old_job)) => {
|
||||||
|
if old_job.user_id != user.user_id {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||||
|
}
|
||||||
|
if old_job.status != "interrupted" && old_job.status != "failed" {
|
||||||
|
return Err((
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
format!("Cannot restart job in state '{}'", old_job.status),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let jm = state.job_manager.as_ref().ok_or((
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"Sandbox not enabled".to_string(),
|
||||||
|
))?;
|
||||||
|
|
||||||
|
// Enrich the task with failure context.
|
||||||
|
let task = if let Some(ref reason) = old_job.failure_reason {
|
||||||
|
format!(
|
||||||
|
"Previous attempt failed: {}. Retry: {}",
|
||||||
|
reason, old_job.task
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
old_job.task.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_job_id = Uuid::new_v4();
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
|
||||||
|
let record = crate::history::SandboxJobRecord {
|
||||||
|
id: new_job_id,
|
||||||
|
task: task.clone(),
|
||||||
|
status: "creating".to_string(),
|
||||||
|
user_id: old_job.user_id.clone(),
|
||||||
|
project_dir: old_job.project_dir.clone(),
|
||||||
|
success: None,
|
||||||
|
failure_reason: None,
|
||||||
|
created_at: now,
|
||||||
|
started_at: None,
|
||||||
|
completed_at: None,
|
||||||
|
credential_grants_json: old_job.credential_grants_json.clone(),
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.save_sandbox_job(&record)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
let mode = match store.get_sandbox_job_mode(old_job_id).await {
|
||||||
|
Ok(Some(m)) if m == "claude_code" => {
|
||||||
|
crate::orchestrator::job_manager::JobMode::ClaudeCode
|
||||||
|
}
|
||||||
|
_ => crate::orchestrator::job_manager::JobMode::Worker,
|
||||||
|
};
|
||||||
|
|
||||||
|
let credential_grants: Vec<crate::orchestrator::auth::CredentialGrant> =
|
||||||
|
serde_json::from_str(&old_job.credential_grants_json).unwrap_or_else(|e| {
|
||||||
|
tracing::warn!(
|
||||||
|
job_id = %old_job.id,
|
||||||
|
"Failed to deserialize credential grants from stored job: {}. \
|
||||||
|
Restarted job will have no credentials.",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
vec![]
|
||||||
|
});
|
||||||
|
|
||||||
|
let project_dir = std::path::PathBuf::from(&old_job.project_dir);
|
||||||
|
let _token = jm
|
||||||
|
.create_job(
|
||||||
|
new_job_id,
|
||||||
|
&task,
|
||||||
|
Some(project_dir),
|
||||||
|
mode,
|
||||||
|
credential_grants,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Failed to create container: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
store
|
||||||
|
.update_sandbox_job_status(new_job_id, "running", None, None, Some(now), None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
return Ok(Json(serde_json::json!({
|
||||||
|
"status": "restarted",
|
||||||
|
"old_job_id": old_job_id,
|
||||||
|
"new_job_id": new_job_id,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(e) => {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::CONFLICT,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
format!("Cannot restart job in state '{}'", old_job.status),
|
format!("Database error: {}", e),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let jm = state.job_manager.as_ref().ok_or((
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"Sandbox not enabled".to_string(),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
// Enrich the task with failure context.
|
|
||||||
let task = if let Some(ref reason) = old_job.failure_reason {
|
|
||||||
format!(
|
|
||||||
"Previous attempt failed: {}. Retry: {}",
|
|
||||||
reason, old_job.task
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
old_job.task.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
let new_job_id = Uuid::new_v4();
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
|
|
||||||
let record = crate::history::SandboxJobRecord {
|
|
||||||
id: new_job_id,
|
|
||||||
task: task.clone(),
|
|
||||||
status: "creating".to_string(),
|
|
||||||
user_id: old_job.user_id.clone(),
|
|
||||||
project_dir: old_job.project_dir.clone(),
|
|
||||||
success: None,
|
|
||||||
failure_reason: None,
|
|
||||||
created_at: now,
|
|
||||||
started_at: None,
|
|
||||||
completed_at: None,
|
|
||||||
credential_grants_json: old_job.credential_grants_json.clone(),
|
|
||||||
};
|
|
||||||
store
|
|
||||||
.save_sandbox_job(&record)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
let mode = match store.get_sandbox_job_mode(old_job_id).await {
|
|
||||||
Ok(Some(m)) if m == "claude_code" => {
|
|
||||||
crate::orchestrator::job_manager::JobMode::ClaudeCode
|
|
||||||
}
|
|
||||||
_ => crate::orchestrator::job_manager::JobMode::Worker,
|
|
||||||
};
|
|
||||||
|
|
||||||
let credential_grants: Vec<crate::orchestrator::auth::CredentialGrant> =
|
|
||||||
serde_json::from_str(&old_job.credential_grants_json).unwrap_or_else(|e| {
|
|
||||||
tracing::warn!(
|
|
||||||
job_id = %old_job.id,
|
|
||||||
"Failed to deserialize credential grants from stored job: {}. \
|
|
||||||
Restarted job will have no credentials.",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
vec![]
|
|
||||||
});
|
|
||||||
|
|
||||||
let project_dir = std::path::PathBuf::from(&old_job.project_dir);
|
|
||||||
let _token = jm
|
|
||||||
.create_job(
|
|
||||||
new_job_id,
|
|
||||||
&task,
|
|
||||||
Some(project_dir),
|
|
||||||
mode,
|
|
||||||
credential_grants,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("Failed to create container: {}", e),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
store
|
|
||||||
.update_sandbox_job_status(new_job_id, "running", None, None, Some(now), None)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
return Ok(Json(serde_json::json!({
|
|
||||||
"status": "restarted",
|
|
||||||
"old_job_id": old_job_id,
|
|
||||||
"new_job_id": new_job_id,
|
|
||||||
})));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try agent job restart: dispatch a new job via the scheduler.
|
// Try agent job restart: dispatch a new job via the scheduler.
|
||||||
if let Ok(Some(old_job)) = store.get_job(old_job_id).await {
|
match store.get_job(old_job_id).await {
|
||||||
if old_job.state.is_active() {
|
Ok(Some(old_job)) => {
|
||||||
return Err((
|
if old_job.user_id != user.user_id {
|
||||||
StatusCode::CONFLICT,
|
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||||
format!("Cannot restart job in state '{}'", old_job.state),
|
}
|
||||||
));
|
if old_job.state.is_active() {
|
||||||
|
return Err((
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
format!("Cannot restart job in state '{}'", old_job.state),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let slot = state.scheduler.as_ref().ok_or((
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"Scheduler not available".to_string(),
|
||||||
|
))?;
|
||||||
|
let scheduler_guard = slot.read().await;
|
||||||
|
let scheduler = scheduler_guard.as_ref().ok_or((
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"Agent not started yet".to_string(),
|
||||||
|
))?;
|
||||||
|
|
||||||
|
// Look up failure reason (O(1) point lookup).
|
||||||
|
let failure_reason = store
|
||||||
|
.get_agent_job_failure_reason(old_job_id)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let title = if !failure_reason.is_empty() {
|
||||||
|
format!(
|
||||||
|
"Previous attempt failed: {}. Retry: {}",
|
||||||
|
failure_reason, old_job.title
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
old_job.title.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_job_id = scheduler
|
||||||
|
.dispatch_job(&old_job.user_id, &title, &old_job.description, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"status": "restarted",
|
||||||
|
"old_job_id": old_job_id,
|
||||||
|
"new_job_id": new_job_id,
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
Ok(None) => Err((StatusCode::NOT_FOUND, "Job not found".to_string())),
|
||||||
let slot = state.scheduler.as_ref().ok_or((
|
Err(e) => Err((
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
"Scheduler not available".to_string(),
|
format!("Database error: {}", e),
|
||||||
))?;
|
)),
|
||||||
let scheduler_guard = slot.read().await;
|
|
||||||
let scheduler = scheduler_guard.as_ref().ok_or((
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"Agent not started yet".to_string(),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
// Look up failure reason (O(1) point lookup).
|
|
||||||
let failure_reason = store
|
|
||||||
.get_agent_job_failure_reason(old_job_id)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let title = if !failure_reason.is_empty() {
|
|
||||||
format!(
|
|
||||||
"Previous attempt failed: {}. Retry: {}",
|
|
||||||
failure_reason, old_job.title
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
old_job.title.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
let new_job_id = scheduler
|
|
||||||
.dispatch_job(&old_job.user_id, &title, &old_job.description, None)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
return Ok(Json(serde_json::json!({
|
|
||||||
"status": "restarted",
|
|
||||||
"old_job_id": old_job_id,
|
|
||||||
"new_job_id": new_job_id,
|
|
||||||
})));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Err((StatusCode::NOT_FOUND, "Job not found".to_string()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Submit a follow-up prompt to a running job.
|
/// Submit a follow-up prompt to a running job.
|
||||||
@@ -476,6 +545,7 @@ pub async fn jobs_restart_handler(
|
|||||||
/// - Worker-mode sandbox jobs → not supported (no mechanism to inject)
|
/// - Worker-mode sandbox jobs → not supported (no mechanism to inject)
|
||||||
pub async fn jobs_prompt_handler(
|
pub async fn jobs_prompt_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(body): Json<serde_json::Value>,
|
Json(body): Json<serde_json::Value>,
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
@@ -494,10 +564,15 @@ pub async fn jobs_prompt_handler(
|
|||||||
|
|
||||||
let done = body.get("done").and_then(|v| v.as_bool()).unwrap_or(false);
|
let done = body.get("done").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
|
|
||||||
// Try sandbox job path: check if we have a sandbox record for this ID.
|
// Try sandbox job path first: verify ownership, then route to Claude Code or reject.
|
||||||
if let Some(ref s) = state.store
|
if let Some(ref s) = state.store
|
||||||
&& let Ok(Some(_)) = s.get_sandbox_job(job_id).await
|
&& let Ok(Some(sandbox_job)) = s.get_sandbox_job(job_id).await
|
||||||
{
|
{
|
||||||
|
// Verify ownership.
|
||||||
|
if sandbox_job.user_id != user.user_id {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
// It's a sandbox job. Check if Claude Code mode.
|
// It's a sandbox job. Check if Claude Code mode.
|
||||||
let mode = s.get_sandbox_job_mode(job_id).await.ok().flatten();
|
let mode = s.get_sandbox_job_mode(job_id).await.ok().flatten();
|
||||||
if mode.as_deref() == Some("claude_code") {
|
if mode.as_deref() == Some("claude_code") {
|
||||||
@@ -522,7 +597,14 @@ pub async fn jobs_prompt_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try agent job path: send via scheduler.
|
// Try agent job path: verify ownership, then send via scheduler.
|
||||||
|
if let Some(ref store) = state.store
|
||||||
|
&& let Ok(Some(agent_job)) = store.get_job(job_id).await
|
||||||
|
&& agent_job.user_id != user.user_id
|
||||||
|
{
|
||||||
|
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
let slot = state.scheduler.as_ref().ok_or((
|
let slot = state.scheduler.as_ref().ok_or((
|
||||||
StatusCode::NOT_IMPLEMENTED,
|
StatusCode::NOT_IMPLEMENTED,
|
||||||
"Agent job prompts require the scheduler to be configured".to_string(),
|
"Agent job prompts require the scheduler to be configured".to_string(),
|
||||||
@@ -550,6 +632,7 @@ pub async fn jobs_prompt_handler(
|
|||||||
/// Load persisted job events for a job (for history replay on page open).
|
/// Load persisted job events for a job (for history replay on page open).
|
||||||
pub async fn jobs_events_handler(
|
pub async fn jobs_events_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
let store = state.store.as_ref().ok_or((
|
let store = state.store.as_ref().ok_or((
|
||||||
@@ -561,6 +644,24 @@ pub async fn jobs_events_handler(
|
|||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||||
|
|
||||||
|
// Verify ownership before returning events.
|
||||||
|
match store.get_sandbox_job(job_id).await {
|
||||||
|
Ok(Some(job)) => {
|
||||||
|
if job.user_id != user.user_id {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Database error: {}", e),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let events = store
|
let events = store
|
||||||
.list_job_events(job_id, None)
|
.list_job_events(job_id, None)
|
||||||
.await
|
.await
|
||||||
@@ -593,6 +694,7 @@ pub struct FilePathQuery {
|
|||||||
|
|
||||||
pub async fn job_files_list_handler(
|
pub async fn job_files_list_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Query(query): Query<FilePathQuery>,
|
Query(query): Query<FilePathQuery>,
|
||||||
) -> Result<Json<ProjectFilesResponse>, (StatusCode, String)> {
|
) -> Result<Json<ProjectFilesResponse>, (StatusCode, String)> {
|
||||||
@@ -610,6 +712,10 @@ pub async fn job_files_list_handler(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?;
|
||||||
|
|
||||||
|
if job.user_id != user.user_id {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
let base = std::path::PathBuf::from(&job.project_dir);
|
let base = std::path::PathBuf::from(&job.project_dir);
|
||||||
let rel_path = query.path.as_deref().unwrap_or("");
|
let rel_path = query.path.as_deref().unwrap_or("");
|
||||||
let target = base.join(rel_path);
|
let target = base.join(rel_path);
|
||||||
@@ -656,6 +762,7 @@ pub async fn job_files_list_handler(
|
|||||||
|
|
||||||
pub async fn job_files_read_handler(
|
pub async fn job_files_read_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Query(query): Query<FilePathQuery>,
|
Query(query): Query<FilePathQuery>,
|
||||||
) -> Result<Json<ProjectFileReadResponse>, (StatusCode, String)> {
|
) -> Result<Json<ProjectFileReadResponse>, (StatusCode, String)> {
|
||||||
@@ -673,6 +780,10 @@ pub async fn job_files_read_handler(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?;
|
||||||
|
|
||||||
|
if job.user_id != user.user_id {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
let path = query.path.as_deref().ok_or((
|
let path = query.path.as_deref().ok_or((
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
"path parameter required".to_string(),
|
"path parameter required".to_string(),
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
//! Memory/workspace API handlers.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use axum::{
|
|
||||||
Json,
|
|
||||||
extract::{Query, State},
|
|
||||||
http::StatusCode,
|
|
||||||
};
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use crate::channels::web::server::GatewayState;
|
|
||||||
use crate::channels::web::types::*;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct TreeQuery {
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub depth: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn memory_tree_handler(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
Query(_query): Query<TreeQuery>,
|
|
||||||
) -> Result<Json<MemoryTreeResponse>, (StatusCode, String)> {
|
|
||||||
let workspace = state.workspace.as_ref().ok_or((
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"Workspace not available".to_string(),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
// Build tree from list_all (flat list of all paths)
|
|
||||||
let all_paths = workspace
|
|
||||||
.list_all()
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
// Collect unique directories and files
|
|
||||||
let mut entries: Vec<TreeEntry> = Vec::new();
|
|
||||||
let mut seen_dirs: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
||||||
|
|
||||||
for path in &all_paths {
|
|
||||||
// Add parent directories
|
|
||||||
let parts: Vec<&str> = path.split('/').collect();
|
|
||||||
for i in 0..parts.len().saturating_sub(1) {
|
|
||||||
let dir_path = parts[..=i].join("/");
|
|
||||||
if seen_dirs.insert(dir_path.clone()) {
|
|
||||||
entries.push(TreeEntry {
|
|
||||||
path: dir_path,
|
|
||||||
is_dir: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Add the file itself
|
|
||||||
entries.push(TreeEntry {
|
|
||||||
path: path.clone(),
|
|
||||||
is_dir: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.sort_by(|a, b| a.path.cmp(&b.path));
|
|
||||||
|
|
||||||
Ok(Json(MemoryTreeResponse { entries }))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct ListQuery {
|
|
||||||
pub path: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn memory_list_handler(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
Query(query): Query<ListQuery>,
|
|
||||||
) -> Result<Json<MemoryListResponse>, (StatusCode, String)> {
|
|
||||||
let workspace = state.workspace.as_ref().ok_or((
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"Workspace not available".to_string(),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let path = query.path.as_deref().unwrap_or("");
|
|
||||||
let entries = workspace
|
|
||||||
.list(path)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
let list_entries: Vec<ListEntry> = entries
|
|
||||||
.iter()
|
|
||||||
.map(|e| ListEntry {
|
|
||||||
name: e.path.rsplit('/').next().unwrap_or(&e.path).to_string(),
|
|
||||||
path: e.path.clone(),
|
|
||||||
is_dir: e.is_directory,
|
|
||||||
updated_at: e.updated_at.map(|dt| dt.to_rfc3339()),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(Json(MemoryListResponse {
|
|
||||||
path: path.to_string(),
|
|
||||||
entries: list_entries,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct ReadQuery {
|
|
||||||
pub path: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn memory_read_handler(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
Query(query): Query<ReadQuery>,
|
|
||||||
) -> Result<Json<MemoryReadResponse>, (StatusCode, String)> {
|
|
||||||
let workspace = state.workspace.as_ref().ok_or((
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"Workspace not available".to_string(),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let doc = workspace
|
|
||||||
.read(&query.path)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(Json(MemoryReadResponse {
|
|
||||||
path: query.path,
|
|
||||||
content: doc.content,
|
|
||||||
updated_at: Some(doc.updated_at.to_rfc3339()),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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>>,
|
|
||||||
Json(req): Json<MemorySearchRequest>,
|
|
||||||
) -> Result<Json<MemorySearchResponse>, (StatusCode, String)> {
|
|
||||||
let workspace = state.workspace.as_ref().ok_or((
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"Workspace not available".to_string(),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let limit = req.limit.unwrap_or(10);
|
|
||||||
let results = workspace
|
|
||||||
.search(&req.query, limit)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
let hits: Vec<SearchHit> = results
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| SearchHit {
|
|
||||||
path: r.document_path,
|
|
||||||
content: r.content,
|
|
||||||
score: r.score as f64,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(Json(MemorySearchResponse { results: hits }))
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
//! Handler modules for the web gateway API.
|
//! Handler modules for the web gateway API.
|
||||||
//!
|
//!
|
||||||
//! Each module groups related endpoint handlers by domain.
|
//! Each module groups related endpoint handlers by domain.
|
||||||
//!
|
|
||||||
//! # Migration status
|
|
||||||
//!
|
|
||||||
//! `skills` is the canonical implementation used by `server.rs`.
|
|
||||||
//! The remaining modules are in-progress migrations from inline server.rs
|
|
||||||
//! handlers; their functions are not yet wired up, hence the `dead_code` allow.
|
|
||||||
|
|
||||||
|
pub mod jobs;
|
||||||
|
pub mod routines;
|
||||||
pub mod skills;
|
pub mod skills;
|
||||||
|
|
||||||
// Modules not yet wired into server.rs router -- suppress dead_code until
|
// Modules not yet wired into server.rs router -- suppress dead_code until
|
||||||
@@ -17,12 +13,6 @@ pub mod chat;
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub mod extensions;
|
pub mod extensions;
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub mod jobs;
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub mod memory;
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub mod routines;
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub mod static_files;
|
pub mod static_files;
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ use serde::Deserialize;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::agent::routine::{Trigger, next_cron_fire};
|
use crate::agent::routine::{Trigger, next_cron_fire};
|
||||||
|
use crate::channels::web::auth::AuthenticatedUser;
|
||||||
use crate::channels::web::server::GatewayState;
|
use crate::channels::web::server::GatewayState;
|
||||||
use crate::channels::web::types::*;
|
use crate::channels::web::types::*;
|
||||||
use crate::error::RoutineError;
|
use crate::error::RoutineError;
|
||||||
|
|
||||||
pub async fn routines_list_handler(
|
pub async fn routines_list_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
) -> Result<Json<RoutineListResponse>, (StatusCode, String)> {
|
) -> Result<Json<RoutineListResponse>, (StatusCode, String)> {
|
||||||
let store = state.store.as_ref().ok_or((
|
let store = state.store.as_ref().ok_or((
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
@@ -24,7 +26,7 @@ pub async fn routines_list_handler(
|
|||||||
))?;
|
))?;
|
||||||
|
|
||||||
let routines = store
|
let routines = store
|
||||||
.list_all_routines()
|
.list_routines(&user.user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
@@ -35,6 +37,7 @@ pub async fn routines_list_handler(
|
|||||||
|
|
||||||
pub async fn routines_summary_handler(
|
pub async fn routines_summary_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
) -> Result<Json<RoutineSummaryResponse>, (StatusCode, String)> {
|
) -> Result<Json<RoutineSummaryResponse>, (StatusCode, String)> {
|
||||||
let store = state.store.as_ref().ok_or((
|
let store = state.store.as_ref().ok_or((
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
@@ -42,7 +45,7 @@ pub async fn routines_summary_handler(
|
|||||||
))?;
|
))?;
|
||||||
|
|
||||||
let routines = store
|
let routines = store
|
||||||
.list_all_routines()
|
.list_routines(&user.user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
@@ -78,6 +81,7 @@ pub async fn routines_summary_handler(
|
|||||||
|
|
||||||
pub async fn routines_detail_handler(
|
pub async fn routines_detail_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<RoutineDetailResponse>, (StatusCode, String)> {
|
) -> Result<Json<RoutineDetailResponse>, (StatusCode, String)> {
|
||||||
let store = state.store.as_ref().ok_or((
|
let store = state.store.as_ref().ok_or((
|
||||||
@@ -94,6 +98,10 @@ pub async fn routines_detail_handler(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
|
||||||
|
|
||||||
|
if routine.user_id != user.user_id {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "Routine not found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
let runs = store
|
let runs = store
|
||||||
.list_routine_runs(routine_id, 20)
|
.list_routine_runs(routine_id, 20)
|
||||||
.await
|
.await
|
||||||
@@ -137,6 +145,7 @@ pub async fn routines_detail_handler(
|
|||||||
|
|
||||||
pub async fn routines_trigger_handler(
|
pub async fn routines_trigger_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
// Clone the Arc out of the lock to avoid holding the RwLock across .await.
|
// Clone the Arc out of the lock to avoid holding the RwLock across .await.
|
||||||
@@ -152,7 +161,7 @@ pub async fn routines_trigger_handler(
|
|||||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?;
|
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?;
|
||||||
|
|
||||||
let run_id = engine
|
let run_id = engine
|
||||||
.fire_manual(routine_id, Some(&state.user_id))
|
.fire_manual(routine_id, Some(&user.user_id))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (routine_error_status(&e), e.to_string()))?;
|
.map_err(|e| (routine_error_status(&e), e.to_string()))?;
|
||||||
|
|
||||||
@@ -170,6 +179,7 @@ pub struct ToggleRequest {
|
|||||||
|
|
||||||
pub async fn routines_toggle_handler(
|
pub async fn routines_toggle_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
body: Option<Json<ToggleRequest>>,
|
body: Option<Json<ToggleRequest>>,
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
@@ -187,6 +197,10 @@ pub async fn routines_toggle_handler(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
|
||||||
|
|
||||||
|
if routine.user_id != user.user_id {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "Routine not found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
let was_enabled = routine.enabled;
|
let was_enabled = routine.enabled;
|
||||||
// If a specific value was provided, use it; otherwise toggle.
|
// If a specific value was provided, use it; otherwise toggle.
|
||||||
routine.enabled = match body {
|
routine.enabled = match body {
|
||||||
@@ -230,6 +244,7 @@ pub async fn routines_toggle_handler(
|
|||||||
|
|
||||||
pub async fn routines_delete_handler(
|
pub async fn routines_delete_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
let store = state.store.as_ref().ok_or((
|
let store = state.store.as_ref().ok_or((
|
||||||
@@ -240,6 +255,17 @@ pub async fn routines_delete_handler(
|
|||||||
let routine_id = Uuid::parse_str(&id)
|
let routine_id = Uuid::parse_str(&id)
|
||||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?;
|
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?;
|
||||||
|
|
||||||
|
// Verify ownership before deleting.
|
||||||
|
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()))?;
|
||||||
|
|
||||||
|
if routine.user_id != user.user_id {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "Routine not found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
let deleted = store
|
let deleted = store
|
||||||
.delete_routine(routine_id)
|
.delete_routine(routine_id)
|
||||||
.await
|
.await
|
||||||
@@ -261,8 +287,10 @@ pub async fn routines_delete_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // Used by server.rs inline version; kept in sync here for future migration.
|
||||||
pub async fn routines_runs_handler(
|
pub async fn routines_runs_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
let store = state.store.as_ref().ok_or((
|
let store = state.store.as_ref().ok_or((
|
||||||
@@ -273,6 +301,17 @@ pub async fn routines_runs_handler(
|
|||||||
let routine_id = Uuid::parse_str(&id)
|
let routine_id = Uuid::parse_str(&id)
|
||||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?;
|
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?;
|
||||||
|
|
||||||
|
// Verify ownership before listing runs.
|
||||||
|
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()))?;
|
||||||
|
|
||||||
|
if routine.user_id != user.user_id {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "Routine not found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
let runs = store
|
let runs = store
|
||||||
.list_routine_runs(routine_id, 50)
|
.list_routine_runs(routine_id, 50)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -8,17 +8,19 @@ use axum::{
|
|||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::channels::web::auth::AuthenticatedUser;
|
||||||
use crate::channels::web::server::GatewayState;
|
use crate::channels::web::server::GatewayState;
|
||||||
use crate::channels::web::types::*;
|
use crate::channels::web::types::*;
|
||||||
|
|
||||||
pub async fn settings_list_handler(
|
pub async fn settings_list_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
) -> Result<Json<SettingsListResponse>, StatusCode> {
|
) -> Result<Json<SettingsListResponse>, StatusCode> {
|
||||||
let store = state
|
let store = state
|
||||||
.store
|
.store
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||||
let rows = store.list_settings(&state.user_id).await.map_err(|e| {
|
let rows = store.list_settings(&user.user_id).await.map_err(|e| {
|
||||||
tracing::error!("Failed to list settings: {}", e);
|
tracing::error!("Failed to list settings: {}", e);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
@@ -37,6 +39,7 @@ pub async fn settings_list_handler(
|
|||||||
|
|
||||||
pub async fn settings_get_handler(
|
pub async fn settings_get_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
Path(key): Path<String>,
|
Path(key): Path<String>,
|
||||||
) -> Result<Json<SettingResponse>, StatusCode> {
|
) -> Result<Json<SettingResponse>, StatusCode> {
|
||||||
let store = state
|
let store = state
|
||||||
@@ -44,7 +47,7 @@ pub async fn settings_get_handler(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||||
let row = store
|
let row = store
|
||||||
.get_setting_full(&state.user_id, &key)
|
.get_setting_full(&user.user_id, &key)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!("Failed to get setting '{}': {}", key, e);
|
tracing::error!("Failed to get setting '{}': {}", key, e);
|
||||||
@@ -61,6 +64,7 @@ pub async fn settings_get_handler(
|
|||||||
|
|
||||||
pub async fn settings_set_handler(
|
pub async fn settings_set_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
Path(key): Path<String>,
|
Path(key): Path<String>,
|
||||||
Json(body): Json<SettingWriteRequest>,
|
Json(body): Json<SettingWriteRequest>,
|
||||||
) -> Result<StatusCode, StatusCode> {
|
) -> Result<StatusCode, StatusCode> {
|
||||||
@@ -69,7 +73,7 @@ pub async fn settings_set_handler(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||||
store
|
store
|
||||||
.set_setting(&state.user_id, &key, &body.value)
|
.set_setting(&user.user_id, &key, &body.value)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!("Failed to set setting '{}': {}", key, e);
|
tracing::error!("Failed to set setting '{}': {}", key, e);
|
||||||
@@ -81,6 +85,7 @@ pub async fn settings_set_handler(
|
|||||||
|
|
||||||
pub async fn settings_delete_handler(
|
pub async fn settings_delete_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
Path(key): Path<String>,
|
Path(key): Path<String>,
|
||||||
) -> Result<StatusCode, StatusCode> {
|
) -> Result<StatusCode, StatusCode> {
|
||||||
let store = state
|
let store = state
|
||||||
@@ -88,7 +93,7 @@ pub async fn settings_delete_handler(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||||
store
|
store
|
||||||
.delete_setting(&state.user_id, &key)
|
.delete_setting(&user.user_id, &key)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!("Failed to delete setting '{}': {}", key, e);
|
tracing::error!("Failed to delete setting '{}': {}", key, e);
|
||||||
@@ -100,12 +105,13 @@ pub async fn settings_delete_handler(
|
|||||||
|
|
||||||
pub async fn settings_export_handler(
|
pub async fn settings_export_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
) -> Result<Json<SettingsExportResponse>, StatusCode> {
|
) -> Result<Json<SettingsExportResponse>, StatusCode> {
|
||||||
let store = state
|
let store = state
|
||||||
.store
|
.store
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||||
let settings = store.get_all_settings(&state.user_id).await.map_err(|e| {
|
let settings = store.get_all_settings(&user.user_id).await.map_err(|e| {
|
||||||
tracing::error!("Failed to export settings: {}", e);
|
tracing::error!("Failed to export settings: {}", e);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
@@ -115,6 +121,7 @@ pub async fn settings_export_handler(
|
|||||||
|
|
||||||
pub async fn settings_import_handler(
|
pub async fn settings_import_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
Json(body): Json<SettingsImportRequest>,
|
Json(body): Json<SettingsImportRequest>,
|
||||||
) -> Result<StatusCode, StatusCode> {
|
) -> Result<StatusCode, StatusCode> {
|
||||||
let store = state
|
let store = state
|
||||||
@@ -122,7 +129,7 @@ pub async fn settings_import_handler(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||||
store
|
store
|
||||||
.set_all_settings(&state.user_id, &body.settings)
|
.set_all_settings(&user.user_id, &body.settings)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!("Failed to import settings: {}", e);
|
tracing::error!("Failed to import settings: {}", e);
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ use axum::{
|
|||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::channels::web::auth::AuthenticatedUser;
|
||||||
use crate::channels::web::server::GatewayState;
|
use crate::channels::web::server::GatewayState;
|
||||||
use crate::channels::web::types::*;
|
use crate::channels::web::types::*;
|
||||||
|
|
||||||
pub async fn skills_list_handler(
|
pub async fn skills_list_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(_user): AuthenticatedUser,
|
||||||
) -> Result<Json<SkillListResponse>, (StatusCode, String)> {
|
) -> Result<Json<SkillListResponse>, (StatusCode, String)> {
|
||||||
let registry = state.skill_registry.as_ref().ok_or((
|
let registry = state.skill_registry.as_ref().ok_or((
|
||||||
StatusCode::NOT_IMPLEMENTED,
|
StatusCode::NOT_IMPLEMENTED,
|
||||||
@@ -45,6 +47,7 @@ pub async fn skills_list_handler(
|
|||||||
|
|
||||||
pub async fn skills_search_handler(
|
pub async fn skills_search_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(_user): AuthenticatedUser,
|
||||||
Json(req): Json<SkillSearchRequest>,
|
Json(req): Json<SkillSearchRequest>,
|
||||||
) -> Result<Json<SkillSearchResponse>, (StatusCode, String)> {
|
) -> Result<Json<SkillSearchResponse>, (StatusCode, String)> {
|
||||||
let registry = state.skill_registry.as_ref().ok_or((
|
let registry = state.skill_registry.as_ref().ok_or((
|
||||||
@@ -119,6 +122,7 @@ pub async fn skills_search_handler(
|
|||||||
|
|
||||||
pub async fn skills_install_handler(
|
pub async fn skills_install_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
headers: axum::http::HeaderMap,
|
headers: axum::http::HeaderMap,
|
||||||
Json(req): Json<SkillInstallRequest>,
|
Json(req): Json<SkillInstallRequest>,
|
||||||
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
||||||
@@ -135,6 +139,8 @@ pub async fn skills_install_handler(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tracing::info!(user_id = %user.user_id, skill = %req.name, "skill install requested");
|
||||||
|
|
||||||
let registry = state.skill_registry.as_ref().ok_or((
|
let registry = state.skill_registry.as_ref().ok_or((
|
||||||
StatusCode::NOT_IMPLEMENTED,
|
StatusCode::NOT_IMPLEMENTED,
|
||||||
"Skills system not enabled".to_string(),
|
"Skills system not enabled".to_string(),
|
||||||
@@ -219,6 +225,7 @@ pub async fn skills_install_handler(
|
|||||||
|
|
||||||
pub async fn skills_remove_handler(
|
pub async fn skills_remove_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(user): AuthenticatedUser,
|
||||||
headers: axum::http::HeaderMap,
|
headers: axum::http::HeaderMap,
|
||||||
Path(name): Path<String>,
|
Path(name): Path<String>,
|
||||||
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
||||||
@@ -234,6 +241,8 @@ pub async fn skills_remove_handler(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tracing::info!(user_id = %user.user_id, skill = %name, "skill remove requested");
|
||||||
|
|
||||||
let registry = state.skill_registry.as_ref().ok_or((
|
let registry = state.skill_registry.as_ref().ok_or((
|
||||||
StatusCode::NOT_IMPLEMENTED,
|
StatusCode::NOT_IMPLEMENTED,
|
||||||
"Skills system not enabled".to_string(),
|
"Skills system not enabled".to_string(),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use axum::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::bootstrap::ironclaw_base_dir;
|
use crate::bootstrap::ironclaw_base_dir;
|
||||||
|
use crate::channels::web::auth::AuthenticatedUser;
|
||||||
use crate::channels::web::types::*;
|
use crate::channels::web::types::*;
|
||||||
|
|
||||||
// --- Static file handlers ---
|
// --- Static file handlers ---
|
||||||
@@ -113,6 +114,7 @@ use crate::channels::web::server::GatewayState;
|
|||||||
|
|
||||||
pub async fn logs_events_handler(
|
pub async fn logs_events_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(_user): AuthenticatedUser,
|
||||||
) -> Result<
|
) -> Result<
|
||||||
Sse<impl futures::Stream<Item = Result<Event, Infallible>> + Send + 'static>,
|
Sse<impl futures::Stream<Item = Result<Event, Infallible>> + Send + 'static>,
|
||||||
(StatusCode, String),
|
(StatusCode, String),
|
||||||
@@ -152,6 +154,7 @@ pub async fn logs_events_handler(
|
|||||||
|
|
||||||
pub async fn gateway_status_handler(
|
pub async fn gateway_status_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
AuthenticatedUser(_user): AuthenticatedUser,
|
||||||
) -> Json<GatewayStatusResponse> {
|
) -> Json<GatewayStatusResponse> {
|
||||||
let sse_connections = state.sse.connection_count();
|
let sse_connections = state.sse.connection_count();
|
||||||
let ws_connections = state
|
let ws_connections = state
|
||||||
|
|||||||
+100
-22
@@ -31,6 +31,9 @@ pub mod ws;
|
|||||||
/// [`TestGatewayBuilder`](test_helpers::TestGatewayBuilder).
|
/// [`TestGatewayBuilder`](test_helpers::TestGatewayBuilder).
|
||||||
pub mod test_helpers;
|
pub mod test_helpers;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -52,6 +55,7 @@ use crate::workspace::Workspace;
|
|||||||
|
|
||||||
use self::log_layer::{LogBroadcaster, LogLevelHandle};
|
use self::log_layer::{LogBroadcaster, LogLevelHandle};
|
||||||
|
|
||||||
|
use self::auth::MultiAuthState;
|
||||||
use self::server::GatewayState;
|
use self::server::GatewayState;
|
||||||
use self::sse::SseManager;
|
use self::sse::SseManager;
|
||||||
use self::types::SseEvent;
|
use self::types::SseEvent;
|
||||||
@@ -60,14 +64,15 @@ use self::types::SseEvent;
|
|||||||
pub struct GatewayChannel {
|
pub struct GatewayChannel {
|
||||||
config: GatewayConfig,
|
config: GatewayConfig,
|
||||||
state: Arc<GatewayState>,
|
state: Arc<GatewayState>,
|
||||||
/// The actual auth token in use (generated or from config).
|
/// Multi-user auth state (replaces bare auth_token).
|
||||||
auth_token: String,
|
auth: MultiAuthState,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GatewayChannel {
|
impl GatewayChannel {
|
||||||
/// Create a new gateway channel.
|
/// Create a new gateway channel.
|
||||||
///
|
///
|
||||||
/// If no auth token is configured, generates a random one and prints it.
|
/// If no auth token is configured, generates a random one and prints it.
|
||||||
|
/// Builds a single-user `MultiAuthState` from the config.
|
||||||
pub fn new(config: GatewayConfig) -> Self {
|
pub fn new(config: GatewayConfig) -> Self {
|
||||||
let auth_token = config.auth_token.clone().unwrap_or_else(|| {
|
let auth_token = config.auth_token.clone().unwrap_or_else(|| {
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
@@ -77,10 +82,13 @@ impl GatewayChannel {
|
|||||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let auth = MultiAuthState::single(auth_token, config.user_id.clone());
|
||||||
|
|
||||||
let state = Arc::new(GatewayState {
|
let state = Arc::new(GatewayState {
|
||||||
msg_tx: tokio::sync::RwLock::new(None),
|
msg_tx: tokio::sync::RwLock::new(None),
|
||||||
sse: SseManager::new(),
|
sse: Arc::new(SseManager::new()),
|
||||||
workspace: None,
|
workspace: None,
|
||||||
|
workspace_pool: None,
|
||||||
session_manager: None,
|
session_manager: None,
|
||||||
log_broadcaster: None,
|
log_broadcaster: None,
|
||||||
log_level_handle: None,
|
log_level_handle: None,
|
||||||
@@ -90,13 +98,13 @@ impl GatewayChannel {
|
|||||||
job_manager: None,
|
job_manager: None,
|
||||||
prompt_queue: None,
|
prompt_queue: None,
|
||||||
scheduler: None,
|
scheduler: None,
|
||||||
user_id: config.user_id.clone(),
|
default_user_id: config.user_id.clone(),
|
||||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
|
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
|
||||||
llm_provider: None,
|
llm_provider: None,
|
||||||
skill_registry: None,
|
skill_registry: None,
|
||||||
skill_catalog: None,
|
skill_catalog: None,
|
||||||
chat_rate_limiter: server::RateLimiter::new(30, 60),
|
chat_rate_limiter: server::PerUserRateLimiter::new(30, 60),
|
||||||
oauth_rate_limiter: server::RateLimiter::new(10, 60),
|
oauth_rate_limiter: server::RateLimiter::new(10, 60),
|
||||||
webhook_rate_limiter: server::RateLimiter::new(10, 60),
|
webhook_rate_limiter: server::RateLimiter::new(10, 60),
|
||||||
registry_entries: Vec::new(),
|
registry_entries: Vec::new(),
|
||||||
@@ -109,7 +117,46 @@ impl GatewayChannel {
|
|||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
state,
|
state,
|
||||||
auth_token,
|
auth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a gateway channel with a pre-built multi-user auth state.
|
||||||
|
pub fn new_multi_auth(config: GatewayConfig, auth: MultiAuthState) -> Self {
|
||||||
|
let state = Arc::new(GatewayState {
|
||||||
|
msg_tx: tokio::sync::RwLock::new(None),
|
||||||
|
sse: Arc::new(SseManager::new()),
|
||||||
|
workspace: None,
|
||||||
|
workspace_pool: None,
|
||||||
|
session_manager: None,
|
||||||
|
log_broadcaster: None,
|
||||||
|
log_level_handle: None,
|
||||||
|
extension_manager: None,
|
||||||
|
tool_registry: None,
|
||||||
|
store: None,
|
||||||
|
job_manager: None,
|
||||||
|
prompt_queue: None,
|
||||||
|
scheduler: None,
|
||||||
|
default_user_id: config.user_id.clone(),
|
||||||
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
|
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
|
||||||
|
llm_provider: None,
|
||||||
|
skill_registry: None,
|
||||||
|
skill_catalog: None,
|
||||||
|
chat_rate_limiter: server::PerUserRateLimiter::new(30, 60),
|
||||||
|
oauth_rate_limiter: server::RateLimiter::new(10, 60),
|
||||||
|
registry_entries: Vec::new(),
|
||||||
|
cost_guard: None,
|
||||||
|
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
|
startup_time: std::time::Instant::now(),
|
||||||
|
webhook_rate_limiter: server::RateLimiter::new(10, 60),
|
||||||
|
active_config: server::ActiveConfigSnapshot::default(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
state,
|
||||||
|
auth,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,8 +165,9 @@ impl GatewayChannel {
|
|||||||
let mut new_state = GatewayState {
|
let mut new_state = GatewayState {
|
||||||
msg_tx: tokio::sync::RwLock::new(None),
|
msg_tx: tokio::sync::RwLock::new(None),
|
||||||
// Preserve the existing broadcast channel so sender handles remain valid.
|
// Preserve the existing broadcast channel so sender handles remain valid.
|
||||||
sse: SseManager::from_sender(self.state.sse.sender()),
|
sse: Arc::new(SseManager::from_sender(self.state.sse.sender())),
|
||||||
workspace: self.state.workspace.clone(),
|
workspace: self.state.workspace.clone(),
|
||||||
|
workspace_pool: self.state.workspace_pool.clone(),
|
||||||
session_manager: self.state.session_manager.clone(),
|
session_manager: self.state.session_manager.clone(),
|
||||||
log_broadcaster: self.state.log_broadcaster.clone(),
|
log_broadcaster: self.state.log_broadcaster.clone(),
|
||||||
log_level_handle: self.state.log_level_handle.clone(),
|
log_level_handle: self.state.log_level_handle.clone(),
|
||||||
@@ -129,13 +177,13 @@ impl GatewayChannel {
|
|||||||
job_manager: self.state.job_manager.clone(),
|
job_manager: self.state.job_manager.clone(),
|
||||||
prompt_queue: self.state.prompt_queue.clone(),
|
prompt_queue: self.state.prompt_queue.clone(),
|
||||||
scheduler: self.state.scheduler.clone(),
|
scheduler: self.state.scheduler.clone(),
|
||||||
user_id: self.state.user_id.clone(),
|
default_user_id: self.state.default_user_id.clone(),
|
||||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
ws_tracker: self.state.ws_tracker.clone(),
|
ws_tracker: self.state.ws_tracker.clone(),
|
||||||
llm_provider: self.state.llm_provider.clone(),
|
llm_provider: self.state.llm_provider.clone(),
|
||||||
skill_registry: self.state.skill_registry.clone(),
|
skill_registry: self.state.skill_registry.clone(),
|
||||||
skill_catalog: self.state.skill_catalog.clone(),
|
skill_catalog: self.state.skill_catalog.clone(),
|
||||||
chat_rate_limiter: server::RateLimiter::new(30, 60),
|
chat_rate_limiter: server::PerUserRateLimiter::new(30, 60),
|
||||||
oauth_rate_limiter: server::RateLimiter::new(10, 60),
|
oauth_rate_limiter: server::RateLimiter::new(10, 60),
|
||||||
webhook_rate_limiter: server::RateLimiter::new(10, 60),
|
webhook_rate_limiter: server::RateLimiter::new(10, 60),
|
||||||
registry_entries: self.state.registry_entries.clone(),
|
registry_entries: self.state.registry_entries.clone(),
|
||||||
@@ -260,9 +308,15 @@ impl GatewayChannel {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the auth token (for printing to console on startup).
|
/// Inject the per-user workspace pool for multi-user mode.
|
||||||
|
pub fn with_workspace_pool(mut self, pool: Arc<server::WorkspacePool>) -> Self {
|
||||||
|
self.rebuild_state(|s| s.workspace_pool = Some(pool));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the first auth token (for printing to console on startup).
|
||||||
pub fn auth_token(&self) -> &str {
|
pub fn auth_token(&self) -> &str {
|
||||||
&self.auth_token
|
self.auth.first_token().unwrap_or("")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a reference to the shared gateway state (for the agent to push SSE events).
|
/// Get a reference to the shared gateway state (for the agent to push SSE events).
|
||||||
@@ -291,7 +345,7 @@ impl Channel for GatewayChannel {
|
|||||||
),
|
),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
server::start_server(addr, self.state.clone(), self.auth_token.clone()).await?;
|
server::start_server(addr, self.state.clone(), self.auth.clone()).await?;
|
||||||
|
|
||||||
Ok(Box::pin(ReceiverStream::new(rx)))
|
Ok(Box::pin(ReceiverStream::new(rx)))
|
||||||
}
|
}
|
||||||
@@ -311,10 +365,13 @@ impl Channel for GatewayChannel {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
self.state.sse.broadcast(SseEvent::Response {
|
self.state.sse.broadcast_for_user(
|
||||||
content: response.content,
|
&msg.user_id,
|
||||||
thread_id,
|
SseEvent::Response {
|
||||||
});
|
content: response.content,
|
||||||
|
thread_id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -415,15 +472,33 @@ impl Channel for GatewayChannel {
|
|||||||
suggestions,
|
suggestions,
|
||||||
thread_id,
|
thread_id,
|
||||||
},
|
},
|
||||||
|
StatusUpdate::TurnCost {
|
||||||
|
input_tokens,
|
||||||
|
output_tokens,
|
||||||
|
cost_usd,
|
||||||
|
} => SseEvent::TurnCost {
|
||||||
|
input_tokens,
|
||||||
|
output_tokens,
|
||||||
|
cost_usd,
|
||||||
|
thread_id,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
self.state.sse.broadcast(event);
|
// Scope events to the user when user_id is available in metadata.
|
||||||
|
// When user_id is missing (heartbeat, routines), events go to all
|
||||||
|
// subscribers. In multi-tenant mode this leaks status across users.
|
||||||
|
if let Some(uid) = metadata.get("user_id").and_then(|v| v.as_str()) {
|
||||||
|
self.state.sse.broadcast_for_user(uid, event);
|
||||||
|
} else {
|
||||||
|
tracing::debug!("Status event missing user_id in metadata; broadcasting globally");
|
||||||
|
self.state.sse.broadcast(event);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn broadcast(
|
async fn broadcast(
|
||||||
&self,
|
&self,
|
||||||
_user_id: &str,
|
user_id: &str,
|
||||||
response: OutgoingResponse,
|
response: OutgoingResponse,
|
||||||
) -> Result<(), ChannelError> {
|
) -> Result<(), ChannelError> {
|
||||||
let thread_id = match response.thread_id {
|
let thread_id = match response.thread_id {
|
||||||
@@ -435,10 +510,13 @@ impl Channel for GatewayChannel {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
self.state.sse.broadcast(SseEvent::Response {
|
self.state.sse.broadcast_for_user(
|
||||||
content: response.content,
|
user_id,
|
||||||
thread_id,
|
SseEvent::Response {
|
||||||
});
|
content: response.content,
|
||||||
|
thread_id,
|
||||||
|
},
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -463,9 +463,10 @@ fn build_tool_request(
|
|||||||
|
|
||||||
pub async fn chat_completions_handler(
|
pub async fn chat_completions_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
super::auth::AuthenticatedUser(user): super::auth::AuthenticatedUser,
|
||||||
Json(req): Json<OpenAiChatRequest>,
|
Json(req): Json<OpenAiChatRequest>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, Json<OpenAiErrorResponse>)> {
|
) -> Result<impl IntoResponse, (StatusCode, Json<OpenAiErrorResponse>)> {
|
||||||
if !state.chat_rate_limiter.check() {
|
if !state.chat_rate_limiter.check(&user.user_id) {
|
||||||
return Err(openai_error(
|
return Err(openai_error(
|
||||||
StatusCode::TOO_MANY_REQUESTS,
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
"Rate limit exceeded. Please try again later.",
|
"Rate limit exceeded. Please try again later.",
|
||||||
|
|||||||
+516
-188
File diff suppressed because it is too large
Load Diff
+130
-29
@@ -17,9 +17,25 @@ use crate::channels::web::types::SseEvent;
|
|||||||
/// Prevents resource exhaustion from connection flooding.
|
/// Prevents resource exhaustion from connection flooding.
|
||||||
const MAX_CONNECTIONS: u64 = 100;
|
const MAX_CONNECTIONS: u64 = 100;
|
||||||
|
|
||||||
|
/// Envelope for broadcast events: carries an optional user scope.
|
||||||
|
///
|
||||||
|
/// `user_id = None` means the event is global (e.g. Heartbeat) and delivered
|
||||||
|
/// to all subscribers. `user_id = Some(id)` means the event is only delivered
|
||||||
|
/// to subscribers that match that user_id.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct ScopedEvent {
|
||||||
|
pub(crate) user_id: Option<String>,
|
||||||
|
pub(crate) event: SseEvent,
|
||||||
|
}
|
||||||
|
|
||||||
/// Manages SSE broadcast to all connected browser tabs.
|
/// Manages SSE broadcast to all connected browser tabs.
|
||||||
|
///
|
||||||
|
/// In multi-user mode, events are scoped by user_id so that each subscriber
|
||||||
|
/// only receives events intended for their user (plus global events like
|
||||||
|
/// Heartbeat). In single-user mode, all events are delivered to all subscribers
|
||||||
|
/// (backwards compatible).
|
||||||
pub struct SseManager {
|
pub struct SseManager {
|
||||||
tx: broadcast::Sender<SseEvent>,
|
tx: broadcast::Sender<ScopedEvent>,
|
||||||
connection_count: Arc<AtomicU64>,
|
connection_count: Arc<AtomicU64>,
|
||||||
max_connections: u64,
|
max_connections: u64,
|
||||||
}
|
}
|
||||||
@@ -45,7 +61,7 @@ impl SseManager {
|
|||||||
/// only be called before the server starts accepting connections (i.e.,
|
/// only be called before the server starts accepting connections (i.e.,
|
||||||
/// during startup wiring). Calling it after connections are established
|
/// during startup wiring). Calling it after connections are established
|
||||||
/// will break connection tracking and allow exceeding `MAX_CONNECTIONS`.
|
/// will break connection tracking and allow exceeding `MAX_CONNECTIONS`.
|
||||||
pub fn from_sender(tx: broadcast::Sender<SseEvent>) -> Self {
|
pub(crate) fn from_sender(tx: broadcast::Sender<ScopedEvent>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
tx,
|
tx,
|
||||||
connection_count: Arc::new(AtomicU64::new(0)),
|
connection_count: Arc::new(AtomicU64::new(0)),
|
||||||
@@ -53,15 +69,28 @@ impl SseManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Broadcast an event to all connected clients.
|
/// Get a clone of the broadcast sender for use by other components.
|
||||||
pub fn broadcast(&self, event: SseEvent) {
|
pub(crate) fn sender(&self) -> broadcast::Sender<ScopedEvent> {
|
||||||
// Ignore send errors (no receivers is fine)
|
self.tx.clone()
|
||||||
let _ = self.tx.send(event);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a clone of the broadcast sender for use by other components.
|
/// Broadcast an event to all connected clients (global/unscoped).
|
||||||
pub fn sender(&self) -> broadcast::Sender<SseEvent> {
|
pub fn broadcast(&self, event: SseEvent) {
|
||||||
self.tx.clone()
|
let _ = self.tx.send(ScopedEvent {
|
||||||
|
user_id: None,
|
||||||
|
event,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Broadcast an event scoped to a specific user.
|
||||||
|
///
|
||||||
|
/// Only subscribers for this user_id (or unscoped subscribers) will
|
||||||
|
/// receive the event.
|
||||||
|
pub fn broadcast_for_user(&self, user_id: &str, event: SseEvent) {
|
||||||
|
let _ = self.tx.send(ScopedEvent {
|
||||||
|
user_id: Some(user_id.to_string()),
|
||||||
|
event,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current number of active connections.
|
/// Get current number of active connections.
|
||||||
@@ -71,11 +100,15 @@ impl SseManager {
|
|||||||
|
|
||||||
/// Create a raw broadcast subscription for non-SSE consumers (e.g. WebSocket).
|
/// Create a raw broadcast subscription for non-SSE consumers (e.g. WebSocket).
|
||||||
///
|
///
|
||||||
/// Returns a stream of `SseEvent` values and increments/decrements the
|
/// When `user_id` is `Some`, only events scoped to that user (or global
|
||||||
/// connection counter on creation/drop, just like `subscribe()` does for SSE.
|
/// events) are delivered. When `None`, all events are delivered (single-user
|
||||||
|
/// backwards compatibility).
|
||||||
///
|
///
|
||||||
/// Returns `None` if the maximum connection limit has been reached.
|
/// Returns `None` if the maximum connection limit has been reached.
|
||||||
pub fn subscribe_raw(&self) -> Option<impl Stream<Item = SseEvent> + Send + 'static + use<>> {
|
pub fn subscribe_raw(
|
||||||
|
&self,
|
||||||
|
user_id: Option<String>,
|
||||||
|
) -> Option<impl Stream<Item = SseEvent> + Send + 'static + use<>> {
|
||||||
// Atomically increment only if below the limit. This prevents
|
// Atomically increment only if below the limit. This prevents
|
||||||
// concurrent callers from overshooting max_connections.
|
// concurrent callers from overshooting max_connections.
|
||||||
let counter = Arc::clone(&self.connection_count);
|
let counter = Arc::clone(&self.connection_count);
|
||||||
@@ -91,7 +124,19 @@ impl SseManager {
|
|||||||
.ok()?;
|
.ok()?;
|
||||||
let rx = self.tx.subscribe();
|
let rx = self.tx.subscribe();
|
||||||
|
|
||||||
let stream = BroadcastStream::new(rx).filter_map(|result| result.ok());
|
let stream = BroadcastStream::new(rx).filter_map(move |result| match result {
|
||||||
|
Ok(scoped) => {
|
||||||
|
// Global events (user_id=None) always pass through.
|
||||||
|
// Scoped events only pass if the subscriber matches (or subscriber is unscoped).
|
||||||
|
match (&user_id, &scoped.user_id) {
|
||||||
|
(_, None) => Some(scoped.event), // global -> all
|
||||||
|
(None, _) => Some(scoped.event), // unscoped subscriber -> all
|
||||||
|
(Some(sub), Some(ev)) if sub == ev => Some(scoped.event), // match
|
||||||
|
_ => None, // different user -> skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => None,
|
||||||
|
});
|
||||||
|
|
||||||
Some(CountedStream {
|
Some(CountedStream {
|
||||||
inner: stream,
|
inner: stream,
|
||||||
@@ -101,9 +146,13 @@ impl SseManager {
|
|||||||
|
|
||||||
/// Create a new SSE stream for a client connection.
|
/// Create a new SSE stream for a client connection.
|
||||||
///
|
///
|
||||||
|
/// When `user_id` is `Some`, only events for that user (or global events)
|
||||||
|
/// are delivered. When `None`, all events are delivered.
|
||||||
|
///
|
||||||
/// Returns `None` if the maximum connection limit has been reached.
|
/// Returns `None` if the maximum connection limit has been reached.
|
||||||
pub fn subscribe(
|
pub fn subscribe(
|
||||||
&self,
|
&self,
|
||||||
|
user_id: Option<String>,
|
||||||
) -> Option<Sse<impl Stream<Item = Result<Event, Infallible>> + Send + 'static + use<>>> {
|
) -> Option<Sse<impl Stream<Item = Result<Event, Infallible>> + Send + 'static + use<>>> {
|
||||||
// Atomically increment only if below the limit.
|
// Atomically increment only if below the limit.
|
||||||
let counter = Arc::clone(&self.connection_count);
|
let counter = Arc::clone(&self.connection_count);
|
||||||
@@ -120,9 +169,23 @@ impl SseManager {
|
|||||||
let rx = self.tx.subscribe();
|
let rx = self.tx.subscribe();
|
||||||
|
|
||||||
let stream = BroadcastStream::new(rx)
|
let stream = BroadcastStream::new(rx)
|
||||||
.filter_map(|result| result.ok())
|
.filter_map(move |result| match result {
|
||||||
.map(|event| {
|
Ok(scoped) => match (&user_id, &scoped.user_id) {
|
||||||
let data = serde_json::to_string(&event).unwrap_or_default();
|
(_, None) => Some(scoped.event),
|
||||||
|
(None, _) => Some(scoped.event),
|
||||||
|
(Some(sub), Some(ev)) if sub == ev => Some(scoped.event),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
Err(_) => None,
|
||||||
|
})
|
||||||
|
.filter_map(|event| {
|
||||||
|
let data = match serde_json::to_string(&event) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to serialize SSE event: {}", e);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
let event_type = match &event {
|
let event_type = match &event {
|
||||||
SseEvent::Response { .. } => "response",
|
SseEvent::Response { .. } => "response",
|
||||||
SseEvent::Thinking { .. } => "thinking",
|
SseEvent::Thinking { .. } => "thinking",
|
||||||
@@ -144,9 +207,10 @@ impl SseManager {
|
|||||||
SseEvent::Heartbeat => "heartbeat",
|
SseEvent::Heartbeat => "heartbeat",
|
||||||
SseEvent::ImageGenerated { .. } => "image_generated",
|
SseEvent::ImageGenerated { .. } => "image_generated",
|
||||||
SseEvent::Suggestions { .. } => "suggestions",
|
SseEvent::Suggestions { .. } => "suggestions",
|
||||||
|
SseEvent::TurnCost { .. } => "turn_cost",
|
||||||
SseEvent::ExtensionStatus { .. } => "extension_status",
|
SseEvent::ExtensionStatus { .. } => "extension_status",
|
||||||
};
|
};
|
||||||
Ok(Event::default().event(event_type).data(data))
|
Some(Ok(Event::default().event(event_type).data(data)))
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wrap in a stream that decrements on drop
|
// Wrap in a stream that decrements on drop
|
||||||
@@ -214,16 +278,14 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_broadcast_to_receiver() {
|
async fn test_broadcast_to_receiver() {
|
||||||
let manager = SseManager::new();
|
let manager = SseManager::new();
|
||||||
let mut rx = BroadcastStream::new(manager.tx.subscribe());
|
let mut stream = Box::pin(manager.subscribe_raw(None).expect("should subscribe"));
|
||||||
|
|
||||||
manager.broadcast(SseEvent::Status {
|
manager.broadcast(SseEvent::Status {
|
||||||
message: "test".to_string(),
|
message: "test".to_string(),
|
||||||
thread_id: None,
|
thread_id: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
let event = rx.next().await;
|
let event = stream.next().await.unwrap();
|
||||||
assert!(event.is_some());
|
|
||||||
let event = event.unwrap().unwrap();
|
|
||||||
match event {
|
match event {
|
||||||
SseEvent::Status { message, .. } => assert_eq!(message, "test"),
|
SseEvent::Status { message, .. } => assert_eq!(message, "test"),
|
||||||
_ => panic!("unexpected event type"),
|
_ => panic!("unexpected event type"),
|
||||||
@@ -233,7 +295,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_subscribe_raw_receives_events() {
|
async fn test_subscribe_raw_receives_events() {
|
||||||
let manager = SseManager::new();
|
let manager = SseManager::new();
|
||||||
let mut stream = Box::pin(manager.subscribe_raw().expect("should subscribe"));
|
let mut stream = Box::pin(manager.subscribe_raw(None).expect("should subscribe"));
|
||||||
|
|
||||||
assert_eq!(manager.connection_count(), 1);
|
assert_eq!(manager.connection_count(), 1);
|
||||||
|
|
||||||
@@ -253,7 +315,7 @@ mod tests {
|
|||||||
async fn test_subscribe_raw_decrements_on_drop() {
|
async fn test_subscribe_raw_decrements_on_drop() {
|
||||||
let manager = SseManager::new();
|
let manager = SseManager::new();
|
||||||
{
|
{
|
||||||
let _stream = Box::pin(manager.subscribe_raw().expect("should subscribe"));
|
let _stream = Box::pin(manager.subscribe_raw(None).expect("should subscribe"));
|
||||||
assert_eq!(manager.connection_count(), 1);
|
assert_eq!(manager.connection_count(), 1);
|
||||||
}
|
}
|
||||||
// Stream dropped, counter should decrement
|
// Stream dropped, counter should decrement
|
||||||
@@ -263,8 +325,8 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_subscribe_raw_multiple_subscribers() {
|
async fn test_subscribe_raw_multiple_subscribers() {
|
||||||
let manager = SseManager::new();
|
let manager = SseManager::new();
|
||||||
let mut s1 = Box::pin(manager.subscribe_raw().expect("should subscribe"));
|
let mut s1 = Box::pin(manager.subscribe_raw(None).expect("should subscribe"));
|
||||||
let mut s2 = Box::pin(manager.subscribe_raw().expect("should subscribe"));
|
let mut s2 = Box::pin(manager.subscribe_raw(None).expect("should subscribe"));
|
||||||
assert_eq!(manager.connection_count(), 2);
|
assert_eq!(manager.connection_count(), 2);
|
||||||
|
|
||||||
manager.broadcast(SseEvent::Heartbeat);
|
manager.broadcast(SseEvent::Heartbeat);
|
||||||
@@ -285,12 +347,51 @@ mod tests {
|
|||||||
let mut manager = SseManager::new();
|
let mut manager = SseManager::new();
|
||||||
manager.max_connections = 2; // Low limit for testing
|
manager.max_connections = 2; // Low limit for testing
|
||||||
|
|
||||||
let _s1 = Box::pin(manager.subscribe_raw().expect("first should succeed"));
|
let _s1 = Box::pin(manager.subscribe_raw(None).expect("first should succeed"));
|
||||||
let _s2 = Box::pin(manager.subscribe_raw().expect("second should succeed"));
|
let _s2 = Box::pin(manager.subscribe_raw(None).expect("second should succeed"));
|
||||||
assert_eq!(manager.connection_count(), 2);
|
assert_eq!(manager.connection_count(), 2);
|
||||||
|
|
||||||
// Third should be rejected
|
// Third should be rejected
|
||||||
assert!(manager.subscribe_raw().is_none());
|
assert!(manager.subscribe_raw(None).is_none());
|
||||||
assert!(manager.subscribe().is_none());
|
assert!(manager.subscribe(None).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_scoped_events_filtered_by_user() {
|
||||||
|
let manager = SseManager::new();
|
||||||
|
let mut alice = Box::pin(
|
||||||
|
manager
|
||||||
|
.subscribe_raw(Some("alice".to_string()))
|
||||||
|
.expect("subscribe"),
|
||||||
|
);
|
||||||
|
let mut bob = Box::pin(
|
||||||
|
manager
|
||||||
|
.subscribe_raw(Some("bob".to_string()))
|
||||||
|
.expect("subscribe"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send event scoped to alice
|
||||||
|
manager.broadcast_for_user(
|
||||||
|
"alice",
|
||||||
|
SseEvent::Status {
|
||||||
|
message: "alice only".to_string(),
|
||||||
|
thread_id: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send global event
|
||||||
|
manager.broadcast(SseEvent::Heartbeat);
|
||||||
|
|
||||||
|
// Alice gets her scoped event
|
||||||
|
let e = alice.next().await.unwrap();
|
||||||
|
assert!(matches!(e, SseEvent::Status { .. }));
|
||||||
|
|
||||||
|
// Alice also gets the global heartbeat
|
||||||
|
let e = alice.next().await.unwrap();
|
||||||
|
assert!(matches!(e, SseEvent::Heartbeat));
|
||||||
|
|
||||||
|
// Bob only gets the global heartbeat (alice's event was filtered)
|
||||||
|
let e = bob.next().await.unwrap();
|
||||||
|
assert!(matches!(e, SseEvent::Heartbeat));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+582
-84
@@ -61,8 +61,16 @@ if (mql.addEventListener) {
|
|||||||
mql.addListener(onSchemeChange);
|
mql.addListener(onSchemeChange);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bind theme toggle button (CSP-compliant — no inline onclick).
|
// Bind theme toggle buttons (CSP-compliant — no inline onclick).
|
||||||
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
|
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
|
||||||
|
document.getElementById('settings-theme-toggle')?.addEventListener('click', () => {
|
||||||
|
toggleTheme();
|
||||||
|
const btn = document.getElementById('settings-theme-toggle');
|
||||||
|
if (btn) {
|
||||||
|
const mode = localStorage.getItem('ironclaw-theme') || 'system';
|
||||||
|
btn.textContent = 'Theme: ' + mode.charAt(0).toUpperCase() + mode.slice(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let token = '';
|
let token = '';
|
||||||
let eventSource = null;
|
let eventSource = null;
|
||||||
@@ -87,6 +95,19 @@ let authFlowPending = false;
|
|||||||
let _ghostSuggestion = '';
|
let _ghostSuggestion = '';
|
||||||
let currentSettingsSubtab = 'inference';
|
let currentSettingsSubtab = 'inference';
|
||||||
|
|
||||||
|
// --- Streaming Debounce State ---
|
||||||
|
let _streamBuffer = '';
|
||||||
|
let _streamDebounceTimer = null;
|
||||||
|
const STREAM_DEBOUNCE_MS = 50;
|
||||||
|
|
||||||
|
// --- Connection Status Banner State ---
|
||||||
|
let _connectionLostTimer = null;
|
||||||
|
let _connectionLostAt = null;
|
||||||
|
let _reconnectAttempts = 0;
|
||||||
|
|
||||||
|
// --- Send Cooldown State ---
|
||||||
|
let _sendCooldown = false;
|
||||||
|
|
||||||
// --- Slash Commands ---
|
// --- Slash Commands ---
|
||||||
|
|
||||||
const SLASH_COMMANDS = [
|
const SLASH_COMMANDS = [
|
||||||
@@ -126,12 +147,36 @@ function authenticate() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Loading state for Connect button
|
||||||
|
const connectBtn = document.getElementById('auth-connect-btn');
|
||||||
|
if (connectBtn) {
|
||||||
|
connectBtn.disabled = true;
|
||||||
|
connectBtn.textContent = 'Connecting...';
|
||||||
|
}
|
||||||
|
|
||||||
// Test the token against the health-ish endpoint (chat/threads requires auth)
|
// Test the token against the health-ish endpoint (chat/threads requires auth)
|
||||||
apiFetch('/api/chat/threads')
|
apiFetch('/api/chat/threads')
|
||||||
.then(() => {
|
.then(() => {
|
||||||
sessionStorage.setItem('ironclaw_token', token);
|
sessionStorage.setItem('ironclaw_token', token);
|
||||||
document.getElementById('auth-screen').style.display = 'none';
|
const authScreen = document.getElementById('auth-screen');
|
||||||
document.getElementById('app').style.display = 'flex';
|
const app = document.getElementById('app');
|
||||||
|
// Cross-fade: fade out auth screen, then show app
|
||||||
|
if (authScreen) authScreen.style.opacity = '0';
|
||||||
|
// Show app container (invisible — opacity:0 in CSS) so layout computes
|
||||||
|
app.style.display = 'flex';
|
||||||
|
// Position tab indicator instantly (no transition) before fade-in
|
||||||
|
const indicator = document.getElementById('tab-indicator');
|
||||||
|
if (indicator) indicator.style.transition = 'none';
|
||||||
|
updateTabIndicator();
|
||||||
|
// Force layout so the instant position is applied, then restore transition
|
||||||
|
if (indicator) {
|
||||||
|
void indicator.offsetLeft;
|
||||||
|
indicator.style.transition = '';
|
||||||
|
}
|
||||||
|
// Now fade in
|
||||||
|
app.classList.add('visible');
|
||||||
|
// Hide auth screen after fade-out transition completes
|
||||||
|
setTimeout(() => { if (authScreen) authScreen.style.display = 'none'; }, 300);
|
||||||
// Strip token and log_level from URL so they're not visible in the address bar
|
// Strip token and log_level from URL so they're not visible in the address bar
|
||||||
const cleaned = new URL(window.location);
|
const cleaned = new URL(window.location);
|
||||||
const urlLogLevel = cleaned.searchParams.get('log_level');
|
const urlLogLevel = cleaned.searchParams.get('log_level');
|
||||||
@@ -155,8 +200,14 @@ function authenticate() {
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
sessionStorage.removeItem('ironclaw_token');
|
sessionStorage.removeItem('ironclaw_token');
|
||||||
document.getElementById('auth-screen').style.display = '';
|
document.getElementById('auth-screen').style.display = '';
|
||||||
|
document.getElementById('auth-screen').style.opacity = '';
|
||||||
document.getElementById('app').style.display = 'none';
|
document.getElementById('app').style.display = 'none';
|
||||||
document.getElementById('auth-error').textContent = I18n.t('auth.errorInvalid');
|
document.getElementById('auth-error').textContent = I18n.t('auth.errorInvalid');
|
||||||
|
// Reset Connect button on error
|
||||||
|
if (connectBtn) {
|
||||||
|
connectBtn.disabled = false;
|
||||||
|
connectBtn.textContent = 'Connect';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,29 +215,8 @@ document.getElementById('token-input').addEventListener('keydown', (e) => {
|
|||||||
if (e.key === 'Enter') authenticate();
|
if (e.key === 'Enter') authenticate();
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Static element event bindings (CSP-compliant, no inline handlers) ---
|
// Note: main event listener registration is at the bottom of this file (search
|
||||||
document.getElementById('auth-connect-btn').addEventListener('click', () => authenticate());
|
// "Event Listener Registration"). Do NOT add duplicate listeners here.
|
||||||
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
|
// Auto-authenticate from URL param or saved session
|
||||||
(function autoAuth() {
|
(function autoAuth() {
|
||||||
@@ -221,7 +251,9 @@ function apiFetch(path, options) {
|
|||||||
return fetch(path, opts).then((res) => {
|
return fetch(path, opts).then((res) => {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return res.text().then(function(body) {
|
return res.text().then(function(body) {
|
||||||
throw new Error(body || (res.status + ' ' + res.statusText));
|
const err = new Error(body || (res.status + ' ' + res.statusText));
|
||||||
|
err.status = res.status;
|
||||||
|
throw err;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (res.status === 204) return null;
|
if (res.status === 204) return null;
|
||||||
@@ -327,6 +359,25 @@ function connectSSE() {
|
|||||||
eventSource.onopen = () => {
|
eventSource.onopen = () => {
|
||||||
document.getElementById('sse-dot').classList.remove('disconnected');
|
document.getElementById('sse-dot').classList.remove('disconnected');
|
||||||
document.getElementById('sse-status').textContent = I18n.t('status.connected');
|
document.getElementById('sse-status').textContent = I18n.t('status.connected');
|
||||||
|
_reconnectAttempts = 0;
|
||||||
|
|
||||||
|
// Dismiss connection-lost banner and show reconnected flash
|
||||||
|
if (_connectionLostTimer) {
|
||||||
|
clearTimeout(_connectionLostTimer);
|
||||||
|
_connectionLostTimer = null;
|
||||||
|
}
|
||||||
|
const lostBanner = document.getElementById('connection-banner');
|
||||||
|
if (lostBanner) {
|
||||||
|
const wasDisconnectedLong = _connectionLostAt && (Date.now() - _connectionLostAt > 10000);
|
||||||
|
lostBanner.textContent = 'Reconnected';
|
||||||
|
lostBanner.className = 'connection-banner connection-banner-success';
|
||||||
|
setTimeout(() => { lostBanner.remove(); }, 2000);
|
||||||
|
_connectionLostAt = null;
|
||||||
|
// If disconnected >10s, reload chat history to catch missed messages
|
||||||
|
if (wasDisconnectedLong && currentThreadId) {
|
||||||
|
loadHistory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If we were restarting, close the modal and reset button now that server is back
|
// If we were restarting, close the modal and reset button now that server is back
|
||||||
if (isRestarting) {
|
if (isRestarting) {
|
||||||
@@ -347,8 +398,28 @@ function connectSSE() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
eventSource.onerror = () => {
|
eventSource.onerror = () => {
|
||||||
|
_reconnectAttempts++;
|
||||||
document.getElementById('sse-dot').classList.add('disconnected');
|
document.getElementById('sse-dot').classList.add('disconnected');
|
||||||
document.getElementById('sse-status').textContent = I18n.t('status.reconnecting');
|
document.getElementById('sse-status').textContent = I18n.t('status.reconnecting');
|
||||||
|
|
||||||
|
// Update existing banner with attempt count
|
||||||
|
const existingBanner = document.getElementById('connection-banner');
|
||||||
|
if (existingBanner && existingBanner.classList.contains('connection-banner-warning')) {
|
||||||
|
existingBanner.textContent = 'Connection lost. Reconnecting... (attempt ' + _reconnectAttempts + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start connection-lost banner timer (3s delay)
|
||||||
|
if (!_connectionLostTimer && !existingBanner) {
|
||||||
|
_connectionLostAt = _connectionLostAt || Date.now();
|
||||||
|
_connectionLostTimer = setTimeout(() => {
|
||||||
|
_connectionLostTimer = null;
|
||||||
|
// Only show if still disconnected
|
||||||
|
const dot = document.getElementById('sse-dot');
|
||||||
|
if (dot?.classList.contains('disconnected')) {
|
||||||
|
showConnectionBanner('Connection lost. Reconnecting... (attempt ' + _reconnectAttempts + ')', 'warning');
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
eventSource.addEventListener('response', (e) => {
|
eventSource.addEventListener('response', (e) => {
|
||||||
@@ -360,6 +431,19 @@ function connectSSE() {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Flush any remaining streaming buffer
|
||||||
|
if (_streamDebounceTimer) {
|
||||||
|
clearInterval(_streamDebounceTimer);
|
||||||
|
_streamDebounceTimer = null;
|
||||||
|
}
|
||||||
|
if (_streamBuffer) {
|
||||||
|
appendToLastAssistant(_streamBuffer);
|
||||||
|
_streamBuffer = '';
|
||||||
|
}
|
||||||
|
// Remove streaming attribute from active assistant message
|
||||||
|
const streamingMsg = document.querySelector('.message.assistant[data-streaming="true"]');
|
||||||
|
if (streamingMsg) streamingMsg.removeAttribute('data-streaming');
|
||||||
|
|
||||||
finalizeActivityGroup();
|
finalizeActivityGroup();
|
||||||
addMessage('assistant', data.content);
|
addMessage('assistant', data.content);
|
||||||
enableChatInput();
|
enableChatInput();
|
||||||
@@ -417,7 +501,31 @@ function connectSSE() {
|
|||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
if (!isCurrentThread(data.thread_id)) return;
|
if (!isCurrentThread(data.thread_id)) return;
|
||||||
finalizeActivityGroup();
|
finalizeActivityGroup();
|
||||||
appendToLastAssistant(data.content);
|
|
||||||
|
// Mark the active assistant message as streaming
|
||||||
|
const container = document.getElementById('chat-messages');
|
||||||
|
let lastAssistant = container.querySelector('.message.assistant:last-of-type');
|
||||||
|
if (!lastAssistant) {
|
||||||
|
addMessage('assistant', '');
|
||||||
|
lastAssistant = container.querySelector('.message.assistant:last-of-type');
|
||||||
|
}
|
||||||
|
if (lastAssistant) lastAssistant.setAttribute('data-streaming', 'true');
|
||||||
|
|
||||||
|
// Accumulate chunks and debounce rendering at 50ms intervals
|
||||||
|
_streamBuffer += data.content;
|
||||||
|
// Force flush when buffer exceeds 10K chars to prevent memory buildup
|
||||||
|
if (_streamBuffer.length > 10000) {
|
||||||
|
appendToLastAssistant(_streamBuffer);
|
||||||
|
_streamBuffer = '';
|
||||||
|
}
|
||||||
|
if (!_streamDebounceTimer) {
|
||||||
|
_streamDebounceTimer = setInterval(() => {
|
||||||
|
if (_streamBuffer) {
|
||||||
|
appendToLastAssistant(_streamBuffer);
|
||||||
|
_streamBuffer = '';
|
||||||
|
}
|
||||||
|
}, STREAM_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
eventSource.addEventListener('status', (e) => {
|
eventSource.addEventListener('status', (e) => {
|
||||||
@@ -487,6 +595,22 @@ function connectSSE() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener('turn_cost', (e) => {
|
||||||
|
const event = JSON.parse(e.data);
|
||||||
|
if (!isCurrentThread(event.thread_id)) return;
|
||||||
|
// Add cost badge below last assistant message
|
||||||
|
const messages = document.querySelectorAll('.message.assistant');
|
||||||
|
const lastMsg = messages[messages.length - 1];
|
||||||
|
const tokens = (event.input_tokens || 0) + (event.output_tokens || 0);
|
||||||
|
if (lastMsg && tokens > 0) {
|
||||||
|
const badge = document.createElement('div');
|
||||||
|
badge.className = 'turn-cost-badge';
|
||||||
|
const cost = event.cost_usd ? ' \u00b7 ' + event.cost_usd : '';
|
||||||
|
badge.textContent = tokens.toLocaleString() + ' tokens' + cost;
|
||||||
|
lastMsg.appendChild(badge);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Job event listeners (activity stream for all sandbox jobs)
|
// Job event listeners (activity stream for all sandbox jobs)
|
||||||
const jobEventTypes = [
|
const jobEventTypes = [
|
||||||
'job_message', 'job_tool_use', 'job_tool_result',
|
'job_message', 'job_tool_use', 'job_tool_result',
|
||||||
@@ -578,6 +702,7 @@ function clearSuggestionChips() {
|
|||||||
|
|
||||||
function sendMessage() {
|
function sendMessage() {
|
||||||
clearSuggestionChips();
|
clearSuggestionChips();
|
||||||
|
removeWelcomeCard();
|
||||||
const input = document.getElementById('chat-input');
|
const input = document.getElementById('chat-input');
|
||||||
if (authFlowPending) {
|
if (authFlowPending) {
|
||||||
showToast('Complete the auth step before sending chat messages.', 'info');
|
showToast('Complete the auth step before sending chat messages.', 'info');
|
||||||
@@ -589,10 +714,11 @@ function sendMessage() {
|
|||||||
console.warn('sendMessage: no thread selected, ignoring');
|
console.warn('sendMessage: no thread selected, ignoring');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (_sendCooldown) return;
|
||||||
const content = input.value.trim();
|
const content = input.value.trim();
|
||||||
if (!content && stagedImages.length === 0) return;
|
if (!content && stagedImages.length === 0) return;
|
||||||
|
|
||||||
addMessage('user', content || '(images attached)');
|
const userMsg = addMessage('user', content || '(images attached)');
|
||||||
input.value = '';
|
input.value = '';
|
||||||
autoResizeTextarea(input);
|
autoResizeTextarea(input);
|
||||||
input.focus();
|
input.focus();
|
||||||
@@ -608,7 +734,33 @@ function sendMessage() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: body,
|
body: body,
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
addMessage('system', 'Failed to send: ' + err.message);
|
// Handle rate limiting (429)
|
||||||
|
if (err.status === 429) {
|
||||||
|
showToast('Rate limited. Please wait.', 'error');
|
||||||
|
_sendCooldown = true;
|
||||||
|
const sendBtn = document.getElementById('send-btn');
|
||||||
|
if (sendBtn) sendBtn.disabled = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
_sendCooldown = false;
|
||||||
|
if (sendBtn) sendBtn.disabled = false;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
// Keep the user message in DOM, add a retry link
|
||||||
|
if (userMsg) {
|
||||||
|
userMsg.classList.add('send-failed');
|
||||||
|
userMsg.style.borderStyle = 'dashed';
|
||||||
|
const retryLink = document.createElement('a');
|
||||||
|
retryLink.className = 'retry-link';
|
||||||
|
retryLink.href = '#';
|
||||||
|
retryLink.textContent = 'Retry';
|
||||||
|
retryLink.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (userMsg.parentNode) userMsg.parentNode.removeChild(userMsg);
|
||||||
|
input.value = content;
|
||||||
|
sendMessage();
|
||||||
|
});
|
||||||
|
userMsg.appendChild(retryLink);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -887,11 +1039,36 @@ function copyMessage(btn) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _lastMessageDate = null;
|
||||||
|
|
||||||
|
function maybeInsertTimeSeparator(container, timestamp) {
|
||||||
|
const date = timestamp ? new Date(timestamp) : new Date();
|
||||||
|
const dateStr = date.toDateString();
|
||||||
|
if (_lastMessageDate === dateStr) return;
|
||||||
|
_lastMessageDate = dateStr;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const today = now.toDateString();
|
||||||
|
const yesterday = new Date(now.getTime() - 86400000).toDateString();
|
||||||
|
|
||||||
|
let label;
|
||||||
|
if (dateStr === today) label = 'Today';
|
||||||
|
else if (dateStr === yesterday) label = 'Yesterday';
|
||||||
|
else label = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
|
||||||
|
const sep = document.createElement('div');
|
||||||
|
sep.className = 'time-separator';
|
||||||
|
sep.textContent = label;
|
||||||
|
container.appendChild(sep);
|
||||||
|
}
|
||||||
|
|
||||||
function addMessage(role, content) {
|
function addMessage(role, content) {
|
||||||
const container = document.getElementById('chat-messages');
|
const container = document.getElementById('chat-messages');
|
||||||
|
maybeInsertTimeSeparator(container);
|
||||||
const div = createMessageElement(role, content);
|
const div = createMessageElement(role, content);
|
||||||
container.appendChild(div);
|
container.appendChild(div);
|
||||||
container.scrollTop = container.scrollHeight;
|
container.scrollTop = container.scrollHeight;
|
||||||
|
return div;
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendToLastAssistant(chunk) {
|
function appendToLastAssistant(chunk) {
|
||||||
@@ -905,6 +1082,14 @@ function appendToLastAssistant(chunk) {
|
|||||||
const content = last.querySelector('.message-content');
|
const content = last.querySelector('.message-content');
|
||||||
if (content) {
|
if (content) {
|
||||||
content.innerHTML = renderMarkdown(raw);
|
content.innerHTML = renderMarkdown(raw);
|
||||||
|
// Syntax highlighting for code blocks
|
||||||
|
if (typeof hljs !== 'undefined') {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
content.querySelectorAll('pre code').forEach(block => {
|
||||||
|
hljs.highlightElement(block);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
container.scrollTop = container.scrollHeight;
|
container.scrollTop = container.scrollHeight;
|
||||||
} else {
|
} else {
|
||||||
@@ -992,16 +1177,14 @@ function addToolCard(name) {
|
|||||||
|
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = 'activity-tool-body';
|
body.className = 'activity-tool-body';
|
||||||
body.style.display = 'none';
|
|
||||||
|
|
||||||
const output = document.createElement('pre');
|
const output = document.createElement('pre');
|
||||||
output.className = 'activity-tool-output';
|
output.className = 'activity-tool-output';
|
||||||
body.appendChild(output);
|
body.appendChild(output);
|
||||||
|
|
||||||
header.addEventListener('click', () => {
|
header.addEventListener('click', () => {
|
||||||
const isOpen = body.style.display !== 'none';
|
body.classList.toggle('expanded');
|
||||||
body.style.display = isOpen ? 'none' : 'block';
|
chevron.classList.toggle('expanded', body.classList.contains('expanded'));
|
||||||
chevron.classList.toggle('expanded', !isOpen);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
card.appendChild(header);
|
card.appendChild(header);
|
||||||
@@ -1060,7 +1243,7 @@ function completeToolCard(name, success, error, parameters) {
|
|||||||
// Auto-expand so the error is immediately visible
|
// Auto-expand so the error is immediately visible
|
||||||
const body = entry.card.querySelector('.activity-tool-body');
|
const body = entry.card.querySelector('.activity-tool-body');
|
||||||
const chevron = entry.card.querySelector('.activity-tool-chevron');
|
const chevron = entry.card.querySelector('.activity-tool-chevron');
|
||||||
if (body) body.style.display = 'block';
|
if (body) body.classList.add('expanded');
|
||||||
if (chevron) chevron.classList.add('expanded');
|
if (chevron) chevron.classList.add('expanded');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1547,6 +1730,13 @@ function loadHistory(before) {
|
|||||||
const isPaginating = !!before;
|
const isPaginating = !!before;
|
||||||
if (isPaginating) loadingOlder = true;
|
if (isPaginating) loadingOlder = true;
|
||||||
|
|
||||||
|
// Show skeleton while loading (only for fresh loads)
|
||||||
|
if (!isPaginating) {
|
||||||
|
const chatContainer = document.getElementById('chat-messages');
|
||||||
|
chatContainer.innerHTML = '';
|
||||||
|
chatContainer.appendChild(renderSkeleton('message', 3));
|
||||||
|
}
|
||||||
|
|
||||||
apiFetch(historyUrl).then((data) => {
|
apiFetch(historyUrl).then((data) => {
|
||||||
const container = document.getElementById('chat-messages');
|
const container = document.getElementById('chat-messages');
|
||||||
|
|
||||||
@@ -1564,6 +1754,10 @@ function loadHistory(before) {
|
|||||||
addMessage('assistant', turn.response);
|
addMessage('assistant', turn.response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Show welcome card when history is empty
|
||||||
|
if (data.turns.length === 0) {
|
||||||
|
showWelcomeCard();
|
||||||
|
}
|
||||||
// Show processing indicator if the last turn is still in-progress
|
// Show processing indicator if the last turn is still in-progress
|
||||||
var lastTurn = data.turns.length > 0 ? data.turns[data.turns.length - 1] : null;
|
var lastTurn = data.turns.length > 0 ? data.turns[data.turns.length - 1] : null;
|
||||||
if (lastTurn && !lastTurn.response && lastTurn.state === 'Processing') {
|
if (lastTurn && !lastTurn.response && lastTurn.state === 'Processing') {
|
||||||
@@ -1610,6 +1804,30 @@ function createMessageElement(role, content) {
|
|||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'message ' + role;
|
div.className = 'message ' + role;
|
||||||
|
|
||||||
|
const ts = document.createElement('span');
|
||||||
|
ts.className = 'message-timestamp';
|
||||||
|
ts.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
div.appendChild(ts);
|
||||||
|
|
||||||
|
// Message content
|
||||||
|
const contentEl = document.createElement('div');
|
||||||
|
contentEl.className = 'message-content';
|
||||||
|
if (role === 'user' || role === 'system') {
|
||||||
|
contentEl.textContent = content;
|
||||||
|
} else {
|
||||||
|
div.setAttribute('data-raw', content);
|
||||||
|
contentEl.innerHTML = renderMarkdown(content);
|
||||||
|
// Syntax highlighting for code blocks
|
||||||
|
if (typeof hljs !== 'undefined') {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
contentEl.querySelectorAll('pre code').forEach(block => {
|
||||||
|
hljs.highlightElement(block);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div.appendChild(contentEl);
|
||||||
|
|
||||||
if (role === 'assistant' || role === 'user') {
|
if (role === 'assistant' || role === 'user') {
|
||||||
div.classList.add('has-copy');
|
div.classList.add('has-copy');
|
||||||
div.setAttribute('data-copy-text', content);
|
div.setAttribute('data-copy-text', content);
|
||||||
@@ -1625,15 +1843,6 @@ function createMessageElement(role, content) {
|
|||||||
div.appendChild(copyBtn);
|
div.appendChild(copyBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = document.createElement('div');
|
|
||||||
body.className = 'message-content';
|
|
||||||
if (role === 'user' || role === 'system') {
|
|
||||||
body.textContent = content;
|
|
||||||
} else {
|
|
||||||
div.setAttribute('data-raw', content);
|
|
||||||
body.innerHTML = renderMarkdown(content);
|
|
||||||
}
|
|
||||||
div.appendChild(body);
|
|
||||||
return div;
|
return div;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1731,6 +1940,13 @@ function debouncedLoadThreads() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadThreads() {
|
function loadThreads() {
|
||||||
|
// Show skeleton while loading
|
||||||
|
const threadListEl = document.getElementById('thread-list');
|
||||||
|
if (threadListEl && threadListEl.children.length === 0) {
|
||||||
|
threadListEl.innerHTML = '';
|
||||||
|
threadListEl.appendChild(renderSkeleton('row', 4));
|
||||||
|
}
|
||||||
|
|
||||||
apiFetch('/api/chat/threads').then((data) => {
|
apiFetch('/api/chat/threads').then((data) => {
|
||||||
// Pinned assistant thread
|
// Pinned assistant thread
|
||||||
if (data.assistant_thread) {
|
if (data.assistant_thread) {
|
||||||
@@ -1828,6 +2044,11 @@ function switchToAssistant() {
|
|||||||
oldestTimestamp = null;
|
oldestTimestamp = null;
|
||||||
loadHistory();
|
loadHistory();
|
||||||
loadThreads();
|
loadThreads();
|
||||||
|
if (window.innerWidth <= 768) {
|
||||||
|
const sidebar = document.getElementById('thread-sidebar');
|
||||||
|
sidebar.classList.remove('expanded-mobile');
|
||||||
|
document.getElementById('thread-toggle-btn').innerHTML = '»';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchThread(threadId) {
|
function switchThread(threadId) {
|
||||||
@@ -1839,12 +2060,18 @@ function switchThread(threadId) {
|
|||||||
oldestTimestamp = null;
|
oldestTimestamp = null;
|
||||||
loadHistory();
|
loadHistory();
|
||||||
loadThreads();
|
loadThreads();
|
||||||
|
if (window.innerWidth <= 768) {
|
||||||
|
const sidebar = document.getElementById('thread-sidebar');
|
||||||
|
sidebar.classList.remove('expanded-mobile');
|
||||||
|
document.getElementById('thread-toggle-btn').innerHTML = '»';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createNewThread() {
|
function createNewThread() {
|
||||||
apiFetch('/api/chat/thread/new', { method: 'POST' }).then((data) => {
|
apiFetch('/api/chat/thread/new', { method: 'POST' }).then((data) => {
|
||||||
currentThreadId = data.id || null;
|
currentThreadId = data.id || null;
|
||||||
document.getElementById('chat-messages').innerHTML = '';
|
document.getElementById('chat-messages').innerHTML = '';
|
||||||
|
showWelcomeCard();
|
||||||
loadThreads();
|
loadThreads();
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
showToast('Failed to create thread: ' + err.message, 'error');
|
showToast('Failed to create thread: ' + err.message, 'error');
|
||||||
@@ -1853,9 +2080,17 @@ function createNewThread() {
|
|||||||
|
|
||||||
function toggleThreadSidebar() {
|
function toggleThreadSidebar() {
|
||||||
const sidebar = document.getElementById('thread-sidebar');
|
const sidebar = document.getElementById('thread-sidebar');
|
||||||
sidebar.classList.toggle('collapsed');
|
const isMobile = window.innerWidth <= 768;
|
||||||
|
if (isMobile) {
|
||||||
|
sidebar.classList.toggle('expanded-mobile');
|
||||||
|
} else {
|
||||||
|
sidebar.classList.toggle('collapsed');
|
||||||
|
}
|
||||||
const btn = document.getElementById('thread-toggle-btn');
|
const btn = document.getElementById('thread-toggle-btn');
|
||||||
btn.innerHTML = sidebar.classList.contains('collapsed') ? '»' : '«';
|
const isOpen = isMobile
|
||||||
|
? sidebar.classList.contains('expanded-mobile')
|
||||||
|
: !sidebar.classList.contains('collapsed');
|
||||||
|
btn.innerHTML = isOpen ? '«' : '»';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chat input auto-resize and keyboard handling
|
// Chat input auto-resize and keyboard handling
|
||||||
@@ -1922,6 +2157,10 @@ chatInput.addEventListener('input', () => {
|
|||||||
ghost.style.display = 'block';
|
ghost.style.display = 'block';
|
||||||
wrapper.classList.add('has-ghost');
|
wrapper.classList.add('has-ghost');
|
||||||
}
|
}
|
||||||
|
const sendBtn = document.getElementById('send-btn');
|
||||||
|
if (sendBtn) {
|
||||||
|
sendBtn.classList.toggle('active', chatInput.value.trim().length > 0);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
chatInput.addEventListener('blur', () => {
|
chatInput.addEventListener('blur', () => {
|
||||||
// Small delay so mousedown on autocomplete item fires first
|
// Small delay so mousedown on autocomplete item fires first
|
||||||
@@ -1943,8 +2182,13 @@ document.getElementById('chat-messages').addEventListener('scroll', function ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
function autoResizeTextarea(el) {
|
function autoResizeTextarea(el) {
|
||||||
|
const prev = el.offsetHeight;
|
||||||
el.style.height = 'auto';
|
el.style.height = 'auto';
|
||||||
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
|
const target = Math.min(el.scrollHeight, 120);
|
||||||
|
el.style.height = prev + 'px';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.style.height = target + 'px';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Tabs ---
|
// --- Tabs ---
|
||||||
@@ -1964,6 +2208,7 @@ function switchTab(tab) {
|
|||||||
document.querySelectorAll('.tab-panel').forEach((p) => {
|
document.querySelectorAll('.tab-panel').forEach((p) => {
|
||||||
p.classList.toggle('active', p.id === 'tab-' + tab);
|
p.classList.toggle('active', p.id === 'tab-' + tab);
|
||||||
});
|
});
|
||||||
|
applyAriaAttributes();
|
||||||
|
|
||||||
if (tab === 'memory') loadMemoryTree();
|
if (tab === 'memory') loadMemoryTree();
|
||||||
if (tab === 'jobs') loadJobs();
|
if (tab === 'jobs') loadJobs();
|
||||||
@@ -1974,8 +2219,26 @@ function switchTab(tab) {
|
|||||||
} else {
|
} else {
|
||||||
stopPairingPoll();
|
stopPairingPoll();
|
||||||
}
|
}
|
||||||
|
updateTabIndicator();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateTabIndicator() {
|
||||||
|
const indicator = document.getElementById('tab-indicator');
|
||||||
|
if (!indicator) return;
|
||||||
|
const activeBtn = document.querySelector('.tab-bar button[data-tab].active');
|
||||||
|
if (!activeBtn) {
|
||||||
|
indicator.style.width = '0';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bar = activeBtn.closest('.tab-bar');
|
||||||
|
const barRect = bar.getBoundingClientRect();
|
||||||
|
const btnRect = activeBtn.getBoundingClientRect();
|
||||||
|
indicator.style.left = (btnRect.left - barRect.left) + 'px';
|
||||||
|
indicator.style.width = btnRect.width + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', updateTabIndicator);
|
||||||
|
|
||||||
// --- Memory (filesystem tree) ---
|
// --- Memory (filesystem tree) ---
|
||||||
|
|
||||||
let memorySearchTimeout = null;
|
let memorySearchTimeout = null;
|
||||||
@@ -4694,13 +4957,27 @@ document.addEventListener('keydown', (e) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escape: close autocomplete, job detail, or blur input
|
// Mod+/: toggle shortcuts overlay
|
||||||
|
if (mod && e.key === '/') {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleShortcutsOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape: close modals, autocomplete, job detail, or blur input
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
const acEl = document.getElementById('slash-autocomplete');
|
const acEl = document.getElementById('slash-autocomplete');
|
||||||
if (acEl && acEl.style.display !== 'none') {
|
if (acEl && acEl.style.display !== 'none') {
|
||||||
hideSlashAutocomplete();
|
hideSlashAutocomplete();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Close shortcuts overlay if open
|
||||||
|
const shortcutsOverlay = document.getElementById('shortcuts-overlay');
|
||||||
|
if (shortcutsOverlay?.style.display === 'flex') {
|
||||||
|
shortcutsOverlay.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeModals();
|
||||||
if (currentJobId) {
|
if (currentJobId) {
|
||||||
closeJobDetail();
|
closeJobDetail();
|
||||||
} else if (inInput) {
|
} else if (inInput) {
|
||||||
@@ -4732,9 +5009,17 @@ function switchSettingsSubtab(subtab) {
|
|||||||
searchInput.value = '';
|
searchInput.value = '';
|
||||||
searchInput.dispatchEvent(new Event('input'));
|
searchInput.dispatchEvent(new Event('input'));
|
||||||
}
|
}
|
||||||
|
// On mobile, drill into detail view
|
||||||
|
if (window.innerWidth <= 768) {
|
||||||
|
document.querySelector('.settings-layout').classList.add('settings-detail-active');
|
||||||
|
}
|
||||||
loadSettingsSubtab(subtab);
|
loadSettingsSubtab(subtab);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function settingsBack() {
|
||||||
|
document.querySelector('.settings-layout').classList.remove('settings-detail-active');
|
||||||
|
}
|
||||||
|
|
||||||
function loadSettingsSubtab(subtab) {
|
function loadSettingsSubtab(subtab) {
|
||||||
if (subtab === 'inference') loadInferenceSettings();
|
if (subtab === 'inference') loadInferenceSettings();
|
||||||
else if (subtab === 'agent') loadAgentSettings();
|
else if (subtab === 'agent') loadAgentSettings();
|
||||||
@@ -4870,6 +5155,19 @@ function renderCardsSkeleton(count) {
|
|||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderSkeleton(type, count) {
|
||||||
|
count = count || 3;
|
||||||
|
var container = document.createElement('div');
|
||||||
|
container.className = 'skeleton-container';
|
||||||
|
for (var i = 0; i < count; i++) {
|
||||||
|
var el = document.createElement('div');
|
||||||
|
el.className = 'skeleton-' + type;
|
||||||
|
el.innerHTML = '<div class="skeleton-bar shimmer"></div>';
|
||||||
|
container.appendChild(el);
|
||||||
|
}
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
function loadInferenceSettings() {
|
function loadInferenceSettings() {
|
||||||
var container = document.getElementById('settings-inference-content');
|
var container = document.getElementById('settings-inference-content');
|
||||||
container.innerHTML = renderSettingsSkeleton(6);
|
container.innerHTML = renderSettingsSkeleton(6);
|
||||||
@@ -4888,11 +5186,13 @@ function loadInferenceSettings() {
|
|||||||
};
|
};
|
||||||
// Inject available model IDs as suggestions for the selected_model field
|
// Inject available model IDs as suggestions for the selected_model field
|
||||||
var modelIds = (modelsData.data || []).map(function(m) { return m.id; }).filter(Boolean);
|
var modelIds = (modelsData.data || []).map(function(m) { return m.id; }).filter(Boolean);
|
||||||
var llmGroup = INFERENCE_SETTINGS[0];
|
if (modelIds.length > 0) {
|
||||||
for (var i = 0; i < llmGroup.settings.length; i++) {
|
var llmGroup = INFERENCE_SETTINGS[0];
|
||||||
if (llmGroup.settings[i].key === 'selected_model') {
|
for (var i = 0; i < llmGroup.settings.length; i++) {
|
||||||
llmGroup.settings[i].suggestions = modelIds;
|
if (llmGroup.settings[i].key === 'selected_model') {
|
||||||
break;
|
llmGroup.settings[i].suggestions = modelIds;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
@@ -5020,34 +5320,30 @@ function renderStructuredSettingsRow(def, value, activeValue) {
|
|||||||
var placeholderText = activeValueText ? I18n.t('settings.envValue', { value: activeValueText }) : (def.placeholder || I18n.t('settings.envDefault'));
|
var placeholderText = activeValueText ? I18n.t('settings.envValue', { value: activeValueText }) : (def.placeholder || I18n.t('settings.envDefault'));
|
||||||
|
|
||||||
if (def.type === 'boolean') {
|
if (def.type === 'boolean') {
|
||||||
var boolSel = document.createElement('select');
|
var toggle = document.createElement('div');
|
||||||
boolSel.className = 'settings-select';
|
toggle.className = 'toggle-switch' + (value === 'true' || value === true ? ' on' : '');
|
||||||
boolSel.setAttribute('data-setting-key', def.key);
|
toggle.setAttribute('role', 'switch');
|
||||||
boolSel.setAttribute('aria-label', ariaLabel);
|
toggle.setAttribute('aria-checked', value === 'true' || value === true ? 'true' : 'false');
|
||||||
var boolDefault = document.createElement('option');
|
toggle.setAttribute('aria-label', ariaLabel);
|
||||||
boolDefault.value = '';
|
toggle.setAttribute('tabindex', '0');
|
||||||
boolDefault.textContent = activeValue !== undefined && activeValue !== null
|
|
||||||
? '\u2014 ' + I18n.t('settings.envValue', { value: String(activeValue) }) + ' \u2014'
|
var savedIndicator = document.createElement('span');
|
||||||
: '\u2014 ' + I18n.t('settings.useEnvDefault') + ' \u2014';
|
savedIndicator.className = 'settings-saved-indicator';
|
||||||
if (value === null || value === undefined) boolDefault.selected = true;
|
savedIndicator.textContent = I18n.t('settings.saved');
|
||||||
boolSel.appendChild(boolDefault);
|
|
||||||
var boolOn = document.createElement('option');
|
toggle.addEventListener('click', function() {
|
||||||
boolOn.value = 'true';
|
var isOn = this.classList.toggle('on');
|
||||||
boolOn.textContent = I18n.t('settings.on');
|
this.setAttribute('aria-checked', isOn ? 'true' : 'false');
|
||||||
if (value === true) boolOn.selected = true;
|
saveSetting(def.key, isOn ? 'true' : 'false', savedIndicator);
|
||||||
boolSel.appendChild(boolOn);
|
});
|
||||||
var boolOff = document.createElement('option');
|
toggle.addEventListener('keydown', function(e) {
|
||||||
boolOff.value = 'false';
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
boolOff.textContent = I18n.t('settings.off');
|
e.preventDefault();
|
||||||
if (value === false) boolOff.selected = true;
|
this.click();
|
||||||
boolSel.appendChild(boolOff);
|
}
|
||||||
boolSel.addEventListener('change', (function(k, el) {
|
});
|
||||||
return function() {
|
inputWrap.appendChild(toggle);
|
||||||
if (el.value === '') saveSetting(k, null);
|
inputWrap.appendChild(savedIndicator);
|
||||||
else saveSetting(k, el.value === 'true');
|
|
||||||
};
|
|
||||||
})(def.key, boolSel));
|
|
||||||
inputWrap.appendChild(boolSel);
|
|
||||||
} else if (def.type === 'select' && def.options) {
|
} else if (def.type === 'select' && def.options) {
|
||||||
var sel = document.createElement('select');
|
var sel = document.createElement('select');
|
||||||
sel.className = 'settings-select';
|
sel.className = 'settings-select';
|
||||||
@@ -5421,16 +5717,207 @@ function showToast(message, type) {
|
|||||||
const container = document.getElementById('toasts');
|
const container = document.getElementById('toasts');
|
||||||
const toast = document.createElement('div');
|
const toast = document.createElement('div');
|
||||||
toast.className = 'toast toast-' + (type || 'info');
|
toast.className = 'toast toast-' + (type || 'info');
|
||||||
toast.textContent = message;
|
|
||||||
|
// Icon prefix
|
||||||
|
const icon = document.createElement('span');
|
||||||
|
icon.className = 'toast-icon';
|
||||||
|
if (type === 'success') icon.textContent = '\u2713';
|
||||||
|
else if (type === 'error') icon.textContent = '\u2717';
|
||||||
|
else icon.textContent = '\u2139';
|
||||||
|
toast.appendChild(icon);
|
||||||
|
|
||||||
|
// Message text
|
||||||
|
const text = document.createElement('span');
|
||||||
|
text.textContent = message;
|
||||||
|
toast.appendChild(text);
|
||||||
|
|
||||||
|
// Countdown bar
|
||||||
|
const countdown = document.createElement('div');
|
||||||
|
countdown.className = 'toast-countdown';
|
||||||
|
toast.appendChild(countdown);
|
||||||
|
|
||||||
container.appendChild(toast);
|
container.appendChild(toast);
|
||||||
// Trigger slide-in
|
// Trigger slide-in
|
||||||
requestAnimationFrame(() => toast.classList.add('visible'));
|
requestAnimationFrame(() => toast.classList.add('visible'));
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
toast.classList.remove('visible');
|
toast.classList.add('dismissing');
|
||||||
toast.addEventListener('transitionend', () => toast.remove());
|
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
|
||||||
|
// Fallback removal if transitionend doesn't fire
|
||||||
|
setTimeout(() => { if (toast.parentNode) toast.remove(); }, 500);
|
||||||
}, 4000);
|
}, 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Welcome Card (Phase 4.2) ---
|
||||||
|
|
||||||
|
function showWelcomeCard() {
|
||||||
|
const container = document.getElementById('chat-messages');
|
||||||
|
if (!container || container.querySelector('.welcome-card')) return;
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'welcome-card';
|
||||||
|
|
||||||
|
const heading = document.createElement('h2');
|
||||||
|
heading.className = 'welcome-heading';
|
||||||
|
heading.textContent = I18n.t('welcome.heading');
|
||||||
|
card.appendChild(heading);
|
||||||
|
|
||||||
|
const desc = document.createElement('p');
|
||||||
|
desc.className = 'welcome-description';
|
||||||
|
desc.textContent = I18n.t('welcome.description');
|
||||||
|
card.appendChild(desc);
|
||||||
|
|
||||||
|
const chips = document.createElement('div');
|
||||||
|
chips.className = 'welcome-chips';
|
||||||
|
|
||||||
|
const suggestions = [
|
||||||
|
{ key: 'welcome.runTool', fallback: 'Run a tool' },
|
||||||
|
{ key: 'welcome.checkJobs', fallback: 'Check job status' },
|
||||||
|
{ key: 'welcome.searchMemory', fallback: 'Search memory' },
|
||||||
|
{ key: 'welcome.manageRoutines', fallback: 'Manage routines' },
|
||||||
|
{ key: 'welcome.systemStatus', fallback: 'System status' },
|
||||||
|
{ key: 'welcome.writeCode', fallback: 'Write code' },
|
||||||
|
];
|
||||||
|
suggestions.forEach(({ key, fallback }) => {
|
||||||
|
const chip = document.createElement('button');
|
||||||
|
chip.className = 'welcome-chip';
|
||||||
|
chip.textContent = I18n.t(key) || fallback;
|
||||||
|
chip.addEventListener('click', () => sendSuggestion(chip));
|
||||||
|
chips.appendChild(chip);
|
||||||
|
});
|
||||||
|
|
||||||
|
card.appendChild(chips);
|
||||||
|
container.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmptyState({ icon, title, hint, action }) {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'empty-state-card';
|
||||||
|
|
||||||
|
if (icon) {
|
||||||
|
const iconEl = document.createElement('div');
|
||||||
|
iconEl.className = 'empty-state-icon';
|
||||||
|
iconEl.textContent = icon;
|
||||||
|
wrapper.appendChild(iconEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
const titleEl = document.createElement('div');
|
||||||
|
titleEl.className = 'empty-state-title';
|
||||||
|
titleEl.textContent = title;
|
||||||
|
wrapper.appendChild(titleEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hint) {
|
||||||
|
const hintEl = document.createElement('div');
|
||||||
|
hintEl.className = 'empty-state-hint';
|
||||||
|
hintEl.textContent = hint;
|
||||||
|
wrapper.appendChild(hintEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'empty-state-action';
|
||||||
|
btn.textContent = action.label || 'Go';
|
||||||
|
if (action.onClick) btn.addEventListener('click', action.onClick);
|
||||||
|
wrapper.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendSuggestion(btn) {
|
||||||
|
const textarea = document.getElementById('chat-input');
|
||||||
|
if (textarea) {
|
||||||
|
textarea.value = btn.textContent;
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeWelcomeCard() {
|
||||||
|
const card = document.querySelector('.welcome-card');
|
||||||
|
if (card) card.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Connection Status Banner (Phase 4.1) ---
|
||||||
|
|
||||||
|
function showConnectionBanner(message, type) {
|
||||||
|
const existing = document.getElementById('connection-banner');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const banner = document.createElement('div');
|
||||||
|
banner.id = 'connection-banner';
|
||||||
|
banner.className = 'connection-banner connection-banner-' + type;
|
||||||
|
banner.textContent = message;
|
||||||
|
document.body.appendChild(banner);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Keyboard Shortcut Helpers (Phase 7.4) ---
|
||||||
|
|
||||||
|
function focusMemorySearch() {
|
||||||
|
const memSearch = document.getElementById('memory-search');
|
||||||
|
if (memSearch) {
|
||||||
|
if (currentTab !== 'memory') switchTab('memory');
|
||||||
|
memSearch.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleShortcutsOverlay() {
|
||||||
|
let overlay = document.getElementById('shortcuts-overlay');
|
||||||
|
if (!overlay) {
|
||||||
|
overlay = document.createElement('div');
|
||||||
|
overlay.id = 'shortcuts-overlay';
|
||||||
|
overlay.className = 'shortcuts-overlay';
|
||||||
|
overlay.style.display = 'none';
|
||||||
|
overlay.innerHTML =
|
||||||
|
'<div class="shortcuts-content">'
|
||||||
|
+ '<h3>Keyboard Shortcuts</h3>'
|
||||||
|
+ '<div class="shortcut-row"><kbd>Ctrl/Cmd + 1-5</kbd> Switch tabs</div>'
|
||||||
|
+ '<div class="shortcut-row"><kbd>Ctrl/Cmd + N</kbd> New thread</div>'
|
||||||
|
+ '<div class="shortcut-row"><kbd>Ctrl/Cmd + K</kbd> Focus search/input</div>'
|
||||||
|
+ '<div class="shortcut-row"><kbd>Ctrl/Cmd + /</kbd> Toggle this overlay</div>'
|
||||||
|
+ '<div class="shortcut-row"><kbd>Escape</kbd> Close modals</div>'
|
||||||
|
+ '<button class="shortcuts-close">Close</button>'
|
||||||
|
+ '</div>';
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
overlay.querySelector('.shortcuts-close').addEventListener('click', () => {
|
||||||
|
overlay.style.display = 'none';
|
||||||
|
});
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) overlay.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
overlay.style.display = overlay.style.display === 'flex' ? 'none' : 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModals() {
|
||||||
|
// Close shortcuts overlay
|
||||||
|
const shortcutsOverlay = document.getElementById('shortcuts-overlay');
|
||||||
|
if (shortcutsOverlay) shortcutsOverlay.style.display = 'none';
|
||||||
|
|
||||||
|
// Close restart confirmation modal
|
||||||
|
const restartModal = document.getElementById('restart-confirm-modal');
|
||||||
|
if (restartModal) restartModal.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ARIA Accessibility (Phase 5.2) ---
|
||||||
|
|
||||||
|
function applyAriaAttributes() {
|
||||||
|
const tabBar = document.querySelector('.tab-bar');
|
||||||
|
if (tabBar) tabBar.setAttribute('role', 'tablist');
|
||||||
|
|
||||||
|
document.querySelectorAll('.tab-bar button[data-tab]').forEach(btn => {
|
||||||
|
btn.setAttribute('role', 'tab');
|
||||||
|
btn.setAttribute('aria-selected', btn.classList.contains('active') ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(panel => {
|
||||||
|
panel.setAttribute('role', 'tabpanel');
|
||||||
|
panel.setAttribute('aria-hidden', panel.classList.contains('active') ? 'false' : 'true');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply ARIA attributes on initial load
|
||||||
|
applyAriaAttributes();
|
||||||
|
|
||||||
// --- Utilities ---
|
// --- Utilities ---
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
@@ -5469,6 +5956,17 @@ document.getElementById('skill-search-btn').addEventListener('click', () => sear
|
|||||||
document.getElementById('skill-install-btn').addEventListener('click', () => installSkillFromForm());
|
document.getElementById('skill-install-btn').addEventListener('click', () => installSkillFromForm());
|
||||||
document.getElementById('settings-export-btn').addEventListener('click', () => exportSettings());
|
document.getElementById('settings-export-btn').addEventListener('click', () => exportSettings());
|
||||||
document.getElementById('settings-import-btn').addEventListener('click', () => importSettings());
|
document.getElementById('settings-import-btn').addEventListener('click', () => importSettings());
|
||||||
|
document.getElementById('settings-back-btn')?.addEventListener('click', () => settingsBack());
|
||||||
|
|
||||||
|
// --- Mobile: close thread sidebar on outside click ---
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
const sidebar = document.getElementById('thread-sidebar');
|
||||||
|
if (sidebar && sidebar.classList.contains('expanded-mobile') &&
|
||||||
|
!sidebar.contains(e.target)) {
|
||||||
|
sidebar.classList.remove('expanded-mobile');
|
||||||
|
document.getElementById('thread-toggle-btn').innerHTML = '»';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// --- Delegated Event Handlers (for dynamically generated HTML) ---
|
// --- Delegated Event Handlers (for dynamically generated HTML) ---
|
||||||
|
|
||||||
|
|||||||
@@ -521,4 +521,29 @@ I18n.register('en', {
|
|||||||
'channels.replDesc': 'Simple read-eval-print loop for testing',
|
'channels.replDesc': 'Simple read-eval-print loop for testing',
|
||||||
'channels.configureVia': 'Configure via {env}',
|
'channels.configureVia': 'Configure via {env}',
|
||||||
'channels.runWith': 'Run with: {cmd}',
|
'channels.runWith': 'Run with: {cmd}',
|
||||||
|
|
||||||
|
// Welcome Card
|
||||||
|
'welcome.heading': 'What can I help you with?',
|
||||||
|
'welcome.description': 'IronClaw is your secure AI assistant. Choose a suggestion below or type your own message.',
|
||||||
|
'welcome.runTool': 'Run a tool',
|
||||||
|
'welcome.checkJobs': 'Check job status',
|
||||||
|
'welcome.searchMemory': 'Search memory',
|
||||||
|
'welcome.manageRoutines': 'Manage routines',
|
||||||
|
'welcome.systemStatus': 'System status',
|
||||||
|
'welcome.writeCode': 'Write code',
|
||||||
|
|
||||||
|
// Connection
|
||||||
|
'connection.disconnected': 'Disconnected — attempting to reconnect',
|
||||||
|
'connection.reconnecting': 'Reconnecting (attempt {count})...',
|
||||||
|
'connection.reconnected': 'Reconnected',
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
'message.you': 'You',
|
||||||
|
'message.assistant': 'IronClaw',
|
||||||
|
'message.system': 'System',
|
||||||
|
'message.copy': 'Copy',
|
||||||
|
'message.copied': 'Copied!',
|
||||||
|
|
||||||
|
// Approval
|
||||||
|
'approval.pressY': 'Press Y to approve, N to deny',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -520,4 +520,29 @@ I18n.register('zh-CN', {
|
|||||||
'channels.replDesc': '用于测试的简单读取-求值-打印循环',
|
'channels.replDesc': '用于测试的简单读取-求值-打印循环',
|
||||||
'channels.configureVia': '通过 {env} 配置',
|
'channels.configureVia': '通过 {env} 配置',
|
||||||
'channels.runWith': '运行命令: {cmd}',
|
'channels.runWith': '运行命令: {cmd}',
|
||||||
|
|
||||||
|
// Welcome Card
|
||||||
|
'welcome.heading': '有什么可以帮助您的?',
|
||||||
|
'welcome.description': 'IronClaw 是您的安全 AI 助手。选择下方的建议或输入您自己的消息。',
|
||||||
|
'welcome.runTool': '运行工具',
|
||||||
|
'welcome.checkJobs': '查看任务状态',
|
||||||
|
'welcome.searchMemory': '搜索记忆',
|
||||||
|
'welcome.manageRoutines': '管理例程',
|
||||||
|
'welcome.systemStatus': '系统状态',
|
||||||
|
'welcome.writeCode': '编写代码',
|
||||||
|
|
||||||
|
// Connection
|
||||||
|
'connection.disconnected': '已断开连接 — 正在尝试重新连接',
|
||||||
|
'connection.reconnecting': '正在重新连接(第 {count} 次尝试)...',
|
||||||
|
'connection.reconnected': '已重新连接',
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
'message.you': '你',
|
||||||
|
'message.assistant': 'IronClaw',
|
||||||
|
'message.system': '系统',
|
||||||
|
'message.copy': '复制',
|
||||||
|
'message.copied': '已复制!',
|
||||||
|
|
||||||
|
// Approval
|
||||||
|
'approval.pressY': '按 Y 批准,N 拒绝',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -92,6 +92,7 @@
|
|||||||
<div id="app">
|
<div id="app">
|
||||||
<!-- Tab Bar -->
|
<!-- Tab Bar -->
|
||||||
<div class="tab-bar">
|
<div class="tab-bar">
|
||||||
|
<div class="tab-indicator" id="tab-indicator"></div>
|
||||||
<button class="active" data-tab="chat" data-i18n="tab.chat">Chat</button>
|
<button class="active" data-tab="chat" data-i18n="tab.chat">Chat</button>
|
||||||
<button data-tab="memory" data-i18n="tab.memory">Memory</button>
|
<button data-tab="memory" data-i18n="tab.memory">Memory</button>
|
||||||
<button data-tab="jobs" data-i18n="tab.jobs">Jobs</button>
|
<button data-tab="jobs" data-i18n="tab.jobs">Jobs</button>
|
||||||
@@ -292,9 +293,11 @@
|
|||||||
<button class="settings-subtab" data-settings-subtab="extensions" data-i18n="tab.extensions">Extensions</button>
|
<button class="settings-subtab" data-settings-subtab="extensions" data-i18n="tab.extensions">Extensions</button>
|
||||||
<button class="settings-subtab" data-settings-subtab="mcp" data-i18n="settings.mcp">MCP</button>
|
<button class="settings-subtab" data-settings-subtab="mcp" data-i18n="settings.mcp">MCP</button>
|
||||||
<button class="settings-subtab" data-settings-subtab="skills" data-i18n="tab.skills">Skills</button>
|
<button class="settings-subtab" data-settings-subtab="skills" data-i18n="tab.skills">Skills</button>
|
||||||
|
<button class="settings-theme-toggle" id="settings-theme-toggle" data-i18n="theme.tooltipSystem" title="Toggle theme">Theme</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-content">
|
<div class="settings-content">
|
||||||
<div class="settings-toolbar">
|
<div class="settings-toolbar">
|
||||||
|
<button id="settings-back-btn" class="settings-back-btn">← Back</button>
|
||||||
<div class="settings-search">
|
<div class="settings-search">
|
||||||
<input type="text" id="settings-search-input" data-i18n-placeholder="settings.searchPlaceholder" placeholder="Search settings..." data-i18n-attr="aria-label" data-i18n="settings.searchPlaceholder" aria-label="Search settings...">
|
<input type="text" id="settings-search-input" data-i18n-placeholder="settings.searchPlaceholder" placeholder="Search settings..." data-i18n-attr="aria-label" data-i18n="settings.searchPlaceholder" aria-label="Search settings...">
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+868
-240
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,8 @@ use std::sync::Arc;
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::channels::IncomingMessage;
|
use crate::channels::IncomingMessage;
|
||||||
use crate::channels::web::server::{GatewayState, RateLimiter, start_server};
|
use crate::channels::web::auth::MultiAuthState;
|
||||||
|
use crate::channels::web::server::{GatewayState, PerUserRateLimiter, RateLimiter, start_server};
|
||||||
use crate::channels::web::sse::SseManager;
|
use crate::channels::web::sse::SseManager;
|
||||||
use crate::channels::web::ws::WsConnectionTracker;
|
use crate::channels::web::ws::WsConnectionTracker;
|
||||||
|
|
||||||
@@ -64,8 +65,9 @@ impl TestGatewayBuilder {
|
|||||||
pub fn build(self) -> Arc<GatewayState> {
|
pub fn build(self) -> Arc<GatewayState> {
|
||||||
Arc::new(GatewayState {
|
Arc::new(GatewayState {
|
||||||
msg_tx: tokio::sync::RwLock::new(self.msg_tx),
|
msg_tx: tokio::sync::RwLock::new(self.msg_tx),
|
||||||
sse: SseManager::new(),
|
sse: Arc::new(SseManager::new()),
|
||||||
workspace: None,
|
workspace: None,
|
||||||
|
workspace_pool: None,
|
||||||
session_manager: None,
|
session_manager: None,
|
||||||
log_broadcaster: None,
|
log_broadcaster: None,
|
||||||
log_level_handle: None,
|
log_level_handle: None,
|
||||||
@@ -74,14 +76,14 @@ impl TestGatewayBuilder {
|
|||||||
store: None,
|
store: None,
|
||||||
job_manager: None,
|
job_manager: None,
|
||||||
prompt_queue: None,
|
prompt_queue: None,
|
||||||
user_id: self.user_id,
|
default_user_id: self.user_id,
|
||||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
||||||
llm_provider: self.llm_provider,
|
llm_provider: self.llm_provider,
|
||||||
skill_registry: None,
|
skill_registry: None,
|
||||||
skill_catalog: None,
|
skill_catalog: None,
|
||||||
scheduler: None,
|
scheduler: None,
|
||||||
chat_rate_limiter: RateLimiter::new(30, 60),
|
chat_rate_limiter: PerUserRateLimiter::new(30, 60),
|
||||||
oauth_rate_limiter: RateLimiter::new(10, 60),
|
oauth_rate_limiter: RateLimiter::new(10, 60),
|
||||||
webhook_rate_limiter: RateLimiter::new(10, 60),
|
webhook_rate_limiter: RateLimiter::new(10, 60),
|
||||||
registry_entries: Vec::new(),
|
registry_entries: Vec::new(),
|
||||||
@@ -98,11 +100,26 @@ impl TestGatewayBuilder {
|
|||||||
self,
|
self,
|
||||||
auth_token: &str,
|
auth_token: &str,
|
||||||
) -> Result<(SocketAddr, Arc<GatewayState>), crate::error::ChannelError> {
|
) -> Result<(SocketAddr, Arc<GatewayState>), crate::error::ChannelError> {
|
||||||
|
let auth = MultiAuthState::single(auth_token.to_string(), "test-user".to_string());
|
||||||
let state = self.build();
|
let state = self.build();
|
||||||
let addr: SocketAddr = "127.0.0.1:0"
|
let addr: SocketAddr = "127.0.0.1:0"
|
||||||
.parse()
|
.parse()
|
||||||
.expect("hard-coded address must parse");
|
.expect("hard-coded address must parse"); // safety: constant literal
|
||||||
let bound = start_server(addr, state.clone(), auth_token.to_string()).await?;
|
let bound = start_server(addr, state.clone(), auth).await?;
|
||||||
|
Ok((bound, state))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the state and start a gateway server with multi-user auth.
|
||||||
|
/// Returns the bound address and the shared state.
|
||||||
|
pub async fn start_multi(
|
||||||
|
self,
|
||||||
|
auth: MultiAuthState,
|
||||||
|
) -> Result<(SocketAddr, Arc<GatewayState>), crate::error::ChannelError> {
|
||||||
|
let state = self.build();
|
||||||
|
let addr: SocketAddr = "127.0.0.1:0"
|
||||||
|
.parse()
|
||||||
|
.expect("hard-coded address must parse"); // safety: constant literal
|
||||||
|
let bound = start_server(addr, state.clone(), auth).await?;
|
||||||
Ok((bound, state))
|
Ok((bound, state))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
//! Integration tests for the web gateway module.
|
||||||
|
|
||||||
|
mod multi_tenant;
|
||||||
@@ -0,0 +1,796 @@
|
|||||||
|
//! Multi-tenant isolation tests for the web gateway.
|
||||||
|
//!
|
||||||
|
//! Tests cover workspace pool scoping, job handler isolation, and auth
|
||||||
|
//! enforcement on protected endpoints. Uses `LibSqlBackend::new_local()`
|
||||||
|
//! with a temporary directory for a real (but ephemeral) database.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Method, Request, StatusCode};
|
||||||
|
use axum::middleware;
|
||||||
|
use axum::routing::{delete, get, post};
|
||||||
|
use tower::ServiceExt;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::channels::web::auth::{
|
||||||
|
AuthenticatedUser, MultiAuthState, UserIdentity, auth_middleware,
|
||||||
|
};
|
||||||
|
use crate::channels::web::server::{
|
||||||
|
ActiveConfigSnapshot, GatewayState, PerUserRateLimiter, PromptQueue, RateLimiter, WorkspacePool,
|
||||||
|
};
|
||||||
|
use crate::channels::web::sse::SseManager;
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Create a two-user `MultiAuthState` for alice and bob.
|
||||||
|
fn two_user_auth() -> MultiAuthState {
|
||||||
|
let mut tokens = HashMap::new();
|
||||||
|
tokens.insert(
|
||||||
|
"tok-alice".to_string(),
|
||||||
|
UserIdentity {
|
||||||
|
user_id: "alice".to_string(),
|
||||||
|
workspace_read_scopes: vec!["shared".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
tokens.insert(
|
||||||
|
"tok-bob".to_string(),
|
||||||
|
UserIdentity {
|
||||||
|
user_id: "bob".to_string(),
|
||||||
|
workspace_read_scopes: vec!["shared".to_string(), "alice".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
MultiAuthState::multi(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `GatewayState` with configurable store and prompt queue.
|
||||||
|
fn build_state(
|
||||||
|
store: Option<Arc<dyn crate::db::Database>>,
|
||||||
|
prompt_queue: Option<PromptQueue>,
|
||||||
|
) -> Arc<GatewayState> {
|
||||||
|
Arc::new(GatewayState {
|
||||||
|
msg_tx: tokio::sync::RwLock::new(None),
|
||||||
|
sse: Arc::new(SseManager::new()),
|
||||||
|
workspace: None,
|
||||||
|
workspace_pool: None,
|
||||||
|
session_manager: None,
|
||||||
|
log_broadcaster: None,
|
||||||
|
log_level_handle: None,
|
||||||
|
extension_manager: None,
|
||||||
|
tool_registry: None,
|
||||||
|
store,
|
||||||
|
job_manager: None,
|
||||||
|
prompt_queue,
|
||||||
|
default_user_id: "test".to_string(),
|
||||||
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
|
ws_tracker: None,
|
||||||
|
llm_provider: None,
|
||||||
|
skill_registry: None,
|
||||||
|
skill_catalog: None,
|
||||||
|
scheduler: None,
|
||||||
|
chat_rate_limiter: PerUserRateLimiter::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)),
|
||||||
|
startup_time: std::time::Instant::now(),
|
||||||
|
active_config: ActiveConfigSnapshot::default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a libSQL-backed test database in a temporary directory.
|
||||||
|
///
|
||||||
|
/// Returns the database and a `TempDir` guard — the database file is
|
||||||
|
/// deleted when the guard is dropped.
|
||||||
|
#[cfg(feature = "libsql")]
|
||||||
|
async fn test_db() -> (Arc<dyn crate::db::Database>, tempfile::TempDir) {
|
||||||
|
use crate::db::Database;
|
||||||
|
let dir = tempfile::tempdir().expect("failed to create temp dir"); // safety: test-only
|
||||||
|
let path = dir.path().join("test.db");
|
||||||
|
let backend = crate::db::libsql::LibSqlBackend::new_local(&path)
|
||||||
|
.await
|
||||||
|
.expect("failed to create test LibSqlBackend"); // safety: test-only
|
||||||
|
backend
|
||||||
|
.run_migrations()
|
||||||
|
.await
|
||||||
|
.expect("failed to run migrations"); // safety: test-only
|
||||||
|
(Arc::new(backend) as Arc<dyn crate::db::Database>, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a minimal Routine for testing.
|
||||||
|
fn make_routine(user_id: &str, name: &str) -> crate::agent::routine::Routine {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
crate::agent::routine::Routine {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: name.to_string(),
|
||||||
|
description: format!("Test routine: {name}"),
|
||||||
|
user_id: user_id.to_string(),
|
||||||
|
enabled: true,
|
||||||
|
trigger: crate::agent::routine::Trigger::Cron {
|
||||||
|
schedule: "0 9 * * *".to_string(),
|
||||||
|
timezone: None,
|
||||||
|
},
|
||||||
|
action: crate::agent::routine::RoutineAction::Lightweight {
|
||||||
|
prompt: "hello".to_string(),
|
||||||
|
context_paths: vec![],
|
||||||
|
max_tokens: 1024,
|
||||||
|
use_tools: false,
|
||||||
|
max_tool_rounds: 3,
|
||||||
|
},
|
||||||
|
guardrails: crate::agent::routine::RoutineGuardrails {
|
||||||
|
cooldown: Duration::from_secs(60),
|
||||||
|
max_concurrent: 1,
|
||||||
|
dedup_window: None,
|
||||||
|
},
|
||||||
|
notify: crate::agent::routine::NotifyConfig {
|
||||||
|
channel: None,
|
||||||
|
user: None,
|
||||||
|
on_success: false,
|
||||||
|
on_failure: true,
|
||||||
|
on_attention: true,
|
||||||
|
},
|
||||||
|
last_run_at: None,
|
||||||
|
next_fire_at: None,
|
||||||
|
run_count: 0,
|
||||||
|
consecutive_failures: 0,
|
||||||
|
state: serde_json::json!({}),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a minimal SandboxJobRecord for testing.
|
||||||
|
fn make_sandbox_job(user_id: &str, task: &str) -> crate::history::SandboxJobRecord {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
crate::history::SandboxJobRecord {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
task: task.to_string(),
|
||||||
|
status: "completed".to_string(),
|
||||||
|
user_id: user_id.to_string(),
|
||||||
|
project_dir: format!("/tmp/test-{}", Uuid::new_v4()),
|
||||||
|
success: Some(true),
|
||||||
|
failure_reason: None,
|
||||||
|
created_at: now,
|
||||||
|
started_at: Some(now),
|
||||||
|
completed_at: Some(now),
|
||||||
|
credential_grants_json: "[]".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// WorkspacePool Tests
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
#[cfg(feature = "libsql")]
|
||||||
|
mod workspace_pool {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::{WorkspaceConfig, WorkspaceSearchConfig};
|
||||||
|
use crate::workspace::EmbeddingCacheConfig;
|
||||||
|
use crate::workspace::layer::MemoryLayer;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_workspace_pool_applies_search_config() {
|
||||||
|
let (db, _dir) = test_db().await;
|
||||||
|
let search_config = WorkspaceSearchConfig {
|
||||||
|
rrf_k: 42,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let pool = WorkspacePool::new(
|
||||||
|
db,
|
||||||
|
None,
|
||||||
|
EmbeddingCacheConfig::default(),
|
||||||
|
search_config,
|
||||||
|
WorkspaceConfig::default(),
|
||||||
|
);
|
||||||
|
let identity = UserIdentity {
|
||||||
|
user_id: "alice".to_string(),
|
||||||
|
workspace_read_scopes: vec![],
|
||||||
|
};
|
||||||
|
let ws = pool.get_or_create(&identity).await;
|
||||||
|
assert_eq!(ws.user_id(), "alice");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_workspace_pool_applies_memory_layers() {
|
||||||
|
let (db, _dir) = test_db().await;
|
||||||
|
let layers = vec![MemoryLayer {
|
||||||
|
name: "shared-layer".to_string(),
|
||||||
|
scope: "shared".to_string(),
|
||||||
|
writable: false,
|
||||||
|
sensitivity: Default::default(),
|
||||||
|
}];
|
||||||
|
let ws_config = WorkspaceConfig {
|
||||||
|
memory_layers: layers,
|
||||||
|
read_scopes: vec![],
|
||||||
|
};
|
||||||
|
let pool = WorkspacePool::new(
|
||||||
|
db,
|
||||||
|
None,
|
||||||
|
EmbeddingCacheConfig::default(),
|
||||||
|
WorkspaceSearchConfig::default(),
|
||||||
|
ws_config,
|
||||||
|
);
|
||||||
|
let identity = UserIdentity {
|
||||||
|
user_id: "alice".to_string(),
|
||||||
|
workspace_read_scopes: vec![],
|
||||||
|
};
|
||||||
|
let ws = pool.get_or_create(&identity).await;
|
||||||
|
// Memory layer scope "shared" should appear in read_user_ids.
|
||||||
|
assert!(
|
||||||
|
ws.read_user_ids().contains(&"shared".to_string()),
|
||||||
|
"expected 'shared' in read_user_ids, got {:?}",
|
||||||
|
ws.read_user_ids()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_workspace_pool_applies_identity_read_scopes() {
|
||||||
|
let (db, _dir) = test_db().await;
|
||||||
|
let pool = WorkspacePool::new(
|
||||||
|
db,
|
||||||
|
None,
|
||||||
|
EmbeddingCacheConfig::default(),
|
||||||
|
WorkspaceSearchConfig::default(),
|
||||||
|
WorkspaceConfig::default(),
|
||||||
|
);
|
||||||
|
let identity = UserIdentity {
|
||||||
|
user_id: "bob".to_string(),
|
||||||
|
workspace_read_scopes: vec!["alice".to_string(), "shared".to_string()],
|
||||||
|
};
|
||||||
|
let ws = pool.get_or_create(&identity).await;
|
||||||
|
assert_eq!(ws.user_id(), "bob");
|
||||||
|
assert!(
|
||||||
|
ws.read_user_ids().contains(&"alice".to_string()),
|
||||||
|
"expected 'alice' in read_user_ids from identity scopes"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ws.read_user_ids().contains(&"shared".to_string()),
|
||||||
|
"expected 'shared' in read_user_ids from identity scopes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_workspace_pool_caches_per_user() {
|
||||||
|
let (db, _dir) = test_db().await;
|
||||||
|
let pool = WorkspacePool::new(
|
||||||
|
db,
|
||||||
|
None,
|
||||||
|
EmbeddingCacheConfig::default(),
|
||||||
|
WorkspaceSearchConfig::default(),
|
||||||
|
WorkspaceConfig::default(),
|
||||||
|
);
|
||||||
|
let alice_id = UserIdentity {
|
||||||
|
user_id: "alice".to_string(),
|
||||||
|
workspace_read_scopes: vec![],
|
||||||
|
};
|
||||||
|
let bob_id = UserIdentity {
|
||||||
|
user_id: "bob".to_string(),
|
||||||
|
workspace_read_scopes: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let alice_ws1 = pool.get_or_create(&alice_id).await;
|
||||||
|
let alice_ws2 = pool.get_or_create(&alice_id).await;
|
||||||
|
let bob_ws = pool.get_or_create(&bob_id).await;
|
||||||
|
|
||||||
|
// Same user gets the same Arc.
|
||||||
|
assert!(Arc::ptr_eq(&alice_ws1, &alice_ws2));
|
||||||
|
// Different users get different instances.
|
||||||
|
assert!(!Arc::ptr_eq(&alice_ws1, &bob_ws));
|
||||||
|
assert_eq!(alice_ws1.user_id(), "alice");
|
||||||
|
assert_eq!(bob_ws.user_id(), "bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_workspace_pool_combines_global_and_identity_scopes() {
|
||||||
|
let (db, _dir) = test_db().await;
|
||||||
|
let ws_config = WorkspaceConfig {
|
||||||
|
memory_layers: vec![],
|
||||||
|
read_scopes: vec!["global-shared".to_string()],
|
||||||
|
};
|
||||||
|
let pool = WorkspacePool::new(
|
||||||
|
db,
|
||||||
|
None,
|
||||||
|
EmbeddingCacheConfig::default(),
|
||||||
|
WorkspaceSearchConfig::default(),
|
||||||
|
ws_config,
|
||||||
|
);
|
||||||
|
let identity = UserIdentity {
|
||||||
|
user_id: "alice".to_string(),
|
||||||
|
workspace_read_scopes: vec!["token-scope".to_string()],
|
||||||
|
};
|
||||||
|
let ws = pool.get_or_create(&identity).await;
|
||||||
|
let scopes = ws.read_user_ids();
|
||||||
|
// Primary scope
|
||||||
|
assert!(scopes.contains(&"alice".to_string()));
|
||||||
|
// Global config scope
|
||||||
|
assert!(
|
||||||
|
scopes.contains(&"global-shared".to_string()),
|
||||||
|
"expected global scope 'global-shared', got {:?}",
|
||||||
|
scopes
|
||||||
|
);
|
||||||
|
// Token identity scope
|
||||||
|
assert!(
|
||||||
|
scopes.contains(&"token-scope".to_string()),
|
||||||
|
"expected token scope 'token-scope', got {:?}",
|
||||||
|
scopes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// Jobs Handler Isolation Tests
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
#[cfg(feature = "libsql")]
|
||||||
|
mod jobs_isolation {
|
||||||
|
use super::*;
|
||||||
|
use crate::channels::web::handlers::jobs::{
|
||||||
|
jobs_cancel_handler, jobs_prompt_handler, jobs_restart_handler, jobs_summary_handler,
|
||||||
|
};
|
||||||
|
// SandboxStore methods are accessed through the Database supertrait.
|
||||||
|
|
||||||
|
/// Build a router with job endpoints behind multi-user auth.
|
||||||
|
fn jobs_router(state: Arc<GatewayState>, auth: MultiAuthState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/jobs/summary", get(jobs_summary_handler))
|
||||||
|
.route("/api/jobs/{id}/cancel", post(jobs_cancel_handler))
|
||||||
|
.route("/api/jobs/{id}/restart", post(jobs_restart_handler))
|
||||||
|
.route("/api/jobs/{id}/prompt", post(jobs_prompt_handler))
|
||||||
|
.layer(middleware::from_fn_with_state(auth, auth_middleware))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_jobs_summary_scoped_to_user() {
|
||||||
|
let (db, _dir) = test_db().await;
|
||||||
|
|
||||||
|
// Insert sandbox jobs for alice and bob.
|
||||||
|
let alice_job = make_sandbox_job("alice", "alice task");
|
||||||
|
let bob_job = make_sandbox_job("bob", "bob task");
|
||||||
|
db.save_sandbox_job(&alice_job).await.unwrap();
|
||||||
|
db.save_sandbox_job(&bob_job).await.unwrap();
|
||||||
|
|
||||||
|
let state = build_state(Some(db), None);
|
||||||
|
let auth = two_user_auth();
|
||||||
|
let app = jobs_router(state, auth);
|
||||||
|
|
||||||
|
// Alice should see 1 job.
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/api/jobs/summary")
|
||||||
|
.header("Authorization", "Bearer tok-alice")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), 4096).await.unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(body["total"], 1, "alice should see only her own jobs");
|
||||||
|
|
||||||
|
// Bob should see 1 job.
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/api/jobs/summary")
|
||||||
|
.header("Authorization", "Bearer tok-bob")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), 4096).await.unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(body["total"], 1, "bob should see only his own jobs");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_jobs_restart_rejects_other_user() {
|
||||||
|
let (db, _dir) = test_db().await;
|
||||||
|
|
||||||
|
// Insert a failed sandbox job owned by alice.
|
||||||
|
let mut alice_job = make_sandbox_job("alice", "alice task");
|
||||||
|
alice_job.status = "failed".to_string();
|
||||||
|
alice_job.success = Some(false);
|
||||||
|
db.save_sandbox_job(&alice_job).await.unwrap();
|
||||||
|
|
||||||
|
let state = build_state(Some(db), None);
|
||||||
|
let auth = two_user_auth();
|
||||||
|
let app = jobs_router(state, auth);
|
||||||
|
|
||||||
|
// Bob tries to restart alice's job.
|
||||||
|
let req = Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.uri(format!("/api/jobs/{}/restart", alice_job.id))
|
||||||
|
.header("Authorization", "Bearer tok-bob")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resp.status(),
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"bob should not be able to restart alice's job"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_jobs_prompt_works_for_agent_jobs() {
|
||||||
|
let (db, _dir) = test_db().await;
|
||||||
|
|
||||||
|
// Insert a running sandbox job owned by alice in claude_code mode.
|
||||||
|
let mut alice_job = make_sandbox_job("alice", "prompt test");
|
||||||
|
alice_job.status = "running".to_string();
|
||||||
|
alice_job.success = None;
|
||||||
|
alice_job.completed_at = None;
|
||||||
|
db.save_sandbox_job(&alice_job).await.unwrap();
|
||||||
|
db.update_sandbox_job_mode(alice_job.id, "claude_code")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let prompt_queue: PromptQueue =
|
||||||
|
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
|
||||||
|
let state = build_state(Some(db), Some(prompt_queue.clone()));
|
||||||
|
let auth = two_user_auth();
|
||||||
|
let app = jobs_router(state, auth);
|
||||||
|
|
||||||
|
// Alice prompts her own job.
|
||||||
|
let req = Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.uri(format!("/api/jobs/{}/prompt", alice_job.id))
|
||||||
|
.header("Authorization", "Bearer tok-alice")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
serde_json::to_string(&serde_json::json!({"content": "hello"})).unwrap(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resp.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"alice should be able to prompt her own job"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify prompt was enqueued.
|
||||||
|
let queue = prompt_queue.lock().await;
|
||||||
|
assert!(
|
||||||
|
queue.contains_key(&alice_job.id),
|
||||||
|
"prompt queue should contain alice's job"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_jobs_prompt_rejects_other_user() {
|
||||||
|
let (db, _dir) = test_db().await;
|
||||||
|
|
||||||
|
let mut alice_job = make_sandbox_job("alice", "alice task");
|
||||||
|
alice_job.status = "running".to_string();
|
||||||
|
alice_job.success = None;
|
||||||
|
alice_job.completed_at = None;
|
||||||
|
db.save_sandbox_job(&alice_job).await.unwrap();
|
||||||
|
db.update_sandbox_job_mode(alice_job.id, "claude_code")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let prompt_queue: PromptQueue =
|
||||||
|
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
|
||||||
|
let state = build_state(Some(db), Some(prompt_queue));
|
||||||
|
let auth = two_user_auth();
|
||||||
|
let app = jobs_router(state, auth);
|
||||||
|
|
||||||
|
// Bob tries to prompt alice's job.
|
||||||
|
let req = Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.uri(format!("/api/jobs/{}/prompt", alice_job.id))
|
||||||
|
.header("Authorization", "Bearer tok-bob")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
serde_json::to_string(&serde_json::json!({"content": "sneaky"})).unwrap(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resp.status(),
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"bob should not be able to prompt alice's job"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_jobs_cancel_rejects_other_user() {
|
||||||
|
let (db, _dir) = test_db().await;
|
||||||
|
|
||||||
|
let mut alice_job = make_sandbox_job("alice", "alice running");
|
||||||
|
alice_job.status = "running".to_string();
|
||||||
|
alice_job.success = None;
|
||||||
|
alice_job.completed_at = None;
|
||||||
|
db.save_sandbox_job(&alice_job).await.unwrap();
|
||||||
|
|
||||||
|
let state = build_state(Some(db), None);
|
||||||
|
let auth = two_user_auth();
|
||||||
|
let app = jobs_router(state, auth);
|
||||||
|
|
||||||
|
// Bob tries to cancel alice's job.
|
||||||
|
let req = Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.uri(format!("/api/jobs/{}/cancel", alice_job.id))
|
||||||
|
.header("Authorization", "Bearer tok-bob")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resp.status(),
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"bob should not be able to cancel alice's job"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// Routines Isolation Tests
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
#[cfg(feature = "libsql")]
|
||||||
|
mod routines_isolation {
|
||||||
|
use super::*;
|
||||||
|
use crate::channels::web::handlers::routines::{
|
||||||
|
routines_delete_handler, routines_detail_handler, routines_list_handler,
|
||||||
|
routines_summary_handler, routines_toggle_handler,
|
||||||
|
};
|
||||||
|
// RoutineStore methods are accessed through the Database supertrait.
|
||||||
|
|
||||||
|
fn routines_router(state: Arc<GatewayState>, auth: MultiAuthState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/routines", get(routines_list_handler))
|
||||||
|
.route("/api/routines/summary", get(routines_summary_handler))
|
||||||
|
.route("/api/routines/{id}", get(routines_detail_handler))
|
||||||
|
.route("/api/routines/{id}/toggle", post(routines_toggle_handler))
|
||||||
|
.route("/api/routines/{id}", delete(routines_delete_handler))
|
||||||
|
.layer(middleware::from_fn_with_state(auth, auth_middleware))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_routines_isolation() {
|
||||||
|
let (db, _dir) = test_db().await;
|
||||||
|
|
||||||
|
// Create routines for alice and bob.
|
||||||
|
let alice_routine = make_routine("alice", "alice-daily");
|
||||||
|
let bob_routine = make_routine("bob", "bob-daily");
|
||||||
|
db.create_routine(&alice_routine).await.unwrap();
|
||||||
|
db.create_routine(&bob_routine).await.unwrap();
|
||||||
|
|
||||||
|
let state = build_state(Some(db), None);
|
||||||
|
let auth = two_user_auth();
|
||||||
|
let app = routines_router(state, auth);
|
||||||
|
|
||||||
|
// Alice sees only her routine in the list.
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/api/routines")
|
||||||
|
.header("Authorization", "Bearer tok-alice")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), 8192).await.unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let routines = body["routines"].as_array().unwrap();
|
||||||
|
assert_eq!(routines.len(), 1, "alice should see only her routines");
|
||||||
|
assert_eq!(routines[0]["name"], "alice-daily");
|
||||||
|
|
||||||
|
// Bob sees only his routine.
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/api/routines")
|
||||||
|
.header("Authorization", "Bearer tok-bob")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), 8192).await.unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let routines = body["routines"].as_array().unwrap();
|
||||||
|
assert_eq!(routines.len(), 1, "bob should see only his routines");
|
||||||
|
assert_eq!(routines[0]["name"], "bob-daily");
|
||||||
|
|
||||||
|
// Bob cannot view alice's routine detail.
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri(format!("/api/routines/{}", alice_routine.id))
|
||||||
|
.header("Authorization", "Bearer tok-bob")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resp.status(),
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"bob should not see alice's routine detail"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bob cannot toggle alice's routine.
|
||||||
|
let req = Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.uri(format!("/api/routines/{}/toggle", alice_routine.id))
|
||||||
|
.header("Authorization", "Bearer tok-bob")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resp.status(),
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"bob should not toggle alice's routine"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bob cannot delete alice's routine.
|
||||||
|
let req = Request::builder()
|
||||||
|
.method(Method::DELETE)
|
||||||
|
.uri(format!("/api/routines/{}", alice_routine.id))
|
||||||
|
.header("Authorization", "Bearer tok-bob")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resp.status(),
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"bob should not delete alice's routine"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// Handler Auth Enforcement Tests
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
mod auth_enforcement {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Dummy handler that extracts `AuthenticatedUser` — if the auth middleware
|
||||||
|
/// rejects the request, this handler is never reached.
|
||||||
|
async fn authed_handler(AuthenticatedUser(_user): AuthenticatedUser) -> &'static str {
|
||||||
|
"ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a router with the real auth middleware and dummy handlers at all
|
||||||
|
/// the paths we want to verify require authentication.
|
||||||
|
fn auth_test_router(auth: MultiAuthState) -> Router {
|
||||||
|
let state = build_state(None, None);
|
||||||
|
Router::new()
|
||||||
|
// Routines
|
||||||
|
.route("/api/routines", get(authed_handler))
|
||||||
|
.route("/api/routines/summary", get(authed_handler))
|
||||||
|
.route("/api/routines/{id}", get(authed_handler))
|
||||||
|
.route("/api/routines/{id}/toggle", post(authed_handler))
|
||||||
|
.route("/api/routines/{id}", delete(authed_handler))
|
||||||
|
// Skills
|
||||||
|
.route("/api/skills", get(authed_handler))
|
||||||
|
.route("/api/skills/search", post(authed_handler))
|
||||||
|
.route("/api/skills/install", post(authed_handler))
|
||||||
|
.route("/api/skills/{name}", delete(authed_handler))
|
||||||
|
// Logs
|
||||||
|
.route("/api/logs/events", get(authed_handler))
|
||||||
|
.route("/api/logs/level", get(authed_handler).put(authed_handler))
|
||||||
|
// Gateway status
|
||||||
|
.route("/api/gateway/status", get(authed_handler))
|
||||||
|
.layer(middleware::from_fn_with_state(auth, auth_middleware))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a request without auth and assert it returns UNAUTHORIZED.
|
||||||
|
async fn assert_requires_auth(app: &Router, method: Method, uri: &str) {
|
||||||
|
let req = Request::builder()
|
||||||
|
.method(method.clone())
|
||||||
|
.uri(uri)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resp.status(),
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"{} {} should require auth",
|
||||||
|
method,
|
||||||
|
uri
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a request with a valid token and assert it succeeds.
|
||||||
|
async fn assert_passes_with_token(app: &Router, method: Method, uri: &str, token: &str) {
|
||||||
|
let req = Request::builder()
|
||||||
|
.method(method.clone())
|
||||||
|
.uri(uri)
|
||||||
|
.header("Authorization", format!("Bearer {token}"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resp.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"{} {} should pass with valid token",
|
||||||
|
method,
|
||||||
|
uri
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_routines_handlers_require_auth() {
|
||||||
|
let auth = MultiAuthState::single("secret-tok".to_string(), "user".to_string());
|
||||||
|
let app = auth_test_router(auth);
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
|
||||||
|
assert_requires_auth(&app, Method::GET, "/api/routines").await;
|
||||||
|
assert_requires_auth(&app, Method::GET, "/api/routines/summary").await;
|
||||||
|
assert_requires_auth(&app, Method::GET, &format!("/api/routines/{id}")).await;
|
||||||
|
assert_requires_auth(&app, Method::POST, &format!("/api/routines/{id}/toggle")).await;
|
||||||
|
assert_requires_auth(&app, Method::DELETE, &format!("/api/routines/{id}")).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_skills_handlers_require_auth() {
|
||||||
|
let auth = MultiAuthState::single("secret-tok".to_string(), "user".to_string());
|
||||||
|
let app = auth_test_router(auth);
|
||||||
|
|
||||||
|
assert_requires_auth(&app, Method::GET, "/api/skills").await;
|
||||||
|
assert_requires_auth(&app, Method::POST, "/api/skills/search").await;
|
||||||
|
assert_requires_auth(&app, Method::POST, "/api/skills/install").await;
|
||||||
|
assert_requires_auth(&app, Method::DELETE, "/api/skills/test-skill").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_logs_handlers_require_auth() {
|
||||||
|
let auth = MultiAuthState::single("secret-tok".to_string(), "user".to_string());
|
||||||
|
let app = auth_test_router(auth);
|
||||||
|
|
||||||
|
assert_requires_auth(&app, Method::GET, "/api/logs/events").await;
|
||||||
|
assert_requires_auth(&app, Method::GET, "/api/logs/level").await;
|
||||||
|
assert_requires_auth(&app, Method::PUT, "/api/logs/level").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_gateway_status_requires_auth() {
|
||||||
|
let auth = MultiAuthState::single("secret-tok".to_string(), "user".to_string());
|
||||||
|
let app = auth_test_router(auth);
|
||||||
|
|
||||||
|
assert_requires_auth(&app, Method::GET, "/api/gateway/status").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_valid_token_passes_all_endpoints() {
|
||||||
|
let auth = MultiAuthState::single("secret-tok".to_string(), "user".to_string());
|
||||||
|
let app = auth_test_router(auth);
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
|
||||||
|
assert_passes_with_token(&app, Method::GET, "/api/routines", "secret-tok").await;
|
||||||
|
assert_passes_with_token(&app, Method::GET, "/api/skills", "secret-tok").await;
|
||||||
|
assert_passes_with_token(&app, Method::GET, "/api/logs/events", "secret-tok").await;
|
||||||
|
assert_passes_with_token(&app, Method::GET, "/api/gateway/status", "secret-tok").await;
|
||||||
|
assert_passes_with_token(
|
||||||
|
&app,
|
||||||
|
Method::GET,
|
||||||
|
&format!("/api/routines/{id}"),
|
||||||
|
"secret-tok",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_wrong_token_rejected_on_all_endpoints() {
|
||||||
|
let auth = MultiAuthState::single("secret-tok".to_string(), "user".to_string());
|
||||||
|
let app = auth_test_router(auth);
|
||||||
|
|
||||||
|
// Wrong token should be rejected.
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/api/routines")
|
||||||
|
.header("Authorization", "Bearer wrong-tok")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.clone().oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri("/api/gateway/status")
|
||||||
|
.header("Authorization", "Bearer wrong-tok")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -254,6 +254,16 @@ pub enum SseEvent {
|
|||||||
thread_id: Option<String>,
|
thread_id: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Per-turn token usage and cost summary.
|
||||||
|
#[serde(rename = "turn_cost")]
|
||||||
|
TurnCost {
|
||||||
|
input_tokens: u64,
|
||||||
|
output_tokens: u64,
|
||||||
|
cost_usd: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
thread_id: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
/// Extension activation status change (WASM channels).
|
/// Extension activation status change (WASM channels).
|
||||||
#[serde(rename = "extension_status")]
|
#[serde(rename = "extension_status")]
|
||||||
ExtensionStatus {
|
ExtensionStatus {
|
||||||
@@ -797,6 +807,7 @@ impl WsServerMessage {
|
|||||||
SseEvent::JobResult { .. } => "job_result",
|
SseEvent::JobResult { .. } => "job_result",
|
||||||
SseEvent::ImageGenerated { .. } => "image_generated",
|
SseEvent::ImageGenerated { .. } => "image_generated",
|
||||||
SseEvent::Suggestions { .. } => "suggestions",
|
SseEvent::Suggestions { .. } => "suggestions",
|
||||||
|
SseEvent::TurnCost { .. } => "turn_cost",
|
||||||
SseEvent::ExtensionStatus { .. } => "extension_status",
|
SseEvent::ExtensionStatus { .. } => "extension_status",
|
||||||
};
|
};
|
||||||
let data = serde_json::to_value(event).unwrap_or(serde_json::Value::Null);
|
let data = serde_json::to_value(event).unwrap_or(serde_json::Value::Null);
|
||||||
|
|||||||
+24
-13
@@ -62,7 +62,11 @@ impl Default for WsConnectionTracker {
|
|||||||
///
|
///
|
||||||
/// When either task ends (client disconnect or broadcast closed), both are
|
/// When either task ends (client disconnect or broadcast closed), both are
|
||||||
/// cleaned up.
|
/// cleaned up.
|
||||||
pub async fn handle_ws_connection(socket: WebSocket, state: Arc<GatewayState>) {
|
pub async fn handle_ws_connection(
|
||||||
|
socket: WebSocket,
|
||||||
|
state: Arc<GatewayState>,
|
||||||
|
user: crate::channels::web::auth::UserIdentity,
|
||||||
|
) {
|
||||||
let (mut ws_sink, mut ws_stream) = socket.split();
|
let (mut ws_sink, mut ws_stream) = socket.split();
|
||||||
|
|
||||||
// Track connection
|
// Track connection
|
||||||
@@ -71,9 +75,9 @@ pub async fn handle_ws_connection(socket: WebSocket, state: Arc<GatewayState>) {
|
|||||||
}
|
}
|
||||||
let tracker_for_drop = state.ws_tracker.clone();
|
let tracker_for_drop = state.ws_tracker.clone();
|
||||||
|
|
||||||
// Subscribe to broadcast events (same source as SSE).
|
// Subscribe to broadcast events (same source as SSE), scoped to this user.
|
||||||
// Reject if we've hit the connection limit.
|
// Reject if we've hit the connection limit.
|
||||||
let Some(raw_stream) = state.sse.subscribe_raw() else {
|
let Some(raw_stream) = state.sse.subscribe_raw(Some(user.user_id.clone())) else {
|
||||||
tracing::warn!("WebSocket rejected: too many connections");
|
tracing::warn!("WebSocket rejected: too many connections");
|
||||||
// Decrement the WS tracker we already incremented above.
|
// Decrement the WS tracker we already incremented above.
|
||||||
if let Some(ref tracker) = tracker_for_drop {
|
if let Some(ref tracker) = tracker_for_drop {
|
||||||
@@ -117,7 +121,7 @@ pub async fn handle_ws_connection(socket: WebSocket, state: Arc<GatewayState>) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Receiver task: read client frames and route to agent
|
// Receiver task: read client frames and route to agent
|
||||||
let user_id = state.user_id.clone();
|
let user_id = user.user_id;
|
||||||
while let Some(Ok(frame)) = ws_stream.next().await {
|
while let Some(Ok(frame)) = ws_stream.next().await {
|
||||||
match frame {
|
match frame {
|
||||||
Message::Text(text) => {
|
Message::Text(text) => {
|
||||||
@@ -263,10 +267,14 @@ async fn handle_client_message(
|
|||||||
token,
|
token,
|
||||||
} => {
|
} => {
|
||||||
if let Some(ref ext_mgr) = state.extension_manager {
|
if let Some(ref ext_mgr) = state.extension_manager {
|
||||||
match ext_mgr.configure_token(&extension_name, &token).await {
|
match ext_mgr
|
||||||
|
.configure_token(&extension_name, &token, user_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
if result.verification.is_some() {
|
if result.verification.is_some() {
|
||||||
state.sse.broadcast(
|
state.sse.broadcast_for_user(
|
||||||
|
user_id,
|
||||||
crate::channels::web::types::SseEvent::AuthRequired {
|
crate::channels::web::types::SseEvent::AuthRequired {
|
||||||
extension_name: extension_name.clone(),
|
extension_name: extension_name.clone(),
|
||||||
instructions: Some(result.message),
|
instructions: Some(result.message),
|
||||||
@@ -275,8 +283,9 @@ async fn handle_client_message(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
crate::channels::web::server::clear_auth_mode(state).await;
|
crate::channels::web::server::clear_auth_mode(state, user_id).await;
|
||||||
state.sse.broadcast(
|
state.sse.broadcast_for_user(
|
||||||
|
user_id,
|
||||||
crate::channels::web::types::SseEvent::AuthCompleted {
|
crate::channels::web::types::SseEvent::AuthCompleted {
|
||||||
extension_name,
|
extension_name,
|
||||||
success: true,
|
success: true,
|
||||||
@@ -288,7 +297,8 @@ async fn handle_client_message(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
let msg = format!("Auth failed: {}", e);
|
let msg = format!("Auth failed: {}", e);
|
||||||
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
|
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
|
||||||
state.sse.broadcast(
|
state.sse.broadcast_for_user(
|
||||||
|
user_id,
|
||||||
crate::channels::web::types::SseEvent::AuthRequired {
|
crate::channels::web::types::SseEvent::AuthRequired {
|
||||||
extension_name: extension_name.clone(),
|
extension_name: extension_name.clone(),
|
||||||
instructions: Some(msg.clone()),
|
instructions: Some(msg.clone()),
|
||||||
@@ -311,7 +321,7 @@ async fn handle_client_message(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
WsClientMessage::AuthCancel { .. } => {
|
WsClientMessage::AuthCancel { .. } => {
|
||||||
crate::channels::web::server::clear_auth_mode(state).await;
|
crate::channels::web::server::clear_auth_mode(state, user_id).await;
|
||||||
}
|
}
|
||||||
WsClientMessage::Ping => {
|
WsClientMessage::Ping => {
|
||||||
let _ = direct_tx.send(WsServerMessage::Pong).await;
|
let _ = direct_tx.send(WsServerMessage::Pong).await;
|
||||||
@@ -498,8 +508,9 @@ mod tests {
|
|||||||
|
|
||||||
GatewayState {
|
GatewayState {
|
||||||
msg_tx: tokio::sync::RwLock::new(msg_tx),
|
msg_tx: tokio::sync::RwLock::new(msg_tx),
|
||||||
sse: SseManager::new(),
|
sse: Arc::new(SseManager::new()),
|
||||||
workspace: None,
|
workspace: None,
|
||||||
|
workspace_pool: None,
|
||||||
session_manager: None,
|
session_manager: None,
|
||||||
log_broadcaster: None,
|
log_broadcaster: None,
|
||||||
log_level_handle: None,
|
log_level_handle: None,
|
||||||
@@ -509,13 +520,13 @@ mod tests {
|
|||||||
job_manager: None,
|
job_manager: None,
|
||||||
prompt_queue: None,
|
prompt_queue: None,
|
||||||
scheduler: None,
|
scheduler: None,
|
||||||
user_id: "test".to_string(),
|
default_user_id: "test".to_string(),
|
||||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
||||||
llm_provider: None,
|
llm_provider: None,
|
||||||
skill_registry: None,
|
skill_registry: None,
|
||||||
skill_catalog: None,
|
skill_catalog: None,
|
||||||
chat_rate_limiter: crate::channels::web::server::RateLimiter::new(30, 60),
|
chat_rate_limiter: crate::channels::web::server::PerUserRateLimiter::new(30, 60),
|
||||||
oauth_rate_limiter: crate::channels::web::server::RateLimiter::new(10, 60),
|
oauth_rate_limiter: crate::channels::web::server::RateLimiter::new(10, 60),
|
||||||
webhook_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(),
|
registry_entries: Vec::new(),
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ impl WebhookServer {
|
|||||||
reason: format!("Failed to bind to {}: {}", self.config.addr, e),
|
reason: format!("Failed to bind to {}: {}", self.config.addr, e),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
tracing::info!("Webhook server listening on {}", self.config.addr);
|
tracing::debug!("Webhook server listening on {}", self.config.addr);
|
||||||
|
|
||||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||||
self.shutdown_tx = Some(shutdown_tx);
|
self.shutdown_tx = Some(shutdown_tx);
|
||||||
@@ -129,7 +129,7 @@ impl WebhookServer {
|
|||||||
});
|
});
|
||||||
self.handle = Some(handle);
|
self.handle = Some(handle);
|
||||||
|
|
||||||
tracing::info!("Webhook server listening on {}", new_addr);
|
tracing::debug!("Webhook server listening on {}", new_addr);
|
||||||
|
|
||||||
(old_shutdown_tx, old_handle)
|
(old_shutdown_tx, old_handle)
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-13
@@ -7,12 +7,13 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::bootstrap::ironclaw_base_dir;
|
use crate::bootstrap::ironclaw_base_dir;
|
||||||
|
use crate::cli::fmt;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
|
|
||||||
/// Run all diagnostic checks and print results.
|
/// Run all diagnostic checks and print results.
|
||||||
pub async fn run_doctor_command() -> anyhow::Result<()> {
|
pub async fn run_doctor_command() -> anyhow::Result<()> {
|
||||||
println!("IronClaw Doctor");
|
println!();
|
||||||
println!("===============\n");
|
println!(" {}IronClaw Doctor{}", fmt::bold(), fmt::reset());
|
||||||
|
|
||||||
let mut passed = 0u32;
|
let mut passed = 0u32;
|
||||||
let mut failed = 0u32;
|
let mut failed = 0u32;
|
||||||
@@ -21,7 +22,9 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
|
|||||||
// Load settings once for checks that need them.
|
// Load settings once for checks that need them.
|
||||||
let settings = Settings::load();
|
let settings = Settings::load();
|
||||||
|
|
||||||
// ── Settings & core config ─────────────────────────────────
|
// ── Core ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
section_header("Core");
|
||||||
|
|
||||||
check(
|
check(
|
||||||
"Settings file",
|
"Settings file",
|
||||||
@@ -63,7 +66,9 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
|
|||||||
&mut skipped,
|
&mut skipped,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Subsystem configuration checks ─────────────────────────
|
// ── Features ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
section_header("Features");
|
||||||
|
|
||||||
check(
|
check(
|
||||||
"Embeddings",
|
"Embeddings",
|
||||||
@@ -121,7 +126,9 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
|
|||||||
&mut skipped,
|
&mut skipped,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── External binary checks ────────────────────────────────
|
// ── External ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
section_header("External");
|
||||||
|
|
||||||
check(
|
check(
|
||||||
"Docker daemon",
|
"Docker daemon",
|
||||||
@@ -158,7 +165,18 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
|
|||||||
// ── Summary ───────────────────────────────────────────────
|
// ── Summary ───────────────────────────────────────────────
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!(" {passed} passed, {failed} failed, {skipped} skipped");
|
println!(
|
||||||
|
" {}{} passed{}, {}{} failed{}, {}{} skipped{}",
|
||||||
|
fmt::success(),
|
||||||
|
passed,
|
||||||
|
fmt::reset(),
|
||||||
|
if failed > 0 { fmt::error() } else { fmt::dim() },
|
||||||
|
failed,
|
||||||
|
fmt::reset(),
|
||||||
|
fmt::dim(),
|
||||||
|
skipped,
|
||||||
|
fmt::reset(),
|
||||||
|
);
|
||||||
|
|
||||||
if failed > 0 {
|
if failed > 0 {
|
||||||
println!("\n Some checks failed. This is normal if you don't use those features.");
|
println!("\n Some checks failed. This is normal if you don't use those features.");
|
||||||
@@ -167,21 +185,38 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Print a section header with a separator and bold group name.
|
||||||
|
fn section_header(name: &str) {
|
||||||
|
println!();
|
||||||
|
println!(" {}", fmt::separator(36));
|
||||||
|
println!(" {}{}{}", fmt::bold(), name, fmt::reset());
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Individual checks ───────────────────────────────────────
|
// ── Individual checks ───────────────────────────────────────
|
||||||
|
|
||||||
fn check(name: &str, result: CheckResult, passed: &mut u32, failed: &mut u32, skipped: &mut u32) {
|
fn check(name: &str, result: CheckResult, passed: &mut u32, failed: &mut u32, skipped: &mut u32) {
|
||||||
match result {
|
match result {
|
||||||
CheckResult::Pass(detail) => {
|
CheckResult::Pass(detail) => {
|
||||||
*passed += 1;
|
*passed += 1;
|
||||||
println!(" [pass] {name}: {detail}");
|
println!(
|
||||||
|
"{}",
|
||||||
|
fmt::check_line(fmt::StatusKind::Pass, name, &detail, 18)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
CheckResult::Fail(detail) => {
|
CheckResult::Fail(detail) => {
|
||||||
*failed += 1;
|
*failed += 1;
|
||||||
println!(" [FAIL] {name}: {detail}");
|
println!(
|
||||||
|
"{}",
|
||||||
|
fmt::check_line(fmt::StatusKind::Fail, name, &detail, 18)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
CheckResult::Skip(reason) => {
|
CheckResult::Skip(reason) => {
|
||||||
*skipped += 1;
|
*skipped += 1;
|
||||||
println!(" [skip] {name}: {reason}");
|
println!(
|
||||||
|
"{}",
|
||||||
|
fmt::check_line(fmt::StatusKind::Skip, name, &reason, 18)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -657,7 +692,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _mutex = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
|
let _mutex = crate::config::helpers::lock_env();
|
||||||
let prev = std::env::var("LLM_BACKEND").ok();
|
let prev = std::env::var("LLM_BACKEND").ok();
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -777,7 +812,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn check_llm_config_shows_nearai_model_for_nearai_backend() {
|
fn check_llm_config_shows_nearai_model_for_nearai_backend() {
|
||||||
let _guard = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
|
let _guard = crate::config::helpers::lock_env();
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::remove_var("LLM_BACKEND");
|
std::env::remove_var("LLM_BACKEND");
|
||||||
@@ -804,7 +839,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn check_embeddings_disabled_by_default_returns_skip() {
|
fn check_embeddings_disabled_by_default_returns_skip() {
|
||||||
let _guard = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
|
let _guard = crate::config::helpers::lock_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::remove_var("EMBEDDING_ENABLED");
|
std::env::remove_var("EMBEDDING_ENABLED");
|
||||||
@@ -826,7 +861,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn check_routines_enabled_by_default() {
|
fn check_routines_enabled_by_default() {
|
||||||
let _guard = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
|
let _guard = crate::config::helpers::lock_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::remove_var("ROUTINES_ENABLED");
|
std::env::remove_var("ROUTINES_ENABLED");
|
||||||
|
|||||||
+296
@@ -0,0 +1,296 @@
|
|||||||
|
//! Shared terminal design system.
|
||||||
|
//!
|
||||||
|
//! Centralizes color tokens, rendering primitives, and width detection
|
||||||
|
//! for consistent CLI output. Respects `NO_COLOR` env var and non-TTY
|
||||||
|
//! output (piping to file, CI, etc.).
|
||||||
|
|
||||||
|
use std::io::IsTerminal;
|
||||||
|
|
||||||
|
// ── Color detection ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Returns `true` when ANSI colors should be emitted.
|
||||||
|
///
|
||||||
|
/// Disabled when:
|
||||||
|
/// - `NO_COLOR` env var is set (any value — per <https://no-color.org/>)
|
||||||
|
/// - stdout is not a terminal (pipe, file redirect, CI)
|
||||||
|
fn colors_enabled() -> bool {
|
||||||
|
if std::env::var_os("NO_COLOR").is_some() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::io::stdout().is_terminal()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` when the terminal supports 24-bit true-color.
|
||||||
|
///
|
||||||
|
/// Checks `$COLORTERM` for `truecolor` or `24bit`.
|
||||||
|
fn truecolor_enabled() -> bool {
|
||||||
|
std::env::var("COLORTERM")
|
||||||
|
.map(|v| v.eq_ignore_ascii_case("truecolor") || v.eq_ignore_ascii_case("24bit"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Color tokens ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Emerald green accent — primary brand color.
|
||||||
|
///
|
||||||
|
/// Uses true-color `#34d399` when supported, falls back to basic green.
|
||||||
|
pub fn accent() -> &'static str {
|
||||||
|
if !colors_enabled() {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if truecolor_enabled() {
|
||||||
|
"\x1b[38;2;52;211;153m"
|
||||||
|
} else {
|
||||||
|
"\x1b[32m"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bold text.
|
||||||
|
pub fn bold() -> &'static str {
|
||||||
|
if colors_enabled() { "\x1b[1m" } else { "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Green — success indicators.
|
||||||
|
pub fn success() -> &'static str {
|
||||||
|
if colors_enabled() { "\x1b[32m" } else { "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Yellow — warning indicators.
|
||||||
|
pub fn warning() -> &'static str {
|
||||||
|
if colors_enabled() { "\x1b[33m" } else { "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Red — error indicators.
|
||||||
|
pub fn error() -> &'static str {
|
||||||
|
if colors_enabled() { "\x1b[31m" } else { "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dim gray — labels, secondary text.
|
||||||
|
pub fn dim() -> &'static str {
|
||||||
|
if colors_enabled() { "\x1b[90m" } else { "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Yellow underline — URLs and links.
|
||||||
|
pub fn link() -> &'static str {
|
||||||
|
if colors_enabled() { "\x1b[33;4m" } else { "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bold accent — commands and interactive elements.
|
||||||
|
///
|
||||||
|
/// Uses bold + true-color emerald when supported, falls back to bold green.
|
||||||
|
pub fn bold_accent() -> &'static str {
|
||||||
|
if !colors_enabled() {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if truecolor_enabled() {
|
||||||
|
"\x1b[1;38;2;52;211;153m"
|
||||||
|
} else {
|
||||||
|
"\x1b[1;32m"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dim italic — contextual tips and hints.
|
||||||
|
pub fn hint() -> &'static str {
|
||||||
|
if colors_enabled() { "\x1b[2;3m" } else { "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset all attributes.
|
||||||
|
pub fn reset() -> &'static str {
|
||||||
|
if colors_enabled() { "\x1b[0m" } else { "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Width detection ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Detect terminal width, clamped to [40, 120].
|
||||||
|
pub fn term_width() -> usize {
|
||||||
|
crossterm::terminal::size()
|
||||||
|
.map(|(w, _)| w as usize)
|
||||||
|
.unwrap_or(80)
|
||||||
|
.clamp(40, 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering primitives ────────────────────────────────────
|
||||||
|
|
||||||
|
/// Horizontal separator line (dim `─` characters).
|
||||||
|
pub fn separator(width: usize) -> String {
|
||||||
|
format!("{}{}{}", dim(), "\u{2500}".repeat(width), reset())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Key-value line with right-padded dim key and accent value.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// Database libsql (connected)
|
||||||
|
/// ```
|
||||||
|
pub fn kv_line(key: &str, value: &str, key_width: usize) -> String {
|
||||||
|
format!(
|
||||||
|
" {}{:<width$}{} {}{}{}",
|
||||||
|
dim(),
|
||||||
|
key,
|
||||||
|
reset(),
|
||||||
|
accent(),
|
||||||
|
value,
|
||||||
|
reset(),
|
||||||
|
width = key_width,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Status icon for check results.
|
||||||
|
///
|
||||||
|
/// - `pass` → green `✓`
|
||||||
|
/// - `fail` → red `✗`
|
||||||
|
/// - `skip` → dim `○`
|
||||||
|
pub fn status_icon(kind: StatusKind) -> String {
|
||||||
|
match kind {
|
||||||
|
StatusKind::Pass => format!("{}\u{2713}{}", success(), reset()),
|
||||||
|
StatusKind::Fail => format!("{}\u{2717}{}", error(), reset()),
|
||||||
|
StatusKind::Skip => format!("{}\u{25CB}{}", dim(), reset()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kind of status check result.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum StatusKind {
|
||||||
|
Pass,
|
||||||
|
Fail,
|
||||||
|
Skip,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Top border of a box with an optional label.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// ┌─ label ──────────────────┐
|
||||||
|
/// ```
|
||||||
|
pub fn box_top(label: &str, width: usize) -> String {
|
||||||
|
if label.is_empty() {
|
||||||
|
let fill = width.saturating_sub(2);
|
||||||
|
return format!("\u{250C}{}\u{2510}", "\u{2500}".repeat(fill));
|
||||||
|
}
|
||||||
|
let label_part = format!(" {} ", label);
|
||||||
|
// ┌ (1) + ─ (1) + label_part + fill + ┐ (1) = width
|
||||||
|
let fill = width.saturating_sub(label_part.len() + 3);
|
||||||
|
format!(
|
||||||
|
"\u{250C}\u{2500}{}{}{}\u{2510}",
|
||||||
|
bold(),
|
||||||
|
label_part,
|
||||||
|
reset(),
|
||||||
|
)
|
||||||
|
.replace("\u{2510}", &format!("{}\u{2510}", "\u{2500}".repeat(fill)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Content line inside a box.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// │ content │
|
||||||
|
/// ```
|
||||||
|
pub fn box_line(content: &str, width: usize) -> String {
|
||||||
|
let inner = width.saturating_sub(4); // │ + space + space + │
|
||||||
|
let padded = if content.len() >= inner {
|
||||||
|
content.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}{}", content, " ".repeat(inner - content.len()))
|
||||||
|
};
|
||||||
|
format!("\u{2502} {} \u{2502}", padded)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bottom border of a box.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// └──────────────────────────┘
|
||||||
|
/// ```
|
||||||
|
pub fn box_bottom(width: usize) -> String {
|
||||||
|
let fill = width.saturating_sub(2);
|
||||||
|
format!("\u{2514}{}\u{2518}", "\u{2500}".repeat(fill))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a check result line for doctor/status commands.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// ✓ Database libsql (connected)
|
||||||
|
/// ✗ Docker not running — start with: open -a Docker
|
||||||
|
/// ○ Embeddings disabled
|
||||||
|
/// ```
|
||||||
|
pub fn check_line(kind: StatusKind, name: &str, detail: &str, name_width: usize) -> String {
|
||||||
|
format!(
|
||||||
|
" {} {:<width$} {}",
|
||||||
|
status_icon(kind),
|
||||||
|
name,
|
||||||
|
detail,
|
||||||
|
width = name_width,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn separator_produces_correct_width() {
|
||||||
|
// In test environment NO_COLOR or non-TTY may be active,
|
||||||
|
// so strip ANSI to count visible characters.
|
||||||
|
let s = separator(10);
|
||||||
|
let visible: String = strip_ansi(&s);
|
||||||
|
assert_eq!(visible.chars().count(), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kv_line_contains_key_and_value() {
|
||||||
|
let line = kv_line("model", "gpt-4o", 12);
|
||||||
|
let visible = strip_ansi(&line);
|
||||||
|
assert!(visible.contains("model"));
|
||||||
|
assert!(visible.contains("gpt-4o"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_icon_all_kinds() {
|
||||||
|
// Just verify no panic for each variant
|
||||||
|
let _ = status_icon(StatusKind::Pass);
|
||||||
|
let _ = status_icon(StatusKind::Fail);
|
||||||
|
let _ = status_icon(StatusKind::Skip);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn box_drawing() {
|
||||||
|
let top = box_top("test", 30);
|
||||||
|
let line = box_line("content", 30);
|
||||||
|
let bottom = box_bottom(30);
|
||||||
|
|
||||||
|
assert!(top.contains('\u{250C}')); // ┌
|
||||||
|
assert!(line.contains('\u{2502}')); // │
|
||||||
|
assert!(bottom.contains('\u{2514}')); // └
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_line_formatting() {
|
||||||
|
let line = check_line(StatusKind::Pass, "Database", "connected", 18);
|
||||||
|
let visible = strip_ansi(&line);
|
||||||
|
assert!(visible.contains("Database"));
|
||||||
|
assert!(visible.contains("connected"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn term_width_in_range() {
|
||||||
|
let w = term_width();
|
||||||
|
assert!(w >= 40);
|
||||||
|
assert!(w <= 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip ANSI escape sequences for visible-character counting.
|
||||||
|
fn strip_ansi(s: &str) -> String {
|
||||||
|
let mut result = String::new();
|
||||||
|
let mut in_escape = false;
|
||||||
|
for c in s.chars() {
|
||||||
|
if c == '\x1b' {
|
||||||
|
in_escape = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if in_escape {
|
||||||
|
if c == 'm' {
|
||||||
|
in_escape = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.push(c);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-3
@@ -18,12 +18,14 @@ mod channels;
|
|||||||
mod completion;
|
mod completion;
|
||||||
mod config;
|
mod config;
|
||||||
mod doctor;
|
mod doctor;
|
||||||
|
pub mod fmt;
|
||||||
mod hooks;
|
mod hooks;
|
||||||
#[cfg(feature = "import")]
|
#[cfg(feature = "import")]
|
||||||
pub mod import;
|
pub mod import;
|
||||||
mod logs;
|
mod logs;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
|
mod models;
|
||||||
pub mod oauth_defaults;
|
pub mod oauth_defaults;
|
||||||
mod pairing;
|
mod pairing;
|
||||||
mod registry;
|
mod registry;
|
||||||
@@ -44,6 +46,7 @@ pub use logs::{LogsCommand, run_logs_command};
|
|||||||
pub use mcp::{McpCommand, run_mcp_command};
|
pub use mcp::{McpCommand, run_mcp_command};
|
||||||
pub use memory::MemoryCommand;
|
pub use memory::MemoryCommand;
|
||||||
pub use memory::run_memory_command_with_db;
|
pub use memory::run_memory_command_with_db;
|
||||||
|
pub use models::{ModelsCommand, run_models_command};
|
||||||
pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store};
|
pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store};
|
||||||
pub use registry::{RegistryCommand, run_registry_command};
|
pub use registry::{RegistryCommand, run_registry_command};
|
||||||
pub use routines::{RoutinesCommand, run_routines_command};
|
pub use routines::{RoutinesCommand, run_routines_command};
|
||||||
@@ -111,16 +114,20 @@ pub enum Command {
|
|||||||
skip_auth: bool,
|
skip_auth: bool,
|
||||||
|
|
||||||
/// Reconfigure channels only
|
/// Reconfigure channels only
|
||||||
#[arg(long, conflicts_with_all = ["provider_only", "quick"])]
|
#[arg(long, conflicts_with_all = ["provider_only", "quick", "step"], help = "Deprecated: use --step channels")]
|
||||||
channels_only: bool,
|
channels_only: bool,
|
||||||
|
|
||||||
/// Reconfigure LLM provider and model only
|
/// Reconfigure LLM provider and model only
|
||||||
#[arg(long, conflicts_with_all = ["channels_only", "quick"])]
|
#[arg(long, conflicts_with_all = ["channels_only", "quick", "step"], help = "Deprecated: use --step provider")]
|
||||||
provider_only: bool,
|
provider_only: bool,
|
||||||
|
|
||||||
/// Quick setup: auto-defaults everything except LLM provider and model
|
/// Quick setup: auto-defaults everything except LLM provider and model
|
||||||
#[arg(long, conflicts_with_all = ["channels_only", "provider_only"])]
|
#[arg(long, conflicts_with_all = ["channels_only", "provider_only", "step"])]
|
||||||
quick: bool,
|
quick: bool,
|
||||||
|
|
||||||
|
/// Run only specific setup steps (comma-separated: provider, channels, model, database, security)
|
||||||
|
#[arg(long, value_delimiter = ',', conflicts_with_all = ["channels_only", "provider_only", "quick"])]
|
||||||
|
step: Vec<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Manage configuration settings
|
/// Manage configuration settings
|
||||||
@@ -212,6 +219,14 @@ pub enum Command {
|
|||||||
)]
|
)]
|
||||||
Hooks(HooksCommand),
|
Hooks(HooksCommand),
|
||||||
|
|
||||||
|
/// Manage LLM providers and models
|
||||||
|
#[command(
|
||||||
|
subcommand,
|
||||||
|
about = "Manage LLM providers and models",
|
||||||
|
long_about = "List providers, view current configuration, and set active provider/model.\nExamples:\n ironclaw models list\n ironclaw models list openai --verbose\n ironclaw models status\n ironclaw models set gpt-4o\n ironclaw models set-provider anthropic --model claude-sonnet-4-6-20250514"
|
||||||
|
)]
|
||||||
|
Models(ModelsCommand),
|
||||||
|
|
||||||
/// Probe external dependencies and validate configuration
|
/// Probe external dependencies and validate configuration
|
||||||
#[command(
|
#[command(
|
||||||
about = "Run diagnostics",
|
about = "Run diagnostics",
|
||||||
|
|||||||
@@ -0,0 +1,864 @@
|
|||||||
|
//! Models management CLI commands.
|
||||||
|
//!
|
||||||
|
//! Provides subcommands for listing providers, viewing current model
|
||||||
|
//! configuration, and setting the active provider/model. Settings are
|
||||||
|
//! persisted to both `config.toml` and `~/.ironclaw/.env` so changes
|
||||||
|
//! take effect immediately (no DB connection required).
|
||||||
|
|
||||||
|
use clap::Subcommand;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::llm::registry::ProviderRegistry;
|
||||||
|
use crate::settings::Settings;
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug, Clone)]
|
||||||
|
pub enum ModelsCommand {
|
||||||
|
/// List providers (or available models for a specific provider)
|
||||||
|
List {
|
||||||
|
/// Show only a specific provider (by ID or alias)
|
||||||
|
provider: Option<String>,
|
||||||
|
|
||||||
|
/// Show detailed information (env vars, base URL, protocol)
|
||||||
|
#[arg(short, long)]
|
||||||
|
verbose: bool,
|
||||||
|
|
||||||
|
/// Output as JSON
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Show current model configuration
|
||||||
|
Status {
|
||||||
|
/// Output as JSON
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Set the default model
|
||||||
|
Set {
|
||||||
|
/// Model name (e.g., "gpt-5-mini", "claude-sonnet-4-6-20250514")
|
||||||
|
model: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Set the LLM provider
|
||||||
|
SetProvider {
|
||||||
|
/// Provider ID or alias (e.g., "openai", "anthropic", "ollama")
|
||||||
|
provider: String,
|
||||||
|
|
||||||
|
/// Also set the model (defaults to provider's default model)
|
||||||
|
#[arg(long)]
|
||||||
|
model: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the models CLI subcommand.
|
||||||
|
pub async fn run_models_command(
|
||||||
|
cmd: ModelsCommand,
|
||||||
|
config_path: Option<&Path>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
match cmd {
|
||||||
|
ModelsCommand::List {
|
||||||
|
provider,
|
||||||
|
verbose,
|
||||||
|
json,
|
||||||
|
} => {
|
||||||
|
if let Some(ref id) = provider {
|
||||||
|
cmd_show_provider(id, verbose, json, config_path).await
|
||||||
|
} else {
|
||||||
|
cmd_list_providers(verbose, json, config_path).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ModelsCommand::Status { json } => cmd_status(json, config_path),
|
||||||
|
ModelsCommand::Set { model } => cmd_set_model(&model, config_path),
|
||||||
|
ModelsCommand::SetProvider { provider, model } => {
|
||||||
|
cmd_set_provider(&provider, model.as_deref(), config_path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Shared helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Resolve the currently active backend and model from env + settings.
|
||||||
|
fn resolve_active(config_path: Option<&Path>) -> (String, String) {
|
||||||
|
let settings = load_settings(config_path);
|
||||||
|
resolve_active_from_settings(&settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve active backend + model from a pre-loaded Settings.
|
||||||
|
fn resolve_active_from_settings(settings: &Settings) -> (String, String) {
|
||||||
|
let backend = std::env::var("LLM_BACKEND")
|
||||||
|
.ok()
|
||||||
|
.or_else(|| settings.llm_backend.clone())
|
||||||
|
.unwrap_or_else(|| "nearai".to_string());
|
||||||
|
|
||||||
|
let registry = ProviderRegistry::load();
|
||||||
|
|
||||||
|
let canonical_backend = registry
|
||||||
|
.find(&backend)
|
||||||
|
.map(|d| d.id.clone())
|
||||||
|
.unwrap_or_else(|| backend.clone());
|
||||||
|
|
||||||
|
let model = if canonical_backend == "nearai" {
|
||||||
|
std::env::var("NEARAI_MODEL")
|
||||||
|
.ok()
|
||||||
|
.or_else(|| settings.selected_model.clone())
|
||||||
|
.unwrap_or_else(|| "qwen2.5-72b-instruct:free".to_string())
|
||||||
|
} else if let Some(def) = registry.find(&canonical_backend) {
|
||||||
|
std::env::var(&def.model_env)
|
||||||
|
.ok()
|
||||||
|
.or_else(|| settings.selected_model.clone())
|
||||||
|
.unwrap_or_else(|| def.default_model.clone())
|
||||||
|
} else {
|
||||||
|
settings
|
||||||
|
.selected_model
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "unknown".to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
(canonical_backend, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_settings(config_path: Option<&Path>) -> Settings {
|
||||||
|
if let Some(path) = config_path {
|
||||||
|
Settings::load_toml(path).ok().flatten().unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
let toml_path = config_toml_path();
|
||||||
|
if toml_path.exists() {
|
||||||
|
Settings::load_toml(&toml_path)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
Settings::load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_settings(settings: &Settings, config_path: Option<&Path>) -> anyhow::Result<()> {
|
||||||
|
let path = config_path
|
||||||
|
.map(|p| p.to_path_buf())
|
||||||
|
.unwrap_or_else(config_toml_path);
|
||||||
|
|
||||||
|
settings
|
||||||
|
.save_toml(&path)
|
||||||
|
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_toml_path() -> std::path::PathBuf {
|
||||||
|
crate::bootstrap::ironclaw_base_dir().join("config.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to fetch the live model list from a provider.
|
||||||
|
///
|
||||||
|
/// Best-effort: returns `None` if config loading, provider creation, or the
|
||||||
|
/// `list_models()` call fails (missing API key, network error, etc.).
|
||||||
|
async fn try_fetch_models(provider_id: &str, config_path: Option<&Path>) -> Option<Vec<String>> {
|
||||||
|
let config = crate::config::Config::from_env_with_toml(config_path)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
// Override backend to the requested provider so create_llm_provider
|
||||||
|
// constructs the right one.
|
||||||
|
let mut llm_config = config.llm.clone();
|
||||||
|
llm_config.backend = provider_id.to_string();
|
||||||
|
|
||||||
|
// For registry providers, resolve the RegistryProviderConfig if not
|
||||||
|
// already set for this backend.
|
||||||
|
if provider_id != "nearai" && provider_id != "bedrock" {
|
||||||
|
let registry = ProviderRegistry::load();
|
||||||
|
if let Some(def) = registry.find(provider_id)
|
||||||
|
&& llm_config
|
||||||
|
.provider
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|p| p.provider_id != def.id)
|
||||||
|
{
|
||||||
|
// Build a minimal RegistryProviderConfig from env + registry
|
||||||
|
let api_key = def
|
||||||
|
.api_key_env
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|env| std::env::var(env).ok());
|
||||||
|
if def.api_key_required && api_key.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let base_url = def.default_base_url.clone().unwrap_or_default();
|
||||||
|
llm_config.provider = Some(crate::llm::RegistryProviderConfig {
|
||||||
|
protocol: def.protocol,
|
||||||
|
provider_id: def.id.clone(),
|
||||||
|
model: def.default_model.clone(),
|
||||||
|
api_key: api_key.map(secrecy::SecretString::from),
|
||||||
|
base_url,
|
||||||
|
extra_headers: Vec::new(),
|
||||||
|
oauth_token: None,
|
||||||
|
is_codex_chatgpt: false,
|
||||||
|
refresh_token: None,
|
||||||
|
auth_path: None,
|
||||||
|
cache_retention: Default::default(),
|
||||||
|
unsupported_params: def.unsupported_params.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = crate::llm::create_session_manager(config.llm.session.clone()).await;
|
||||||
|
let provider = crate::llm::create_llm_provider(&llm_config, session)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
provider.list_models().await.ok().filter(|m| !m.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print available models section (text output).
|
||||||
|
fn print_model_list(models: &Option<Vec<String>>, active_model: Option<&String>) {
|
||||||
|
match models {
|
||||||
|
Some(models) => {
|
||||||
|
println!("\n Available models ({}):", models.len());
|
||||||
|
for m in models {
|
||||||
|
let marker = active_model
|
||||||
|
.filter(|a| a.as_str() == m)
|
||||||
|
.map(|_| " (active)")
|
||||||
|
.unwrap_or("");
|
||||||
|
println!(" {}{}", m, marker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
println!(
|
||||||
|
"\n Could not fetch model list (missing credentials or provider unavailable)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Also update `~/.ironclaw/.env` so changes take effect immediately.
|
||||||
|
///
|
||||||
|
/// Skipped when `config_path` is `Some` (custom `--config`), because the user
|
||||||
|
/// is explicitly targeting a different config file and we must not pollute the
|
||||||
|
/// default profile's `.env`.
|
||||||
|
fn sync_to_dotenv(config_path: Option<&Path>, vars: &[(&str, &str)]) {
|
||||||
|
if config_path.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(e) = crate::bootstrap::upsert_bootstrap_vars(vars) {
|
||||||
|
eprintln!("Warning: failed to update .env: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── status ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn cmd_status(json: bool, config_path: Option<&Path>) -> anyhow::Result<()> {
|
||||||
|
let settings = load_settings(config_path);
|
||||||
|
let (backend, model) = resolve_active_from_settings(&settings);
|
||||||
|
let registry = ProviderRegistry::load();
|
||||||
|
|
||||||
|
let fallback = std::env::var("NEARAI_FALLBACK_MODEL").ok();
|
||||||
|
let cheap = std::env::var("NEARAI_CHEAP_MODEL").ok();
|
||||||
|
|
||||||
|
let description = if backend == "nearai" {
|
||||||
|
"NEAR AI inference (default)".to_string()
|
||||||
|
} else {
|
||||||
|
registry
|
||||||
|
.find(&backend)
|
||||||
|
.map(|d| d.description.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
|
||||||
|
if json {
|
||||||
|
let v = serde_json::json!({
|
||||||
|
"provider": backend,
|
||||||
|
"model": model,
|
||||||
|
"description": description,
|
||||||
|
"fallback_model": fallback,
|
||||||
|
"cheap_model": cheap,
|
||||||
|
});
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Provider: {} ({})", backend, description);
|
||||||
|
println!("Model: {}", model);
|
||||||
|
if let Some(ref fb) = fallback {
|
||||||
|
println!("Fallback: {}", fb);
|
||||||
|
}
|
||||||
|
if let Some(ref ch) = cheap {
|
||||||
|
println!("Cheap: {}", ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── set ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn cmd_set_model(model: &str, config_path: Option<&Path>) -> anyhow::Result<()> {
|
||||||
|
let trimmed = model.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
anyhow::bail!("Model name cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut settings = load_settings(config_path);
|
||||||
|
let registry = ProviderRegistry::load();
|
||||||
|
|
||||||
|
// Warn if model name doesn't match any known provider's default model
|
||||||
|
let known_model = registry.all().iter().any(|d| d.default_model == trimmed)
|
||||||
|
|| trimmed.contains("qwen") // nearai models
|
||||||
|
|| trimmed.contains("llama")
|
||||||
|
|| trimmed.contains("gpt")
|
||||||
|
|| trimmed.contains("claude")
|
||||||
|
|| trimmed.contains("gemini")
|
||||||
|
|| trimmed.contains("mistral");
|
||||||
|
if !known_model {
|
||||||
|
eprintln!(
|
||||||
|
"Warning: '{}' is not a recognized model name. Proceeding anyway.",
|
||||||
|
trimmed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.selected_model = Some(trimmed.to_string());
|
||||||
|
save_settings(&settings, config_path)?;
|
||||||
|
|
||||||
|
let backend = std::env::var("LLM_BACKEND")
|
||||||
|
.ok()
|
||||||
|
.or_else(|| settings.llm_backend.clone())
|
||||||
|
.unwrap_or_else(|| "nearai".to_string());
|
||||||
|
|
||||||
|
// Also write to .env so the change takes effect immediately
|
||||||
|
let model_env = if backend == "nearai" {
|
||||||
|
"NEARAI_MODEL".to_string()
|
||||||
|
} else {
|
||||||
|
registry
|
||||||
|
.find(&backend)
|
||||||
|
.map(|d| d.model_env.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
if !model_env.is_empty() {
|
||||||
|
sync_to_dotenv(config_path, &[(&model_env, trimmed)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Model set to '{}' (provider: {})", trimmed, backend);
|
||||||
|
println!(
|
||||||
|
"Saved to {}",
|
||||||
|
config_path
|
||||||
|
.map(|p| p.display().to_string())
|
||||||
|
.unwrap_or_else(|| config_toml_path().display().to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── set-provider ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn cmd_set_provider(
|
||||||
|
provider: &str,
|
||||||
|
model: Option<&str>,
|
||||||
|
config_path: Option<&Path>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let registry = ProviderRegistry::load();
|
||||||
|
|
||||||
|
// Validate and normalize provider
|
||||||
|
let canonical_id = if provider == "nearai" || provider == "near_ai" || provider == "near" {
|
||||||
|
"nearai".to_string()
|
||||||
|
} else {
|
||||||
|
let def = registry.find(provider).ok_or_else(|| {
|
||||||
|
let known: Vec<&str> = std::iter::once("nearai")
|
||||||
|
.chain(registry.all().iter().map(|d| d.id.as_str()))
|
||||||
|
.collect();
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"Unknown provider '{}'. Known providers: {}",
|
||||||
|
provider,
|
||||||
|
known.join(", ")
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
def.id.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve model: explicit > provider default
|
||||||
|
let resolved_model = if let Some(m) = model {
|
||||||
|
m.to_string()
|
||||||
|
} else if canonical_id == "nearai" {
|
||||||
|
"qwen2.5-72b-instruct:free".to_string()
|
||||||
|
} else if let Some(def) = registry.find(&canonical_id) {
|
||||||
|
def.default_model.clone()
|
||||||
|
} else {
|
||||||
|
"default".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut settings = load_settings(config_path);
|
||||||
|
settings.llm_backend = Some(canonical_id.clone());
|
||||||
|
settings.selected_model = Some(resolved_model.clone());
|
||||||
|
save_settings(&settings, config_path)?;
|
||||||
|
|
||||||
|
// Also write to .env so the change takes effect immediately
|
||||||
|
let model_env = if canonical_id == "nearai" {
|
||||||
|
"NEARAI_MODEL".to_string()
|
||||||
|
} else {
|
||||||
|
registry
|
||||||
|
.find(&canonical_id)
|
||||||
|
.map(|d| d.model_env.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let mut vars: Vec<(&str, &str)> = vec![("LLM_BACKEND", &canonical_id)];
|
||||||
|
if !model_env.is_empty() {
|
||||||
|
vars.push((&model_env, &resolved_model));
|
||||||
|
}
|
||||||
|
sync_to_dotenv(config_path, &vars);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Provider set to '{}', model set to '{}'",
|
||||||
|
canonical_id, resolved_model
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Saved to {}",
|
||||||
|
config_path
|
||||||
|
.map(|p| p.display().to_string())
|
||||||
|
.unwrap_or_else(|| config_toml_path().display().to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── list ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// List all providers with their default models.
|
||||||
|
async fn cmd_list_providers(
|
||||||
|
verbose: bool,
|
||||||
|
json: bool,
|
||||||
|
config_path: Option<&Path>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let registry = ProviderRegistry::load();
|
||||||
|
let (active_backend, active_model) = resolve_active(config_path);
|
||||||
|
|
||||||
|
if json {
|
||||||
|
let mut entries: Vec<serde_json::Value> = Vec::new();
|
||||||
|
|
||||||
|
// NEAR AI (not in registry)
|
||||||
|
let nearai_active = active_backend == "nearai";
|
||||||
|
entries.push(serde_json::json!({
|
||||||
|
"id": "nearai",
|
||||||
|
"description": "NEAR AI inference (default)",
|
||||||
|
"default_model": "qwen2.5-72b-instruct:free",
|
||||||
|
"active": nearai_active,
|
||||||
|
"active_model": if nearai_active { Some(&active_model) } else { None },
|
||||||
|
}));
|
||||||
|
|
||||||
|
for def in registry.all() {
|
||||||
|
let is_active = active_backend == def.id;
|
||||||
|
let mut v = serde_json::json!({
|
||||||
|
"id": def.id,
|
||||||
|
"description": def.description,
|
||||||
|
"default_model": def.default_model,
|
||||||
|
"protocol": format!("{:?}", def.protocol),
|
||||||
|
"active": is_active,
|
||||||
|
});
|
||||||
|
if is_active {
|
||||||
|
v["active_model"] = serde_json::json!(active_model);
|
||||||
|
}
|
||||||
|
if verbose {
|
||||||
|
v["aliases"] = serde_json::json!(def.aliases);
|
||||||
|
v["model_env"] = serde_json::json!(def.model_env);
|
||||||
|
v["api_key_env"] = serde_json::json!(def.api_key_env);
|
||||||
|
v["api_key_required"] = serde_json::json!(def.api_key_required);
|
||||||
|
if let Some(ref url) = def.default_base_url {
|
||||||
|
v["base_url"] = serde_json::json!(url);
|
||||||
|
}
|
||||||
|
if let Some(ref setup) = def.setup {
|
||||||
|
v["can_list_models"] = serde_json::json!(setup.can_list_models());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries.push(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string())
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let providers = registry.all();
|
||||||
|
|
||||||
|
println!("Active: {} (model: {})\n", active_backend, active_model);
|
||||||
|
println!(
|
||||||
|
"{} provider(s) available:\n",
|
||||||
|
providers.len() + 1 // +1 for NEAR AI
|
||||||
|
);
|
||||||
|
|
||||||
|
// NEAR AI (not in registry)
|
||||||
|
let nearai_marker = if active_backend == "nearai" { " *" } else { "" };
|
||||||
|
if verbose {
|
||||||
|
println!(" nearai{}", nearai_marker);
|
||||||
|
println!(" Description: NEAR AI inference (default)");
|
||||||
|
println!(" Default model: qwen2.5-72b-instruct:free");
|
||||||
|
println!(" Model env: NEARAI_MODEL");
|
||||||
|
if active_backend == "nearai" {
|
||||||
|
println!(" Active model: {}", active_model);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
" {:<22} {:<40} NEAR AI inference (default)",
|
||||||
|
format!("nearai{nearai_marker}"),
|
||||||
|
"qwen2.5-72b-instruct:free"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for def in providers {
|
||||||
|
let is_active = active_backend == def.id;
|
||||||
|
let marker = if is_active { " *" } else { "" };
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
println!(" {}{}", def.id, marker);
|
||||||
|
println!(" Description: {}", def.description);
|
||||||
|
println!(" Default model: {}", def.default_model);
|
||||||
|
println!(" Protocol: {:?}", def.protocol);
|
||||||
|
println!(" Model env: {}", def.model_env);
|
||||||
|
if let Some(ref env) = def.api_key_env {
|
||||||
|
println!(
|
||||||
|
" API key env: {} ({})",
|
||||||
|
env,
|
||||||
|
if def.api_key_required {
|
||||||
|
"required"
|
||||||
|
} else {
|
||||||
|
"optional"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(ref url) = def.default_base_url {
|
||||||
|
println!(" Base URL: {}", url);
|
||||||
|
}
|
||||||
|
if !def.aliases.is_empty() {
|
||||||
|
println!(" Aliases: {}", def.aliases.join(", "));
|
||||||
|
}
|
||||||
|
if is_active {
|
||||||
|
println!(" Active model: {}", active_model);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
} else {
|
||||||
|
let model_display = if is_active {
|
||||||
|
active_model.clone()
|
||||||
|
} else {
|
||||||
|
def.default_model.clone()
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
" {:<22} {:<40} {}",
|
||||||
|
format!("{}{marker}", def.id),
|
||||||
|
model_display,
|
||||||
|
def.description,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !verbose {
|
||||||
|
println!();
|
||||||
|
println!("* = active provider. Use --verbose for details.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show details for a specific provider.
|
||||||
|
async fn cmd_show_provider(
|
||||||
|
id: &str,
|
||||||
|
verbose: bool,
|
||||||
|
json: bool,
|
||||||
|
config_path: Option<&Path>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let registry = ProviderRegistry::load();
|
||||||
|
let (active_backend, active_model) = resolve_active(config_path);
|
||||||
|
|
||||||
|
// Resolve canonical ID for model fetching
|
||||||
|
let canonical_id = if id == "nearai" || id == "near_ai" || id == "near" {
|
||||||
|
"nearai".to_string()
|
||||||
|
} else {
|
||||||
|
registry
|
||||||
|
.find(id)
|
||||||
|
.map(|d| d.id.clone())
|
||||||
|
.unwrap_or_else(|| id.to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to fetch live model list from the provider
|
||||||
|
let live_models = try_fetch_models(&canonical_id, config_path).await;
|
||||||
|
|
||||||
|
// Check NEAR AI first (not in registry)
|
||||||
|
if id == "nearai" || id == "near_ai" || id == "near" {
|
||||||
|
let is_active = active_backend == "nearai";
|
||||||
|
if json {
|
||||||
|
let mut v = serde_json::json!({
|
||||||
|
"id": "nearai",
|
||||||
|
"description": "NEAR AI inference (default)",
|
||||||
|
"default_model": "qwen2.5-72b-instruct:free",
|
||||||
|
"model_env": "NEARAI_MODEL",
|
||||||
|
"active": is_active,
|
||||||
|
});
|
||||||
|
if is_active {
|
||||||
|
v["active_model"] = serde_json::json!(active_model);
|
||||||
|
}
|
||||||
|
if let Some(ref models) = live_models {
|
||||||
|
v["available_models"] = serde_json::json!(models);
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("Provider: nearai");
|
||||||
|
println!(" Description: NEAR AI inference (default)");
|
||||||
|
println!(" Default model: qwen2.5-72b-instruct:free");
|
||||||
|
println!(" Model env: NEARAI_MODEL");
|
||||||
|
println!(" Active: {}", if is_active { "yes" } else { "no" });
|
||||||
|
if is_active {
|
||||||
|
println!(" Active model: {}", active_model);
|
||||||
|
}
|
||||||
|
print_model_list(&live_models, is_active.then_some(&active_model));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let def = registry.find(id).ok_or_else(|| {
|
||||||
|
let known: Vec<&str> = std::iter::once("nearai")
|
||||||
|
.chain(registry.all().iter().map(|d| d.id.as_str()))
|
||||||
|
.collect();
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"Unknown provider '{}'. Known providers: {}",
|
||||||
|
id,
|
||||||
|
known.join(", ")
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let is_active = active_backend == def.id;
|
||||||
|
|
||||||
|
if json {
|
||||||
|
let mut v = serde_json::json!({
|
||||||
|
"id": def.id,
|
||||||
|
"description": def.description,
|
||||||
|
"protocol": format!("{:?}", def.protocol),
|
||||||
|
"default_model": def.default_model,
|
||||||
|
"model_env": def.model_env,
|
||||||
|
"api_key_env": def.api_key_env,
|
||||||
|
"api_key_required": def.api_key_required,
|
||||||
|
"aliases": def.aliases,
|
||||||
|
"active": is_active,
|
||||||
|
});
|
||||||
|
if let Some(ref url) = def.default_base_url {
|
||||||
|
v["base_url"] = serde_json::json!(url);
|
||||||
|
}
|
||||||
|
if let Some(ref setup) = def.setup {
|
||||||
|
v["can_list_models"] = serde_json::json!(setup.can_list_models());
|
||||||
|
v["display_name"] = serde_json::json!(setup.display_name());
|
||||||
|
}
|
||||||
|
if is_active {
|
||||||
|
v["active_model"] = serde_json::json!(active_model);
|
||||||
|
}
|
||||||
|
if verbose && !def.unsupported_params.is_empty() {
|
||||||
|
v["unsupported_params"] = serde_json::json!(def.unsupported_params);
|
||||||
|
}
|
||||||
|
if let Some(ref models) = live_models {
|
||||||
|
v["available_models"] = serde_json::json!(models);
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Provider: {}", def.id);
|
||||||
|
println!(" Description: {}", def.description);
|
||||||
|
println!(" Protocol: {:?}", def.protocol);
|
||||||
|
println!(" Default model: {}", def.default_model);
|
||||||
|
println!(" Model env: {}", def.model_env);
|
||||||
|
if let Some(ref env) = def.api_key_env {
|
||||||
|
println!(
|
||||||
|
" API key env: {} ({})",
|
||||||
|
env,
|
||||||
|
if def.api_key_required {
|
||||||
|
"required"
|
||||||
|
} else {
|
||||||
|
"optional"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(ref url) = def.default_base_url {
|
||||||
|
println!(" Base URL: {}", url);
|
||||||
|
}
|
||||||
|
if !def.aliases.is_empty() {
|
||||||
|
println!(" Aliases: {}", def.aliases.join(", "));
|
||||||
|
}
|
||||||
|
if let Some(ref setup) = def.setup {
|
||||||
|
println!(
|
||||||
|
" List models: {}",
|
||||||
|
if setup.can_list_models() {
|
||||||
|
"supported"
|
||||||
|
} else {
|
||||||
|
"not supported"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
println!(" Display name: {}", setup.display_name());
|
||||||
|
}
|
||||||
|
if !def.unsupported_params.is_empty() {
|
||||||
|
println!(" Unsupported: {}", def.unsupported_params.join(", "));
|
||||||
|
}
|
||||||
|
println!(" Active: {}", if is_active { "yes" } else { "no" });
|
||||||
|
if is_active {
|
||||||
|
println!(" Active model: {}", active_model);
|
||||||
|
}
|
||||||
|
print_model_list(&live_models, is_active.then_some(&active_model));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_active_defaults_to_nearai() {
|
||||||
|
let settings = Settings::default();
|
||||||
|
assert!(settings.llm_backend.is_none());
|
||||||
|
assert!(settings.selected_model.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_loads_all_providers() {
|
||||||
|
let registry = ProviderRegistry::load();
|
||||||
|
let all = registry.all();
|
||||||
|
assert!(
|
||||||
|
all.len() >= 10,
|
||||||
|
"should have at least 10 built-in providers, got {}",
|
||||||
|
all.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_find_by_alias() {
|
||||||
|
let registry = ProviderRegistry::load();
|
||||||
|
let def = registry
|
||||||
|
.find("claude")
|
||||||
|
.expect("claude alias should resolve");
|
||||||
|
assert_eq!(def.id, "anthropic");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_providers_have_description() {
|
||||||
|
let registry = ProviderRegistry::load();
|
||||||
|
for def in registry.all() {
|
||||||
|
assert!(
|
||||||
|
!def.description.is_empty(),
|
||||||
|
"provider {} should have a description",
|
||||||
|
def.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_model_persists_to_toml() {
|
||||||
|
let dir = tempfile::tempdir().expect("create temp dir");
|
||||||
|
let toml_path = dir.path().join("config.toml");
|
||||||
|
|
||||||
|
cmd_set_model("gpt-5-mini", Some(&toml_path)).expect("set model");
|
||||||
|
|
||||||
|
let settings = Settings::load_toml(&toml_path)
|
||||||
|
.expect("read toml")
|
||||||
|
.expect("should have settings");
|
||||||
|
assert_eq!(settings.selected_model.as_deref(), Some("gpt-5-mini"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_provider_validates_unknown() {
|
||||||
|
let dir = tempfile::tempdir().expect("create temp dir");
|
||||||
|
let toml_path = dir.path().join("config.toml");
|
||||||
|
|
||||||
|
let result = cmd_set_provider("nonexistent_provider", None, Some(&toml_path));
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
err.contains("Unknown provider"),
|
||||||
|
"should mention unknown provider: {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_provider_persists_to_toml() {
|
||||||
|
let dir = tempfile::tempdir().expect("create temp dir");
|
||||||
|
let toml_path = dir.path().join("config.toml");
|
||||||
|
|
||||||
|
cmd_set_provider("groq", None, Some(&toml_path)).expect("set provider");
|
||||||
|
|
||||||
|
let settings = Settings::load_toml(&toml_path)
|
||||||
|
.expect("read toml")
|
||||||
|
.expect("should have settings");
|
||||||
|
assert_eq!(settings.llm_backend.as_deref(), Some("groq"));
|
||||||
|
assert_eq!(
|
||||||
|
settings.selected_model.as_deref(),
|
||||||
|
Some("llama-3.3-70b-versatile")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_provider_with_custom_model() {
|
||||||
|
let dir = tempfile::tempdir().expect("create temp dir");
|
||||||
|
let toml_path = dir.path().join("config.toml");
|
||||||
|
|
||||||
|
cmd_set_provider("anthropic", Some("claude-opus-4-6"), Some(&toml_path))
|
||||||
|
.expect("set provider with model");
|
||||||
|
|
||||||
|
let settings = Settings::load_toml(&toml_path)
|
||||||
|
.expect("read toml")
|
||||||
|
.expect("should have settings");
|
||||||
|
assert_eq!(settings.llm_backend.as_deref(), Some("anthropic"));
|
||||||
|
assert_eq!(settings.selected_model.as_deref(), Some("claude-opus-4-6"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_config_does_not_pollute_default_dotenv() {
|
||||||
|
let dir = tempfile::tempdir().expect("create temp dir");
|
||||||
|
let toml_path = dir.path().join("config.toml");
|
||||||
|
|
||||||
|
// With a custom config path, sync_to_dotenv should be a no-op
|
||||||
|
// (it returns early when config_path is Some).
|
||||||
|
// We verify by checking that cmd_set_provider succeeds without
|
||||||
|
// trying to write to the default ~/.ironclaw/.env.
|
||||||
|
cmd_set_provider("groq", None, Some(&toml_path)).expect("set provider with custom config");
|
||||||
|
|
||||||
|
let settings = Settings::load_toml(&toml_path)
|
||||||
|
.expect("read toml")
|
||||||
|
.expect("should have settings");
|
||||||
|
assert_eq!(settings.llm_backend.as_deref(), Some("groq"));
|
||||||
|
// The key assertion is that no error was thrown trying to write
|
||||||
|
// to the default .env — sync_to_dotenv skipped it.
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_model_rejects_empty_name() {
|
||||||
|
let dir = tempfile::tempdir().expect("create temp dir");
|
||||||
|
let toml_path = dir.path().join("config.toml");
|
||||||
|
|
||||||
|
let result = cmd_set_model("", Some(&toml_path));
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(
|
||||||
|
result.unwrap_err().to_string().contains("cannot be empty"),
|
||||||
|
"should reject empty model name"
|
||||||
|
);
|
||||||
|
|
||||||
|
let result2 = cmd_set_model(" ", Some(&toml_path));
|
||||||
|
assert!(result2.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_provider_normalizes_alias() {
|
||||||
|
let dir = tempfile::tempdir().expect("create temp dir");
|
||||||
|
let toml_path = dir.path().join("config.toml");
|
||||||
|
|
||||||
|
cmd_set_provider("claude", None, Some(&toml_path)).expect("set via alias");
|
||||||
|
|
||||||
|
let settings = Settings::load_toml(&toml_path)
|
||||||
|
.expect("read toml")
|
||||||
|
.expect("should have settings");
|
||||||
|
assert_eq!(
|
||||||
|
settings.llm_backend.as_deref(),
|
||||||
|
Some("anthropic"),
|
||||||
|
"alias should be normalized to canonical ID"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-14
@@ -447,8 +447,8 @@ pub struct PendingOAuthFlow {
|
|||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
/// Secrets store reference for token persistence.
|
/// Secrets store reference for token persistence.
|
||||||
pub secrets: Arc<dyn SecretsStore + Send + Sync>,
|
pub secrets: Arc<dyn SecretsStore + Send + Sync>,
|
||||||
/// SSE broadcast sender for notifying the web UI.
|
/// SSE broadcast manager for notifying the web UI.
|
||||||
pub sse_sender: Option<tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>>,
|
pub sse_manager: Option<Arc<crate::channels::web::sse::SseManager>>,
|
||||||
/// Gateway auth token for authenticating with the platform token exchange proxy.
|
/// Gateway auth token for authenticating with the platform token exchange proxy.
|
||||||
pub gateway_token: Option<String>,
|
pub gateway_token: Option<String>,
|
||||||
/// Additional form params for the token exchange request.
|
/// Additional form params for the token exchange request.
|
||||||
@@ -758,7 +758,7 @@ mod tests {
|
|||||||
use crate::cli::oauth_defaults::{
|
use crate::cli::oauth_defaults::{
|
||||||
builtin_credentials, callback_host, callback_url, is_loopback_host, landing_html,
|
builtin_credentials, callback_host, callback_url, is_loopback_host, landing_html,
|
||||||
};
|
};
|
||||||
use crate::config::helpers::ENV_MUTEX;
|
use crate::config::helpers::lock_env;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_is_loopback_host() {
|
fn test_is_loopback_host() {
|
||||||
@@ -775,7 +775,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_callback_host_default() {
|
fn test_callback_host_default() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
let original = std::env::var("OAUTH_CALLBACK_HOST").ok();
|
let original = std::env::var("OAUTH_CALLBACK_HOST").ok();
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -792,7 +792,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_callback_host_env_override() {
|
fn test_callback_host_env_override() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
let original_host = std::env::var("OAUTH_CALLBACK_HOST").ok();
|
let original_host = std::env::var("OAUTH_CALLBACK_HOST").ok();
|
||||||
let original_url = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
|
let original_url = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||||
@@ -819,7 +819,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_callback_url_default() {
|
fn test_callback_url_default() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
// Clear both env vars to test default behavior
|
// Clear both env vars to test default behavior
|
||||||
let original_url = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
|
let original_url = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
|
||||||
let original_host = std::env::var("OAUTH_CALLBACK_HOST").ok();
|
let original_host = std::env::var("OAUTH_CALLBACK_HOST").ok();
|
||||||
@@ -843,7 +843,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_callback_url_env_override() {
|
fn test_callback_url_env_override() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
|
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -1008,7 +1008,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_use_gateway_callback_false_by_default() {
|
fn test_use_gateway_callback_false_by_default() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
|
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -1024,7 +1024,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_use_gateway_callback_true_for_hosted() {
|
fn test_use_gateway_callback_true_for_hosted() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
|
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -1045,7 +1045,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_use_gateway_callback_false_for_localhost() {
|
fn test_use_gateway_callback_false_for_localhost() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
|
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -1063,7 +1063,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_use_gateway_callback_false_for_empty() {
|
fn test_use_gateway_callback_false_for_empty() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
|
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -1083,7 +1083,7 @@ mod tests {
|
|||||||
fn test_build_platform_state_with_instance() {
|
fn test_build_platform_state_with_instance() {
|
||||||
use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
|
use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
|
||||||
|
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
|
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -1107,7 +1107,7 @@ mod tests {
|
|||||||
fn test_build_platform_state_without_instance() {
|
fn test_build_platform_state_without_instance() {
|
||||||
use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
|
use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
|
||||||
|
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
|
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
|
||||||
let original_oc = std::env::var("OPENCLAW_INSTANCE_NAME").ok();
|
let original_oc = std::env::var("OPENCLAW_INSTANCE_NAME").ok();
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||||
@@ -1134,7 +1134,7 @@ mod tests {
|
|||||||
fn test_build_platform_state_with_openclaw_instance() {
|
fn test_build_platform_state_with_openclaw_instance() {
|
||||||
use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
|
use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
|
||||||
|
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
let original_ic = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
|
let original_ic = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
|
||||||
let original_oc = std::env::var("OPENCLAW_INSTANCE_NAME").ok();
|
let original_oc = std::env::var("OPENCLAW_INSTANCE_NAME").ok();
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ Commands:
|
|||||||
service Manage OS service
|
service Manage OS service
|
||||||
skills Manage skills
|
skills Manage skills
|
||||||
hooks Manage lifecycle hooks
|
hooks Manage lifecycle hooks
|
||||||
|
models Manage LLM providers and models
|
||||||
doctor Run diagnostics
|
doctor Run diagnostics
|
||||||
logs View and manage gateway logs
|
logs View and manage gateway logs
|
||||||
status Show system status
|
status Show system status
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ Commands:
|
|||||||
service Manage OS service
|
service Manage OS service
|
||||||
skills Manage skills
|
skills Manage skills
|
||||||
hooks Manage lifecycle hooks
|
hooks Manage lifecycle hooks
|
||||||
|
models Manage LLM providers and models
|
||||||
doctor Run diagnostics
|
doctor Run diagnostics
|
||||||
logs View and manage gateway logs
|
logs View and manage gateway logs
|
||||||
status Show system status
|
status Show system status
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ Commands:
|
|||||||
service Manage OS service
|
service Manage OS service
|
||||||
skills Manage skills
|
skills Manage skills
|
||||||
hooks Manage lifecycle hooks
|
hooks Manage lifecycle hooks
|
||||||
|
models Manage LLM providers and models
|
||||||
doctor Run diagnostics
|
doctor Run diagnostics
|
||||||
logs View and manage gateway logs
|
logs View and manage gateway logs
|
||||||
status Show system status
|
status Show system status
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ Commands:
|
|||||||
service Manage OS service
|
service Manage OS service
|
||||||
skills Manage skills
|
skills Manage skills
|
||||||
hooks Manage lifecycle hooks
|
hooks Manage lifecycle hooks
|
||||||
|
models Manage LLM providers and models
|
||||||
doctor Run diagnostics
|
doctor Run diagnostics
|
||||||
logs View and manage gateway logs
|
logs View and manage gateway logs
|
||||||
status Show system status
|
status Show system status
|
||||||
|
|||||||
+57
-48
@@ -6,6 +6,7 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::bootstrap::ironclaw_base_dir;
|
use crate::bootstrap::ironclaw_base_dir;
|
||||||
|
use crate::cli::fmt;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
|
|
||||||
/// Load settings from JSON and TOML config files, matching the runtime
|
/// Load settings from JSON and TOML config files, matching the runtime
|
||||||
@@ -38,22 +39,25 @@ fn load_settings_from(json_path: &std::path::Path, toml_path: &std::path::Path)
|
|||||||
pub async fn run_status_command() -> anyhow::Result<()> {
|
pub async fn run_status_command() -> anyhow::Result<()> {
|
||||||
let settings = load_settings();
|
let settings = load_settings();
|
||||||
|
|
||||||
println!("IronClaw Status");
|
println!();
|
||||||
println!("===============\n");
|
println!(" {}IronClaw Status{}", fmt::bold(), fmt::reset());
|
||||||
|
println!();
|
||||||
|
|
||||||
// Version
|
// Version
|
||||||
println!(
|
println!(
|
||||||
" Version: {} v{}",
|
"{}",
|
||||||
env!("CARGO_PKG_NAME"),
|
fmt::kv_line(
|
||||||
env!("CARGO_PKG_VERSION")
|
"Version",
|
||||||
|
&format!("{} v{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")),
|
||||||
|
12,
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
print!(" Database: ");
|
|
||||||
let db_backend = std::env::var("DATABASE_BACKEND")
|
let db_backend = std::env::var("DATABASE_BACKEND")
|
||||||
.ok()
|
.ok()
|
||||||
.unwrap_or_else(|| "postgres".to_string());
|
.unwrap_or_else(|| "postgres".to_string());
|
||||||
match db_backend.as_str() {
|
let db_value = match db_backend.as_str() {
|
||||||
"libsql" | "turso" | "sqlite" => {
|
"libsql" | "turso" | "sqlite" => {
|
||||||
let path = std::env::var("LIBSQL_PATH")
|
let path = std::env::var("LIBSQL_PATH")
|
||||||
.map(std::path::PathBuf::from)
|
.map(std::path::PathBuf::from)
|
||||||
@@ -64,77 +68,77 @@ pub async fn run_status_command() -> anyhow::Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
""
|
""
|
||||||
};
|
};
|
||||||
println!("libSQL ({}{})", path.display(), turso);
|
format!("libSQL ({}{})", path.display(), turso)
|
||||||
} else {
|
} else {
|
||||||
println!("libSQL (file missing: {})", path.display());
|
format!("libSQL (file missing: {})", path.display())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
if std::env::var("DATABASE_URL").is_ok() {
|
if std::env::var("DATABASE_URL").is_ok() {
|
||||||
match check_database().await {
|
match check_database().await {
|
||||||
Ok(()) => println!("connected (PostgreSQL)"),
|
Ok(()) => "connected (PostgreSQL)".to_string(),
|
||||||
Err(e) => println!("error ({})", e),
|
Err(e) => format!("error ({})", e),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
println!("not configured");
|
"not configured".to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
println!("{}", fmt::kv_line("Database", &db_value, 12));
|
||||||
|
|
||||||
// Session / Auth
|
// Session / Auth
|
||||||
print!(" Session: ");
|
|
||||||
let session_path = crate::config::llm::default_session_path();
|
let session_path = crate::config::llm::default_session_path();
|
||||||
if session_path.exists() {
|
let session_value = if session_path.exists() {
|
||||||
println!("found ({})", session_path.display());
|
format!("found ({})", session_path.display())
|
||||||
} else {
|
} else {
|
||||||
println!("not found (run `ironclaw onboard`)");
|
"not found (run `ironclaw onboard`)".to_string()
|
||||||
}
|
};
|
||||||
|
println!("{}", fmt::kv_line("Session", &session_value, 12));
|
||||||
|
|
||||||
// Secrets (auto-detect from env only; skip keychain probe to avoid
|
// Secrets (auto-detect from env only; skip keychain probe to avoid
|
||||||
// triggering macOS system password dialogs on a simple status check)
|
// triggering macOS system password dialogs on a simple status check)
|
||||||
print!(" Secrets: ");
|
let secrets_value = if std::env::var("SECRETS_MASTER_KEY").is_ok() {
|
||||||
if std::env::var("SECRETS_MASTER_KEY").is_ok() {
|
"configured (env)".to_string()
|
||||||
println!("configured (env)");
|
|
||||||
} else {
|
} else {
|
||||||
// We don't probe the keychain here because get_generic_password()
|
// We don't probe the keychain here because get_generic_password()
|
||||||
// triggers macOS unlock+authorization dialogs, which is bad UX for
|
// triggers macOS unlock+authorization dialogs, which is bad UX for
|
||||||
// a read-only status command. If onboarding completed with keychain
|
// a read-only status command. If onboarding completed with keychain
|
||||||
// storage, the key is there; we just can't cheaply verify it.
|
// storage, the key is there; we just can't cheaply verify it.
|
||||||
println!("env not set (keychain may be configured)");
|
"env not set (keychain may be configured)".to_string()
|
||||||
}
|
};
|
||||||
|
println!("{}", fmt::kv_line("Secrets", &secrets_value, 12));
|
||||||
|
|
||||||
// Embeddings
|
// Embeddings
|
||||||
print!(" Embeddings: ");
|
|
||||||
let emb_enabled = settings.embeddings.enabled
|
let emb_enabled = settings.embeddings.enabled
|
||||||
|| std::env::var("OPENAI_API_KEY").is_ok()
|
|| std::env::var("OPENAI_API_KEY").is_ok()
|
||||||
|| std::env::var("EMBEDDING_ENABLED")
|
|| std::env::var("EMBEDDING_ENABLED")
|
||||||
.map(|v| v == "true")
|
.map(|v| v == "true")
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if emb_enabled {
|
let emb_value = if emb_enabled {
|
||||||
println!(
|
format!(
|
||||||
"enabled (provider: {}, model: {})",
|
"enabled (provider: {}, model: {})",
|
||||||
settings.embeddings.provider, settings.embeddings.model
|
settings.embeddings.provider, settings.embeddings.model
|
||||||
);
|
)
|
||||||
} else {
|
} else {
|
||||||
println!("disabled");
|
"disabled".to_string()
|
||||||
}
|
};
|
||||||
|
println!("{}", fmt::kv_line("Embeddings", &emb_value, 12));
|
||||||
|
|
||||||
// WASM tools
|
// WASM tools
|
||||||
print!(" WASM Tools: ");
|
|
||||||
let tools_dir = settings
|
let tools_dir = settings
|
||||||
.wasm
|
.wasm
|
||||||
.tools_dir
|
.tools_dir
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(default_tools_dir);
|
.unwrap_or_else(default_tools_dir);
|
||||||
if tools_dir.exists() {
|
let tools_value = if tools_dir.exists() {
|
||||||
let count = count_wasm_files(&tools_dir);
|
let count = count_wasm_files(&tools_dir);
|
||||||
println!("{} installed ({})", count, tools_dir.display());
|
format!("{} installed ({})", count, tools_dir.display())
|
||||||
} else {
|
} else {
|
||||||
println!("directory not found ({})", tools_dir.display());
|
format!("directory not found ({})", tools_dir.display())
|
||||||
}
|
};
|
||||||
|
println!("{}", fmt::kv_line("WASM Tools", &tools_value, 12));
|
||||||
|
|
||||||
// WASM channels
|
// WASM channels
|
||||||
print!(" Channels: ");
|
|
||||||
let channels_dir = settings
|
let channels_dir = settings
|
||||||
.channels
|
.channels
|
||||||
.wasm_channels_dir
|
.wasm_channels_dir
|
||||||
@@ -153,35 +157,40 @@ pub async fn run_status_command() -> anyhow::Result<()> {
|
|||||||
channel_info.push(format!("{} wasm", wasm_count));
|
channel_info.push(format!("{} wasm", wasm_count));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
println!("{}", channel_info.join(", "));
|
println!("{}", fmt::kv_line("Channels", &channel_info.join(", "), 12));
|
||||||
|
|
||||||
// Heartbeat
|
// Heartbeat
|
||||||
print!(" Heartbeat: ");
|
|
||||||
let hb_enabled = settings.heartbeat.enabled
|
let hb_enabled = settings.heartbeat.enabled
|
||||||
|| std::env::var("HEARTBEAT_ENABLED")
|
|| std::env::var("HEARTBEAT_ENABLED")
|
||||||
.map(|v| v == "true")
|
.map(|v| v == "true")
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if hb_enabled {
|
let hb_value = if hb_enabled {
|
||||||
println!("enabled (interval: {}s)", settings.heartbeat.interval_secs);
|
format!("enabled (interval: {}s)", settings.heartbeat.interval_secs)
|
||||||
} else {
|
} else {
|
||||||
println!("disabled");
|
"disabled".to_string()
|
||||||
}
|
};
|
||||||
|
println!("{}", fmt::kv_line("Heartbeat", &hb_value, 12));
|
||||||
|
|
||||||
// MCP servers
|
// MCP servers
|
||||||
print!(" MCP Servers: ");
|
let mcp_value = match crate::tools::mcp::config::load_mcp_servers().await {
|
||||||
match crate::tools::mcp::config::load_mcp_servers().await {
|
|
||||||
Ok(servers) => {
|
Ok(servers) => {
|
||||||
let enabled = servers.servers.iter().filter(|s| s.enabled).count();
|
let enabled = servers.servers.iter().filter(|s| s.enabled).count();
|
||||||
let total = servers.servers.len();
|
let total = servers.servers.len();
|
||||||
println!("{} enabled / {} configured", enabled, total);
|
format!("{} enabled / {} configured", enabled, total)
|
||||||
}
|
}
|
||||||
Err(_) => println!("none configured"),
|
Err(_) => "none configured".to_string(),
|
||||||
}
|
};
|
||||||
|
println!("{}", fmt::kv_line("MCP Servers", &mcp_value, 12));
|
||||||
|
|
||||||
// Config path
|
// Config path
|
||||||
|
println!();
|
||||||
println!(
|
println!(
|
||||||
"\n Config: {}",
|
"{}",
|
||||||
crate::bootstrap::ironclaw_env_path().display()
|
fmt::kv_line(
|
||||||
|
"Config",
|
||||||
|
&crate::bootstrap::ironclaw_env_path().display().to_string(),
|
||||||
|
12,
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -63,12 +63,12 @@ impl BuilderModeConfig {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::helpers::ENV_MUTEX;
|
use crate::config::helpers::lock_env;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_falls_back_to_settings() {
|
fn resolve_falls_back_to_settings() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
let mut settings = Settings::default();
|
let mut settings = Settings::default();
|
||||||
settings.builder.max_iterations = 99;
|
settings.builder.max_iterations = 99;
|
||||||
settings.builder.auto_register = false;
|
settings.builder.auto_register = false;
|
||||||
@@ -80,7 +80,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn env_overrides_settings() {
|
fn env_overrides_settings() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
let mut settings = Settings::default();
|
let mut settings = Settings::default();
|
||||||
settings.builder.timeout_secs = 123;
|
settings.builder.timeout_secs = 123;
|
||||||
|
|
||||||
|
|||||||
+145
-3
@@ -2,6 +2,7 @@ use std::collections::HashMap;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use secrecy::SecretString;
|
use secrecy::SecretString;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::bootstrap::ironclaw_base_dir;
|
use crate::bootstrap::ironclaw_base_dir;
|
||||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
||||||
@@ -45,6 +46,26 @@ pub struct GatewayConfig {
|
|||||||
/// Bearer token for authentication. Random hex generated at startup if unset.
|
/// Bearer token for authentication. Random hex generated at startup if unset.
|
||||||
pub auth_token: Option<String>,
|
pub auth_token: Option<String>,
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
|
/// Additional user scopes for workspace reads.
|
||||||
|
///
|
||||||
|
/// When set, the workspace will be able to read (search, read, list) from
|
||||||
|
/// these additional user scopes while writes remain isolated to `user_id`.
|
||||||
|
/// Parsed from `WORKSPACE_READ_SCOPES` (comma-separated).
|
||||||
|
pub workspace_read_scopes: Vec<String>,
|
||||||
|
/// Memory layer definitions (JSON in env var, or from external config).
|
||||||
|
pub memory_layers: Vec<crate::workspace::layer::MemoryLayer>,
|
||||||
|
/// Multi-user token map. When set, each token maps to a user identity.
|
||||||
|
/// Parsed from `GATEWAY_USER_TOKENS` (JSON string). When absent, falls back
|
||||||
|
/// to single-user mode via `auth_token` + `user_id`.
|
||||||
|
pub user_tokens: Option<HashMap<String, UserTokenConfig>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-user token configuration for multi-user mode.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct UserTokenConfig {
|
||||||
|
pub user_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub workspace_read_scopes: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Signal channel configuration (signal-cli daemon HTTP/JSON-RPC).
|
/// Signal channel configuration (signal-cli daemon HTTP/JSON-RPC).
|
||||||
@@ -113,8 +134,120 @@ impl ChannelsConfig {
|
|||||||
let gateway = if gateway_enabled {
|
let gateway = if gateway_enabled {
|
||||||
let user_id = optional_env("GATEWAY_USER_ID")?
|
let user_id = optional_env("GATEWAY_USER_ID")?
|
||||||
.or_else(|| cs.gateway_user_id.clone())
|
.or_else(|| cs.gateway_user_id.clone())
|
||||||
.unwrap_or_else(|| "default".to_string());
|
.unwrap_or_else(|| owner_id.to_string());
|
||||||
|
|
||||||
|
let memory_layers: Vec<crate::workspace::layer::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 => crate::workspace::layer::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_ascii_alphanumeric() || c == '_' || c == '-')
|
||||||
|
{
|
||||||
|
return Err(ConfigError::InvalidValue {
|
||||||
|
key: "MEMORY_LAYERS".to_string(),
|
||||||
|
message: format!(
|
||||||
|
"layer name '{}' contains invalid characters \
|
||||||
|
(allowed: a-z, A-Z, 0-9, _, -)",
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_tokens: Option<HashMap<String, UserTokenConfig>> =
|
||||||
|
match optional_env("GATEWAY_USER_TOKENS")? {
|
||||||
|
Some(json_str) => {
|
||||||
|
let tokens: HashMap<String, UserTokenConfig> = serde_json::from_str(
|
||||||
|
&json_str,
|
||||||
|
)
|
||||||
|
.map_err(|e| ConfigError::InvalidValue {
|
||||||
|
key: "GATEWAY_USER_TOKENS".to_string(),
|
||||||
|
message: format!(
|
||||||
|
"must be valid JSON object mapping tokens to user configs: {e}"
|
||||||
|
),
|
||||||
|
})?;
|
||||||
|
if tokens.is_empty() {
|
||||||
|
return Err(ConfigError::InvalidValue {
|
||||||
|
key: "GATEWAY_USER_TOKENS".to_string(),
|
||||||
|
message:
|
||||||
|
"token map is empty — remove the variable to use single-user mode"
|
||||||
|
.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (tok, cfg) in &tokens {
|
||||||
|
if cfg.user_id.trim().is_empty() {
|
||||||
|
return Err(ConfigError::InvalidValue {
|
||||||
|
key: "GATEWAY_USER_TOKENS".to_string(),
|
||||||
|
message: format!(
|
||||||
|
"token '{}...' has an empty user_id",
|
||||||
|
&tok[..tok.len().min(8)]
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(tokens)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let workspace_read_scopes: Vec<String> = optional_env("WORKSPACE_READ_SCOPES")?
|
||||||
|
.map(|s| {
|
||||||
|
s.split(',')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
for scope in &workspace_read_scopes {
|
||||||
|
if scope.len() > 128 {
|
||||||
|
return Err(ConfigError::InvalidValue {
|
||||||
|
key: "WORKSPACE_READ_SCOPES".to_string(),
|
||||||
|
message: format!("scope '{}...' exceeds 128 characters", &scope[..32]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Some(GatewayConfig {
|
Some(GatewayConfig {
|
||||||
host: optional_env("GATEWAY_HOST")?
|
host: optional_env("GATEWAY_HOST")?
|
||||||
.or_else(|| cs.gateway_host.clone())
|
.or_else(|| cs.gateway_host.clone())
|
||||||
@@ -126,6 +259,9 @@ impl ChannelsConfig {
|
|||||||
auth_token: optional_env("GATEWAY_AUTH_TOKEN")?
|
auth_token: optional_env("GATEWAY_AUTH_TOKEN")?
|
||||||
.or_else(|| cs.gateway_auth_token.clone()),
|
.or_else(|| cs.gateway_auth_token.clone()),
|
||||||
user_id,
|
user_id,
|
||||||
|
workspace_read_scopes,
|
||||||
|
memory_layers,
|
||||||
|
user_tokens,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -236,7 +372,7 @@ fn default_channels_dir() -> PathBuf {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::config::channels::*;
|
use crate::config::channels::*;
|
||||||
use crate::config::helpers::ENV_MUTEX;
|
use crate::config::helpers::lock_env;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -281,6 +417,9 @@ mod tests {
|
|||||||
port: 3000,
|
port: 3000,
|
||||||
auth_token: Some("tok-abc".to_string()),
|
auth_token: Some("tok-abc".to_string()),
|
||||||
user_id: "default".to_string(),
|
user_id: "default".to_string(),
|
||||||
|
workspace_read_scopes: vec![],
|
||||||
|
memory_layers: vec![],
|
||||||
|
user_tokens: None,
|
||||||
};
|
};
|
||||||
assert_eq!(cfg.host, "127.0.0.1");
|
assert_eq!(cfg.host, "127.0.0.1");
|
||||||
assert_eq!(cfg.port, 3000);
|
assert_eq!(cfg.port, 3000);
|
||||||
@@ -295,6 +434,9 @@ mod tests {
|
|||||||
port: 3001,
|
port: 3001,
|
||||||
auth_token: None,
|
auth_token: None,
|
||||||
user_id: "anon".to_string(),
|
user_id: "anon".to_string(),
|
||||||
|
workspace_read_scopes: vec![],
|
||||||
|
memory_layers: vec![],
|
||||||
|
user_tokens: None,
|
||||||
};
|
};
|
||||||
assert!(cfg.auth_token.is_none());
|
assert!(cfg.auth_token.is_none());
|
||||||
}
|
}
|
||||||
@@ -395,7 +537,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_uses_settings_channel_values_with_owner_scope_user_ids() {
|
fn resolve_uses_settings_channel_values_with_owner_scope_user_ids() {
|
||||||
let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
|
let _guard = lock_env();
|
||||||
let mut settings = Settings::default();
|
let mut settings = Settings::default();
|
||||||
settings.channels.http_enabled = true;
|
settings.channels.http_enabled = true;
|
||||||
settings.channels.http_host = Some("127.0.0.2".to_string());
|
settings.channels.http_host = Some("127.0.0.2".to_string());
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ impl EmbeddingsConfig {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::helpers::ENV_MUTEX;
|
use crate::config::helpers::lock_env;
|
||||||
use crate::settings::{EmbeddingsSettings, Settings};
|
use crate::settings::{EmbeddingsSettings, Settings};
|
||||||
use crate::testing::credentials::*;
|
use crate::testing::credentials::*;
|
||||||
|
|
||||||
@@ -215,7 +215,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn embeddings_disabled_not_overridden_by_openai_key() {
|
fn embeddings_disabled_not_overridden_by_openai_key() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_embedding_env();
|
clear_embedding_env();
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -245,7 +245,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn embeddings_enabled_from_settings() {
|
fn embeddings_enabled_from_settings() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_embedding_env();
|
clear_embedding_env();
|
||||||
|
|
||||||
let settings = Settings {
|
let settings = Settings {
|
||||||
@@ -265,7 +265,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn embeddings_env_override_takes_precedence() {
|
fn embeddings_env_override_takes_precedence() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_embedding_env();
|
clear_embedding_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -294,7 +294,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn embedding_base_url_parsed_from_env() {
|
fn embedding_base_url_parsed_from_env() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_embedding_env();
|
clear_embedding_env();
|
||||||
|
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||||
@@ -313,7 +313,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn embedding_base_url_defaults_to_none() {
|
fn embedding_base_url_defaults_to_none() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_embedding_env();
|
clear_embedding_env();
|
||||||
|
|
||||||
let settings = Settings::default();
|
let settings = Settings::default();
|
||||||
@@ -326,7 +326,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cache_size_zero_rejected() {
|
fn cache_size_zero_rejected() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_embedding_env();
|
clear_embedding_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
|
|||||||
+31
-1
@@ -14,6 +14,16 @@ use crate::config::INJECTED_VARS;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
pub(crate) static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
|
|
||||||
|
/// Acquire the env-var mutex, recovering from poison.
|
||||||
|
///
|
||||||
|
/// A poisoned mutex means a previous test panicked while holding the lock.
|
||||||
|
/// The env state might be slightly stale, but cascading every subsequent
|
||||||
|
/// test into a `PoisonError` panic is far worse. Recover and carry on.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn lock_env() -> std::sync::MutexGuard<'static, ()> {
|
||||||
|
ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
/// Thread-safe mutable overlay for env vars set at runtime.
|
/// Thread-safe mutable overlay for env vars set at runtime.
|
||||||
///
|
///
|
||||||
/// Unlike `INJECTED_VARS` (which is set once at startup from the secrets
|
/// Unlike `INJECTED_VARS` (which is set once at startup from the secrets
|
||||||
@@ -353,7 +363,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn real_env_var_takes_priority_over_runtime_override() {
|
fn real_env_var_takes_priority_over_runtime_override() {
|
||||||
let _guard = ENV_MUTEX.lock().unwrap();
|
let _guard = lock_env();
|
||||||
let key = "IRONCLAW_TEST_ENV_PRIORITY_42";
|
let key = "IRONCLAW_TEST_ENV_PRIORITY_42";
|
||||||
|
|
||||||
// Set runtime override
|
// Set runtime override
|
||||||
@@ -372,6 +382,26 @@ mod tests {
|
|||||||
assert_eq!(env_or_override(key), Some("override_value".to_string()));
|
assert_eq!(env_or_override(key), Some("override_value".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- lock_env poison recovery (regression for env mutex cascade) ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lock_env_recovers_from_poisoned_mutex() {
|
||||||
|
// Simulate a poisoned mutex: spawn a thread that panics while holding the lock.
|
||||||
|
let _ = std::thread::spawn(|| {
|
||||||
|
let _guard = ENV_MUTEX.lock().unwrap();
|
||||||
|
panic!("intentional poison");
|
||||||
|
})
|
||||||
|
.join();
|
||||||
|
|
||||||
|
// The mutex is now poisoned. lock_env() should recover, not cascade.
|
||||||
|
assert!(ENV_MUTEX.lock().is_err(), "mutex should be poisoned");
|
||||||
|
let _guard = lock_env(); // must not panic
|
||||||
|
drop(_guard);
|
||||||
|
|
||||||
|
// Clean up so this test doesn't leave ENV_MUTEX permanently poisoned.
|
||||||
|
ENV_MUTEX.clear_poison();
|
||||||
|
}
|
||||||
|
|
||||||
// --- validate_base_url tests (regression for #1103) ---
|
// --- validate_base_url tests (regression for #1103) ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+52
-29
@@ -9,6 +9,7 @@ use crate::llm::config::*;
|
|||||||
use crate::llm::registry::{ProviderProtocol, ProviderRegistry};
|
use crate::llm::registry::{ProviderProtocol, ProviderRegistry};
|
||||||
use crate::llm::session::SessionConfig;
|
use crate::llm::session::SessionConfig;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
|
|
||||||
impl LlmConfig {
|
impl LlmConfig {
|
||||||
/// Create a test-friendly config without reading env vars.
|
/// Create a test-friendly config without reading env vars.
|
||||||
#[cfg(feature = "libsql")]
|
#[cfg(feature = "libsql")]
|
||||||
@@ -37,6 +38,7 @@ impl LlmConfig {
|
|||||||
},
|
},
|
||||||
provider: None,
|
provider: None,
|
||||||
bedrock: None,
|
bedrock: None,
|
||||||
|
gemini_oauth: None,
|
||||||
openai_codex: None,
|
openai_codex: None,
|
||||||
request_timeout_secs: 120,
|
request_timeout_secs: 120,
|
||||||
cheap_model: None,
|
cheap_model: None,
|
||||||
@@ -73,11 +75,16 @@ impl LlmConfig {
|
|||||||
backend_lower == "nearai" || backend_lower == "near_ai" || backend_lower == "near";
|
backend_lower == "nearai" || backend_lower == "near_ai" || backend_lower == "near";
|
||||||
let is_bedrock =
|
let is_bedrock =
|
||||||
backend_lower == "bedrock" || backend_lower == "aws_bedrock" || backend_lower == "aws";
|
backend_lower == "bedrock" || backend_lower == "aws_bedrock" || backend_lower == "aws";
|
||||||
|
let is_gemini_oauth = backend_lower == "gemini_oauth" || backend_lower == "gemini-oauth";
|
||||||
let is_openai_codex = backend_lower == "openai_codex"
|
let is_openai_codex = backend_lower == "openai_codex"
|
||||||
|| backend_lower == "openai-codex"
|
|| backend_lower == "openai-codex"
|
||||||
|| backend_lower == "codex";
|
|| backend_lower == "codex";
|
||||||
|
|
||||||
if !is_nearai && !is_bedrock && !is_openai_codex && registry.find(&backend_lower).is_none()
|
if !is_nearai
|
||||||
|
&& !is_bedrock
|
||||||
|
&& !is_gemini_oauth
|
||||||
|
&& !is_openai_codex
|
||||||
|
&& registry.find(&backend_lower).is_none()
|
||||||
{
|
{
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"Unknown LLM backend '{}'. Will attempt as openai_compatible fallback.",
|
"Unknown LLM backend '{}'. Will attempt as openai_compatible fallback.",
|
||||||
@@ -131,8 +138,8 @@ impl LlmConfig {
|
|||||||
smart_routing_cascade: parse_optional_env("SMART_ROUTING_CASCADE", true)?,
|
smart_routing_cascade: parse_optional_env("SMART_ROUTING_CASCADE", true)?,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resolve registry provider config (for non-NearAI, non-Bedrock, non-Codex backends)
|
// Resolve registry provider config (for non-NearAI, non-Bedrock, non-Gemini, non-Codex backends)
|
||||||
let provider = if is_nearai || is_bedrock || is_openai_codex {
|
let provider = if is_nearai || is_bedrock || is_gemini_oauth || is_openai_codex {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(Self::resolve_registry_provider(
|
Some(Self::resolve_registry_provider(
|
||||||
@@ -213,6 +220,19 @@ impl LlmConfig {
|
|||||||
|
|
||||||
let request_timeout_secs = parse_optional_env("LLM_REQUEST_TIMEOUT_SECS", 120)?;
|
let request_timeout_secs = parse_optional_env("LLM_REQUEST_TIMEOUT_SECS", 120)?;
|
||||||
|
|
||||||
|
let gemini_oauth = if backend_lower == "gemini_oauth" || backend_lower == "gemini-oauth" {
|
||||||
|
let model = Self::resolve_model("GEMINI_MODEL", settings, "gemini-2.5-flash")?;
|
||||||
|
let credentials_path = optional_env("GEMINI_CREDENTIALS_PATH")?
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(GeminiOauthConfig::default_credentials_path);
|
||||||
|
Some(GeminiOauthConfig {
|
||||||
|
model,
|
||||||
|
credentials_path,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// Generic cheap model (works with any backend).
|
// Generic cheap model (works with any backend).
|
||||||
// Falls back to NearAI-specific cheap_model in provider chain logic.
|
// Falls back to NearAI-specific cheap_model in provider chain logic.
|
||||||
let cheap_model = optional_env("LLM_CHEAP_MODEL")?;
|
let cheap_model = optional_env("LLM_CHEAP_MODEL")?;
|
||||||
@@ -226,6 +246,8 @@ impl LlmConfig {
|
|||||||
"nearai".to_string()
|
"nearai".to_string()
|
||||||
} else if is_bedrock {
|
} else if is_bedrock {
|
||||||
"bedrock".to_string()
|
"bedrock".to_string()
|
||||||
|
} else if is_gemini_oauth {
|
||||||
|
"gemini_oauth".to_string()
|
||||||
} else if is_openai_codex {
|
} else if is_openai_codex {
|
||||||
"openai_codex".to_string()
|
"openai_codex".to_string()
|
||||||
} else if let Some(ref p) = provider {
|
} else if let Some(ref p) = provider {
|
||||||
@@ -237,6 +259,7 @@ impl LlmConfig {
|
|||||||
nearai,
|
nearai,
|
||||||
provider,
|
provider,
|
||||||
bedrock,
|
bedrock,
|
||||||
|
gemini_oauth,
|
||||||
openai_codex,
|
openai_codex,
|
||||||
request_timeout_secs,
|
request_timeout_secs,
|
||||||
cheap_model,
|
cheap_model,
|
||||||
@@ -509,7 +532,7 @@ pub fn default_session_path() -> PathBuf {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::helpers::ENV_MUTEX;
|
use crate::config::helpers::lock_env;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
use crate::testing::credentials::*;
|
use crate::testing::credentials::*;
|
||||||
|
|
||||||
@@ -525,7 +548,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn openai_compatible_uses_selected_model_when_llm_model_unset() {
|
fn openai_compatible_uses_selected_model_when_llm_model_unset() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_openai_compatible_env();
|
clear_openai_compatible_env();
|
||||||
|
|
||||||
let settings = Settings {
|
let settings = Settings {
|
||||||
@@ -543,7 +566,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn openai_compatible_llm_model_env_overrides_selected_model() {
|
fn openai_compatible_llm_model_env_overrides_selected_model() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_openai_compatible_env();
|
clear_openai_compatible_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -667,7 +690,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ollama_uses_selected_model_when_ollama_model_unset() {
|
fn ollama_uses_selected_model_when_ollama_model_unset() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_ollama_env();
|
clear_ollama_env();
|
||||||
|
|
||||||
let settings = Settings {
|
let settings = Settings {
|
||||||
@@ -684,7 +707,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ollama_model_env_overrides_selected_model() {
|
fn ollama_model_env_overrides_selected_model() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_ollama_env();
|
clear_ollama_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -710,7 +733,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn openai_compatible_preserves_dotted_model_name() {
|
fn openai_compatible_preserves_dotted_model_name() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_openai_compatible_env();
|
clear_openai_compatible_env();
|
||||||
|
|
||||||
let settings = Settings {
|
let settings = Settings {
|
||||||
@@ -731,7 +754,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn registry_provider_resolves_groq() {
|
fn registry_provider_resolves_groq() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::remove_var("LLM_BACKEND");
|
std::env::remove_var("LLM_BACKEND");
|
||||||
@@ -756,7 +779,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn registry_provider_resolves_tinfoil() {
|
fn registry_provider_resolves_tinfoil() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::remove_var("LLM_BACKEND");
|
std::env::remove_var("LLM_BACKEND");
|
||||||
@@ -784,7 +807,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn registry_provider_alias_resolves_zai() {
|
fn registry_provider_alias_resolves_zai() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::remove_var("LLM_BACKEND");
|
std::env::remove_var("LLM_BACKEND");
|
||||||
@@ -809,7 +832,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn registry_provider_resolves_github_copilot_alias() {
|
fn registry_provider_resolves_github_copilot_alias() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::set_var("LLM_BACKEND", "github-copilot");
|
std::env::set_var("LLM_BACKEND", "github-copilot");
|
||||||
@@ -857,7 +880,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn nearai_backend_has_no_registry_provider() {
|
fn nearai_backend_has_no_registry_provider() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::remove_var("LLM_BACKEND");
|
std::env::remove_var("LLM_BACKEND");
|
||||||
@@ -871,7 +894,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_alias_normalized_to_canonical_id() {
|
fn backend_alias_normalized_to_canonical_id() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_openai_compatible_env();
|
clear_openai_compatible_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -897,7 +920,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_backend_falls_back_to_openai_compatible() {
|
fn unknown_backend_falls_back_to_openai_compatible() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_openai_compatible_env();
|
clear_openai_compatible_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -921,7 +944,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn nearai_aliases_all_resolve_to_nearai() {
|
fn nearai_aliases_all_resolve_to_nearai() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
|
|
||||||
for alias in &["nearai", "near_ai", "near"] {
|
for alias in &["nearai", "near_ai", "near"] {
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
@@ -948,7 +971,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn base_url_resolution_priority() {
|
fn base_url_resolution_priority() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_openai_compatible_env();
|
clear_openai_compatible_env();
|
||||||
|
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
@@ -1006,7 +1029,7 @@ mod tests {
|
|||||||
fn anthropic_oauth_token_sets_placeholder_api_key() {
|
fn anthropic_oauth_token_sets_placeholder_api_key() {
|
||||||
use secrecy::ExposeSecret;
|
use secrecy::ExposeSecret;
|
||||||
|
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_anthropic_env();
|
clear_anthropic_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -1044,7 +1067,7 @@ mod tests {
|
|||||||
fn anthropic_api_key_takes_priority_over_oauth() {
|
fn anthropic_api_key_takes_priority_over_oauth() {
|
||||||
use secrecy::ExposeSecret;
|
use secrecy::ExposeSecret;
|
||||||
|
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_anthropic_env();
|
clear_anthropic_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -1077,7 +1100,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn non_anthropic_provider_has_no_oauth_token() {
|
fn non_anthropic_provider_has_no_oauth_token() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_anthropic_env();
|
clear_anthropic_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -1185,7 +1208,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_request_timeout_defaults_to_120() {
|
fn test_request_timeout_defaults_to_120() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::remove_var("LLM_REQUEST_TIMEOUT_SECS");
|
std::env::remove_var("LLM_REQUEST_TIMEOUT_SECS");
|
||||||
@@ -1196,7 +1219,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_request_timeout_configurable() {
|
fn test_request_timeout_configurable() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::set_var("LLM_REQUEST_TIMEOUT_SECS", "300");
|
std::env::set_var("LLM_REQUEST_TIMEOUT_SECS", "300");
|
||||||
@@ -1223,7 +1246,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn openai_codex_resolves_config() {
|
fn openai_codex_resolves_config() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_openai_codex_env();
|
clear_openai_codex_env();
|
||||||
|
|
||||||
let settings = Settings {
|
let settings = Settings {
|
||||||
@@ -1243,7 +1266,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn openai_codex_model_env_resolution() {
|
fn openai_codex_model_env_resolution() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_openai_codex_env();
|
clear_openai_codex_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -1267,7 +1290,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn openai_codex_falls_back_to_openai_model() {
|
fn openai_codex_falls_back_to_openai_model() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_openai_codex_env();
|
clear_openai_codex_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -1291,7 +1314,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn openai_codex_falls_back_to_selected_model() {
|
fn openai_codex_falls_back_to_selected_model() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_openai_codex_env();
|
clear_openai_codex_env();
|
||||||
|
|
||||||
let settings = Settings {
|
let settings = Settings {
|
||||||
@@ -1308,7 +1331,7 @@ mod tests {
|
|||||||
/// Regression: SSRF validation on OPENAI_CODEX_API_URL (#1103).
|
/// Regression: SSRF validation on OPENAI_CODEX_API_URL (#1103).
|
||||||
#[test]
|
#[test]
|
||||||
fn openai_codex_rejects_ssrf_api_url() {
|
fn openai_codex_rejects_ssrf_api_url() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_openai_codex_env();
|
clear_openai_codex_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -1339,7 +1362,7 @@ mod tests {
|
|||||||
/// Regression: SSRF validation on OPENAI_CODEX_AUTH_URL (#1103).
|
/// Regression: SSRF validation on OPENAI_CODEX_AUTH_URL (#1103).
|
||||||
#[test]
|
#[test]
|
||||||
fn openai_codex_rejects_ssrf_auth_url() {
|
fn openai_codex_rejects_ssrf_auth_url() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_openai_codex_env();
|
clear_openai_codex_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
|
|||||||
+10
-9
@@ -24,7 +24,7 @@ mod skills;
|
|||||||
mod transcription;
|
mod transcription;
|
||||||
mod tunnel;
|
mod tunnel;
|
||||||
mod wasm;
|
mod wasm;
|
||||||
mod workspace;
|
pub(crate) mod workspace;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{LazyLock, Mutex, Once};
|
use std::sync::{LazyLock, Mutex, Once};
|
||||||
@@ -56,8 +56,8 @@ pub use self::tunnel::TunnelConfig;
|
|||||||
pub use self::wasm::WasmConfig;
|
pub use self::wasm::WasmConfig;
|
||||||
pub use self::workspace::WorkspaceConfig;
|
pub use self::workspace::WorkspaceConfig;
|
||||||
pub use crate::llm::config::{
|
pub use crate::llm::config::{
|
||||||
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER, OpenAiCodexConfig,
|
BedrockConfig, CacheRetention, GeminiOauthConfig, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER,
|
||||||
RegistryProviderConfig,
|
OpenAiCodexConfig, RegistryProviderConfig,
|
||||||
};
|
};
|
||||||
pub use crate::llm::session::SessionConfig;
|
pub use crate::llm::session::SessionConfig;
|
||||||
|
|
||||||
@@ -178,9 +178,7 @@ impl Config {
|
|||||||
},
|
},
|
||||||
transcription: TranscriptionConfig::default(),
|
transcription: TranscriptionConfig::default(),
|
||||||
search: WorkspaceSearchConfig::default(),
|
search: WorkspaceSearchConfig::default(),
|
||||||
workspace: WorkspaceConfig {
|
workspace: WorkspaceConfig::default(),
|
||||||
memory_layers: vec![],
|
|
||||||
},
|
|
||||||
observability: crate::observability::ObservabilityConfig::default(),
|
observability: crate::observability::ObservabilityConfig::default(),
|
||||||
relay: None,
|
relay: None,
|
||||||
}
|
}
|
||||||
@@ -313,11 +311,14 @@ impl Config {
|
|||||||
|
|
||||||
let tunnel = TunnelConfig::resolve(settings)?;
|
let tunnel = TunnelConfig::resolve(settings)?;
|
||||||
let channels = ChannelsConfig::resolve(settings, &owner_id)?;
|
let channels = ChannelsConfig::resolve(settings, &owner_id)?;
|
||||||
|
|
||||||
|
// Resolve workspace config using the gateway user_id for default layers.
|
||||||
let workspace_user_id = channels
|
let workspace_user_id = channels
|
||||||
.gateway
|
.gateway
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|gw| gw.user_id.clone())
|
.map(|gw| gw.user_id.as_str())
|
||||||
.unwrap_or_else(|| "default".to_string());
|
.unwrap_or("default");
|
||||||
|
let workspace = WorkspaceConfig::resolve(workspace_user_id)?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
owner_id: owner_id.clone(),
|
owner_id: owner_id.clone(),
|
||||||
@@ -339,7 +340,7 @@ impl Config {
|
|||||||
skills: SkillsConfig::resolve()?,
|
skills: SkillsConfig::resolve()?,
|
||||||
transcription: TranscriptionConfig::resolve(settings)?,
|
transcription: TranscriptionConfig::resolve(settings)?,
|
||||||
search: WorkspaceSearchConfig::resolve()?,
|
search: WorkspaceSearchConfig::resolve()?,
|
||||||
workspace: WorkspaceConfig::resolve(&workspace_user_id)?,
|
workspace,
|
||||||
observability: crate::observability::ObservabilityConfig {
|
observability: crate::observability::ObservabilityConfig {
|
||||||
backend: std::env::var("OBSERVABILITY_BACKEND").unwrap_or_else(|_| "none".into()),
|
backend: std::env::var("OBSERVABILITY_BACKEND").unwrap_or_else(|_| "none".into()),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ pub(crate) fn resolve_safety_config(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::helpers::ENV_MUTEX;
|
use crate::config::helpers::lock_env;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_falls_back_to_settings() {
|
fn resolve_falls_back_to_settings() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
let mut settings = Settings::default();
|
let mut settings = Settings::default();
|
||||||
settings.safety.max_output_length = 42;
|
settings.safety.max_output_length = 42;
|
||||||
settings.safety.injection_check_enabled = false;
|
settings.safety.injection_check_enabled = false;
|
||||||
@@ -36,7 +36,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn env_overrides_settings() {
|
fn env_overrides_settings() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
let mut settings = Settings::default();
|
let mut settings = Settings::default();
|
||||||
settings.safety.max_output_length = 42;
|
settings.safety.max_output_length = 42;
|
||||||
|
|
||||||
|
|||||||
+5
-15
@@ -594,9 +594,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sandbox_resolve_falls_back_to_settings() {
|
fn sandbox_resolve_falls_back_to_settings() {
|
||||||
let _guard = crate::config::helpers::ENV_MUTEX
|
let _guard = crate::config::helpers::lock_env();
|
||||||
.lock()
|
|
||||||
.expect("env mutex poisoned");
|
|
||||||
let mut settings = crate::settings::Settings::default();
|
let mut settings = crate::settings::Settings::default();
|
||||||
settings.sandbox.cpu_shares = 99;
|
settings.sandbox.cpu_shares = 99;
|
||||||
settings.sandbox.auto_pull_image = false;
|
settings.sandbox.auto_pull_image = false;
|
||||||
@@ -610,9 +608,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sandbox_env_overrides_settings() {
|
fn sandbox_env_overrides_settings() {
|
||||||
let _guard = crate::config::helpers::ENV_MUTEX
|
let _guard = crate::config::helpers::lock_env();
|
||||||
.lock()
|
|
||||||
.expect("env mutex poisoned");
|
|
||||||
let mut settings = crate::settings::Settings::default();
|
let mut settings = crate::settings::Settings::default();
|
||||||
settings.sandbox.timeout_secs = 999;
|
settings.sandbox.timeout_secs = 999;
|
||||||
|
|
||||||
@@ -628,9 +624,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn claude_code_resolve_uses_settings_enabled() {
|
fn claude_code_resolve_uses_settings_enabled() {
|
||||||
let _guard = crate::config::helpers::ENV_MUTEX
|
let _guard = crate::config::helpers::lock_env();
|
||||||
.lock()
|
|
||||||
.expect("env mutex poisoned");
|
|
||||||
let mut settings = crate::settings::Settings::default();
|
let mut settings = crate::settings::Settings::default();
|
||||||
settings.sandbox.claude_code_enabled = true;
|
settings.sandbox.claude_code_enabled = true;
|
||||||
|
|
||||||
@@ -640,9 +634,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn claude_code_resolve_defaults_disabled() {
|
fn claude_code_resolve_defaults_disabled() {
|
||||||
let _guard = crate::config::helpers::ENV_MUTEX
|
let _guard = crate::config::helpers::lock_env();
|
||||||
.lock()
|
|
||||||
.expect("env mutex poisoned");
|
|
||||||
let settings = crate::settings::Settings::default();
|
let settings = crate::settings::Settings::default();
|
||||||
let cfg = ClaudeCodeConfig::resolve(&settings).expect("resolve");
|
let cfg = ClaudeCodeConfig::resolve(&settings).expect("resolve");
|
||||||
assert!(!cfg.enabled);
|
assert!(!cfg.enabled);
|
||||||
@@ -650,9 +642,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn claude_code_env_overrides_settings() {
|
fn claude_code_env_overrides_settings() {
|
||||||
let _guard = crate::config::helpers::ENV_MUTEX
|
let _guard = crate::config::helpers::lock_env();
|
||||||
.lock()
|
|
||||||
.expect("env mutex poisoned");
|
|
||||||
let mut settings = crate::settings::Settings::default();
|
let mut settings = crate::settings::Settings::default();
|
||||||
settings.sandbox.claude_code_enabled = true;
|
settings.sandbox.claude_code_enabled = true;
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ impl WorkspaceSearchConfig {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::helpers::ENV_MUTEX;
|
use crate::config::helpers::lock_env;
|
||||||
|
|
||||||
fn clear_search_env() {
|
fn clear_search_env() {
|
||||||
// SAFETY: Only called under ENV_MUTEX in tests.
|
// SAFETY: Only called under ENV_MUTEX in tests.
|
||||||
@@ -106,7 +106,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn defaults_when_no_env() {
|
fn defaults_when_no_env() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_search_env();
|
clear_search_env();
|
||||||
|
|
||||||
let config = WorkspaceSearchConfig::resolve().expect("should resolve");
|
let config = WorkspaceSearchConfig::resolve().expect("should resolve");
|
||||||
@@ -118,7 +118,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn env_overrides() {
|
fn env_overrides() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_search_env();
|
clear_search_env();
|
||||||
|
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
@@ -140,7 +140,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invalid_strategy_rejected() {
|
fn invalid_strategy_rejected() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_search_env();
|
clear_search_env();
|
||||||
|
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
@@ -156,7 +156,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn weighted_strategy_defaults() {
|
fn weighted_strategy_defaults() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_search_env();
|
clear_search_env();
|
||||||
|
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
@@ -175,7 +175,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn weighted_both_zero_rejected() {
|
fn weighted_both_zero_rejected() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_search_env();
|
clear_search_env();
|
||||||
|
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
@@ -193,7 +193,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rrf_both_zero_allowed() {
|
fn rrf_both_zero_allowed() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
clear_search_env();
|
clear_search_env();
|
||||||
|
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
|
|||||||
@@ -89,7 +89,9 @@ impl TranscriptionConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create the transcription provider if enabled and configured.
|
/// Create the transcription provider if enabled and configured.
|
||||||
pub fn create_provider(&self) -> Option<Box<dyn crate::transcription::TranscriptionProvider>> {
|
pub fn create_provider(
|
||||||
|
&self,
|
||||||
|
) -> Option<Box<dyn crate::llm::transcription::TranscriptionProvider>> {
|
||||||
if !self.enabled {
|
if !self.enabled {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -103,10 +105,11 @@ impl TranscriptionConfig {
|
|||||||
"Audio transcription enabled via Chat Completions API"
|
"Audio transcription enabled via Chat Completions API"
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut provider = crate::transcription::ChatCompletionsTranscriptionProvider::new(
|
let mut provider =
|
||||||
api_key.clone(),
|
crate::llm::transcription::ChatCompletionsTranscriptionProvider::new(
|
||||||
)
|
api_key.clone(),
|
||||||
.with_model(&self.model);
|
)
|
||||||
|
.with_model(&self.model);
|
||||||
|
|
||||||
if let Some(ref base_url) = self.base_url {
|
if let Some(ref base_url) = self.base_url {
|
||||||
provider = provider.with_base_url(base_url);
|
provider = provider.with_base_url(base_url);
|
||||||
@@ -121,7 +124,7 @@ impl TranscriptionConfig {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut provider =
|
let mut provider =
|
||||||
crate::transcription::OpenAiWhisperProvider::new(api_key.clone())
|
crate::llm::transcription::OpenAiWhisperProvider::new(api_key.clone())
|
||||||
.with_model(&self.model);
|
.with_model(&self.model);
|
||||||
|
|
||||||
if let Some(ref base_url) = self.base_url {
|
if let Some(ref base_url) = self.base_url {
|
||||||
|
|||||||
+3
-3
@@ -95,12 +95,12 @@ impl WasmConfig {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::helpers::ENV_MUTEX;
|
use crate::config::helpers::lock_env;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_falls_back_to_settings() {
|
fn resolve_falls_back_to_settings() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
let mut settings = Settings::default();
|
let mut settings = Settings::default();
|
||||||
settings.wasm.default_memory_limit = 42;
|
settings.wasm.default_memory_limit = 42;
|
||||||
settings.wasm.cache_compiled = false;
|
settings.wasm.cache_compiled = false;
|
||||||
@@ -112,7 +112,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn env_overrides_settings() {
|
fn env_overrides_settings() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
let mut settings = Settings::default();
|
let mut settings = Settings::default();
|
||||||
settings.wasm.default_fuel_limit = 42;
|
settings.wasm.default_fuel_limit = 42;
|
||||||
|
|
||||||
|
|||||||
+70
-12
@@ -2,18 +2,29 @@ use crate::config::helpers::optional_env;
|
|||||||
use crate::error::ConfigError;
|
use crate::error::ConfigError;
|
||||||
use crate::workspace::layer::MemoryLayer;
|
use crate::workspace::layer::MemoryLayer;
|
||||||
|
|
||||||
/// Workspace memory configuration.
|
/// Workspace-level configuration (memory layers, read scopes).
|
||||||
///
|
///
|
||||||
/// Controls memory layer definitions for privacy-aware writes.
|
/// Parsed from environment variables. Lives outside of `GatewayConfig`
|
||||||
/// Layers are parsed from the `MEMORY_LAYERS` env var (JSON array)
|
/// so that non-gateway channels can eventually use the same settings.
|
||||||
/// or default to a single private layer scoped to the gateway user.
|
#[derive(Debug, Clone, Default)]
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct WorkspaceConfig {
|
pub struct WorkspaceConfig {
|
||||||
|
/// Memory layer definitions (JSON in `MEMORY_LAYERS` env var, or defaults).
|
||||||
pub memory_layers: Vec<MemoryLayer>,
|
pub memory_layers: Vec<MemoryLayer>,
|
||||||
|
/// Additional user scopes for workspace reads.
|
||||||
|
///
|
||||||
|
/// When set, the workspace can read (search, read, list) from these
|
||||||
|
/// additional user scopes while writes remain isolated to the primary
|
||||||
|
/// `user_id`. Parsed from `WORKSPACE_READ_SCOPES` (comma-separated).
|
||||||
|
pub read_scopes: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkspaceConfig {
|
impl WorkspaceConfig {
|
||||||
pub(crate) fn resolve(user_id: &str) -> Result<Self, ConfigError> {
|
/// Resolve workspace config from environment variables.
|
||||||
|
///
|
||||||
|
/// `user_id` is used to derive default memory layers when `MEMORY_LAYERS`
|
||||||
|
/// is not set.
|
||||||
|
pub fn resolve(user_id: &str) -> Result<Self, ConfigError> {
|
||||||
|
// --- Memory layers ---
|
||||||
let memory_layers: Vec<MemoryLayer> = match optional_env("MEMORY_LAYERS")? {
|
let memory_layers: Vec<MemoryLayer> = match optional_env("MEMORY_LAYERS")? {
|
||||||
Some(json_str) => {
|
Some(json_str) => {
|
||||||
serde_json::from_str(&json_str).map_err(|e| ConfigError::InvalidValue {
|
serde_json::from_str(&json_str).map_err(|e| ConfigError::InvalidValue {
|
||||||
@@ -57,6 +68,20 @@ impl WorkspaceConfig {
|
|||||||
message: format!("layer '{}' has an empty scope", layer.name),
|
message: format!("layer '{}' has an empty scope", layer.name),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if !layer
|
||||||
|
.scope
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||||
|
{
|
||||||
|
return Err(ConfigError::InvalidValue {
|
||||||
|
key: "MEMORY_LAYERS".to_string(),
|
||||||
|
message: format!(
|
||||||
|
"layer '{}' scope '{}' contains invalid characters \
|
||||||
|
(allowed: a-z, A-Z, 0-9, _, -)",
|
||||||
|
layer.name, layer.scope
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate layer names
|
// Check for duplicate layer names
|
||||||
@@ -72,20 +97,53 @@ impl WorkspaceConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Self { memory_layers })
|
// --- Read scopes ---
|
||||||
|
let read_scopes: Vec<String> = optional_env("WORKSPACE_READ_SCOPES")?
|
||||||
|
.map(|s| {
|
||||||
|
s.split(',')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
for scope in &read_scopes {
|
||||||
|
if scope.len() > 128 {
|
||||||
|
let prefix: String = scope.chars().take(32).collect();
|
||||||
|
return Err(ConfigError::InvalidValue {
|
||||||
|
key: "WORKSPACE_READ_SCOPES".to_string(),
|
||||||
|
message: format!("scope '{prefix}...' exceeds 128 characters"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !scope
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||||
|
{
|
||||||
|
return Err(ConfigError::InvalidValue {
|
||||||
|
key: "WORKSPACE_READ_SCOPES".to_string(),
|
||||||
|
message: format!(
|
||||||
|
"scope '{}' contains invalid characters \
|
||||||
|
(allowed: a-z, A-Z, 0-9, _, -)",
|
||||||
|
scope
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
memory_layers,
|
||||||
|
read_scopes,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::sync::Mutex;
|
use crate::config::helpers::lock_env;
|
||||||
|
|
||||||
// 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()) {
|
fn with_env(key: &str, val: Option<&str>, f: impl FnOnce()) {
|
||||||
let _guard = ENV_LOCK.lock().unwrap();
|
let _guard = lock_env();
|
||||||
let prev = std::env::var(key).ok();
|
let prev = std::env::var(key).ok();
|
||||||
match val {
|
match val {
|
||||||
Some(v) => unsafe { std::env::set_var(key, v) },
|
Some(v) => unsafe { std::env::set_var(key, v) },
|
||||||
|
|||||||
@@ -230,6 +230,49 @@ impl JobStore for LibSqlBackend {
|
|||||||
Ok(jobs)
|
Ok(jobs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_agent_jobs_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<Vec<AgentJobRecord>, DatabaseError> {
|
||||||
|
let conn = self.connect().await?;
|
||||||
|
let mut rows = conn
|
||||||
|
.query(
|
||||||
|
r#"
|
||||||
|
SELECT id, title, status, user_id, failure_reason,
|
||||||
|
created_at, started_at, completed_at
|
||||||
|
FROM agent_jobs WHERE source = 'direct' AND user_id = ?1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
"#,
|
||||||
|
params![user_id],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut jobs = Vec::new();
|
||||||
|
while let Some(row) = rows
|
||||||
|
.next()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||||
|
{
|
||||||
|
let id_str = get_text(&row, 0);
|
||||||
|
let Ok(id) = id_str.parse() else {
|
||||||
|
tracing::warn!("Skipping agent job with invalid UUID: {}", id_str);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
jobs.push(AgentJobRecord {
|
||||||
|
id,
|
||||||
|
title: get_text(&row, 1),
|
||||||
|
status: get_text(&row, 2),
|
||||||
|
user_id: get_text(&row, 3),
|
||||||
|
failure_reason: get_opt_text(&row, 4),
|
||||||
|
created_at: get_ts(&row, 5),
|
||||||
|
started_at: get_opt_ts(&row, 6),
|
||||||
|
completed_at: get_opt_ts(&row, 7),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(jobs)
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_agent_job_failure_reason(
|
async fn get_agent_job_failure_reason(
|
||||||
&self,
|
&self,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
@@ -277,6 +320,32 @@ impl JobStore for LibSqlBackend {
|
|||||||
Ok(summary)
|
Ok(summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn agent_job_summary_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<AgentJobSummary, DatabaseError> {
|
||||||
|
let conn = self.connect().await?;
|
||||||
|
let mut rows = conn
|
||||||
|
.query(
|
||||||
|
"SELECT status, COUNT(*) as cnt FROM agent_jobs WHERE source = 'direct' AND user_id = ?1 GROUP BY status",
|
||||||
|
params![user_id],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut summary = AgentJobSummary::default();
|
||||||
|
while let Some(row) = rows
|
||||||
|
.next()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||||
|
{
|
||||||
|
let status = get_text(&row, 0);
|
||||||
|
let count = get_i64(&row, 1) as usize;
|
||||||
|
summary.add_count(&status, count);
|
||||||
|
}
|
||||||
|
Ok(summary)
|
||||||
|
}
|
||||||
|
|
||||||
async fn save_action(&self, job_id: Uuid, action: &ActionRecord) -> Result<(), DatabaseError> {
|
async fn save_action(&self, job_id: Uuid, action: &ActionRecord) -> Result<(), DatabaseError> {
|
||||||
let conn = self.connect().await?;
|
let conn = self.connect().await?;
|
||||||
let duration_ms = action.duration.as_millis() as i64;
|
let duration_ms = action.duration.as_millis() as i64;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ pub(crate) fn resolve_embedding_dimension() -> Option<usize> {
|
|||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
if !enabled {
|
if !enabled {
|
||||||
tracing::info!("Vector index setup skipped (EMBEDDING_ENABLED not set in env)");
|
tracing::debug!("Vector index setup skipped (EMBEDDING_ENABLED not set in env)");
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1017,7 +1017,7 @@ mod tests {
|
|||||||
|
|
||||||
mod resolve_dimension {
|
mod resolve_dimension {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::helpers::ENV_MUTEX;
|
use crate::config::helpers::lock_env;
|
||||||
|
|
||||||
fn clear_embedding_env() {
|
fn clear_embedding_env() {
|
||||||
// SAFETY: called under ENV_MUTEX
|
// SAFETY: called under ENV_MUTEX
|
||||||
@@ -1030,14 +1030,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn returns_none_when_disabled() {
|
fn returns_none_when_disabled() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex");
|
let _guard = lock_env();
|
||||||
clear_embedding_env();
|
clear_embedding_env();
|
||||||
assert!(resolve_embedding_dimension().is_none());
|
assert!(resolve_embedding_dimension().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn returns_explicit_dimension() {
|
fn returns_explicit_dimension() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex");
|
let _guard = lock_env();
|
||||||
clear_embedding_env();
|
clear_embedding_env();
|
||||||
// SAFETY: under ENV_MUTEX
|
// SAFETY: under ENV_MUTEX
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -1053,7 +1053,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn infers_from_model() {
|
fn infers_from_model() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex");
|
let _guard = lock_env();
|
||||||
clear_embedding_env();
|
clear_embedding_env();
|
||||||
// SAFETY: under ENV_MUTEX
|
// SAFETY: under ENV_MUTEX
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -1069,7 +1069,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn defaults_to_1536_for_unknown_model() {
|
fn defaults_to_1536_for_unknown_model() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex");
|
let _guard = lock_env();
|
||||||
clear_embedding_env();
|
clear_embedding_env();
|
||||||
// SAFETY: under ENV_MUTEX
|
// SAFETY: under ENV_MUTEX
|
||||||
unsafe {
|
unsafe {
|
||||||
|
|||||||
+106
-1
@@ -97,7 +97,7 @@ pub async fn connect_with_handles(
|
|||||||
.map_err(|e| DatabaseError::Pool(e.to_string()))?
|
.map_err(|e| DatabaseError::Pool(e.to_string()))?
|
||||||
};
|
};
|
||||||
backend.run_migrations().await?;
|
backend.run_migrations().await?;
|
||||||
tracing::info!("libSQL database connected and migrations applied");
|
tracing::debug!("libSQL database connected and migrations applied");
|
||||||
|
|
||||||
handles.libsql_db = Some(backend.shared_db());
|
handles.libsql_db = Some(backend.shared_db());
|
||||||
|
|
||||||
@@ -409,7 +409,15 @@ pub trait JobStore: Send + Sync {
|
|||||||
async fn mark_job_stuck(&self, id: Uuid) -> Result<(), DatabaseError>;
|
async fn mark_job_stuck(&self, id: Uuid) -> Result<(), DatabaseError>;
|
||||||
async fn get_stuck_jobs(&self) -> Result<Vec<Uuid>, DatabaseError>;
|
async fn get_stuck_jobs(&self) -> Result<Vec<Uuid>, DatabaseError>;
|
||||||
async fn list_agent_jobs(&self) -> Result<Vec<AgentJobRecord>, DatabaseError>;
|
async fn list_agent_jobs(&self) -> Result<Vec<AgentJobRecord>, DatabaseError>;
|
||||||
|
async fn list_agent_jobs_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<Vec<AgentJobRecord>, DatabaseError>;
|
||||||
async fn agent_job_summary(&self) -> Result<AgentJobSummary, DatabaseError>;
|
async fn agent_job_summary(&self) -> Result<AgentJobSummary, DatabaseError>;
|
||||||
|
async fn agent_job_summary_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<AgentJobSummary, DatabaseError>;
|
||||||
/// Get the failure reason for a single agent job (O(1) lookup).
|
/// Get the failure reason for a single agent job (O(1) lookup).
|
||||||
async fn get_agent_job_failure_reason(&self, id: Uuid)
|
async fn get_agent_job_failure_reason(&self, id: Uuid)
|
||||||
-> Result<Option<String>, DatabaseError>;
|
-> Result<Option<String>, DatabaseError>;
|
||||||
@@ -644,6 +652,103 @@ pub trait WorkspaceStore: Send + Sync {
|
|||||||
embedding: Option<&[f32]>,
|
embedding: Option<&[f32]>,
|
||||||
config: &SearchConfig,
|
config: &SearchConfig,
|
||||||
) -> Result<Vec<SearchResult>, WorkspaceError>;
|
) -> Result<Vec<SearchResult>, WorkspaceError>;
|
||||||
|
|
||||||
|
// ==================== Multi-scope read methods ====================
|
||||||
|
//
|
||||||
|
// Default implementations loop over user_ids calling single-scope methods,
|
||||||
|
// then merge results. Backends can override with efficient SQL (e.g.,
|
||||||
|
// `WHERE user_id = ANY($1::text[])`).
|
||||||
|
|
||||||
|
/// Hybrid search across multiple user scopes, merging results by score.
|
||||||
|
///
|
||||||
|
/// **Note:** The default implementation calls `hybrid_search` per scope and
|
||||||
|
/// merges by raw score. Because RRF scores are normalized independently
|
||||||
|
/// within each scope, scores are not directly comparable across scopes.
|
||||||
|
/// The Postgres backend overrides this with a single combined query that
|
||||||
|
/// applies RRF once to the unified result set.
|
||||||
|
async fn hybrid_search_multi(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
agent_id: Option<Uuid>,
|
||||||
|
query: &str,
|
||||||
|
embedding: Option<&[f32]>,
|
||||||
|
config: &SearchConfig,
|
||||||
|
) -> Result<Vec<SearchResult>, WorkspaceError> {
|
||||||
|
if user_ids.len() > 1 {
|
||||||
|
tracing::debug!(
|
||||||
|
scope_count = user_ids.len(),
|
||||||
|
"hybrid_search_multi: using default per-scope RRF merge; \
|
||||||
|
cross-scope score comparison may be unreliable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut all_results = Vec::new();
|
||||||
|
for uid in user_ids {
|
||||||
|
let results = self
|
||||||
|
.hybrid_search(uid, agent_id, query, embedding, config)
|
||||||
|
.await?;
|
||||||
|
all_results.extend(results);
|
||||||
|
}
|
||||||
|
// Re-sort by score descending and truncate to limit
|
||||||
|
all_results.sort_by(|a, b| {
|
||||||
|
b.score
|
||||||
|
.partial_cmp(&a.score)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
all_results.truncate(config.limit);
|
||||||
|
Ok(all_results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all file paths across multiple user scopes.
|
||||||
|
async fn list_all_paths_multi(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
agent_id: Option<Uuid>,
|
||||||
|
) -> Result<Vec<String>, WorkspaceError> {
|
||||||
|
let mut all_paths = Vec::new();
|
||||||
|
for uid in user_ids {
|
||||||
|
let paths = self.list_all_paths(uid, agent_id).await?;
|
||||||
|
all_paths.extend(paths);
|
||||||
|
}
|
||||||
|
all_paths.sort();
|
||||||
|
all_paths.dedup();
|
||||||
|
Ok(all_paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a document by path, searching across multiple user scopes.
|
||||||
|
///
|
||||||
|
/// Returns the first match found (tries each user_id in order).
|
||||||
|
async fn get_document_by_path_multi(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
agent_id: Option<Uuid>,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<MemoryDocument, WorkspaceError> {
|
||||||
|
for uid in user_ids {
|
||||||
|
match self.get_document_by_path(uid, agent_id, path).await {
|
||||||
|
Ok(doc) => return Ok(doc),
|
||||||
|
Err(WorkspaceError::DocumentNotFound { .. }) => continue,
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(WorkspaceError::DocumentNotFound {
|
||||||
|
doc_type: path.to_string(),
|
||||||
|
user_id: format!("[{}]", user_ids.join(", ")),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List directory contents across multiple user scopes.
|
||||||
|
async fn list_directory_multi(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
agent_id: Option<Uuid>,
|
||||||
|
directory: &str,
|
||||||
|
) -> Result<Vec<WorkspaceEntry>, WorkspaceError> {
|
||||||
|
let mut all_entries = Vec::new();
|
||||||
|
for uid in user_ids {
|
||||||
|
all_entries.extend(self.list_directory(uid, agent_id, directory).await?);
|
||||||
|
}
|
||||||
|
Ok(crate::workspace::merge_workspace_entries(all_entries))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Backend-agnostic database supertrait.
|
/// Backend-agnostic database supertrait.
|
||||||
|
|||||||
@@ -249,10 +249,24 @@ impl JobStore for PgBackend {
|
|||||||
self.store.list_agent_jobs().await
|
self.store.list_agent_jobs().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_agent_jobs_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<Vec<AgentJobRecord>, DatabaseError> {
|
||||||
|
self.store.list_agent_jobs_for_user(user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn agent_job_summary(&self) -> Result<AgentJobSummary, DatabaseError> {
|
async fn agent_job_summary(&self) -> Result<AgentJobSummary, DatabaseError> {
|
||||||
self.store.agent_job_summary().await
|
self.store.agent_job_summary().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn agent_job_summary_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<AgentJobSummary, DatabaseError> {
|
||||||
|
self.store.agent_job_summary_for_user(user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_agent_job_failure_reason(
|
async fn get_agent_job_failure_reason(
|
||||||
&self,
|
&self,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
@@ -717,4 +731,49 @@ impl WorkspaceStore for PgBackend {
|
|||||||
.hybrid_search(user_id, agent_id, query, embedding, config)
|
.hybrid_search(user_id, agent_id, query, embedding, config)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optimized multi-scope overrides using `ANY($1::text[])` SQL.
|
||||||
|
|
||||||
|
async fn hybrid_search_multi(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
agent_id: Option<Uuid>,
|
||||||
|
query: &str,
|
||||||
|
embedding: Option<&[f32]>,
|
||||||
|
config: &SearchConfig,
|
||||||
|
) -> Result<Vec<SearchResult>, WorkspaceError> {
|
||||||
|
self.repo
|
||||||
|
.hybrid_search_multi(user_ids, agent_id, query, embedding, config)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_all_paths_multi(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
agent_id: Option<Uuid>,
|
||||||
|
) -> Result<Vec<String>, WorkspaceError> {
|
||||||
|
self.repo.list_all_paths_multi(user_ids, agent_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_document_by_path_multi(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
agent_id: Option<Uuid>,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<MemoryDocument, WorkspaceError> {
|
||||||
|
self.repo
|
||||||
|
.get_document_by_path_multi(user_ids, agent_id, path)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_directory_multi(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
agent_id: Option<Uuid>,
|
||||||
|
directory: &str,
|
||||||
|
) -> Result<Vec<WorkspaceEntry>, WorkspaceError> {
|
||||||
|
self.repo
|
||||||
|
.list_directory_multi(user_ids, agent_id, directory)
|
||||||
|
.await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -304,9 +304,6 @@ pub enum WorkspaceError {
|
|||||||
#[error("I/O error: {reason}")]
|
#[error("I/O error: {reason}")]
|
||||||
IoError { reason: String },
|
IoError { reason: String },
|
||||||
|
|
||||||
#[error("Not found: {path}")]
|
|
||||||
NotFound { path: String },
|
|
||||||
|
|
||||||
#[error("Layer not found: {name}")]
|
#[error("Layer not found: {name}")]
|
||||||
LayerNotFound { name: String },
|
LayerNotFound { name: String },
|
||||||
|
|
||||||
|
|||||||
+332
-251
File diff suppressed because it is too large
Load Diff
@@ -842,6 +842,38 @@ impl Store {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_agent_jobs_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<Vec<AgentJobRecord>, DatabaseError> {
|
||||||
|
let conn = self.conn().await?;
|
||||||
|
let rows = conn
|
||||||
|
.query(
|
||||||
|
r#"
|
||||||
|
SELECT id, title, status, user_id, failure_reason,
|
||||||
|
created_at, started_at, completed_at
|
||||||
|
FROM agent_jobs WHERE source = 'direct' AND user_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
"#,
|
||||||
|
&[&user_id],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| AgentJobRecord {
|
||||||
|
id: r.get("id"),
|
||||||
|
title: r.get("title"),
|
||||||
|
status: r.get("status"),
|
||||||
|
user_id: r.get::<_, Option<String>>("user_id").unwrap_or_default(),
|
||||||
|
created_at: r.get("created_at"),
|
||||||
|
started_at: r.get("started_at"),
|
||||||
|
completed_at: r.get("completed_at"),
|
||||||
|
failure_reason: r.get("failure_reason"),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the failure reason for a single agent job.
|
/// Get the failure reason for a single agent job.
|
||||||
pub async fn get_agent_job_failure_reason(
|
pub async fn get_agent_job_failure_reason(
|
||||||
&self,
|
&self,
|
||||||
@@ -875,6 +907,27 @@ impl Store {
|
|||||||
}
|
}
|
||||||
Ok(summary)
|
Ok(summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn agent_job_summary_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<AgentJobSummary, DatabaseError> {
|
||||||
|
let conn = self.conn().await?;
|
||||||
|
let rows = conn
|
||||||
|
.query(
|
||||||
|
"SELECT status, COUNT(*) as cnt FROM agent_jobs WHERE source = 'direct' AND user_id = $1 GROUP BY status",
|
||||||
|
&[&user_id],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut summary = AgentJobSummary::default();
|
||||||
|
for row in &rows {
|
||||||
|
let status: String = row.get("status");
|
||||||
|
let count: i64 = row.get("cnt");
|
||||||
|
summary.add_count(&status, count as usize);
|
||||||
|
}
|
||||||
|
Ok(summary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Job Events ====================
|
// ==================== Job Events ====================
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ pub mod skills;
|
|||||||
pub mod timezone;
|
pub mod timezone;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
pub mod tracing_fmt;
|
pub mod tracing_fmt;
|
||||||
pub mod transcription;
|
|
||||||
pub mod tunnel;
|
pub mod tunnel;
|
||||||
pub mod util;
|
pub mod util;
|
||||||
pub mod webhooks;
|
pub mod webhooks;
|
||||||
|
|||||||
@@ -165,6 +165,8 @@ pub struct LlmConfig {
|
|||||||
pub provider: Option<RegistryProviderConfig>,
|
pub provider: Option<RegistryProviderConfig>,
|
||||||
/// AWS Bedrock config (populated when backend=bedrock, requires --features bedrock).
|
/// AWS Bedrock config (populated when backend=bedrock, requires --features bedrock).
|
||||||
pub bedrock: Option<BedrockConfig>,
|
pub bedrock: Option<BedrockConfig>,
|
||||||
|
/// Gemini OAuth config (populated when backend=gemini_oauth).
|
||||||
|
pub gemini_oauth: Option<GeminiOauthConfig>,
|
||||||
/// OpenAI Codex config (populated when backend=openai_codex).
|
/// OpenAI Codex config (populated when backend=openai_codex).
|
||||||
pub openai_codex: Option<OpenAiCodexConfig>,
|
pub openai_codex: Option<OpenAiCodexConfig>,
|
||||||
/// HTTP request timeout in seconds for LLM API calls.
|
/// HTTP request timeout in seconds for LLM API calls.
|
||||||
@@ -267,3 +269,34 @@ impl NearAiConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Configuration for Gemini OAuth integration.
|
||||||
|
///
|
||||||
|
/// Extended generation config parameters (topP, topK, seed, etc.) are read from
|
||||||
|
/// environment variables at request time:
|
||||||
|
/// - `GEMINI_TOP_P` — nucleus sampling (0.0–1.0)
|
||||||
|
/// - `GEMINI_TOP_K` — top-k sampling (integer)
|
||||||
|
/// - `GEMINI_SEED` — deterministic generation seed
|
||||||
|
/// - `GEMINI_PRESENCE_PENALTY` — presence penalty (-2.0–2.0)
|
||||||
|
/// - `GEMINI_FREQUENCY_PENALTY` — frequency penalty (-2.0–2.0)
|
||||||
|
/// - `GEMINI_RESPONSE_MIME_TYPE` — e.g. "application/json"
|
||||||
|
/// - `GEMINI_RESPONSE_JSON_SCHEMA` — JSON schema string for structured output
|
||||||
|
/// - `GEMINI_CACHED_CONTENT` — cached content resource name
|
||||||
|
/// - `GEMINI_CLI_CUSTOM_HEADERS` — custom headers (key:value,key:value)
|
||||||
|
/// - `GOOGLE_GENAI_API_VERSION` — API version (default: v1beta)
|
||||||
|
/// - `GEMINI_API_KEY` — optional API key for non-OAuth auth mode
|
||||||
|
/// - `GEMINI_API_KEY_AUTH_MECHANISM` — "x-goog-api-key" (default) or "bearer"
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GeminiOauthConfig {
|
||||||
|
pub model: String,
|
||||||
|
pub credentials_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GeminiOauthConfig {
|
||||||
|
pub fn default_credentials_path() -> PathBuf {
|
||||||
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join(".gemini")
|
||||||
|
.join("oauth_creds.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+57
-1
@@ -18,6 +18,7 @@ pub mod config;
|
|||||||
pub mod costs;
|
pub mod costs;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod failover;
|
pub mod failover;
|
||||||
|
pub mod gemini_oauth;
|
||||||
mod github_copilot;
|
mod github_copilot;
|
||||||
pub(crate) mod github_copilot_auth;
|
pub(crate) mod github_copilot_auth;
|
||||||
mod nearai_chat;
|
mod nearai_chat;
|
||||||
@@ -34,6 +35,7 @@ mod rig_adapter;
|
|||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod smart_routing;
|
pub mod smart_routing;
|
||||||
mod token_refreshing;
|
mod token_refreshing;
|
||||||
|
pub mod transcription;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod codex_test_helpers;
|
mod codex_test_helpers;
|
||||||
@@ -50,13 +52,14 @@ pub use config::{
|
|||||||
};
|
};
|
||||||
pub use error::LlmError;
|
pub use error::LlmError;
|
||||||
pub use failover::{CooldownConfig, FailoverProvider};
|
pub use failover::{CooldownConfig, FailoverProvider};
|
||||||
|
pub use gemini_oauth::GeminiOauthProvider;
|
||||||
pub use nearai_chat::{DEFAULT_MODEL, ModelInfo, NearAiChatProvider, default_models};
|
pub use nearai_chat::{DEFAULT_MODEL, ModelInfo, NearAiChatProvider, default_models};
|
||||||
pub use openai_codex_provider::OpenAiCodexProvider;
|
pub use openai_codex_provider::OpenAiCodexProvider;
|
||||||
pub use openai_codex_session::{OpenAiCodexSession, OpenAiCodexSessionManager};
|
pub use openai_codex_session::{OpenAiCodexSession, OpenAiCodexSessionManager};
|
||||||
pub use provider::{
|
pub use provider::{
|
||||||
ChatMessage, CompletionRequest, CompletionResponse, ContentPart, FinishReason, ImageUrl,
|
ChatMessage, CompletionRequest, CompletionResponse, ContentPart, FinishReason, ImageUrl,
|
||||||
LlmProvider, ModelMetadata, Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse,
|
LlmProvider, ModelMetadata, Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse,
|
||||||
ToolDefinition, ToolResult,
|
ToolDefinition, ToolResult, generate_tool_call_id,
|
||||||
};
|
};
|
||||||
pub use reasoning::{
|
pub use reasoning::{
|
||||||
ActionPlan, Reasoning, ReasoningContext, RespondOutput, RespondResult, SILENT_REPLY_TOKEN,
|
ActionPlan, Reasoning, ReasoningContext, RespondOutput, RespondResult, SILENT_REPLY_TOKEN,
|
||||||
@@ -93,6 +96,10 @@ pub async fn create_llm_provider(
|
|||||||
return create_llm_provider_with_config(&config.nearai, session, timeout);
|
return create_llm_provider_with_config(&config.nearai, session, timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.backend == "gemini_oauth" || config.backend == "gemini-oauth" {
|
||||||
|
return create_gemini_oauth_provider(config);
|
||||||
|
}
|
||||||
|
|
||||||
// Bedrock uses a native AWS SDK, not the rig-core registry
|
// Bedrock uses a native AWS SDK, not the rig-core registry
|
||||||
if config.backend == "bedrock" {
|
if config.backend == "bedrock" {
|
||||||
#[cfg(feature = "bedrock")]
|
#[cfg(feature = "bedrock")]
|
||||||
@@ -490,6 +497,19 @@ fn create_cheap_provider_for_backend(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.backend == "gemini_oauth" {
|
||||||
|
let Some(ref gemini_config) = config.gemini_oauth else {
|
||||||
|
return Err(LlmError::RequestFailed {
|
||||||
|
provider: "gemini_oauth".to_string(),
|
||||||
|
reason: "Gemini OAuth config not available for cheap model".to_string(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let mut cheap_gemini_config = gemini_config.clone();
|
||||||
|
cheap_gemini_config.model = cheap_model.to_string();
|
||||||
|
let provider = GeminiOauthProvider::new(cheap_gemini_config)?;
|
||||||
|
return Ok(Some(Arc::new(provider)));
|
||||||
|
}
|
||||||
|
|
||||||
// Registry-based provider: clone config and swap model
|
// Registry-based provider: clone config and swap model
|
||||||
let reg_config = config.provider.as_ref().ok_or_else(|| LlmError::RequestFailed {
|
let reg_config = config.provider.as_ref().ok_or_else(|| LlmError::RequestFailed {
|
||||||
provider: config.backend.clone(),
|
provider: config.backend.clone(),
|
||||||
@@ -674,6 +694,17 @@ pub async fn build_provider_chain(
|
|||||||
Ok((llm, cheap_llm, recording_handle))
|
Ok((llm, cheap_llm, recording_handle))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn create_gemini_oauth_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||||
|
let gemini_config = config
|
||||||
|
.gemini_oauth
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| LlmError::AuthFailed {
|
||||||
|
provider: "gemini_oauth".to_string(),
|
||||||
|
})?;
|
||||||
|
let provider = gemini_oauth::GeminiOauthProvider::new(gemini_config)?;
|
||||||
|
Ok(Arc::new(provider))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -705,6 +736,7 @@ mod tests {
|
|||||||
nearai: test_nearai_config(),
|
nearai: test_nearai_config(),
|
||||||
provider: None,
|
provider: None,
|
||||||
bedrock: None,
|
bedrock: None,
|
||||||
|
gemini_oauth: None,
|
||||||
request_timeout_secs: 120,
|
request_timeout_secs: 120,
|
||||||
cheap_model: None,
|
cheap_model: None,
|
||||||
smart_routing_cascade: true,
|
smart_routing_cascade: true,
|
||||||
@@ -786,6 +818,30 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_create_cheap_llm_provider_gemini_oauth_creates_provider() {
|
||||||
|
let mut config = test_llm_config();
|
||||||
|
config.backend = "gemini_oauth".to_string();
|
||||||
|
config.cheap_model = Some("gemini-2.5-flash-lite".to_string());
|
||||||
|
config.gemini_oauth = Some(crate::config::GeminiOauthConfig {
|
||||||
|
model: "gemini-2.5-pro".to_string(),
|
||||||
|
credentials_path: std::path::PathBuf::from("/tmp/nonexistent-creds.json"),
|
||||||
|
});
|
||||||
|
|
||||||
|
let session = Arc::new(SessionManager::new(SessionConfig::default()));
|
||||||
|
let result = create_cheap_llm_provider(&config, session);
|
||||||
|
|
||||||
|
// Should succeed and return a provider (credentials validation is deferred
|
||||||
|
// until the first LLM call, not at construction time).
|
||||||
|
let provider = result.expect("gemini_oauth cheap provider should succeed");
|
||||||
|
assert!(provider.is_some(), "Should return Some(provider)");
|
||||||
|
assert_eq!(
|
||||||
|
provider.unwrap().model_name(),
|
||||||
|
"gemini-2.5-flash-lite",
|
||||||
|
"Cheap provider should use the overridden model name"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cheap_model_name_resolution() {
|
fn test_cheap_model_name_resolution() {
|
||||||
// Generic takes priority
|
// Generic takes priority
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ pub(crate) fn build_nearai_model_fetch_config() -> crate::config::LlmConfig {
|
|||||||
nearai: crate::config::NearAiConfig::for_model_discovery(),
|
nearai: crate::config::NearAiConfig::for_model_discovery(),
|
||||||
provider: None,
|
provider: None,
|
||||||
bedrock: None,
|
bedrock: None,
|
||||||
|
gemini_oauth: None,
|
||||||
request_timeout_secs: 120,
|
request_timeout_secs: 120,
|
||||||
cheap_model: None,
|
cheap_model: None,
|
||||||
smart_routing_cascade: false,
|
smart_routing_cascade: false,
|
||||||
|
|||||||
@@ -361,7 +361,7 @@ pub fn landing_html(provider_name: &str, success: bool) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::helpers::ENV_MUTEX;
|
use crate::config::helpers::lock_env;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn loopback_detection() {
|
fn loopback_detection() {
|
||||||
@@ -390,7 +390,7 @@ mod tests {
|
|||||||
#[allow(clippy::await_holding_lock)]
|
#[allow(clippy::await_holding_lock)]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn bind_rejects_wildcard_ipv4() {
|
async fn bind_rejects_wildcard_ipv4() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
let original = std::env::var("OAUTH_CALLBACK_HOST").ok();
|
let original = std::env::var("OAUTH_CALLBACK_HOST").ok();
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||||
unsafe { std::env::set_var("OAUTH_CALLBACK_HOST", "0.0.0.0") };
|
unsafe { std::env::set_var("OAUTH_CALLBACK_HOST", "0.0.0.0") };
|
||||||
@@ -414,7 +414,7 @@ mod tests {
|
|||||||
#[allow(clippy::await_holding_lock)]
|
#[allow(clippy::await_holding_lock)]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn bind_rejects_wildcard_ipv6() {
|
async fn bind_rejects_wildcard_ipv6() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = lock_env();
|
||||||
let original = std::env::var("OAUTH_CALLBACK_HOST").ok();
|
let original = std::env::var("OAUTH_CALLBACK_HOST").ok();
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||||
unsafe { std::env::set_var("OAUTH_CALLBACK_HOST", "::") };
|
unsafe { std::env::set_var("OAUTH_CALLBACK_HOST", "::") };
|
||||||
|
|||||||
@@ -233,6 +233,32 @@ pub struct ToolCall {
|
|||||||
pub arguments: serde_json::Value,
|
pub arguments: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generate a tool-call ID that satisfies all providers.
|
||||||
|
///
|
||||||
|
/// Mistral requires exactly 9 alphanumeric characters (`[a-zA-Z0-9]{9}`).
|
||||||
|
/// Other providers accept any non-empty string. By default we produce a
|
||||||
|
/// 9-char base-62 string derived from two seed values so the ID is both
|
||||||
|
/// deterministic (for replayed history) and provider-compatible.
|
||||||
|
pub fn generate_tool_call_id(seed_a: usize, seed_b: usize) -> String {
|
||||||
|
// Mix the two seeds into a single u64 using a simple hash-like combine.
|
||||||
|
let combined = (seed_a as u64)
|
||||||
|
.wrapping_mul(6364136223846793005)
|
||||||
|
.wrapping_add(seed_b as u64);
|
||||||
|
// Format as 9-char zero-padded base-62 (0-9, a-z, A-Z).
|
||||||
|
let mut buf = [b'0'; 9];
|
||||||
|
let mut val = combined;
|
||||||
|
for b in buf.iter_mut().rev() {
|
||||||
|
let digit = (val % 62) as u8;
|
||||||
|
*b = match digit {
|
||||||
|
0..=9 => b'0' + digit,
|
||||||
|
10..=35 => b'a' + (digit - 10),
|
||||||
|
_ => b'A' + (digit - 36),
|
||||||
|
};
|
||||||
|
val /= 62;
|
||||||
|
}
|
||||||
|
buf.iter().map(|&b| b as char).collect::<String>()
|
||||||
|
}
|
||||||
|
|
||||||
/// Result of a tool execution to send back to the LLM.
|
/// Result of a tool execution to send back to the LLM.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ToolResult {
|
pub struct ToolResult {
|
||||||
@@ -533,6 +559,77 @@ pub fn strip_unsupported_tool_params(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_tool_call_id_has_valid_format() {
|
||||||
|
let samples = [
|
||||||
|
(0usize, 0usize),
|
||||||
|
(1usize, 2usize),
|
||||||
|
(42usize, 999usize),
|
||||||
|
(usize::MAX, usize::MAX),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (a, b) in samples {
|
||||||
|
let id = generate_tool_call_id(a, b);
|
||||||
|
assert_eq!(
|
||||||
|
id.len(),
|
||||||
|
9,
|
||||||
|
"tool-call ID must be exactly 9 characters for seeds ({a}, {b})"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
id.chars().all(|c| c.is_ascii_alphanumeric()),
|
||||||
|
"tool-call ID must be ASCII alphanumeric for seeds ({a}, {b}), got: {id}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_tool_call_id_is_deterministic_for_same_seeds() {
|
||||||
|
let pairs = [
|
||||||
|
(0usize, 0usize),
|
||||||
|
(1usize, 2usize),
|
||||||
|
(123usize, 456usize),
|
||||||
|
(usize::MAX, 0usize),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (a, b) in pairs {
|
||||||
|
let id1 = generate_tool_call_id(a, b);
|
||||||
|
let id2 = generate_tool_call_id(a, b);
|
||||||
|
let id3 = generate_tool_call_id(a, b);
|
||||||
|
assert_eq!(
|
||||||
|
id1, id2,
|
||||||
|
"tool-call ID must be deterministic for seeds ({a}, {b})"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
id2, id3,
|
||||||
|
"tool-call ID must be deterministic across multiple calls for seeds ({a}, {b})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_tool_call_id_differs_for_different_seeds_in_small_sample() {
|
||||||
|
let seed_pairs = [
|
||||||
|
(0usize, 1usize),
|
||||||
|
(1usize, 0usize),
|
||||||
|
(1usize, 2usize),
|
||||||
|
(2usize, 3usize),
|
||||||
|
(10usize, 20usize),
|
||||||
|
(100usize, 200usize),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut ids = HashSet::new();
|
||||||
|
for (a, b) in seed_pairs {
|
||||||
|
let id = generate_tool_call_id(a, b);
|
||||||
|
let inserted = ids.insert(id.clone());
|
||||||
|
assert!(
|
||||||
|
inserted,
|
||||||
|
"expected distinct tool-call IDs for different seeds, \
|
||||||
|
but duplicate ID '{id}' found for seeds ({a}, {b})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_sanitize_preserves_valid_pairs() {
|
fn test_sanitize_preserves_valid_pairs() {
|
||||||
|
|||||||
+20
-4
@@ -23,6 +23,13 @@ You said you would perform an action, but you did not include any tool calls.\n\
|
|||||||
Do NOT describe what you intend to do — actually call the tool now.\n\
|
Do NOT describe what you intend to do — actually call the tool now.\n\
|
||||||
Use the tool_calls mechanism to invoke the appropriate tool.";
|
Use the tool_calls mechanism to invoke the appropriate tool.";
|
||||||
|
|
||||||
|
/// Seed value used as the second argument to `generate_tool_call_id` when
|
||||||
|
/// recovering tool calls from malformed LLM text responses. This must differ
|
||||||
|
/// from the `0` seed used in `rig_adapter::normalized_tool_call_id` to avoid
|
||||||
|
/// ID collisions between provider-generated and text-recovered tool calls at
|
||||||
|
/// the same positional index.
|
||||||
|
const RECOVERED_TOOL_CALL_SEED: usize = 99;
|
||||||
|
|
||||||
/// Detect when an LLM response expresses intent to call a tool without
|
/// Detect when an LLM response expresses intent to call a tool without
|
||||||
/// actually issuing tool calls. Returns `true` if the text contains phrases
|
/// actually issuing tool calls. Returns `true` if the text contains phrases
|
||||||
/// like "Let me search …" or "I'll fetch …" outside of fenced/indented code blocks.
|
/// like "Let me search …" or "I'll fetch …" outside of fenced/indented code blocks.
|
||||||
@@ -1337,7 +1344,10 @@ fn recover_tool_calls_from_content(
|
|||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or(serde_json::Value::Object(Default::default()));
|
.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||||
calls.push(ToolCall {
|
calls.push(ToolCall {
|
||||||
id: format!("recovered_{}", calls.len()),
|
id: super::provider::generate_tool_call_id(
|
||||||
|
calls.len(),
|
||||||
|
RECOVERED_TOOL_CALL_SEED,
|
||||||
|
),
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
arguments,
|
arguments,
|
||||||
});
|
});
|
||||||
@@ -1348,7 +1358,10 @@ fn recover_tool_calls_from_content(
|
|||||||
let name = inner.trim();
|
let name = inner.trim();
|
||||||
if tool_names.contains(name) {
|
if tool_names.contains(name) {
|
||||||
calls.push(ToolCall {
|
calls.push(ToolCall {
|
||||||
id: format!("recovered_{}", calls.len()),
|
id: super::provider::generate_tool_call_id(
|
||||||
|
calls.len(),
|
||||||
|
RECOVERED_TOOL_CALL_SEED,
|
||||||
|
),
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
arguments: serde_json::Value::Object(Default::default()),
|
arguments: serde_json::Value::Object(Default::default()),
|
||||||
});
|
});
|
||||||
@@ -1382,7 +1395,10 @@ fn recover_tool_calls_from_content(
|
|||||||
let arguments = serde_json::from_str::<serde_json::Value>(args_str)
|
let arguments = serde_json::from_str::<serde_json::Value>(args_str)
|
||||||
.unwrap_or(serde_json::Value::Object(Default::default()));
|
.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||||
calls.push(ToolCall {
|
calls.push(ToolCall {
|
||||||
id: format!("recovered_{}", calls.len()),
|
id: super::provider::generate_tool_call_id(
|
||||||
|
calls.len(),
|
||||||
|
RECOVERED_TOOL_CALL_SEED,
|
||||||
|
),
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
arguments,
|
arguments,
|
||||||
});
|
});
|
||||||
@@ -1393,7 +1409,7 @@ fn recover_tool_calls_from_content(
|
|||||||
|
|
||||||
// No arguments or malformed — call with empty args
|
// No arguments or malformed — call with empty args
|
||||||
calls.push(ToolCall {
|
calls.push(ToolCall {
|
||||||
id: format!("recovered_{}", calls.len()),
|
id: super::provider::generate_tool_call_id(calls.len(), RECOVERED_TOOL_CALL_SEED),
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
arguments: serde_json::Value::Object(Default::default()),
|
arguments: serde_json::Value::Object(Default::default()),
|
||||||
});
|
});
|
||||||
|
|||||||
+131
-16
@@ -20,6 +20,7 @@ use rust_decimal_macros::dec;
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use serde_json::Value as JsonValue;
|
use serde_json::Value as JsonValue;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
@@ -400,11 +401,48 @@ fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<RigMessage
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Responses-style providers require a non-empty tool call ID.
|
/// Responses-style providers require a non-empty tool call ID.
|
||||||
|
///
|
||||||
|
/// IDs must be compatible with providers like Mistral, which constrain IDs
|
||||||
|
/// to `[a-zA-Z0-9]{9}`. We therefore:
|
||||||
|
/// - pass through any non-empty raw ID that already matches this constraint;
|
||||||
|
/// - otherwise deterministically map the raw string into a provider-compliant ID;
|
||||||
|
/// - and when `raw` is empty/None, delegate to `generate_tool_call_id`.
|
||||||
fn normalized_tool_call_id(raw: Option<&str>, seed: usize) -> String {
|
fn normalized_tool_call_id(raw: Option<&str>, seed: usize) -> String {
|
||||||
match raw.map(str::trim).filter(|id| !id.is_empty()) {
|
// Trim and treat empty as None.
|
||||||
Some(id) => id.to_string(),
|
let trimmed = raw.and_then(|s| {
|
||||||
None => format!("generated_tool_call_{seed}"),
|
let t = s.trim();
|
||||||
|
if t.is_empty() { None } else { Some(t) }
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(id) = trimmed {
|
||||||
|
// If the ID already satisfies `[a-zA-Z0-9]{9}`, pass it through unchanged.
|
||||||
|
if id.len() == 9 && id.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||||
|
return id.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, deterministically hash the raw ID and feed the hash-derived
|
||||||
|
// seed into the provider-level generator so that the encoding and any
|
||||||
|
// provider-specific constraints remain centralized in one place.
|
||||||
|
let digest = Sha256::digest(id.as_bytes());
|
||||||
|
// Derive a 64-bit value from the first 8 bytes of the digest, then
|
||||||
|
// split it into two usize seeds so we preserve all 64 bits of entropy
|
||||||
|
// even on 32-bit targets.
|
||||||
|
let hash64 = {
|
||||||
|
// SHA-256 always produces 32 bytes, so indexing the first 8 is safe.
|
||||||
|
let bytes: [u8; 8] = [
|
||||||
|
digest[0], digest[1], digest[2], digest[3], digest[4], digest[5], digest[6],
|
||||||
|
digest[7],
|
||||||
|
];
|
||||||
|
u64::from_be_bytes(bytes)
|
||||||
|
};
|
||||||
|
let hi_seed: usize = (hash64 >> 32) as usize;
|
||||||
|
let lo_seed: usize = (hash64 & 0xFFFF_FFFF) as usize;
|
||||||
|
return super::provider::generate_tool_call_id(hi_seed, lo_seed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback for missing/empty raw IDs: use the provider-level generator,
|
||||||
|
// which already produces compliant IDs.
|
||||||
|
super::provider::generate_tool_call_id(seed, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert IronClaw tool definitions to rig-core format.
|
/// Convert IronClaw tool definitions to rig-core format.
|
||||||
@@ -813,8 +851,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_convert_messages_tool_result() {
|
fn test_convert_messages_tool_result() {
|
||||||
|
// Use a conforming 9-char alphanumeric ID so it passes through unchanged.
|
||||||
let messages = vec![ChatMessage::tool_result(
|
let messages = vec![ChatMessage::tool_result(
|
||||||
"call_123",
|
"abcDE1234",
|
||||||
"search",
|
"search",
|
||||||
"result text",
|
"result text",
|
||||||
)];
|
)];
|
||||||
@@ -825,8 +864,8 @@ mod tests {
|
|||||||
match &history[0] {
|
match &history[0] {
|
||||||
RigMessage::User { content } => match content.first() {
|
RigMessage::User { content } => match content.first() {
|
||||||
UserContent::ToolResult(r) => {
|
UserContent::ToolResult(r) => {
|
||||||
assert_eq!(r.id, "call_123");
|
assert_eq!(r.id, "abcDE1234");
|
||||||
assert_eq!(r.call_id.as_deref(), Some("call_123"));
|
assert_eq!(r.call_id.as_deref(), Some("abcDE1234"));
|
||||||
}
|
}
|
||||||
other => panic!("Expected tool result content, got: {:?}", other),
|
other => panic!("Expected tool result content, got: {:?}", other),
|
||||||
},
|
},
|
||||||
@@ -836,8 +875,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_convert_messages_assistant_with_tool_calls() {
|
fn test_convert_messages_assistant_with_tool_calls() {
|
||||||
|
// Use a conforming 9-char alphanumeric ID so it passes through unchanged.
|
||||||
let tc = IronToolCall {
|
let tc = IronToolCall {
|
||||||
id: "call_1".to_string(),
|
id: "Xt7mK9pQ2".to_string(),
|
||||||
name: "search".to_string(),
|
name: "search".to_string(),
|
||||||
arguments: serde_json::json!({"query": "test"}),
|
arguments: serde_json::json!({"query": "test"}),
|
||||||
};
|
};
|
||||||
@@ -851,7 +891,7 @@ mod tests {
|
|||||||
assert!(content.iter().count() >= 2);
|
assert!(content.iter().count() >= 2);
|
||||||
for item in content.iter() {
|
for item in content.iter() {
|
||||||
if let AssistantContent::ToolCall(tc) = item {
|
if let AssistantContent::ToolCall(tc) = item {
|
||||||
assert_eq!(tc.call_id.as_deref(), Some("call_1"));
|
assert_eq!(tc.call_id.as_deref(), Some("Xt7mK9pQ2"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -873,7 +913,14 @@ mod tests {
|
|||||||
match &history[0] {
|
match &history[0] {
|
||||||
RigMessage::User { content } => match content.first() {
|
RigMessage::User { content } => match content.first() {
|
||||||
UserContent::ToolResult(r) => {
|
UserContent::ToolResult(r) => {
|
||||||
assert!(r.id.starts_with("generated_tool_call_"));
|
// Missing ID → normalized_tool_call_id generates a 9-char alphanumeric ID.
|
||||||
|
assert_eq!(
|
||||||
|
r.id.len(),
|
||||||
|
9,
|
||||||
|
"fallback ID should be 9 chars, got: {}",
|
||||||
|
r.id
|
||||||
|
);
|
||||||
|
assert!(r.id.chars().all(|c| c.is_ascii_alphanumeric()));
|
||||||
assert_eq!(r.call_id.as_deref(), Some(r.id.as_str()));
|
assert_eq!(r.call_id.as_deref(), Some(r.id.as_str()));
|
||||||
}
|
}
|
||||||
other => panic!("Expected tool result content, got: {:?}", other),
|
other => panic!("Expected tool result content, got: {:?}", other),
|
||||||
@@ -961,12 +1008,14 @@ mod tests {
|
|||||||
_ => None,
|
_ => None,
|
||||||
});
|
});
|
||||||
let tc = tool_call.expect("should have a tool call");
|
let tc = tool_call.expect("should have a tool call");
|
||||||
assert!(!tc.id.is_empty(), "tool call id must not be empty");
|
// Empty ID → normalized_tool_call_id generates a 9-char alphanumeric ID.
|
||||||
assert!(
|
assert_eq!(
|
||||||
tc.id.starts_with("generated_tool_call_"),
|
tc.id.len(),
|
||||||
"empty id should be replaced with generated id, got: {}",
|
9,
|
||||||
|
"generated id should be 9 chars, got: {}",
|
||||||
tc.id
|
tc.id
|
||||||
);
|
);
|
||||||
|
assert!(tc.id.chars().all(|c| c.is_ascii_alphanumeric()));
|
||||||
assert_eq!(tc.call_id.as_deref(), Some(tc.id.as_str()));
|
assert_eq!(tc.call_id.as_deref(), Some(tc.id.as_str()));
|
||||||
}
|
}
|
||||||
other => panic!("Expected Assistant message, got: {:?}", other),
|
other => panic!("Expected Assistant message, got: {:?}", other),
|
||||||
@@ -990,11 +1039,14 @@ mod tests {
|
|||||||
_ => None,
|
_ => None,
|
||||||
});
|
});
|
||||||
let tc = tool_call.expect("should have a tool call");
|
let tc = tool_call.expect("should have a tool call");
|
||||||
assert!(
|
// Whitespace-only ID → normalized_tool_call_id generates a 9-char alphanumeric ID.
|
||||||
tc.id.starts_with("generated_tool_call_"),
|
assert_eq!(
|
||||||
"whitespace-only id should be replaced, got: {:?}",
|
tc.id.len(),
|
||||||
|
9,
|
||||||
|
"generated id should be 9 chars, got: {}",
|
||||||
tc.id
|
tc.id
|
||||||
);
|
);
|
||||||
|
assert!(tc.id.chars().all(|c| c.is_ascii_alphanumeric()));
|
||||||
}
|
}
|
||||||
other => panic!("Expected Assistant message, got: {:?}", other),
|
other => panic!("Expected Assistant message, got: {:?}", other),
|
||||||
}
|
}
|
||||||
@@ -1381,4 +1433,67 @@ mod tests {
|
|||||||
// Should be 2 separate User messages (text user + tool result user)
|
// Should be 2 separate User messages (text user + tool result user)
|
||||||
assert_eq!(history.len(), 2);
|
assert_eq!(history.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- normalized_tool_call_id tests --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalized_tool_call_id_conforming_passthrough() {
|
||||||
|
// A 9-char alphanumeric ID should pass through unchanged.
|
||||||
|
let id = normalized_tool_call_id(Some("abcDE1234"), 42);
|
||||||
|
assert_eq!(id, "abcDE1234");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalized_tool_call_id_non_conforming_hashed() {
|
||||||
|
// An ID that doesn't match [a-zA-Z0-9]{9} should be hashed into one.
|
||||||
|
let id = normalized_tool_call_id(Some("call_abc_long_id"), 0);
|
||||||
|
assert_eq!(id.len(), 9);
|
||||||
|
assert!(id.chars().all(|c| c.is_ascii_alphanumeric()));
|
||||||
|
// Should NOT be the raw input.
|
||||||
|
assert_ne!(id, "call_abc_l");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalized_tool_call_id_empty_input() {
|
||||||
|
let id = normalized_tool_call_id(Some(""), 5);
|
||||||
|
assert_eq!(id.len(), 9);
|
||||||
|
assert!(id.chars().all(|c| c.is_ascii_alphanumeric()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalized_tool_call_id_whitespace_input() {
|
||||||
|
let id = normalized_tool_call_id(Some(" "), 5);
|
||||||
|
assert_eq!(id.len(), 9);
|
||||||
|
assert!(id.chars().all(|c| c.is_ascii_alphanumeric()));
|
||||||
|
// Empty and whitespace-only with the same seed should produce identical results.
|
||||||
|
let id_empty = normalized_tool_call_id(Some(""), 5);
|
||||||
|
assert_eq!(id, id_empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalized_tool_call_id_none_input() {
|
||||||
|
let id = normalized_tool_call_id(None, 7);
|
||||||
|
assert_eq!(id.len(), 9);
|
||||||
|
assert!(id.chars().all(|c| c.is_ascii_alphanumeric()));
|
||||||
|
// None and empty string with same seed should produce identical results.
|
||||||
|
let id_empty = normalized_tool_call_id(Some(""), 7);
|
||||||
|
assert_eq!(id, id_empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalized_tool_call_id_deterministic() {
|
||||||
|
let id1 = normalized_tool_call_id(Some("call_xyz_123"), 0);
|
||||||
|
let id2 = normalized_tool_call_id(Some("call_xyz_123"), 0);
|
||||||
|
assert_eq!(id1, id2, "same input must produce same output");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalized_tool_call_id_different_inputs_differ() {
|
||||||
|
let id_a = normalized_tool_call_id(Some("call_aaa"), 0);
|
||||||
|
let id_b = normalized_tool_call_id(Some("call_bbb"), 0);
|
||||||
|
assert_ne!(
|
||||||
|
id_a, id_b,
|
||||||
|
"different raw IDs should produce different hashed IDs"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+124
-22
@@ -38,10 +38,49 @@ fn main() -> anyhow::Result<()> {
|
|||||||
let _ = dotenvy::dotenv();
|
let _ = dotenvy::dotenv();
|
||||||
ironclaw::bootstrap::load_ironclaw_env();
|
ironclaw::bootstrap::load_ironclaw_env();
|
||||||
|
|
||||||
tokio::runtime::Builder::new_multi_thread()
|
let result = tokio::runtime::Builder::new_multi_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
.build()?
|
.build()?
|
||||||
.block_on(async_main())
|
.block_on(async_main());
|
||||||
|
|
||||||
|
if let Err(ref e) = result {
|
||||||
|
format_top_level_error(e);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a top-level error with color and recovery hints.
|
||||||
|
fn format_top_level_error(err: &anyhow::Error) {
|
||||||
|
use ironclaw::cli::fmt;
|
||||||
|
let msg = format!("{err:#}");
|
||||||
|
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(" {}\u{2717}{} {}", fmt::error(), fmt::reset(), msg);
|
||||||
|
|
||||||
|
// Provide recovery hints for common errors
|
||||||
|
let lower = msg.to_ascii_lowercase();
|
||||||
|
let hint = if lower.contains("database_url")
|
||||||
|
|| lower.contains("database") && lower.contains("not set")
|
||||||
|
{
|
||||||
|
Some("run `ironclaw onboard` or set DATABASE_URL in .env")
|
||||||
|
} else if lower.contains("connection refused") || lower.contains("connect error") {
|
||||||
|
Some("check that the database server is running")
|
||||||
|
} else if lower.contains("session") && lower.contains("not found") {
|
||||||
|
Some("run `ironclaw onboard` to set up authentication")
|
||||||
|
} else if lower.contains("secrets_master_key") {
|
||||||
|
Some("run `ironclaw onboard` or set SECRETS_MASTER_KEY in .env")
|
||||||
|
} else if lower.contains("already running") {
|
||||||
|
Some("stop the other instance or remove the stale PID file")
|
||||||
|
} else if lower.contains("onboard") {
|
||||||
|
Some("run `ironclaw onboard` to complete setup")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(hint_text) = hint {
|
||||||
|
eprintln!(" {}hint:{} {}", fmt::dim(), fmt::reset(), hint_text,);
|
||||||
|
}
|
||||||
|
eprintln!();
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn async_main() -> anyhow::Result<()> {
|
async fn async_main() -> anyhow::Result<()> {
|
||||||
@@ -103,6 +142,11 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
init_cli_tracing();
|
init_cli_tracing();
|
||||||
return ironclaw::cli::run_logs_command(logs_cmd.clone(), cli.config.as_deref()).await;
|
return ironclaw::cli::run_logs_command(logs_cmd.clone(), cli.config.as_deref()).await;
|
||||||
}
|
}
|
||||||
|
Some(Command::Models(models_cmd)) => {
|
||||||
|
init_cli_tracing();
|
||||||
|
return ironclaw::cli::run_models_command(models_cmd.clone(), cli.config.as_deref())
|
||||||
|
.await;
|
||||||
|
}
|
||||||
Some(Command::Doctor) => {
|
Some(Command::Doctor) => {
|
||||||
init_cli_tracing();
|
init_cli_tracing();
|
||||||
return ironclaw::cli::run_doctor_command().await;
|
return ironclaw::cli::run_doctor_command().await;
|
||||||
@@ -190,6 +234,7 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
channels_only,
|
channels_only,
|
||||||
provider_only,
|
provider_only,
|
||||||
quick,
|
quick,
|
||||||
|
step,
|
||||||
}) => {
|
}) => {
|
||||||
#[cfg(any(feature = "postgres", feature = "libsql"))]
|
#[cfg(any(feature = "postgres", feature = "libsql"))]
|
||||||
{
|
{
|
||||||
@@ -198,6 +243,7 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
channels_only: *channels_only,
|
channels_only: *channels_only,
|
||||||
provider_only: *provider_only,
|
provider_only: *provider_only,
|
||||||
quick: *quick,
|
quick: *quick,
|
||||||
|
steps: step.clone(),
|
||||||
};
|
};
|
||||||
let mut wizard =
|
let mut wizard =
|
||||||
SetupWizard::try_with_config_and_toml(config, cli.config.as_deref())?;
|
SetupWizard::try_with_config_and_toml(config, cli.config.as_deref())?;
|
||||||
@@ -205,7 +251,7 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
#[cfg(not(any(feature = "postgres", feature = "libsql")))]
|
#[cfg(not(any(feature = "postgres", feature = "libsql")))]
|
||||||
{
|
{
|
||||||
let _ = (skip_auth, channels_only, provider_only, quick);
|
let _ = (skip_auth, channels_only, provider_only, quick, step);
|
||||||
eprintln!("Onboarding wizard requires the 'postgres' or 'libsql' feature.");
|
eprintln!("Onboarding wizard requires the 'postgres' or 'libsql' feature.");
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -233,6 +279,8 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let startup_start = std::time::Instant::now();
|
||||||
|
|
||||||
// ── Agent startup ──────────────────────────────────────────────────
|
// ── Agent startup ──────────────────────────────────────────────────
|
||||||
|
|
||||||
// Enhanced first-run detection
|
// Enhanced first-run detection
|
||||||
@@ -541,15 +589,48 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
// ── Gateway channel ────────────────────────────────────────────────
|
// ── Gateway channel ────────────────────────────────────────────────
|
||||||
|
|
||||||
let mut gateway_url: Option<String> = None;
|
let mut gateway_url: Option<String> = None;
|
||||||
let mut sse_sender: Option<
|
let mut sse_manager: Option<std::sync::Arc<ironclaw::channels::web::sse::SseManager>> = None;
|
||||||
tokio::sync::broadcast::Sender<ironclaw::channels::web::types::SseEvent>,
|
let mut _gateway_state: Option<std::sync::Arc<ironclaw::channels::web::server::GatewayState>> =
|
||||||
> = None;
|
None;
|
||||||
if let Some(ref gw_config) = config.channels.gateway {
|
if let Some(ref gw_config) = config.channels.gateway {
|
||||||
let mut gw =
|
// Build multi-user auth state if user_tokens is configured, else single-user.
|
||||||
GatewayChannel::new(gw_config.clone()).with_llm_provider(Arc::clone(&components.llm));
|
let mut gw = if let Some(ref user_tokens) = gw_config.user_tokens {
|
||||||
|
use ironclaw::channels::web::auth::{MultiAuthState, UserIdentity};
|
||||||
|
let tokens = user_tokens
|
||||||
|
.iter()
|
||||||
|
.map(|(token, cfg)| {
|
||||||
|
(
|
||||||
|
token.clone(),
|
||||||
|
UserIdentity {
|
||||||
|
user_id: cfg.user_id.clone(),
|
||||||
|
workspace_read_scopes: cfg.workspace_read_scopes.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let auth = MultiAuthState::multi(tokens);
|
||||||
|
GatewayChannel::new_multi_auth(gw_config.clone(), auth)
|
||||||
|
} else {
|
||||||
|
GatewayChannel::new(gw_config.clone())
|
||||||
|
};
|
||||||
|
gw = gw.with_llm_provider(Arc::clone(&components.llm));
|
||||||
if let Some(ref ws) = components.workspace {
|
if let Some(ref ws) = components.workspace {
|
||||||
gw = gw.with_workspace(Arc::clone(ws));
|
gw = gw.with_workspace(Arc::clone(ws));
|
||||||
}
|
}
|
||||||
|
// Create per-user workspace pool for multi-user mode.
|
||||||
|
if let Some(ref db) = components.db {
|
||||||
|
let emb_cache_config = ironclaw::workspace::EmbeddingCacheConfig {
|
||||||
|
max_entries: config.embeddings.cache_size,
|
||||||
|
};
|
||||||
|
let pool = Arc::new(ironclaw::channels::web::server::WorkspacePool::new(
|
||||||
|
Arc::clone(db),
|
||||||
|
components.embeddings.clone(),
|
||||||
|
emb_cache_config,
|
||||||
|
config.search.clone(),
|
||||||
|
config.workspace.clone(),
|
||||||
|
));
|
||||||
|
gw = gw.with_workspace_pool(pool);
|
||||||
|
}
|
||||||
gw = gw.with_session_manager(Arc::clone(&session_manager));
|
gw = gw.with_session_manager(Arc::clone(&session_manager));
|
||||||
gw = gw.with_log_broadcaster(Arc::clone(&log_broadcaster));
|
gw = gw.with_log_broadcaster(Arc::clone(&log_broadcaster));
|
||||||
gw = gw.with_log_level_handle(Arc::clone(&log_level_handle));
|
gw = gw.with_log_level_handle(Arc::clone(&log_level_handle));
|
||||||
@@ -600,8 +681,12 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
let mut rx = tx.subscribe();
|
let mut rx = tx.subscribe();
|
||||||
let gw_state = Arc::clone(gw.state());
|
let gw_state = Arc::clone(gw.state());
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Ok((_job_id, event)) = rx.recv().await {
|
while let Ok((_job_id, user_id, event)) = rx.recv().await {
|
||||||
gw_state.sse.broadcast(event);
|
if user_id.is_empty() {
|
||||||
|
gw_state.sse.broadcast(event);
|
||||||
|
} else {
|
||||||
|
gw_state.sse.broadcast_for_user(&user_id, event);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -643,7 +728,8 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
// Capture SSE sender and routine engine slot before moving gw into channels.
|
// Capture SSE sender and routine engine slot before moving gw into channels.
|
||||||
// IMPORTANT: This must come after all `with_*` calls since `rebuild_state`
|
// IMPORTANT: This must come after all `with_*` calls since `rebuild_state`
|
||||||
// creates a new SseManager, which would orphan this sender.
|
// creates a new SseManager, which would orphan this sender.
|
||||||
sse_sender = Some(gw.state().sse.sender());
|
sse_manager = Some(Arc::clone(&gw.state().sse));
|
||||||
|
_gateway_state = Some(Arc::clone(gw.state()));
|
||||||
channel_names.push("gateway".to_string());
|
channel_names.push("gateway".to_string());
|
||||||
channels.add(Box::new(gw)).await;
|
channels.add(Box::new(gw)).await;
|
||||||
}
|
}
|
||||||
@@ -691,6 +777,7 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
.and_then(|t| t.public_url())
|
.and_then(|t| t.public_url())
|
||||||
.or_else(|| config.tunnel.public_url.clone()),
|
.or_else(|| config.tunnel.public_url.clone()),
|
||||||
tunnel_provider: active_tunnel.as_ref().map(|t| t.name().to_string()),
|
tunnel_provider: active_tunnel.as_ref().map(|t| t.name().to_string()),
|
||||||
|
startup_elapsed: Some(startup_start.elapsed()),
|
||||||
};
|
};
|
||||||
ironclaw::boot_screen::print_boot_screen(&boot_info);
|
ironclaw::boot_screen::print_boot_screen(&boot_info);
|
||||||
}
|
}
|
||||||
@@ -725,12 +812,20 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
// Auto-activate WASM channels that were active in a previous session.
|
// Auto-activate WASM channels that were active in a previous session.
|
||||||
// Relay channels are handled separately below via restore_relay_channels().
|
// Relay channels are handled separately below via restore_relay_channels().
|
||||||
let persisted = ext_mgr.load_persisted_active_channels().await;
|
let ext_user_id = config
|
||||||
|
.channels
|
||||||
|
.gateway
|
||||||
|
.as_ref()
|
||||||
|
.map(|g| g.user_id.clone())
|
||||||
|
.unwrap_or_else(|| "default".to_string());
|
||||||
|
let persisted = ext_mgr.load_persisted_active_channels(&ext_user_id).await;
|
||||||
for name in &persisted {
|
for name in &persisted {
|
||||||
if active_at_startup.contains(name) || ext_mgr.is_relay_channel(name).await {
|
if active_at_startup.contains(name)
|
||||||
|
|| ext_mgr.is_relay_channel(name, &ext_user_id).await
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match ext_mgr.activate(name).await {
|
match ext_mgr.activate(name, &ext_user_id).await {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
channel = %name,
|
channel = %name,
|
||||||
@@ -755,14 +850,20 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
ext_mgr
|
ext_mgr
|
||||||
.set_relay_channel_manager(Arc::clone(&channels))
|
.set_relay_channel_manager(Arc::clone(&channels))
|
||||||
.await;
|
.await;
|
||||||
ext_mgr.restore_relay_channels().await;
|
let ext_user_id = config
|
||||||
|
.channels
|
||||||
|
.gateway
|
||||||
|
.as_ref()
|
||||||
|
.map(|g| g.user_id.clone())
|
||||||
|
.unwrap_or_else(|| "default".to_string());
|
||||||
|
ext_mgr.restore_relay_channels(&ext_user_id).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire SSE sender into extension manager for broadcasting status events.
|
// Wire SSE sender into extension manager for broadcasting status events.
|
||||||
if let Some(ref ext_mgr) = components.extension_manager
|
if let Some(ref ext_mgr) = components.extension_manager
|
||||||
&& let Some(ref sender) = sse_sender
|
&& let Some(sse) = sse_manager
|
||||||
{
|
{
|
||||||
ext_mgr.set_sse_sender(sender.clone()).await;
|
ext_mgr.set_sse_sender(sse).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot memory for trace recording before the agent starts
|
// Snapshot memory for trace recording before the agent starts
|
||||||
@@ -800,12 +901,13 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
skills_config: config.skills.clone(),
|
skills_config: config.skills.clone(),
|
||||||
hooks: components.hooks,
|
hooks: components.hooks,
|
||||||
cost_guard: components.cost_guard,
|
cost_guard: components.cost_guard,
|
||||||
sse_tx: sse_sender,
|
sse_tx: None, // TODO: wire SseManager into scheduler (needs Sender<SseEvent> → Arc<SseManager> refactor)
|
||||||
http_interceptor,
|
http_interceptor,
|
||||||
transcription: config
|
transcription: config.transcription.create_provider().map(|p| {
|
||||||
.transcription
|
Arc::new(ironclaw::llm::transcription::TranscriptionMiddleware::new(
|
||||||
.create_provider()
|
p,
|
||||||
.map(|p| Arc::new(ironclaw::transcription::TranscriptionMiddleware::new(p))),
|
))
|
||||||
|
}),
|
||||||
document_extraction: Some(Arc::new(
|
document_extraction: Some(Arc::new(
|
||||||
ironclaw::document_extraction::DocumentExtractionMiddleware::new(),
|
ironclaw::document_extraction::DocumentExtractionMiddleware::new(),
|
||||||
)),
|
)),
|
||||||
|
|||||||
+24
-6
@@ -40,7 +40,8 @@ pub struct OrchestratorState {
|
|||||||
pub job_manager: Arc<ContainerJobManager>,
|
pub job_manager: Arc<ContainerJobManager>,
|
||||||
pub token_store: TokenStore,
|
pub token_store: TokenStore,
|
||||||
/// Broadcast channel for job events (consumed by the web gateway SSE).
|
/// Broadcast channel for job events (consumed by the web gateway SSE).
|
||||||
pub job_event_tx: Option<broadcast::Sender<(Uuid, SseEvent)>>,
|
/// Tuple: (job_id, user_id, event).
|
||||||
|
pub job_event_tx: Option<broadcast::Sender<(Uuid, String, SseEvent)>>,
|
||||||
/// Buffered follow-up prompts for sandbox jobs, keyed by job_id.
|
/// Buffered follow-up prompts for sandbox jobs, keyed by job_id.
|
||||||
pub prompt_queue: Arc<Mutex<HashMap<Uuid, VecDeque<PendingPrompt>>>>,
|
pub prompt_queue: Arc<Mutex<HashMap<Uuid, VecDeque<PendingPrompt>>>>,
|
||||||
/// Database handle for persisting job events.
|
/// Database handle for persisting job events.
|
||||||
@@ -351,9 +352,24 @@ async fn job_event_handler(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Broadcast via the channel (if configured)
|
// Broadcast via the channel (if configured).
|
||||||
|
// Look up the job owner so the gateway can scope delivery per-user.
|
||||||
if let Some(ref tx) = state.job_event_tx {
|
if let Some(ref tx) = state.job_event_tx {
|
||||||
let _ = tx.send((job_id, sse_event));
|
let user_id = match state.store.as_ref() {
|
||||||
|
Some(store) => store
|
||||||
|
.get_sandbox_job(job_id)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|j| j.user_id),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
if let Some(uid) = user_id {
|
||||||
|
let _ = tx.send((job_id, uid, sse_event));
|
||||||
|
} else {
|
||||||
|
// Fallback: broadcast globally (single-user mode or job not found).
|
||||||
|
let _ = tx.send((job_id, String::new(), sse_event));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
@@ -769,8 +785,10 @@ mod tests {
|
|||||||
let resp = router.oneshot(req).await.unwrap();
|
let resp = router.oneshot(req).await.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
let (recv_id, event) = rx.recv().await.unwrap();
|
let (recv_id, recv_uid, event) = rx.recv().await.unwrap();
|
||||||
assert_eq!(recv_id, job_id);
|
assert_eq!(recv_id, job_id);
|
||||||
|
// No store configured, so user_id falls back to empty string.
|
||||||
|
assert_eq!(recv_uid, "");
|
||||||
match event {
|
match event {
|
||||||
SseEvent::JobMessage {
|
SseEvent::JobMessage {
|
||||||
job_id: jid,
|
job_id: jid,
|
||||||
@@ -824,7 +842,7 @@ mod tests {
|
|||||||
let resp = router.oneshot(req).await.unwrap();
|
let resp = router.oneshot(req).await.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
let (_recv_id, event) = rx.recv().await.unwrap();
|
let (_recv_id, _recv_uid, event) = rx.recv().await.unwrap();
|
||||||
match event {
|
match event {
|
||||||
SseEvent::JobToolUse { tool_name, .. } => {
|
SseEvent::JobToolUse { tool_name, .. } => {
|
||||||
assert_eq!(tool_name, "shell");
|
assert_eq!(tool_name, "shell");
|
||||||
@@ -869,7 +887,7 @@ mod tests {
|
|||||||
let resp = router.oneshot(req).await.unwrap();
|
let resp = router.oneshot(req).await.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
let (_recv_id, event) = rx.recv().await.unwrap();
|
let (_recv_id, _recv_uid, event) = rx.recv().await.unwrap();
|
||||||
// Unknown event types fall through to JobStatus
|
// Unknown event types fall through to JobStatus
|
||||||
assert!(matches!(event, SseEvent::JobStatus { .. }));
|
assert!(matches!(event, SseEvent::JobStatus { .. }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ fn resolve_orchestrator_port() -> u16 {
|
|||||||
/// Result of orchestrator setup, containing all handles needed by the agent.
|
/// Result of orchestrator setup, containing all handles needed by the agent.
|
||||||
pub struct OrchestratorSetup {
|
pub struct OrchestratorSetup {
|
||||||
pub container_job_manager: Option<Arc<ContainerJobManager>>,
|
pub container_job_manager: Option<Arc<ContainerJobManager>>,
|
||||||
pub job_event_tx: Option<broadcast::Sender<(Uuid, SseEvent)>>,
|
pub job_event_tx: Option<broadcast::Sender<(Uuid, String, SseEvent)>>,
|
||||||
pub prompt_queue: Arc<Mutex<HashMap<Uuid, VecDeque<api::PendingPrompt>>>>,
|
pub prompt_queue: Arc<Mutex<HashMap<Uuid, VecDeque<api::PendingPrompt>>>>,
|
||||||
pub docker_status: crate::sandbox::DockerStatus,
|
pub docker_status: crate::sandbox::DockerStatus,
|
||||||
}
|
}
|
||||||
@@ -164,19 +164,15 @@ pub async fn setup_orchestrator(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::sync::Mutex;
|
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::config::helpers::lock_env;
|
||||||
/// Serialize access to `ORCHESTRATOR_PORT` env var across test threads.
|
|
||||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_orchestrator_port_from_env() {
|
fn resolve_orchestrator_port_from_env() {
|
||||||
let _guard = ENV_LOCK.lock().unwrap();
|
let _guard = lock_env();
|
||||||
|
|
||||||
// Safety: env-var mutation requires unsafe in edition 2024;
|
// Safety: env-var mutation requires unsafe in edition 2024;
|
||||||
// ENV_LOCK serializes concurrent access from other test threads.
|
// lock_env() serializes concurrent access from other test threads.
|
||||||
|
|
||||||
// Absent env var → default 50051
|
// Absent env var → default 50051
|
||||||
unsafe { std::env::remove_var("ORCHESTRATOR_PORT") };
|
unsafe { std::env::remove_var("ORCHESTRATOR_PORT") };
|
||||||
|
|||||||
+48
-23
@@ -123,15 +123,32 @@ pub fn select_many(prompt: &str, options: &[(&str, bool)]) -> io::Result<Vec<usi
|
|||||||
writeln!(stdout, "\r")?;
|
writeln!(stdout, "\r")?;
|
||||||
|
|
||||||
for (i, (label, _)) in options.iter().enumerate() {
|
for (i, (label, _)) in options.iter().enumerate() {
|
||||||
let checkbox = if selected[i] { "[x]" } else { "[ ]" };
|
|
||||||
let prefix = if i == cursor_pos { ">" } else { " " };
|
|
||||||
|
|
||||||
if i == cursor_pos {
|
if i == cursor_pos {
|
||||||
|
// Cursor line: cyan cursor, then colored checkbox
|
||||||
execute!(stdout, SetForegroundColor(Color::Cyan))?;
|
execute!(stdout, SetForegroundColor(Color::Cyan))?;
|
||||||
writeln!(stdout, " {} {} {}\r", prefix, checkbox, label)?;
|
write!(stdout, " \u{25b8} ")?;
|
||||||
|
if selected[i] {
|
||||||
|
execute!(stdout, SetForegroundColor(Color::Green))?;
|
||||||
|
write!(stdout, "[\u{2713}]")?;
|
||||||
|
} else {
|
||||||
|
execute!(stdout, SetForegroundColor(Color::DarkGrey))?;
|
||||||
|
write!(stdout, "[\u{00b7}]")?;
|
||||||
|
}
|
||||||
|
execute!(stdout, SetForegroundColor(Color::Cyan))?;
|
||||||
|
writeln!(stdout, " {}\r", label)?;
|
||||||
execute!(stdout, ResetColor)?;
|
execute!(stdout, ResetColor)?;
|
||||||
} else {
|
} else {
|
||||||
writeln!(stdout, " {} {} {}\r", prefix, checkbox, label)?;
|
write!(stdout, " ")?;
|
||||||
|
if selected[i] {
|
||||||
|
execute!(stdout, SetForegroundColor(Color::Green))?;
|
||||||
|
write!(stdout, "[\u{2713}]")?;
|
||||||
|
execute!(stdout, ResetColor)?;
|
||||||
|
} else {
|
||||||
|
execute!(stdout, SetForegroundColor(Color::DarkGrey))?;
|
||||||
|
write!(stdout, "[\u{00b7}]")?;
|
||||||
|
execute!(stdout, ResetColor)?;
|
||||||
|
}
|
||||||
|
writeln!(stdout, " {}\r", label)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,18 +301,12 @@ pub fn confirm(prompt: &str, default: bool) -> io::Result<bool> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Print the IronClaw ASCII art banner in blue.
|
/// Print a minimal wordmark banner.
|
||||||
pub fn print_banner() {
|
pub fn print_banner() {
|
||||||
let mut stdout = io::stdout();
|
use crate::cli::fmt;
|
||||||
let _ = execute!(stdout, SetForegroundColor(Color::Cyan));
|
println!();
|
||||||
|
println!(" {}ironclaw{}", fmt::bold_accent(), fmt::reset());
|
||||||
println!();
|
println!();
|
||||||
println!(r" ██╗██████╗ ██████╗ ███╗ ██╗ ██████╗██╗ █████╗ ██╗ ██╗");
|
|
||||||
println!(r" ██║██╔══██╗██╔═══██╗████╗ ██║██╔════╝██║ ██╔══██╗██║ ██║");
|
|
||||||
println!(r" ██║██████╔╝██║ ██║██╔██╗ ██║██║ ██║ ███████║██║ █╗ ██║");
|
|
||||||
println!(r" ██║██╔══██╗██║ ██║██║╚██╗██║██║ ██║ ██╔══██║██║███╗██║");
|
|
||||||
println!(r" ██║██║ ██║╚██████╔╝██║ ╚████║╚██████╗███████╗██║ ██║╚███╔███╔╝");
|
|
||||||
println!(r" ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ ");
|
|
||||||
let _ = execute!(stdout, ResetColor);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Print a styled header box.
|
/// Print a styled header box.
|
||||||
@@ -310,24 +321,38 @@ pub fn print_header(text: &str) {
|
|||||||
let border = "─".repeat(width);
|
let border = "─".repeat(width);
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!("╭{}╮", border);
|
println!("┌{}┐", border);
|
||||||
println!("│ {} │", text);
|
println!("│ {} │", text);
|
||||||
println!("╰{}╯", border);
|
println!("└{}┘", border);
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Print a step indicator.
|
/// Print a compact dot-based step indicator.
|
||||||
|
///
|
||||||
|
/// `●` = completed (green/success), `◉` = current (accent), `○` = remaining (dim).
|
||||||
///
|
///
|
||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
/// print_step(1, 3, "NEAR AI Authentication");
|
/// print_step(3, 5, "Model Selection");
|
||||||
/// // Output: Step 1/3: NEAR AI Authentication
|
/// // Output: ● ● ◉ ○ ○ Model Selection
|
||||||
/// // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
/// ```
|
/// ```
|
||||||
pub fn print_step(current: usize, total: usize, name: &str) {
|
pub fn print_step(current: usize, total: usize, name: &str) {
|
||||||
println!("Step {}/{}: {}", current, total, name);
|
use crate::cli::fmt;
|
||||||
println!("{}", "━".repeat(32));
|
let mut dots = String::new();
|
||||||
|
for i in 1..=total {
|
||||||
|
if i > 1 {
|
||||||
|
dots.push(' ');
|
||||||
|
}
|
||||||
|
if i < current {
|
||||||
|
dots.push_str(&format!("{}\u{25CF}{}", fmt::success(), fmt::reset())); // ● green
|
||||||
|
} else if i == current {
|
||||||
|
dots.push_str(&format!("{}\u{25C9}{}", fmt::accent(), fmt::reset())); // ◉ accent
|
||||||
|
} else {
|
||||||
|
dots.push_str(&format!("{}\u{25CB}{}", fmt::dim(), fmt::reset())); // ○ dim
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!(" {} {}", dots, name);
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+540
-235
@@ -84,6 +84,8 @@ pub struct SetupConfig {
|
|||||||
pub provider_only: bool,
|
pub provider_only: bool,
|
||||||
/// Quick setup: auto-defaults everything except LLM provider and model.
|
/// Quick setup: auto-defaults everything except LLM provider and model.
|
||||||
pub quick: bool,
|
pub quick: bool,
|
||||||
|
/// Run only specific setup steps (e.g. "provider", "channels", "model", "database", "security").
|
||||||
|
pub steps: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Interactive setup wizard for IronClaw.
|
/// Interactive setup wizard for IronClaw.
|
||||||
@@ -188,6 +190,55 @@ impl SetupWizard {
|
|||||||
print_banner();
|
print_banner();
|
||||||
print_header("IronClaw Setup Wizard");
|
print_header("IronClaw Setup Wizard");
|
||||||
|
|
||||||
|
if !self.config.steps.is_empty() {
|
||||||
|
// Selective step mode: reconnect to existing DB and load settings,
|
||||||
|
// then run only the requested steps.
|
||||||
|
self.reconnect_existing_db().await?;
|
||||||
|
|
||||||
|
let valid_steps = ["provider", "channels", "model", "database", "security"];
|
||||||
|
for s in &self.config.steps {
|
||||||
|
if !valid_steps.contains(&s.as_str()) {
|
||||||
|
return Err(SetupError::Config(format!(
|
||||||
|
"Unknown step '{}'. Valid steps: {}",
|
||||||
|
s,
|
||||||
|
valid_steps.join(", ")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = self.config.steps.len();
|
||||||
|
for (i, step_name) in self.config.steps.clone().iter().enumerate() {
|
||||||
|
let step_num = i + 1;
|
||||||
|
match step_name.as_str() {
|
||||||
|
"database" => {
|
||||||
|
print_step(step_num, total, "Database Connection");
|
||||||
|
self.step_database().await?;
|
||||||
|
}
|
||||||
|
"security" => {
|
||||||
|
print_step(step_num, total, "Security");
|
||||||
|
self.step_security().await?;
|
||||||
|
}
|
||||||
|
"provider" => {
|
||||||
|
print_step(step_num, total, "Inference Provider");
|
||||||
|
self.step_inference_provider().await?;
|
||||||
|
}
|
||||||
|
"model" => {
|
||||||
|
print_step(step_num, total, "Model Selection");
|
||||||
|
self.step_model_selection().await?;
|
||||||
|
}
|
||||||
|
"channels" => {
|
||||||
|
print_step(step_num, total, "Channel Configuration");
|
||||||
|
self.step_channels().await?;
|
||||||
|
}
|
||||||
|
_ => {} // already validated above
|
||||||
|
}
|
||||||
|
self.persist_after_step().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.save_and_summarize().await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
if self.config.channels_only {
|
if self.config.channels_only {
|
||||||
// Channels-only mode: reconnect to existing DB and load settings
|
// Channels-only mode: reconnect to existing DB and load settings
|
||||||
// before running the channel step, so secrets and save work.
|
// before running the channel step, so secrets and save work.
|
||||||
@@ -220,23 +271,23 @@ impl SetupWizard {
|
|||||||
// Pre-populate backend from env so step_inference_provider
|
// Pre-populate backend from env so step_inference_provider
|
||||||
// can offer "Keep current provider?" instead of asking from scratch.
|
// can offer "Keep current provider?" instead of asking from scratch.
|
||||||
if self.settings.llm_backend.is_none() {
|
if self.settings.llm_backend.is_none() {
|
||||||
use crate::config::helpers::env_or_override;
|
if let Ok(b) = std::env::var("LLM_BACKEND") {
|
||||||
if let Some(b) = env_or_override("LLM_BACKEND")
|
self.settings.llm_backend = Some(b);
|
||||||
&& !b.trim().is_empty()
|
} else if std::env::var("NEARAI_API_KEY").is_ok() {
|
||||||
{
|
|
||||||
self.settings.llm_backend = Some(b.trim().to_string());
|
|
||||||
} else if env_or_override("NEARAI_API_KEY").is_some() {
|
|
||||||
self.settings.llm_backend = Some("nearai".to_string());
|
self.settings.llm_backend = Some("nearai".to_string());
|
||||||
} else if env_or_override("ANTHROPIC_API_KEY").is_some()
|
} else if std::env::var("ANTHROPIC_API_KEY").is_ok()
|
||||||
|| env_or_override("ANTHROPIC_OAUTH_TOKEN").is_some()
|
|| std::env::var("ANTHROPIC_OAUTH_TOKEN").is_ok()
|
||||||
{
|
{
|
||||||
self.settings.llm_backend = Some("anthropic".to_string());
|
self.settings.llm_backend = Some("anthropic".to_string());
|
||||||
} else if env_or_override("OPENAI_API_KEY").is_some() {
|
} else if std::env::var("OPENAI_API_KEY").is_ok() {
|
||||||
self.settings.llm_backend = Some("openai".to_string());
|
self.settings.llm_backend = Some("openai".to_string());
|
||||||
|
} else if std::env::var("OPENROUTER_API_KEY").is_ok() {
|
||||||
|
self.settings.llm_backend = Some("openrouter".to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(api_key) = crate::config::helpers::env_or_override("NEARAI_API_KEY")
|
if let Ok(api_key) = std::env::var("NEARAI_API_KEY")
|
||||||
|
&& !api_key.is_empty()
|
||||||
&& self.settings.llm_backend.as_deref() == Some("nearai")
|
&& self.settings.llm_backend.as_deref() == Some("nearai")
|
||||||
{
|
{
|
||||||
// NEARAI_API_KEY is set and backend auto-detected — skip interactive prompts
|
// NEARAI_API_KEY is set and backend auto-detected — skip interactive prompts
|
||||||
@@ -254,6 +305,79 @@ impl SetupWizard {
|
|||||||
print_info(&format!("Using default model: {default}"));
|
print_info(&format!("Using default model: {default}"));
|
||||||
}
|
}
|
||||||
self.persist_after_step().await;
|
self.persist_after_step().await;
|
||||||
|
} else if self.settings.llm_backend.as_deref() == Some("anthropic")
|
||||||
|
&& let Some(api_key) = Self::detect_anthropic_key()
|
||||||
|
{
|
||||||
|
// Anthropic key detected — skip interactive prompts
|
||||||
|
print_info("Anthropic credentials found — using Anthropic provider");
|
||||||
|
let secret_name = if api_key.starts_with("sk-ant-oat") {
|
||||||
|
"llm_anthropic_oauth_token"
|
||||||
|
} else {
|
||||||
|
"llm_anthropic_api_key"
|
||||||
|
};
|
||||||
|
if let Ok(ctx) = self.init_secrets_context().await {
|
||||||
|
let key = SecretString::from(api_key.clone());
|
||||||
|
if let Err(e) = ctx.save_secret(secret_name, &key).await {
|
||||||
|
tracing::warn!("Failed to persist Anthropic key to secrets: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.llm_api_key = Some(SecretString::from(api_key));
|
||||||
|
let registry = crate::llm::ProviderRegistry::load();
|
||||||
|
if self.settings.selected_model.is_none() {
|
||||||
|
let default = registry
|
||||||
|
.find("anthropic")
|
||||||
|
.map(|d| d.default_model.as_str())
|
||||||
|
.unwrap_or("claude-sonnet-4-20250514");
|
||||||
|
self.settings.selected_model = Some(default.to_string());
|
||||||
|
print_info(&format!("Using default model: {default}"));
|
||||||
|
}
|
||||||
|
self.persist_after_step().await;
|
||||||
|
} else if let Ok(api_key) = std::env::var("OPENAI_API_KEY")
|
||||||
|
&& !api_key.is_empty()
|
||||||
|
&& self.settings.llm_backend.as_deref() == Some("openai")
|
||||||
|
{
|
||||||
|
// OpenAI key detected — skip interactive prompts
|
||||||
|
print_info("OPENAI_API_KEY found — using OpenAI provider");
|
||||||
|
if let Ok(ctx) = self.init_secrets_context().await {
|
||||||
|
let key = SecretString::from(api_key.clone());
|
||||||
|
if let Err(e) = ctx.save_secret("llm_openai_api_key", &key).await {
|
||||||
|
tracing::warn!("Failed to persist OPENAI_API_KEY to secrets: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.llm_api_key = Some(SecretString::from(api_key));
|
||||||
|
let registry = crate::llm::ProviderRegistry::load();
|
||||||
|
if self.settings.selected_model.is_none() {
|
||||||
|
let default = registry
|
||||||
|
.find("openai")
|
||||||
|
.map(|d| d.default_model.as_str())
|
||||||
|
.unwrap_or("gpt-5-mini");
|
||||||
|
self.settings.selected_model = Some(default.to_string());
|
||||||
|
print_info(&format!("Using default model: {default}"));
|
||||||
|
}
|
||||||
|
self.persist_after_step().await;
|
||||||
|
} else if let Ok(api_key) = std::env::var("OPENROUTER_API_KEY")
|
||||||
|
&& !api_key.is_empty()
|
||||||
|
&& self.settings.llm_backend.as_deref() == Some("openrouter")
|
||||||
|
{
|
||||||
|
// OpenRouter key detected — skip interactive prompts
|
||||||
|
print_info("OPENROUTER_API_KEY found — using OpenRouter provider");
|
||||||
|
if let Ok(ctx) = self.init_secrets_context().await {
|
||||||
|
let key = SecretString::from(api_key.clone());
|
||||||
|
if let Err(e) = ctx.save_secret("llm_openrouter_api_key", &key).await {
|
||||||
|
tracing::warn!("Failed to persist OPENROUTER_API_KEY to secrets: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.llm_api_key = Some(SecretString::from(api_key));
|
||||||
|
let registry = crate::llm::ProviderRegistry::load();
|
||||||
|
if self.settings.selected_model.is_none() {
|
||||||
|
let default = registry
|
||||||
|
.find("openrouter")
|
||||||
|
.map(|d| d.default_model.as_str())
|
||||||
|
.unwrap_or("openai/gpt-4o");
|
||||||
|
self.settings.selected_model = Some(default.to_string());
|
||||||
|
print_info(&format!("Using default model: {default}"));
|
||||||
|
}
|
||||||
|
self.persist_after_step().await;
|
||||||
} else {
|
} else {
|
||||||
print_step(1, 2, "Inference Provider");
|
print_step(1, 2, "Inference Provider");
|
||||||
self.step_inference_provider().await?;
|
self.step_inference_provider().await?;
|
||||||
@@ -1078,23 +1202,40 @@ impl SetupWizard {
|
|||||||
.map(|s| s.display_name().to_string())
|
.map(|s| s.display_name().to_string())
|
||||||
.unwrap_or_else(|| def.id.clone())
|
.unwrap_or_else(|| def.id.clone())
|
||||||
} else {
|
} else {
|
||||||
current.clone()
|
match current.as_str() {
|
||||||
|
"nearai" => "NEAR AI".to_string(),
|
||||||
|
"gemini_oauth" | "gemini-oauth" => "Gemini API (OAuth)".to_string(),
|
||||||
|
_ => {
|
||||||
|
if let Some(def) = registry.find(¤t) {
|
||||||
|
def.setup
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.display_name().to_string())
|
||||||
|
.unwrap_or_else(|| def.id.clone())
|
||||||
|
} else {
|
||||||
|
current.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
print_info(&format!("Current provider: {}", display));
|
print_info(&format!("Current provider: {}", display));
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
let is_known = current == "nearai"
|
let is_known = current == "nearai"
|
||||||
|| current == "bedrock"
|
|| current == "bedrock"
|
||||||
|
|| current == "gemini_oauth"
|
||||||
|
|| current == "gemini-oauth"
|
||||||
|| current == "openai_codex"
|
|| current == "openai_codex"
|
||||||
|| registry.is_known(¤t);
|
|| registry.is_known(¤t);
|
||||||
|
|
||||||
if is_known && confirm("Keep current provider?", true).map_err(SetupError::Io)? {
|
if is_known && confirm("Keep current provider?", true).map_err(SetupError::Io)? {
|
||||||
if current == "bedrock" {
|
if current == "bedrock" {
|
||||||
// Keeping the existing Bedrock config — no need to re-run
|
|
||||||
// the full setup flow (region, auth, cross-region).
|
|
||||||
print_info("Keeping existing AWS Bedrock configuration.");
|
print_info("Keeping existing AWS Bedrock configuration.");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
if current == "gemini_oauth" || current == "gemini-oauth" {
|
||||||
|
print_info("Keeping existing Gemini CLI OAuth configuration.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
if current == "openai_codex" {
|
if current == "openai_codex" {
|
||||||
print_info("Keeping existing OpenAI Codex configuration.");
|
print_info("Keeping existing OpenAI Codex configuration.");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -1113,33 +1254,100 @@ impl SetupWizard {
|
|||||||
print_info("Select your inference provider:");
|
print_info("Select your inference provider:");
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
// Build menu: NearAI first, then OpenAI Codex, then registry providers, then Bedrock
|
// Build menu: NearAI first, then Gemini OAuth, then OpenAI Codex, then registry providers, then Bedrock
|
||||||
let selectable = registry.selectable();
|
let selectable = registry.selectable();
|
||||||
let mut options: Vec<String> = Vec::with_capacity(2 + selectable.len());
|
|
||||||
let mut provider_ids: Vec<String> = Vec::with_capacity(2 + selectable.len());
|
|
||||||
|
|
||||||
options.push("NEAR AI - multi-model access via NEAR account".to_string());
|
// Detect which providers have API keys already set in the environment.
|
||||||
provider_ids.push("nearai".to_string());
|
let detected_env: HashMap<&str, bool> = [
|
||||||
|
("nearai", std::env::var("NEARAI_API_KEY").is_ok()),
|
||||||
|
(
|
||||||
|
"anthropic",
|
||||||
|
std::env::var("ANTHROPIC_API_KEY").is_ok()
|
||||||
|
|| std::env::var("ANTHROPIC_OAUTH_TOKEN").is_ok(),
|
||||||
|
),
|
||||||
|
("openai", std::env::var("OPENAI_API_KEY").is_ok()),
|
||||||
|
("openrouter", std::env::var("OPENROUTER_API_KEY").is_ok()),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
options.push("OpenAI Codex - ChatGPT subscription (Plus/Pro/Max)".to_string());
|
// Helper: build a label for a provider entry, prepending a checkmark if detected.
|
||||||
provider_ids.push("openai_codex".to_string());
|
let make_label = |id: &str, name: &str, desc: &str| -> String {
|
||||||
|
if detected_env.get(id).copied().unwrap_or(false) {
|
||||||
|
format!("\u{2713} {:<15}- {}", name, desc)
|
||||||
|
} else {
|
||||||
|
format!(" {:<15}- {}", name, desc)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collect all entries as (provider_id, label, is_detected).
|
||||||
|
struct ProviderEntry {
|
||||||
|
id: String,
|
||||||
|
label: String,
|
||||||
|
detected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut entries: Vec<ProviderEntry> = Vec::with_capacity(2 + selectable.len());
|
||||||
|
|
||||||
|
entries.push(ProviderEntry {
|
||||||
|
id: "nearai".to_string(),
|
||||||
|
label: make_label("nearai", "NEAR AI", "multi-model access via NEAR account"),
|
||||||
|
detected: detected_env.get("nearai").copied().unwrap_or(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
entries.push(ProviderEntry {
|
||||||
|
id: "gemini_oauth".to_string(),
|
||||||
|
label: make_label(
|
||||||
|
"gemini_oauth",
|
||||||
|
"Gemini CLI",
|
||||||
|
"Official Gemini API via Gemini CLI OAuth",
|
||||||
|
),
|
||||||
|
detected: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
entries.push(ProviderEntry {
|
||||||
|
id: "openai_codex".to_string(),
|
||||||
|
label: make_label(
|
||||||
|
"openai_codex",
|
||||||
|
"OpenAI Codex",
|
||||||
|
"ChatGPT subscription (Plus/Pro/Max)",
|
||||||
|
),
|
||||||
|
detected: false,
|
||||||
|
});
|
||||||
|
|
||||||
for def in &selectable {
|
for def in &selectable {
|
||||||
let label = format!(
|
let display_name = def
|
||||||
"{:<17}- {}",
|
.setup
|
||||||
def.setup
|
.as_ref()
|
||||||
.as_ref()
|
.map(|s| s.display_name())
|
||||||
.map(|s| s.display_name())
|
.unwrap_or(&def.id);
|
||||||
.unwrap_or(&def.id),
|
entries.push(ProviderEntry {
|
||||||
def.description
|
id: def.id.clone(),
|
||||||
);
|
label: make_label(&def.id, display_name, &def.description),
|
||||||
options.push(label);
|
detected: detected_env.get(def.id.as_str()).copied().unwrap_or(false),
|
||||||
provider_ids.push(def.id.clone());
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bedrock is a special case (native AWS SDK, not registry-based)
|
// Bedrock is a special case (native AWS SDK, not registry-based)
|
||||||
options.push("AWS Bedrock - Claude & other models via AWS (IAM, SSO)".to_string());
|
entries.push(ProviderEntry {
|
||||||
provider_ids.push("bedrock".to_string());
|
id: "bedrock".to_string(),
|
||||||
|
label: make_label(
|
||||||
|
"bedrock",
|
||||||
|
"AWS Bedrock",
|
||||||
|
"Claude & other models via AWS (IAM, SSO)",
|
||||||
|
),
|
||||||
|
detected: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort: detected providers first, preserving relative order within each group.
|
||||||
|
entries.sort_by_key(|e| !e.detected);
|
||||||
|
|
||||||
|
let mut options: Vec<String> = Vec::with_capacity(entries.len());
|
||||||
|
let mut provider_ids: Vec<String> = Vec::with_capacity(entries.len());
|
||||||
|
for entry in &entries {
|
||||||
|
options.push(entry.label.clone());
|
||||||
|
provider_ids.push(entry.id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
let option_refs: Vec<&str> = options.iter().map(|s| s.as_str()).collect();
|
let option_refs: Vec<&str> = options.iter().map(|s| s.as_str()).collect();
|
||||||
let choice = select_one("Provider:", &option_refs).map_err(SetupError::Io)?;
|
let choice = select_one("Provider:", &option_refs).map_err(SetupError::Io)?;
|
||||||
@@ -1147,6 +1355,8 @@ impl SetupWizard {
|
|||||||
|
|
||||||
if selected_id == "bedrock" {
|
if selected_id == "bedrock" {
|
||||||
self.setup_bedrock().await?;
|
self.setup_bedrock().await?;
|
||||||
|
} else if selected_id == "gemini_oauth" {
|
||||||
|
self.setup_gemini_oauth().await?;
|
||||||
} else {
|
} else {
|
||||||
self.run_provider_setup(selected_id, ®istry).await?;
|
self.run_provider_setup(selected_id, ®istry).await?;
|
||||||
}
|
}
|
||||||
@@ -1241,6 +1451,24 @@ impl SetupWizard {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Detect an Anthropic credential from the environment.
|
||||||
|
///
|
||||||
|
/// Checks `ANTHROPIC_API_KEY` first, then `ANTHROPIC_OAUTH_TOKEN`.
|
||||||
|
/// Returns the key/token string if found, or `None`.
|
||||||
|
fn detect_anthropic_key() -> Option<String> {
|
||||||
|
if let Ok(key) = std::env::var("ANTHROPIC_API_KEY")
|
||||||
|
&& !key.is_empty()
|
||||||
|
{
|
||||||
|
return Some(key);
|
||||||
|
}
|
||||||
|
if let Ok(token) = std::env::var("ANTHROPIC_OAUTH_TOKEN")
|
||||||
|
&& !token.is_empty()
|
||||||
|
{
|
||||||
|
return Some(token);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Update the selected LLM backend while preserving the current model when
|
/// Update the selected LLM backend while preserving the current model when
|
||||||
/// the backend did not actually change.
|
/// the backend did not actually change.
|
||||||
fn set_llm_backend_preserving_model(&mut self, backend: &str) {
|
fn set_llm_backend_preserving_model(&mut self, backend: &str) {
|
||||||
@@ -1795,6 +2023,40 @@ impl SetupWizard {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn setup_gemini_oauth(&mut self) -> Result<(), SetupError> {
|
||||||
|
self.settings.llm_backend = Some("gemini_oauth".to_string());
|
||||||
|
print_info("Starting Gemini CLI OAuth authentication...");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let creds_path = crate::config::GeminiOauthConfig::default_credentials_path();
|
||||||
|
let cred_manager =
|
||||||
|
crate::llm::gemini_oauth::CredentialManager::new(&creds_path).map_err(|e| {
|
||||||
|
SetupError::Config(format!(
|
||||||
|
"Failed to initialize Gemini credential manager: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match cred_manager.get_valid_credential().await {
|
||||||
|
Ok(cred) => {
|
||||||
|
print_success("Gemini CLI authentication successful!");
|
||||||
|
if let Some(ref pid) = cred.project_id {
|
||||||
|
print_info(&format!("Cloud Code project: {}", pid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return Err(SetupError::Config(format!(
|
||||||
|
"Gemini CLI authentication failed: {}. Please try again.",
|
||||||
|
e
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
print_success("Gemini API configured via Gemini CLI");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Step 4: Model selection.
|
/// Step 4: Model selection.
|
||||||
///
|
///
|
||||||
/// Branches on the selected LLM backend and fetches models from the
|
/// Branches on the selected LLM backend and fetches models from the
|
||||||
@@ -1818,109 +2080,157 @@ impl SetupWizard {
|
|||||||
let backend = self.settings.llm_backend.as_deref().unwrap_or("nearai");
|
let backend = self.settings.llm_backend.as_deref().unwrap_or("nearai");
|
||||||
let registry = crate::llm::ProviderRegistry::load();
|
let registry = crate::llm::ProviderRegistry::load();
|
||||||
|
|
||||||
if backend == "nearai" {
|
match backend {
|
||||||
// NEAR AI: use existing provider list_models()
|
"nearai" => {
|
||||||
let fetched = self.fetch_nearai_models().await;
|
// NEAR AI: use existing provider list_models()
|
||||||
let models = if fetched.is_empty() {
|
let fetched = self.fetch_nearai_models().await;
|
||||||
crate::llm::default_models()
|
let models = if fetched.is_empty() {
|
||||||
} else {
|
crate::llm::default_models()
|
||||||
fetched.iter().map(|m| (m.clone(), m.clone())).collect()
|
|
||||||
};
|
|
||||||
self.select_from_model_list(&models)?;
|
|
||||||
} else if let Some(def) = registry.find(backend) {
|
|
||||||
let can_list = def
|
|
||||||
.setup
|
|
||||||
.as_ref()
|
|
||||||
.map(|s| s.can_list_models())
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if can_list {
|
|
||||||
// Try to fetch models from the provider's /v1/models endpoint
|
|
||||||
let cached_key = self
|
|
||||||
.llm_api_key
|
|
||||||
.as_ref()
|
|
||||||
.map(|k| k.expose_secret().to_string());
|
|
||||||
|
|
||||||
let models = match backend {
|
|
||||||
"anthropic" => fetch_anthropic_models(cached_key.as_deref()).await,
|
|
||||||
"openai" => fetch_openai_models(cached_key.as_deref()).await,
|
|
||||||
"ollama" => {
|
|
||||||
let base_url = self
|
|
||||||
.settings
|
|
||||||
.ollama_base_url
|
|
||||||
.as_deref()
|
|
||||||
.or(def.default_base_url.as_deref())
|
|
||||||
.unwrap_or("http://localhost:11434");
|
|
||||||
let models = fetch_ollama_models(base_url).await;
|
|
||||||
if models.is_empty() {
|
|
||||||
print_info("No models found. Pull one first: ollama pull llama3");
|
|
||||||
}
|
|
||||||
models
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// Generic OpenAI-compatible model listing
|
|
||||||
let base_url = def.default_base_url.as_deref().unwrap_or("");
|
|
||||||
fetch_openai_compatible_models(base_url, cached_key.as_deref()).await
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Apply models_filter from setup hint (e.g., Groq "chat" filters non-chat models)
|
|
||||||
let models =
|
|
||||||
if let Some(filter) = def.setup.as_ref().and_then(|s| s.models_filter()) {
|
|
||||||
let filter_lower = filter.to_lowercase();
|
|
||||||
models
|
|
||||||
.into_iter()
|
|
||||||
.filter(|(id, _)| id.to_lowercase().contains(&filter_lower))
|
|
||||||
.collect()
|
|
||||||
} else {
|
|
||||||
models
|
|
||||||
};
|
|
||||||
|
|
||||||
if models.is_empty() {
|
|
||||||
// Fall back to manual entry
|
|
||||||
let default = &def.default_model;
|
|
||||||
let model_id = input(&format!("Model name (default: {default})"))
|
|
||||||
.map_err(SetupError::Io)?;
|
|
||||||
let model_id = if model_id.is_empty() {
|
|
||||||
default.clone()
|
|
||||||
} else {
|
|
||||||
model_id
|
|
||||||
};
|
|
||||||
self.settings.selected_model = Some(model_id.clone());
|
|
||||||
print_success(&format!("Selected {}", model_id));
|
|
||||||
} else {
|
} else {
|
||||||
self.select_from_model_list(&models)?;
|
fetched.iter().map(|m| (m.clone(), m.clone())).collect()
|
||||||
}
|
};
|
||||||
} else {
|
self.select_from_model_list(&models)?;
|
||||||
// Manual model entry
|
}
|
||||||
let default = &def.default_model;
|
"gemini_oauth" | "gemini-oauth" => {
|
||||||
|
let default_models: Vec<(String, String)> = vec![
|
||||||
|
(
|
||||||
|
"gemini-3.1-pro-preview".into(),
|
||||||
|
"Gemini 3.1 Pro (Latest, strongest reasoning)".into(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gemini-3.1-pro-preview-customtools".into(),
|
||||||
|
"Gemini 3.1 Pro Custom Tools (Enhanced tool use)".into(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gemini-3-pro-preview".into(),
|
||||||
|
"Gemini 3 Pro (Preview)".into(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gemini-3-flash-preview".into(),
|
||||||
|
"Gemini 3 Flash (Fast preview with thinking)".into(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gemini-3.1-flash-lite-preview".into(),
|
||||||
|
"Gemini 3.1 Flash Lite (Preview, lightweight)".into(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gemini-2.5-pro".into(),
|
||||||
|
"Gemini 2.5 Pro (Stable, strong reasoning)".into(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gemini-2.5-flash".into(),
|
||||||
|
"Gemini 2.5 Flash (Fast, good quality)".into(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gemini-2.5-flash-lite".into(),
|
||||||
|
"Gemini 2.5 Flash Lite (Fastest, lightweight)".into(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
self.select_from_model_list(&default_models)?;
|
||||||
|
}
|
||||||
|
"bedrock" => {
|
||||||
let model_id =
|
let model_id =
|
||||||
input(&format!("Model name (default: {default})")).map_err(SetupError::Io)?;
|
input("Bedrock model ID (e.g., anthropic.claude-v3-sonnet-20240229-v1:0)")
|
||||||
let model_id = if model_id.is_empty() {
|
.map_err(SetupError::Io)?;
|
||||||
default.clone()
|
if model_id.is_empty() {
|
||||||
} else {
|
return Err(SetupError::Config("Model ID is required".to_string()));
|
||||||
model_id
|
}
|
||||||
};
|
|
||||||
self.settings.selected_model = Some(model_id.clone());
|
self.settings.selected_model = Some(model_id.clone());
|
||||||
print_success(&format!("Selected {}", model_id));
|
print_success(&format!("Selected {}", model_id));
|
||||||
}
|
}
|
||||||
} else if backend == "bedrock" {
|
_ => {
|
||||||
let model_id = input("Bedrock model ID (e.g., anthropic.claude-opus-4-6-v1)")
|
if let Some(def) = registry.find(backend) {
|
||||||
.map_err(SetupError::Io)?;
|
let can_list = def
|
||||||
if model_id.is_empty() {
|
.setup
|
||||||
return Err(SetupError::Config("Model ID is required".to_string()));
|
.as_ref()
|
||||||
|
.map(|s| s.can_list_models())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if can_list {
|
||||||
|
// Try to fetch models from the provider's /v1/models endpoint
|
||||||
|
let cached_key = self
|
||||||
|
.llm_api_key
|
||||||
|
.as_ref()
|
||||||
|
.map(|k| k.expose_secret().to_string());
|
||||||
|
|
||||||
|
let models = match backend {
|
||||||
|
"anthropic" => fetch_anthropic_models(cached_key.as_deref()).await,
|
||||||
|
"openai" => fetch_openai_models(cached_key.as_deref()).await,
|
||||||
|
"ollama" => {
|
||||||
|
let base_url = self
|
||||||
|
.settings
|
||||||
|
.ollama_base_url
|
||||||
|
.as_deref()
|
||||||
|
.or(def.default_base_url.as_deref())
|
||||||
|
.unwrap_or("http://localhost:11434");
|
||||||
|
let models = fetch_ollama_models(base_url).await;
|
||||||
|
if models.is_empty() {
|
||||||
|
print_info(
|
||||||
|
"No models found. Pull one first: ollama pull llama3",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
models
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Generic OpenAI-compatible model listing
|
||||||
|
let base_url = def.default_base_url.as_deref().unwrap_or("");
|
||||||
|
fetch_openai_compatible_models(base_url, cached_key.as_deref())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply models_filter from setup hint
|
||||||
|
let models = if let Some(filter) =
|
||||||
|
def.setup.as_ref().and_then(|s| s.models_filter())
|
||||||
|
{
|
||||||
|
let filter_lower = filter.to_lowercase();
|
||||||
|
models
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(id, _)| id.to_lowercase().contains(&filter_lower))
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
models
|
||||||
|
};
|
||||||
|
|
||||||
|
if models.is_empty() {
|
||||||
|
// Fall back to manual entry
|
||||||
|
let default = &def.default_model;
|
||||||
|
let model_id = input(&format!("Model name (default: {default})"))
|
||||||
|
.map_err(SetupError::Io)?;
|
||||||
|
let model_id = if model_id.is_empty() {
|
||||||
|
default.clone()
|
||||||
|
} else {
|
||||||
|
model_id
|
||||||
|
};
|
||||||
|
self.settings.selected_model = Some(model_id.clone());
|
||||||
|
print_success(&format!("Selected {}", model_id));
|
||||||
|
} else {
|
||||||
|
self.select_from_model_list(&models)?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Manual model entry
|
||||||
|
let default = &def.default_model;
|
||||||
|
let model_id = input(&format!("Model name (default: {default})"))
|
||||||
|
.map_err(SetupError::Io)?;
|
||||||
|
let model_id = if model_id.is_empty() {
|
||||||
|
default.clone()
|
||||||
|
} else {
|
||||||
|
model_id
|
||||||
|
};
|
||||||
|
self.settings.selected_model = Some(model_id.clone());
|
||||||
|
print_success(&format!("Selected {}", model_id));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unknown provider, manual entry
|
||||||
|
let model_id = input("Model name (e.g., meta-llama/Llama-3-8b-chat-hf)")
|
||||||
|
.map_err(SetupError::Io)?;
|
||||||
|
if model_id.is_empty() {
|
||||||
|
return Err(SetupError::Config("Model name is required".to_string()));
|
||||||
|
}
|
||||||
|
self.settings.selected_model = Some(model_id.clone());
|
||||||
|
print_success(&format!("Selected {}", model_id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.settings.selected_model = Some(model_id.clone());
|
|
||||||
print_success(&format!("Selected {}", model_id));
|
|
||||||
} else {
|
|
||||||
// Unknown provider, manual entry
|
|
||||||
let model_id = input("Model name (e.g., meta-llama/Llama-3-8b-chat-hf)")
|
|
||||||
.map_err(SetupError::Io)?;
|
|
||||||
if model_id.is_empty() {
|
|
||||||
return Err(SetupError::Config("Model name is required".to_string()));
|
|
||||||
}
|
|
||||||
self.settings.selected_model = Some(model_id.clone());
|
|
||||||
print_success(&format!("Selected {}", model_id));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -2976,8 +3286,11 @@ impl SetupWizard {
|
|||||||
let _ = loaded;
|
let _ = loaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save settings to the database and `~/.ironclaw/.env`, then print summary.
|
/// Save settings to the database and `~/.ironclaw/.env`, then print
|
||||||
|
/// a warm completion card with the 3 key facts.
|
||||||
async fn save_and_summarize(&mut self) -> Result<(), SetupError> {
|
async fn save_and_summarize(&mut self) -> Result<(), SetupError> {
|
||||||
|
use crate::cli::fmt;
|
||||||
|
|
||||||
self.settings.onboard_completed = true;
|
self.settings.onboard_completed = true;
|
||||||
|
|
||||||
// Final persist (idempotent — earlier incremental saves already wrote
|
// Final persist (idempotent — earlier incremental saves already wrote
|
||||||
@@ -2993,117 +3306,108 @@ impl SetupWizard {
|
|||||||
// Write bootstrap env (also idempotent)
|
// Write bootstrap env (also idempotent)
|
||||||
self.write_bootstrap_env()?;
|
self.write_bootstrap_env()?;
|
||||||
|
|
||||||
|
// ── Completion card ───────────────────────────────────
|
||||||
|
let sep = fmt::separator(38);
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
print_success("Configuration saved to database");
|
println!(" {}", sep);
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
// Print summary
|
// Title line: checkmark + "ironclaw is ready"
|
||||||
println!("Configuration Summary:");
|
println!(
|
||||||
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
" {}\u{2713}{} {}ironclaw is ready{}",
|
||||||
|
fmt::success(),
|
||||||
|
fmt::reset(),
|
||||||
|
fmt::bold_accent(),
|
||||||
|
fmt::reset(),
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
|
||||||
let backend = self
|
// Fact 1: Provider + model
|
||||||
.settings
|
let provider_display = match self.settings.llm_backend.as_deref() {
|
||||||
.database_backend
|
Some("nearai") => "NEAR AI".to_string(),
|
||||||
.as_deref()
|
Some("anthropic") => "Anthropic".to_string(),
|
||||||
.unwrap_or("postgres");
|
Some("openai") => "OpenAI".to_string(),
|
||||||
match backend {
|
Some("ollama") => "Ollama".to_string(),
|
||||||
"libsql" => {
|
Some("openai_compatible") => "OpenAI-compatible".to_string(),
|
||||||
if let Some(ref path) = self.settings.libsql_path {
|
Some("bedrock") => "AWS Bedrock".to_string(),
|
||||||
println!(" Database: libSQL ({})", path);
|
Some("openai_codex") => "OpenAI Codex".to_string(),
|
||||||
} else {
|
Some("gemini_oauth") => "Gemini CLI".to_string(),
|
||||||
println!(" Database: libSQL (default path)");
|
Some(other) => other.to_string(),
|
||||||
}
|
None => "unknown".to_string(),
|
||||||
if self.settings.libsql_url.is_some() {
|
};
|
||||||
println!(" Turso sync: enabled");
|
let model_suffix = if let Some(ref model) = self.settings.selected_model {
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
if self.settings.database_url.is_some() {
|
|
||||||
println!(" Database: PostgreSQL (configured)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match self.settings.secrets_master_key_source {
|
|
||||||
KeySource::Keychain => println!(" Security: OS keychain"),
|
|
||||||
KeySource::Env => println!(" Security: environment variable"),
|
|
||||||
KeySource::None => println!(" Security: disabled"),
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref provider) = self.settings.llm_backend {
|
|
||||||
let display = match provider.as_str() {
|
|
||||||
"nearai" => "NEAR AI",
|
|
||||||
"anthropic" => "Anthropic",
|
|
||||||
"openai" => "OpenAI",
|
|
||||||
"ollama" => "Ollama",
|
|
||||||
"openai_compatible" => "OpenAI-compatible",
|
|
||||||
"bedrock" => "AWS Bedrock",
|
|
||||||
"openai_codex" => "OpenAI Codex",
|
|
||||||
other => other,
|
|
||||||
};
|
|
||||||
println!(" Provider: {}", display);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref model) = self.settings.selected_model {
|
|
||||||
// Truncate long model names (char-based to avoid UTF-8 panic)
|
// Truncate long model names (char-based to avoid UTF-8 panic)
|
||||||
let display = if model.chars().count() > 40 {
|
let display = if model.chars().count() > 30 {
|
||||||
let truncated: String = model.chars().take(37).collect();
|
let truncated: String = model.chars().take(27).collect();
|
||||||
format!("{}...", truncated)
|
format!("{}...", truncated)
|
||||||
} else {
|
} else {
|
||||||
model.clone()
|
model.clone()
|
||||||
};
|
};
|
||||||
println!(" Model: {}", display);
|
format!(" ({})", display)
|
||||||
}
|
|
||||||
|
|
||||||
if self.settings.embeddings.enabled {
|
|
||||||
println!(
|
|
||||||
" Embeddings: {} ({})",
|
|
||||||
self.settings.embeddings.provider, self.settings.embeddings.model
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
println!(" Embeddings: disabled");
|
String::new()
|
||||||
}
|
};
|
||||||
|
let provider_value = format!("{}{}", provider_display, model_suffix);
|
||||||
|
println!(
|
||||||
|
" {}provider{} {}{}{}",
|
||||||
|
fmt::dim(),
|
||||||
|
fmt::reset(),
|
||||||
|
fmt::accent(),
|
||||||
|
provider_value,
|
||||||
|
fmt::reset(),
|
||||||
|
);
|
||||||
|
|
||||||
if let Some(ref tunnel_url) = self.settings.tunnel.public_url {
|
// Fact 2: Database
|
||||||
println!(" Tunnel: {} (static)", tunnel_url);
|
let db_display = match self.settings.database_backend.as_deref() {
|
||||||
} else if let Some(ref provider) = self.settings.tunnel.provider {
|
Some("libsql") => "libSQL".to_string(),
|
||||||
println!(" Tunnel: {} (managed, starts at boot)", provider);
|
Some("postgres") | Some("postgresql") => "PostgreSQL".to_string(),
|
||||||
}
|
Some(other) => other.to_string(),
|
||||||
|
None => "unknown".to_string(),
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
" {}database{} {}{}{}",
|
||||||
|
fmt::dim(),
|
||||||
|
fmt::reset(),
|
||||||
|
fmt::accent(),
|
||||||
|
db_display,
|
||||||
|
fmt::reset(),
|
||||||
|
);
|
||||||
|
|
||||||
let has_tunnel =
|
// Fact 3: Security
|
||||||
self.settings.tunnel.public_url.is_some() || self.settings.tunnel.provider.is_some();
|
let security_display = match self.settings.secrets_master_key_source {
|
||||||
|
KeySource::Keychain => "OS keychain",
|
||||||
println!(" Channels:");
|
KeySource::Env => "environment variable",
|
||||||
println!(" - CLI/TUI: enabled");
|
KeySource::None => "disabled",
|
||||||
|
};
|
||||||
if self.settings.channels.http_enabled {
|
println!(
|
||||||
let port = self.settings.channels.http_port.unwrap_or(8080);
|
" {}security{} {}{}{}",
|
||||||
println!(" - HTTP: enabled (port {})", port);
|
fmt::dim(),
|
||||||
}
|
fmt::reset(),
|
||||||
|
fmt::accent(),
|
||||||
for channel_name in &self.settings.channels.wasm_channels {
|
security_display,
|
||||||
let mode = if has_tunnel { "webhook" } else { "polling" };
|
fmt::reset(),
|
||||||
println!(
|
);
|
||||||
" - {}: enabled ({})",
|
|
||||||
capitalize_first(channel_name),
|
|
||||||
mode
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.settings.heartbeat.enabled {
|
|
||||||
println!(
|
|
||||||
" Heartbeat: every {} minutes",
|
|
||||||
self.settings.heartbeat.interval_secs / 60
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!("To start the agent, run:");
|
println!(" {}", sep);
|
||||||
println!(" ironclaw");
|
|
||||||
println!();
|
println!();
|
||||||
println!("To change settings later:");
|
|
||||||
println!(" ironclaw config set <setting> <value>");
|
// Action hints
|
||||||
println!(" ironclaw onboard");
|
println!(
|
||||||
|
" {}Start chatting:{} {}ironclaw{}",
|
||||||
|
fmt::dim(),
|
||||||
|
fmt::reset(),
|
||||||
|
fmt::bold_accent(),
|
||||||
|
fmt::reset(),
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {}Full setup:{} {}ironclaw onboard{}",
|
||||||
|
fmt::dim(),
|
||||||
|
fmt::reset(),
|
||||||
|
fmt::bold_accent(),
|
||||||
|
fmt::reset(),
|
||||||
|
);
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
if self.config.quick {
|
if self.config.quick {
|
||||||
@@ -3432,7 +3736,7 @@ mod tests {
|
|||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::helpers::ENV_MUTEX;
|
use crate::config::helpers::lock_env;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_wizard_creation() {
|
fn test_wizard_creation() {
|
||||||
@@ -3448,6 +3752,7 @@ mod tests {
|
|||||||
channels_only: false,
|
channels_only: false,
|
||||||
provider_only: false,
|
provider_only: false,
|
||||||
quick: false,
|
quick: false,
|
||||||
|
steps: vec![],
|
||||||
};
|
};
|
||||||
let wizard = SetupWizard::with_config(config);
|
let wizard = SetupWizard::with_config(config);
|
||||||
assert!(wizard.config.skip_auth);
|
assert!(wizard.config.skip_auth);
|
||||||
@@ -3455,7 +3760,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_wizard_owner_id_uses_resolved_env_scope() {
|
fn test_wizard_owner_id_uses_resolved_env_scope() {
|
||||||
let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
|
let _guard = lock_env();
|
||||||
let _owner = EnvGuard::set("IRONCLAW_OWNER_ID", " wizard-owner ");
|
let _owner = EnvGuard::set("IRONCLAW_OWNER_ID", " wizard-owner ");
|
||||||
|
|
||||||
let wizard = SetupWizard::new();
|
let wizard = SetupWizard::new();
|
||||||
@@ -3464,7 +3769,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_wizard_owner_id_uses_toml_scope() {
|
fn test_wizard_owner_id_uses_toml_scope() {
|
||||||
let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
|
let _guard = lock_env();
|
||||||
let _owner = EnvGuard::clear("IRONCLAW_OWNER_ID");
|
let _owner = EnvGuard::clear("IRONCLAW_OWNER_ID");
|
||||||
let dir = tempdir().unwrap(); // safety: test-only tempdir setup
|
let dir = tempdir().unwrap(); // safety: test-only tempdir setup
|
||||||
let path = dir.path().join("config.toml");
|
let path = dir.path().join("config.toml");
|
||||||
@@ -3480,7 +3785,7 @@ mod tests {
|
|||||||
fn test_try_with_config_and_toml_propagates_invalid_owner_env() {
|
fn test_try_with_config_and_toml_propagates_invalid_owner_env() {
|
||||||
use std::os::unix::ffi::OsStringExt;
|
use std::os::unix::ffi::OsStringExt;
|
||||||
|
|
||||||
let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
|
let _guard = lock_env();
|
||||||
let original = std::env::var_os("IRONCLAW_OWNER_ID");
|
let original = std::env::var_os("IRONCLAW_OWNER_ID");
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::set_var("IRONCLAW_OWNER_ID", OsString::from_vec(vec![0x66, 0x80]));
|
std::env::set_var("IRONCLAW_OWNER_ID", OsString::from_vec(vec![0x66, 0x80]));
|
||||||
@@ -3940,7 +4245,7 @@ mod tests {
|
|||||||
fn test_build_nearai_model_fetch_config_picks_up_api_key_env() {
|
fn test_build_nearai_model_fetch_config_picks_up_api_key_env() {
|
||||||
use secrecy::ExposeSecret;
|
use secrecy::ExposeSecret;
|
||||||
|
|
||||||
let _lock = ENV_MUTEX.lock().unwrap();
|
let _lock = lock_env();
|
||||||
let _guard = EnvGuard::set("NEARAI_API_KEY", "test-cloud-api-key-12345");
|
let _guard = EnvGuard::set("NEARAI_API_KEY", "test-cloud-api-key-12345");
|
||||||
let _guard2 = EnvGuard::clear("NEARAI_BASE_URL");
|
let _guard2 = EnvGuard::clear("NEARAI_BASE_URL");
|
||||||
|
|
||||||
@@ -3964,7 +4269,7 @@ mod tests {
|
|||||||
/// the config should have `api_key: None` (session token path).
|
/// the config should have `api_key: None` (session token path).
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_nearai_model_fetch_config_none_when_no_api_key() {
|
fn test_build_nearai_model_fetch_config_none_when_no_api_key() {
|
||||||
let _lock = ENV_MUTEX.lock().unwrap();
|
let _lock = lock_env();
|
||||||
let _guard = EnvGuard::clear("NEARAI_API_KEY");
|
let _guard = EnvGuard::clear("NEARAI_API_KEY");
|
||||||
let _guard2 = EnvGuard::clear("NEARAI_BASE_URL");
|
let _guard2 = EnvGuard::clear("NEARAI_BASE_URL");
|
||||||
|
|
||||||
@@ -3983,7 +4288,7 @@ mod tests {
|
|||||||
/// Regression test for #799: empty NEARAI_API_KEY should be treated as absent.
|
/// Regression test for #799: empty NEARAI_API_KEY should be treated as absent.
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_nearai_model_fetch_config_none_when_empty_api_key() {
|
fn test_build_nearai_model_fetch_config_none_when_empty_api_key() {
|
||||||
let _lock = ENV_MUTEX.lock().unwrap();
|
let _lock = lock_env();
|
||||||
let _guard = EnvGuard::set("NEARAI_API_KEY", "");
|
let _guard = EnvGuard::set("NEARAI_API_KEY", "");
|
||||||
|
|
||||||
let config = build_nearai_model_fetch_config();
|
let config = build_nearai_model_fetch_config();
|
||||||
@@ -4001,7 +4306,7 @@ mod tests {
|
|||||||
fn test_model_discovery_picks_up_injected_var() {
|
fn test_model_discovery_picks_up_injected_var() {
|
||||||
use secrecy::ExposeSecret;
|
use secrecy::ExposeSecret;
|
||||||
|
|
||||||
let _lock = ENV_MUTEX.lock().unwrap();
|
let _lock = lock_env();
|
||||||
let _guard = EnvGuard::clear("NEARAI_API_KEY");
|
let _guard = EnvGuard::clear("NEARAI_API_KEY");
|
||||||
let _guard2 = EnvGuard::clear("NEARAI_BASE_URL");
|
let _guard2 = EnvGuard::clear("NEARAI_BASE_URL");
|
||||||
|
|
||||||
@@ -4032,7 +4337,7 @@ mod tests {
|
|||||||
/// the NEAR AI authentication menu.
|
/// the NEAR AI authentication menu.
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_nearai_model_fetch_config_picks_up_runtime_env() {
|
fn test_build_nearai_model_fetch_config_picks_up_runtime_env() {
|
||||||
let _lock = ENV_MUTEX.lock().unwrap();
|
let _lock = lock_env();
|
||||||
// Ensure the real env var is unset so the only source is the overlay.
|
// Ensure the real env var is unset so the only source is the overlay.
|
||||||
let _guard = EnvGuard::clear("NEARAI_API_KEY");
|
let _guard = EnvGuard::clear("NEARAI_API_KEY");
|
||||||
|
|
||||||
|
|||||||
+70
-1
@@ -28,7 +28,7 @@ use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::{Mutex as AsyncMutex, mpsc};
|
||||||
|
|
||||||
use crate::agent::AgentDeps;
|
use crate::agent::AgentDeps;
|
||||||
use crate::channels::{
|
use crate::channels::{
|
||||||
@@ -361,6 +361,75 @@ impl Channel for StubChannel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Captured broadcast deliveries keyed by the target user or chat identifier.
|
||||||
|
pub type BroadcastCapture = Arc<AsyncMutex<Vec<(String, OutgoingResponse)>>>;
|
||||||
|
|
||||||
|
/// A lightweight channel double that only records `broadcast()` traffic.
|
||||||
|
///
|
||||||
|
/// This is useful for unit tests that need to assert message routing without
|
||||||
|
/// spinning up a full interactive channel harness.
|
||||||
|
pub struct RecordingBroadcastChannel {
|
||||||
|
name: &'static str,
|
||||||
|
captures: BroadcastCapture,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecordingBroadcastChannel {
|
||||||
|
pub fn new(name: &'static str) -> (Self, BroadcastCapture) {
|
||||||
|
let captures = Arc::new(AsyncMutex::new(Vec::new()));
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
name,
|
||||||
|
captures: Arc::clone(&captures),
|
||||||
|
},
|
||||||
|
captures,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Channel for RecordingBroadcastChannel {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
||||||
|
let (_tx, rx) = mpsc::channel::<IncomingMessage>(1);
|
||||||
|
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn respond(
|
||||||
|
&self,
|
||||||
|
_msg: &IncomingMessage,
|
||||||
|
_response: OutgoingResponse,
|
||||||
|
) -> Result<(), ChannelError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_status(
|
||||||
|
&self,
|
||||||
|
_status: StatusUpdate,
|
||||||
|
_metadata: &serde_json::Value,
|
||||||
|
) -> Result<(), ChannelError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn broadcast(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
response: OutgoingResponse,
|
||||||
|
) -> Result<(), ChannelError> {
|
||||||
|
self.captures
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.push((user_id.to_string(), response));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health_check(&self) -> Result<(), ChannelError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Assembled test components.
|
/// Assembled test components.
|
||||||
pub struct TestHarness {
|
pub struct TestHarness {
|
||||||
/// The agent dependencies, ready for use.
|
/// The agent dependencies, ready for use.
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ impl Tool for ToolInstallTool {
|
|||||||
async fn execute(
|
async fn execute(
|
||||||
&self,
|
&self,
|
||||||
params: serde_json::Value,
|
params: serde_json::Value,
|
||||||
_ctx: &JobContext,
|
ctx: &JobContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ impl Tool for ToolInstallTool {
|
|||||||
|
|
||||||
let result = self
|
let result = self
|
||||||
.manager
|
.manager
|
||||||
.install(name, url, kind_hint)
|
.install(name, url, kind_hint, &ctx.user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
||||||
|
|
||||||
@@ -205,7 +205,7 @@ impl Tool for ToolAuthTool {
|
|||||||
async fn execute(
|
async fn execute(
|
||||||
&self,
|
&self,
|
||||||
params: serde_json::Value,
|
params: serde_json::Value,
|
||||||
_ctx: &JobContext,
|
ctx: &JobContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
@@ -213,13 +213,13 @@ impl Tool for ToolAuthTool {
|
|||||||
|
|
||||||
let result = self
|
let result = self
|
||||||
.manager
|
.manager
|
||||||
.auth(name)
|
.auth(name, &ctx.user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
||||||
|
|
||||||
// Auto-activate after successful auth so tools are available immediately
|
// Auto-activate after successful auth so tools are available immediately
|
||||||
if result.is_authenticated() {
|
if result.is_authenticated() {
|
||||||
match self.manager.activate(name).await {
|
match self.manager.activate(name, &ctx.user_id).await {
|
||||||
Ok(activate_result) => {
|
Ok(activate_result) => {
|
||||||
let output = serde_json::json!({
|
let output = serde_json::json!({
|
||||||
"status": "authenticated_and_activated",
|
"status": "authenticated_and_activated",
|
||||||
@@ -304,13 +304,13 @@ impl Tool for ToolActivateTool {
|
|||||||
async fn execute(
|
async fn execute(
|
||||||
&self,
|
&self,
|
||||||
params: serde_json::Value,
|
params: serde_json::Value,
|
||||||
_ctx: &JobContext,
|
ctx: &JobContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
let name = require_str(¶ms, "name")?;
|
let name = require_str(¶ms, "name")?;
|
||||||
|
|
||||||
match self.manager.activate(name).await {
|
match self.manager.activate(name, &ctx.user_id).await {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
let output = serde_json::to_value(&result)
|
let output = serde_json::to_value(&result)
|
||||||
.unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"}));
|
.unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"}));
|
||||||
@@ -329,12 +329,12 @@ impl Tool for ToolActivateTool {
|
|||||||
|
|
||||||
// Activation failed due to missing auth; initiate auth flow
|
// Activation failed due to missing auth; initiate auth flow
|
||||||
// so the agent loop can show the auth card.
|
// so the agent loop can show the auth card.
|
||||||
match self.manager.auth(name).await {
|
match self.manager.auth(name, &ctx.user_id).await {
|
||||||
Ok(auth_result) if auth_result.is_authenticated() => {
|
Ok(auth_result) if auth_result.is_authenticated() => {
|
||||||
// Auth succeeded (e.g. env var was set); retry activation.
|
// Auth succeeded (e.g. env var was set); retry activation.
|
||||||
let result = self
|
let result = self
|
||||||
.manager
|
.manager
|
||||||
.activate(name)
|
.activate(name, &ctx.user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
||||||
let output = serde_json::to_value(&result).unwrap_or_else(
|
let output = serde_json::to_value(&result).unwrap_or_else(
|
||||||
@@ -404,7 +404,7 @@ impl Tool for ToolListTool {
|
|||||||
async fn execute(
|
async fn execute(
|
||||||
&self,
|
&self,
|
||||||
params: serde_json::Value,
|
params: serde_json::Value,
|
||||||
_ctx: &JobContext,
|
ctx: &JobContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
@@ -425,7 +425,7 @@ impl Tool for ToolListTool {
|
|||||||
|
|
||||||
let extensions = self
|
let extensions = self
|
||||||
.manager
|
.manager
|
||||||
.list(kind_filter, include_available)
|
.list(kind_filter, include_available, &ctx.user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
||||||
|
|
||||||
@@ -477,7 +477,7 @@ impl Tool for ToolRemoveTool {
|
|||||||
async fn execute(
|
async fn execute(
|
||||||
&self,
|
&self,
|
||||||
params: serde_json::Value,
|
params: serde_json::Value,
|
||||||
_ctx: &JobContext,
|
ctx: &JobContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
@@ -485,7 +485,7 @@ impl Tool for ToolRemoveTool {
|
|||||||
|
|
||||||
let message = self
|
let message = self
|
||||||
.manager
|
.manager
|
||||||
.remove(name)
|
.remove(name, &ctx.user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
||||||
|
|
||||||
@@ -541,7 +541,7 @@ impl Tool for ToolUpgradeTool {
|
|||||||
async fn execute(
|
async fn execute(
|
||||||
&self,
|
&self,
|
||||||
params: serde_json::Value,
|
params: serde_json::Value,
|
||||||
_ctx: &JobContext,
|
ctx: &JobContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
@@ -549,7 +549,7 @@ impl Tool for ToolUpgradeTool {
|
|||||||
|
|
||||||
let result = self
|
let result = self
|
||||||
.manager
|
.manager
|
||||||
.upgrade(name)
|
.upgrade(name, &ctx.user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
||||||
|
|
||||||
@@ -603,7 +603,7 @@ impl Tool for ExtensionInfoTool {
|
|||||||
async fn execute(
|
async fn execute(
|
||||||
&self,
|
&self,
|
||||||
params: serde_json::Value,
|
params: serde_json::Value,
|
||||||
_ctx: &JobContext,
|
ctx: &JobContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
@@ -611,7 +611,7 @@ impl Tool for ExtensionInfoTool {
|
|||||||
|
|
||||||
let info = self
|
let info = self
|
||||||
.manager
|
.manager
|
||||||
.extension_info(name)
|
.extension_info(name, &ctx.user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ pub struct CreateJobTool {
|
|||||||
job_manager: Option<Arc<ContainerJobManager>>,
|
job_manager: Option<Arc<ContainerJobManager>>,
|
||||||
store: Option<Arc<dyn Database>>,
|
store: Option<Arc<dyn Database>>,
|
||||||
/// Broadcast sender for job events (used to subscribe a monitor).
|
/// Broadcast sender for job events (used to subscribe a monitor).
|
||||||
event_tx: Option<tokio::sync::broadcast::Sender<(Uuid, SseEvent)>>,
|
event_tx: Option<tokio::sync::broadcast::Sender<(Uuid, String, SseEvent)>>,
|
||||||
/// Injection channel for pushing messages into the agent loop.
|
/// Injection channel for pushing messages into the agent loop.
|
||||||
inject_tx: Option<tokio::sync::mpsc::Sender<IncomingMessage>>,
|
inject_tx: Option<tokio::sync::mpsc::Sender<IncomingMessage>>,
|
||||||
/// Encrypted secrets store for validating credential grants.
|
/// Encrypted secrets store for validating credential grants.
|
||||||
@@ -120,7 +120,7 @@ impl CreateJobTool {
|
|||||||
/// monitor that forwards Claude Code output to the main agent loop.
|
/// monitor that forwards Claude Code output to the main agent loop.
|
||||||
pub fn with_monitor_deps(
|
pub fn with_monitor_deps(
|
||||||
mut self,
|
mut self,
|
||||||
event_tx: tokio::sync::broadcast::Sender<(Uuid, SseEvent)>,
|
event_tx: tokio::sync::broadcast::Sender<(Uuid, String, SseEvent)>,
|
||||||
inject_tx: tokio::sync::mpsc::Sender<IncomingMessage>,
|
inject_tx: tokio::sync::mpsc::Sender<IncomingMessage>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
self.event_tx = Some(event_tx);
|
self.event_tx = Some(event_tx);
|
||||||
|
|||||||
+359
-47
@@ -12,15 +12,119 @@
|
|||||||
//! Use `memory_write` to persist important facts that should be remembered
|
//! Use `memory_write` to persist important facts that should be remembered
|
||||||
//! across sessions.
|
//! across sessions.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
use crate::context::JobContext;
|
use crate::context::JobContext;
|
||||||
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str};
|
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str};
|
||||||
use crate::workspace::{Workspace, paths};
|
use crate::workspace::{Workspace, paths};
|
||||||
|
|
||||||
|
// ── WorkspaceResolver ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Resolves a workspace for a given user ID.
|
||||||
|
///
|
||||||
|
/// In single-user mode, always returns the same workspace.
|
||||||
|
/// In multi-tenant mode, creates per-user workspaces on demand.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait WorkspaceResolver: Send + Sync {
|
||||||
|
async fn resolve(&self, user_id: &str) -> Arc<Workspace>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a fixed workspace regardless of user ID (single-user mode).
|
||||||
|
pub struct FixedWorkspaceResolver {
|
||||||
|
workspace: Arc<Workspace>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FixedWorkspaceResolver {
|
||||||
|
pub fn new(workspace: Arc<Workspace>) -> Self {
|
||||||
|
Self { workspace }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl WorkspaceResolver for FixedWorkspaceResolver {
|
||||||
|
async fn resolve(&self, _user_id: &str) -> Arc<Workspace> {
|
||||||
|
Arc::clone(&self.workspace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates per-user workspaces on demand, caching them for reuse.
|
||||||
|
///
|
||||||
|
/// Used in multi-tenant mode where each authenticated user gets their own
|
||||||
|
/// workspace scope. The workspace is constructed with the same configuration
|
||||||
|
/// (embeddings, search config, memory layers) as the startup workspace.
|
||||||
|
pub struct PerUserWorkspaceResolver {
|
||||||
|
db: Arc<dyn crate::db::Database>,
|
||||||
|
embeddings: Option<Arc<dyn crate::workspace::EmbeddingProvider>>,
|
||||||
|
embedding_cache_config: crate::workspace::EmbeddingCacheConfig,
|
||||||
|
search_config: crate::config::WorkspaceSearchConfig,
|
||||||
|
workspace_config: crate::config::WorkspaceConfig,
|
||||||
|
cache: RwLock<HashMap<String, Arc<Workspace>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PerUserWorkspaceResolver {
|
||||||
|
pub fn new(
|
||||||
|
db: Arc<dyn crate::db::Database>,
|
||||||
|
embeddings: Option<Arc<dyn crate::workspace::EmbeddingProvider>>,
|
||||||
|
embedding_cache_config: crate::workspace::EmbeddingCacheConfig,
|
||||||
|
search_config: crate::config::WorkspaceSearchConfig,
|
||||||
|
workspace_config: crate::config::WorkspaceConfig,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
db,
|
||||||
|
embeddings,
|
||||||
|
embedding_cache_config,
|
||||||
|
search_config,
|
||||||
|
workspace_config,
|
||||||
|
cache: RwLock::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_workspace(&self, user_id: &str) -> Arc<Workspace> {
|
||||||
|
let mut ws = Workspace::new_with_db(user_id, Arc::clone(&self.db))
|
||||||
|
.with_search_config(&self.search_config);
|
||||||
|
|
||||||
|
if let Some(ref emb) = self.embeddings {
|
||||||
|
ws = ws.with_embeddings_cached(Arc::clone(emb), self.embedding_cache_config.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.workspace_config.read_scopes.is_empty() {
|
||||||
|
ws = ws.with_additional_read_scopes(self.workspace_config.read_scopes.clone());
|
||||||
|
}
|
||||||
|
ws = ws.with_memory_layers(self.workspace_config.memory_layers.clone());
|
||||||
|
|
||||||
|
Arc::new(ws)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl WorkspaceResolver for PerUserWorkspaceResolver {
|
||||||
|
async fn resolve(&self, user_id: &str) -> Arc<Workspace> {
|
||||||
|
// Fast path: read lock
|
||||||
|
{
|
||||||
|
let cache = self.cache.read().await;
|
||||||
|
if let Some(ws) = cache.get(user_id) {
|
||||||
|
return Arc::clone(ws);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slow path: write lock, double-check
|
||||||
|
let mut cache = self.cache.write().await;
|
||||||
|
if let Some(ws) = cache.get(user_id) {
|
||||||
|
return Arc::clone(ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ws = self.build_workspace(user_id);
|
||||||
|
cache.insert(user_id.to_string(), Arc::clone(&ws));
|
||||||
|
tracing::debug!(user_id = user_id, "Created per-user workspace");
|
||||||
|
ws
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Detect paths that are clearly local filesystem references, not workspace-memory docs.
|
/// Detect paths that are clearly local filesystem references, not workspace-memory docs.
|
||||||
///
|
///
|
||||||
/// Examples:
|
/// Examples:
|
||||||
@@ -62,13 +166,20 @@ fn map_write_err(e: crate::error::WorkspaceError) -> ToolError {
|
|||||||
/// The agent should call this tool before answering questions about
|
/// The agent should call this tool before answering questions about
|
||||||
/// prior work, decisions, preferences, or any historical context.
|
/// prior work, decisions, preferences, or any historical context.
|
||||||
pub struct MemorySearchTool {
|
pub struct MemorySearchTool {
|
||||||
workspace: Arc<Workspace>,
|
resolver: Arc<dyn WorkspaceResolver>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemorySearchTool {
|
impl MemorySearchTool {
|
||||||
/// Create a new memory search tool.
|
/// Create a new memory search tool with a workspace resolver.
|
||||||
pub fn new(workspace: Arc<Workspace>) -> Self {
|
pub fn new(resolver: Arc<dyn WorkspaceResolver>) -> Self {
|
||||||
Self { workspace }
|
Self { resolver }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create from a fixed workspace (backward compatibility).
|
||||||
|
pub fn from_workspace(workspace: Arc<Workspace>) -> Self {
|
||||||
|
Self {
|
||||||
|
resolver: Arc::new(FixedWorkspaceResolver::new(workspace)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +218,7 @@ impl Tool for MemorySearchTool {
|
|||||||
async fn execute(
|
async fn execute(
|
||||||
&self,
|
&self,
|
||||||
params: serde_json::Value,
|
params: serde_json::Value,
|
||||||
_ctx: &JobContext,
|
ctx: &JobContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
@@ -119,8 +230,8 @@ impl Tool for MemorySearchTool {
|
|||||||
.unwrap_or(5)
|
.unwrap_or(5)
|
||||||
.min(20) as usize;
|
.min(20) as usize;
|
||||||
|
|
||||||
let results = self
|
let workspace = self.resolver.resolve(&ctx.user_id).await;
|
||||||
.workspace
|
let results = workspace
|
||||||
.search(query, limit)
|
.search(query, limit)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ToolError::ExecutionFailed(format!("Search failed: {}", e)))?;
|
.map_err(|e| ToolError::ExecutionFailed(format!("Search failed: {}", e)))?;
|
||||||
@@ -151,13 +262,20 @@ impl Tool for MemorySearchTool {
|
|||||||
/// Use this to persist important information that should be remembered
|
/// Use this to persist important information that should be remembered
|
||||||
/// across sessions: decisions, preferences, facts, lessons learned.
|
/// across sessions: decisions, preferences, facts, lessons learned.
|
||||||
pub struct MemoryWriteTool {
|
pub struct MemoryWriteTool {
|
||||||
workspace: Arc<Workspace>,
|
resolver: Arc<dyn WorkspaceResolver>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryWriteTool {
|
impl MemoryWriteTool {
|
||||||
/// Create a new memory write tool.
|
/// Create a new memory write tool with a workspace resolver.
|
||||||
pub fn new(workspace: Arc<Workspace>) -> Self {
|
pub fn new(resolver: Arc<dyn WorkspaceResolver>) -> Self {
|
||||||
Self { workspace }
|
Self { resolver }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create from a fixed workspace (backward compatibility).
|
||||||
|
pub fn from_workspace(workspace: Arc<Workspace>) -> Self {
|
||||||
|
Self {
|
||||||
|
resolver: Arc::new(FixedWorkspaceResolver::new(workspace)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,19 +349,21 @@ impl Tool for MemoryWriteTool {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let workspace = self.resolver.resolve(&ctx.user_id).await;
|
||||||
|
|
||||||
// Bootstrap target: clear BOOTSTRAP.md to mark first-run ritual complete.
|
// Bootstrap target: clear BOOTSTRAP.md to mark first-run ritual complete.
|
||||||
// Handled early because it accepts empty content (unlike other targets).
|
// Handled early because it accepts empty content (unlike other targets).
|
||||||
if target == "bootstrap" {
|
if target == "bootstrap" {
|
||||||
// Write empty content to effectively disable the bootstrap injection.
|
// Write empty content to effectively disable the bootstrap injection.
|
||||||
// system_prompt_for_context() skips empty files.
|
// system_prompt_for_context() skips empty files.
|
||||||
self.workspace
|
workspace
|
||||||
.write(paths::BOOTSTRAP, "")
|
.write(paths::BOOTSTRAP, "")
|
||||||
.await
|
.await
|
||||||
.map_err(map_write_err)?;
|
.map_err(map_write_err)?;
|
||||||
|
|
||||||
// Also set the in-memory flag so BOOTSTRAP.md injection stops
|
// Also set the in-memory flag so BOOTSTRAP.md injection stops
|
||||||
// immediately without waiting for a restart.
|
// immediately without waiting for a restart.
|
||||||
self.workspace.mark_bootstrap_completed();
|
workspace.mark_bootstrap_completed();
|
||||||
|
|
||||||
let output = serde_json::json!({
|
let output = serde_json::json!({
|
||||||
"status": "cleared",
|
"status": "cleared",
|
||||||
@@ -271,12 +391,13 @@ impl Tool for MemoryWriteTool {
|
|||||||
.and_then(|v| v.as_bool())
|
.and_then(|v| v.as_bool())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
// Parse timezone once for targets that need it (daily_log).
|
||||||
|
let tz = crate::timezone::parse_timezone(&ctx.user_timezone).unwrap_or(chrono_tz::Tz::UTC);
|
||||||
|
|
||||||
// Resolve the target to a workspace path
|
// Resolve the target to a workspace path
|
||||||
let resolved_path = match target {
|
let resolved_path = match target {
|
||||||
"memory" => paths::MEMORY.to_string(),
|
"memory" => paths::MEMORY.to_string(),
|
||||||
"daily_log" => {
|
"daily_log" => {
|
||||||
let tz = crate::timezone::parse_timezone(&ctx.user_timezone)
|
|
||||||
.unwrap_or(chrono_tz::Tz::UTC);
|
|
||||||
let now = chrono::Utc::now().with_timezone(&tz);
|
let now = chrono::Utc::now().with_timezone(&tz);
|
||||||
format!("daily/{}.md", now.format("%Y-%m-%d"))
|
format!("daily/{}.md", now.format("%Y-%m-%d"))
|
||||||
}
|
}
|
||||||
@@ -288,12 +409,12 @@ impl Tool for MemoryWriteTool {
|
|||||||
// Otherwise, use default workspace methods (which include injection scanning).
|
// Otherwise, use default workspace methods (which include injection scanning).
|
||||||
let layer_result = if let Some(layer_name) = layer {
|
let layer_result = if let Some(layer_name) = layer {
|
||||||
let result = if append {
|
let result = if append {
|
||||||
self.workspace
|
workspace
|
||||||
.append_to_layer(layer_name, &resolved_path, content, force)
|
.append_to_layer(layer_name, &resolved_path, content, force)
|
||||||
.await
|
.await
|
||||||
.map_err(map_write_err)?
|
.map_err(map_write_err)?
|
||||||
} else {
|
} else {
|
||||||
self.workspace
|
workspace
|
||||||
.write_to_layer(layer_name, &resolved_path, content, force)
|
.write_to_layer(layer_name, &resolved_path, content, force)
|
||||||
.await
|
.await
|
||||||
.map_err(map_write_err)?
|
.map_err(map_write_err)?
|
||||||
@@ -306,12 +427,12 @@ impl Tool for MemoryWriteTool {
|
|||||||
match target {
|
match target {
|
||||||
"memory" => {
|
"memory" => {
|
||||||
if append {
|
if append {
|
||||||
self.workspace
|
workspace
|
||||||
.append_memory(content)
|
.append_memory(content)
|
||||||
.await
|
.await
|
||||||
.map_err(map_write_err)?;
|
.map_err(map_write_err)?;
|
||||||
} else {
|
} else {
|
||||||
self.workspace
|
workspace
|
||||||
.write(paths::MEMORY, content)
|
.write(paths::MEMORY, content)
|
||||||
.await
|
.await
|
||||||
.map_err(map_write_err)?;
|
.map_err(map_write_err)?;
|
||||||
@@ -320,19 +441,19 @@ impl Tool for MemoryWriteTool {
|
|||||||
"daily_log" => {
|
"daily_log" => {
|
||||||
let tz = crate::timezone::parse_timezone(&ctx.user_timezone)
|
let tz = crate::timezone::parse_timezone(&ctx.user_timezone)
|
||||||
.unwrap_or(chrono_tz::Tz::UTC);
|
.unwrap_or(chrono_tz::Tz::UTC);
|
||||||
self.workspace
|
workspace
|
||||||
.append_daily_log_tz(content, tz)
|
.append_daily_log_tz(content, tz)
|
||||||
.await
|
.await
|
||||||
.map_err(map_write_err)?;
|
.map_err(map_write_err)?;
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
if append {
|
if append {
|
||||||
self.workspace
|
workspace
|
||||||
.append(&resolved_path, content)
|
.append(&resolved_path, content)
|
||||||
.await
|
.await
|
||||||
.map_err(map_write_err)?;
|
.map_err(map_write_err)?;
|
||||||
} else {
|
} else {
|
||||||
self.workspace
|
workspace
|
||||||
.write(&resolved_path, content)
|
.write(&resolved_path, content)
|
||||||
.await
|
.await
|
||||||
.map_err(map_write_err)?;
|
.map_err(map_write_err)?;
|
||||||
@@ -362,12 +483,12 @@ impl Tool for MemoryWriteTool {
|
|||||||
};
|
};
|
||||||
let mut synced_docs: Vec<&str> = Vec::new();
|
let mut synced_docs: Vec<&str> = Vec::new();
|
||||||
if normalized_path == paths::PROFILE {
|
if normalized_path == paths::PROFILE {
|
||||||
match self.workspace.sync_profile_documents().await {
|
match workspace.sync_profile_documents().await {
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
tracing::info!("profile write: synced USER.md + assistant-directives.md");
|
tracing::info!("profile write: synced USER.md + assistant-directives.md");
|
||||||
synced_docs.extend_from_slice(&[paths::USER, paths::ASSISTANT_DIRECTIVES]);
|
synced_docs.extend_from_slice(&[paths::USER, paths::ASSISTANT_DIRECTIVES]);
|
||||||
|
|
||||||
self.workspace.mark_bootstrap_completed();
|
workspace.mark_bootstrap_completed();
|
||||||
let toml_path = crate::settings::Settings::default_toml_path();
|
let toml_path = crate::settings::Settings::default_toml_path();
|
||||||
if let Ok(Some(mut settings)) = crate::settings::Settings::load_toml(&toml_path)
|
if let Ok(Some(mut settings)) = crate::settings::Settings::load_toml(&toml_path)
|
||||||
&& !settings.profile_onboarding_completed
|
&& !settings.profile_onboarding_completed
|
||||||
@@ -417,13 +538,20 @@ impl Tool for MemoryWriteTool {
|
|||||||
///
|
///
|
||||||
/// Use this to read the full content of any file in the workspace.
|
/// Use this to read the full content of any file in the workspace.
|
||||||
pub struct MemoryReadTool {
|
pub struct MemoryReadTool {
|
||||||
workspace: Arc<Workspace>,
|
resolver: Arc<dyn WorkspaceResolver>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryReadTool {
|
impl MemoryReadTool {
|
||||||
/// Create a new memory read tool.
|
/// Create a new memory read tool with a workspace resolver.
|
||||||
pub fn new(workspace: Arc<Workspace>) -> Self {
|
pub fn new(resolver: Arc<dyn WorkspaceResolver>) -> Self {
|
||||||
Self { workspace }
|
Self { resolver }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create from a fixed workspace (backward compatibility).
|
||||||
|
pub fn from_workspace(workspace: Arc<Workspace>) -> Self {
|
||||||
|
Self {
|
||||||
|
resolver: Arc::new(FixedWorkspaceResolver::new(workspace)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,7 +585,7 @@ impl Tool for MemoryReadTool {
|
|||||||
async fn execute(
|
async fn execute(
|
||||||
&self,
|
&self,
|
||||||
params: serde_json::Value,
|
params: serde_json::Value,
|
||||||
_ctx: &JobContext,
|
ctx: &JobContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
@@ -471,8 +599,8 @@ impl Tool for MemoryReadTool {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let doc = self
|
let workspace = self.resolver.resolve(&ctx.user_id).await;
|
||||||
.workspace
|
let doc = workspace
|
||||||
.read(path)
|
.read(path)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ToolError::ExecutionFailed(format!("Read failed: {}", e)))?;
|
.map_err(|e| ToolError::ExecutionFailed(format!("Read failed: {}", e)))?;
|
||||||
@@ -496,20 +624,27 @@ impl Tool for MemoryReadTool {
|
|||||||
///
|
///
|
||||||
/// Returns a hierarchical view of files and directories with configurable depth.
|
/// Returns a hierarchical view of files and directories with configurable depth.
|
||||||
pub struct MemoryTreeTool {
|
pub struct MemoryTreeTool {
|
||||||
workspace: Arc<Workspace>,
|
resolver: Arc<dyn WorkspaceResolver>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryTreeTool {
|
impl MemoryTreeTool {
|
||||||
/// Create a new memory tree tool.
|
/// Create a new memory tree tool with a workspace resolver.
|
||||||
pub fn new(workspace: Arc<Workspace>) -> Self {
|
pub fn new(resolver: Arc<dyn WorkspaceResolver>) -> Self {
|
||||||
Self { workspace }
|
Self { resolver }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create from a fixed workspace (backward compatibility).
|
||||||
|
pub fn from_workspace(workspace: Arc<Workspace>) -> Self {
|
||||||
|
Self {
|
||||||
|
resolver: Arc::new(FixedWorkspaceResolver::new(workspace)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recursively build tree structure.
|
/// Recursively build tree structure.
|
||||||
///
|
///
|
||||||
/// Returns a compact format where directories end with `/` and may have children.
|
/// Returns a compact format where directories end with `/` and may have children.
|
||||||
async fn build_tree(
|
async fn build_tree(
|
||||||
&self,
|
workspace: &Arc<Workspace>,
|
||||||
path: &str,
|
path: &str,
|
||||||
current_depth: usize,
|
current_depth: usize,
|
||||||
max_depth: usize,
|
max_depth: usize,
|
||||||
@@ -518,8 +653,7 @@ impl MemoryTreeTool {
|
|||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let entries = self
|
let entries = workspace
|
||||||
.workspace
|
|
||||||
.list(path)
|
.list(path)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ToolError::ExecutionFailed(format!("Tree failed: {}", e)))?;
|
.map_err(|e| ToolError::ExecutionFailed(format!("Tree failed: {}", e)))?;
|
||||||
@@ -534,8 +668,13 @@ impl MemoryTreeTool {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if entry.is_directory && current_depth < max_depth {
|
if entry.is_directory && current_depth < max_depth {
|
||||||
let children =
|
let children = Box::pin(Self::build_tree(
|
||||||
Box::pin(self.build_tree(&entry.path, current_depth + 1, max_depth)).await?;
|
workspace,
|
||||||
|
&entry.path,
|
||||||
|
current_depth + 1,
|
||||||
|
max_depth,
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
if children.is_empty() {
|
if children.is_empty() {
|
||||||
result.push(serde_json::Value::String(display_path));
|
result.push(serde_json::Value::String(display_path));
|
||||||
} else {
|
} else {
|
||||||
@@ -585,7 +724,7 @@ impl Tool for MemoryTreeTool {
|
|||||||
async fn execute(
|
async fn execute(
|
||||||
&self,
|
&self,
|
||||||
params: serde_json::Value,
|
params: serde_json::Value,
|
||||||
_ctx: &JobContext,
|
ctx: &JobContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
@@ -597,7 +736,8 @@ impl Tool for MemoryTreeTool {
|
|||||||
.unwrap_or(1)
|
.unwrap_or(1)
|
||||||
.clamp(1, 10) as usize;
|
.clamp(1, 10) as usize;
|
||||||
|
|
||||||
let tree = self.build_tree(path, 1, depth).await?;
|
let workspace = self.resolver.resolve(&ctx.user_id).await;
|
||||||
|
let tree = Self::build_tree(&workspace, path, 1, depth).await?;
|
||||||
|
|
||||||
// Compact output: just the tree array
|
// Compact output: just the tree array
|
||||||
Ok(ToolOutput::success(
|
Ok(ToolOutput::success(
|
||||||
@@ -651,7 +791,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_memory_search_schema() {
|
fn test_memory_search_schema() {
|
||||||
let workspace = make_test_workspace();
|
let workspace = make_test_workspace();
|
||||||
let tool = MemorySearchTool::new(workspace);
|
let tool = MemorySearchTool::from_workspace(workspace);
|
||||||
|
|
||||||
assert_eq!(tool.name(), "memory_search");
|
assert_eq!(tool.name(), "memory_search");
|
||||||
assert!(!tool.requires_sanitization());
|
assert!(!tool.requires_sanitization());
|
||||||
@@ -669,7 +809,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_memory_write_schema() {
|
fn test_memory_write_schema() {
|
||||||
let workspace = make_test_workspace();
|
let workspace = make_test_workspace();
|
||||||
let tool = MemoryWriteTool::new(workspace);
|
let tool = MemoryWriteTool::from_workspace(workspace);
|
||||||
|
|
||||||
assert_eq!(tool.name(), "memory_write");
|
assert_eq!(tool.name(), "memory_write");
|
||||||
|
|
||||||
@@ -682,7 +822,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_memory_read_schema() {
|
fn test_memory_read_schema() {
|
||||||
let workspace = make_test_workspace();
|
let workspace = make_test_workspace();
|
||||||
let tool = MemoryReadTool::new(workspace);
|
let tool = MemoryReadTool::from_workspace(workspace);
|
||||||
|
|
||||||
assert_eq!(tool.name(), "memory_read");
|
assert_eq!(tool.name(), "memory_read");
|
||||||
|
|
||||||
@@ -699,7 +839,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_memory_tree_schema() {
|
fn test_memory_tree_schema() {
|
||||||
let workspace = make_test_workspace();
|
let workspace = make_test_workspace();
|
||||||
let tool = MemoryTreeTool::new(workspace);
|
let tool = MemoryTreeTool::from_workspace(workspace);
|
||||||
|
|
||||||
assert_eq!(tool.name(), "memory_tree");
|
assert_eq!(tool.name(), "memory_tree");
|
||||||
|
|
||||||
@@ -712,7 +852,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_memory_write_rejects_injection_to_identity_file() {
|
async fn test_memory_write_rejects_injection_to_identity_file() {
|
||||||
let workspace = make_test_workspace();
|
let workspace = make_test_workspace();
|
||||||
let tool = MemoryWriteTool::new(workspace);
|
let tool = MemoryWriteTool::from_workspace(workspace);
|
||||||
let ctx = JobContext::default();
|
let ctx = JobContext::default();
|
||||||
|
|
||||||
let params = serde_json::json!({
|
let params = serde_json::json!({
|
||||||
@@ -734,4 +874,176 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regression tests for per-user workspace scoping (multi-tenant mode).
|
||||||
|
// See: https://github.com/nearai/ironclaw/pull/1118
|
||||||
|
// Bug: memory tools used a single startup workspace regardless of which
|
||||||
|
// user was chatting. Fix: resolve workspace per-request via JobContext.user_id.
|
||||||
|
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
mod resolver_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_test_workspace_for_user(user_id: &str) -> Arc<Workspace> {
|
||||||
|
Arc::new(Workspace::new(
|
||||||
|
user_id,
|
||||||
|
deadpool_postgres::Pool::builder(deadpool_postgres::Manager::new(
|
||||||
|
tokio_postgres::Config::new(),
|
||||||
|
tokio_postgres::NoTls,
|
||||||
|
))
|
||||||
|
.build()
|
||||||
|
.unwrap(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_fixed_workspace_resolver_ignores_user_id() {
|
||||||
|
let ws = make_test_workspace_for_user("alice");
|
||||||
|
let resolver = FixedWorkspaceResolver::new(Arc::clone(&ws));
|
||||||
|
|
||||||
|
let ws_alice = resolver.resolve("alice").await;
|
||||||
|
let ws_bob = resolver.resolve("bob").await;
|
||||||
|
|
||||||
|
// Both should return the exact same Arc (pointer equality)
|
||||||
|
assert!(Arc::ptr_eq(&ws_alice, &ws_bob));
|
||||||
|
assert_eq!(ws_alice.user_id(), "alice");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracking resolver that records which user_ids were requested.
|
||||||
|
struct TrackingWorkspaceResolver {
|
||||||
|
inner: FixedWorkspaceResolver,
|
||||||
|
resolved_users: std::sync::Mutex<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrackingWorkspaceResolver {
|
||||||
|
fn new(workspace: Arc<Workspace>) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: FixedWorkspaceResolver::new(workspace),
|
||||||
|
resolved_users: std::sync::Mutex::new(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolved_users(&self) -> Vec<String> {
|
||||||
|
self.resolved_users.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl WorkspaceResolver for TrackingWorkspaceResolver {
|
||||||
|
async fn resolve(&self, user_id: &str) -> Arc<Workspace> {
|
||||||
|
self.resolved_users
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(user_id.to_string());
|
||||||
|
self.inner.resolve(user_id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_memory_search_uses_job_context_user_id() {
|
||||||
|
let ws = make_test_workspace_for_user("default");
|
||||||
|
let tracker = Arc::new(TrackingWorkspaceResolver::new(ws));
|
||||||
|
let tool = MemorySearchTool::new(tracker.clone() as Arc<dyn WorkspaceResolver>);
|
||||||
|
|
||||||
|
// Execute with user_id "alice"
|
||||||
|
let ctx_alice = JobContext::with_user("alice", "test", "test");
|
||||||
|
let params = serde_json::json!({"query": "test"});
|
||||||
|
// The search will fail (no real DB) but we only care about resolver call
|
||||||
|
let _ = tool.execute(params, &ctx_alice).await;
|
||||||
|
|
||||||
|
// Execute with user_id "bob"
|
||||||
|
let ctx_bob = JobContext::with_user("bob", "test", "test");
|
||||||
|
let params = serde_json::json!({"query": "test"});
|
||||||
|
let _ = tool.execute(params, &ctx_bob).await;
|
||||||
|
|
||||||
|
let resolved = tracker.resolved_users();
|
||||||
|
assert_eq!(resolved, vec!["alice", "bob"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_memory_write_uses_job_context_user_id() {
|
||||||
|
let ws = make_test_workspace_for_user("default");
|
||||||
|
let tracker = Arc::new(TrackingWorkspaceResolver::new(ws));
|
||||||
|
let tool = MemoryWriteTool::new(tracker.clone() as Arc<dyn WorkspaceResolver>);
|
||||||
|
|
||||||
|
// Execute with user_id "alice"
|
||||||
|
let ctx_alice = JobContext::with_user("alice", "test", "test");
|
||||||
|
let params = serde_json::json!({
|
||||||
|
"content": "remember this",
|
||||||
|
"target": "daily_log",
|
||||||
|
});
|
||||||
|
let _ = tool.execute(params, &ctx_alice).await;
|
||||||
|
|
||||||
|
// Execute with user_id "bob"
|
||||||
|
let ctx_bob = JobContext::with_user("bob", "test", "test");
|
||||||
|
let params = serde_json::json!({
|
||||||
|
"content": "remember that",
|
||||||
|
"target": "daily_log",
|
||||||
|
});
|
||||||
|
let _ = tool.execute(params, &ctx_bob).await;
|
||||||
|
|
||||||
|
let resolved = tracker.resolved_users();
|
||||||
|
assert_eq!(resolved, vec!["alice", "bob"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "libsql")]
|
||||||
|
mod per_user_resolver_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
async fn make_test_db() -> Arc<dyn crate::db::Database> {
|
||||||
|
use crate::db::libsql::LibSqlBackend;
|
||||||
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let db_path = temp_dir.path().join("resolver_test.db");
|
||||||
|
let backend = LibSqlBackend::new_local(&db_path)
|
||||||
|
.await
|
||||||
|
.expect("LibSqlBackend");
|
||||||
|
<LibSqlBackend as crate::db::Database>::run_migrations(&backend)
|
||||||
|
.await
|
||||||
|
.expect("migrations");
|
||||||
|
// Leak the tempdir so it outlives the test (cleaned up on process exit).
|
||||||
|
std::mem::forget(temp_dir);
|
||||||
|
Arc::new(backend)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_per_user_workspace_resolver_returns_different_workspaces() {
|
||||||
|
let db = make_test_db().await;
|
||||||
|
|
||||||
|
let resolver = PerUserWorkspaceResolver::new(
|
||||||
|
db,
|
||||||
|
None,
|
||||||
|
crate::workspace::EmbeddingCacheConfig::default(),
|
||||||
|
crate::config::WorkspaceSearchConfig::default(),
|
||||||
|
crate::config::WorkspaceConfig::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let ws_alice = resolver.resolve("alice").await;
|
||||||
|
let ws_bob = resolver.resolve("bob").await;
|
||||||
|
|
||||||
|
// Different user IDs should get different workspaces
|
||||||
|
assert_eq!(ws_alice.user_id(), "alice");
|
||||||
|
assert_eq!(ws_bob.user_id(), "bob");
|
||||||
|
assert!(!Arc::ptr_eq(&ws_alice, &ws_bob));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_per_user_workspace_resolver_caches_workspace() {
|
||||||
|
let db = make_test_db().await;
|
||||||
|
|
||||||
|
let resolver = PerUserWorkspaceResolver::new(
|
||||||
|
db,
|
||||||
|
None,
|
||||||
|
crate::workspace::EmbeddingCacheConfig::default(),
|
||||||
|
crate::config::WorkspaceSearchConfig::default(),
|
||||||
|
crate::config::WorkspaceConfig::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let ws1 = resolver.resolve("alice").await;
|
||||||
|
let ws2 = resolver.resolve("alice").await;
|
||||||
|
|
||||||
|
// Same user_id should return the same cached Arc (pointer equality)
|
||||||
|
assert!(Arc::ptr_eq(&ws1, &ws2));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user