mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d7f7b817f | ||
|
|
da733b4711 | ||
|
|
452f98c1b4 | ||
|
|
5f0a7e8c42 | ||
|
|
921ffd9bad | ||
|
|
4ed469e913 | ||
|
|
6bc339f83c | ||
|
|
7284e05cd9 | ||
|
|
5b95d22218 | ||
|
|
dd0a0e10ab | ||
|
|
1d5777824c | ||
|
|
adf4e25c8f | ||
|
|
9c63d189b7 | ||
|
|
935631421f | ||
|
|
6510b05bba | ||
|
|
5705366efb | ||
|
|
ed4d92932a | ||
|
|
b3fbef5287 | ||
|
|
5b2dbd6ea8 | ||
|
|
7dfc5ddd0d | ||
|
|
6b8a38e147 | ||
|
|
ab67f02886 | ||
|
|
f02345fd1f | ||
|
|
4c043bf057 | ||
|
|
0b4e7c761b | ||
|
|
cdc625566f | ||
|
|
bb24952622 | ||
|
|
ef37d705a1 | ||
|
|
b400c2a711 | ||
|
|
ea24d79ace | ||
|
|
8d632872fd | ||
|
|
4c5d961102 | ||
|
|
2b4e881a72 | ||
|
|
c0f33c37f7 | ||
|
|
5d714be354 | ||
|
|
3d43917cd0 | ||
|
|
f9dfb74800 | ||
|
|
cb01800f73 | ||
|
|
16aaea8d74 | ||
|
|
a19deb6812 | ||
|
|
2f80b7b0b8 | ||
|
|
2f47c611d4 | ||
|
|
1f8d901cf6 | ||
|
|
ad20a5ab4f | ||
|
|
e15c50ea2d | ||
|
|
d4e18020e2 | ||
|
|
a23d87fc00 | ||
|
|
c737fb0855 | ||
|
|
0145672f36 | ||
|
|
9fd5537a01 | ||
|
|
492d9d22c9 | ||
|
|
b8b88ab84e | ||
|
|
c98ec3fb18 | ||
|
|
189fa35e64 | ||
|
|
c5dce279e2 | ||
|
|
5a5ffe8d08 | ||
|
|
86d1143064 | ||
|
|
ab0ad948f3 | ||
|
|
c949521d8d | ||
|
|
0341fcc940 | ||
|
|
41ed0a0f98 | ||
|
|
67a025e2fa | ||
|
|
60aaecd684 | ||
|
|
6daa2f155f | ||
|
|
a64c694777 | ||
|
|
706c3a1b47 | ||
|
|
656151783c | ||
|
|
e30d9fe9db | ||
|
|
82822d7b25 | ||
|
|
dcb2d89e3a | ||
|
|
f3da30a454 | ||
|
|
424b470c59 | ||
|
|
5901451603 | ||
|
|
d3d517fd67 | ||
|
|
01678be61d | ||
|
|
fb3548956b | ||
|
|
5847479fd8 | ||
|
|
3fdb187796 | ||
|
|
b441ebec02 | ||
|
|
ae370d7e2b | ||
|
|
fa51b9f52d | ||
|
|
98418b3ef0 | ||
|
|
74b2b4129e | ||
|
|
bb57e36e6d | ||
|
|
0194275792 | ||
|
|
ddf64e8485 | ||
|
|
bd6977e6a8 | ||
|
|
dea789cca9 | ||
|
|
d47b4b0346 | ||
|
|
91a241a3c7 | ||
|
|
d1d74d665a | ||
|
|
e077e1277d | ||
|
|
6fc8cc2f39 | ||
|
|
e031d8246b | ||
|
|
23263029f9 | ||
|
|
d5e08b95f9 | ||
|
|
e4d3200d80 | ||
|
|
7dc3c6d067 | ||
|
|
e1774e9ec0 | ||
|
|
e1d9827b21 | ||
|
|
e582166781 | ||
|
|
656d1f3e86 | ||
|
|
0e3aa4f806 | ||
|
|
44d16732a7 |
@@ -12,6 +12,7 @@ jobs:
|
||||
tests:
|
||||
name: Tests (${{ matrix.name }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -40,11 +41,14 @@ jobs:
|
||||
- name: Build WASM channels (for integration tests)
|
||||
run: ./scripts/build-wasm-extensions.sh --channels
|
||||
- name: Run Tests
|
||||
run: cargo test ${{ matrix.flags }} -- --nocapture
|
||||
run: |
|
||||
timeout --signal=INT --kill-after=30s 40m \
|
||||
cargo test ${{ matrix.flags }} -- --nocapture
|
||||
|
||||
heavy-integration-tests:
|
||||
name: Heavy Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -58,9 +62,13 @@ jobs:
|
||||
- name: Build Telegram WASM channel
|
||||
run: cargo build --manifest-path channels-src/telegram/Cargo.toml --target wasm32-wasip2 --release
|
||||
- name: Run thread scheduling integration tests
|
||||
run: cargo test --no-default-features --features libsql,integration --test e2e_thread_scheduling -- --nocapture
|
||||
run: |
|
||||
timeout --signal=INT --kill-after=30s 15m \
|
||||
cargo test --no-default-features --features libsql,integration --test e2e_thread_scheduling -- --nocapture
|
||||
- name: Run Telegram thread-scope regression test
|
||||
run: cargo test --features integration --test telegram_auth_integration test_private_messages_use_chat_id_as_thread_scope -- --exact
|
||||
run: |
|
||||
timeout --signal=INT --kill-after=30s 10m \
|
||||
cargo test --features integration --test telegram_auth_integration test_private_messages_use_chat_id_as_thread_scope -- --exact
|
||||
|
||||
telegram-tests:
|
||||
name: Telegram Channel Tests
|
||||
@@ -68,6 +76,7 @@ jobs:
|
||||
github.event_name != 'pull_request' ||
|
||||
github.base_ref != 'staging'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -75,7 +84,9 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Run Telegram Channel Tests
|
||||
run: cargo test --manifest-path channels-src/telegram/Cargo.toml -- --nocapture
|
||||
run: |
|
||||
timeout --signal=INT --kill-after=30s 10m \
|
||||
cargo test --manifest-path channels-src/telegram/Cargo.toml -- --nocapture
|
||||
|
||||
windows-build:
|
||||
name: Windows Build (${{ matrix.name }})
|
||||
@@ -110,6 +121,7 @@ jobs:
|
||||
github.event_name != 'pull_request' ||
|
||||
github.base_ref != 'staging'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -125,7 +137,9 @@ jobs:
|
||||
- name: Build all WASM extensions against current WIT
|
||||
run: ./scripts/build-wasm-extensions.sh
|
||||
- name: Instantiation test (host linker compatibility)
|
||||
run: cargo test --all-features wit_compat -- --nocapture
|
||||
run: |
|
||||
timeout --signal=INT --kill-after=30s 20m \
|
||||
cargo test --all-features wit_compat -- --nocapture
|
||||
|
||||
bench-compile:
|
||||
name: Benchmark Compilation
|
||||
|
||||
+132
@@ -7,6 +7,138 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.22.0](https://github.com/nearai/ironclaw/compare/ironclaw-v0.21.0...ironclaw-v0.22.0) - 2026-03-25
|
||||
|
||||
### Added
|
||||
|
||||
- *(agent)* thread per-tool reasoning through provider, session, and all surfaces ([#1513](https://github.com/nearai/ironclaw/pull/1513))
|
||||
- *(cli)* show credential auth status in tool info ([#1572](https://github.com/nearai/ironclaw/pull/1572))
|
||||
- multi-tenant auth with per-user workspace isolation ([#1118](https://github.com/nearai/ironclaw/pull/1118))
|
||||
- *(cli)* add ironclaw models subcommands (list/status/set/set-provider) ([#1043](https://github.com/nearai/ironclaw/pull/1043))
|
||||
- *(workspace)* multi-scope workspace reads ([#1117](https://github.com/nearai/ironclaw/pull/1117))
|
||||
- *(ux)* complete UX overhaul — design system, onboarding, web polish ([#1277](https://github.com/nearai/ironclaw/pull/1277))
|
||||
- *(gemini_oauth)* full Gemini CLI OAuth integration with Cloud Code API ([#1356](https://github.com/nearai/ironclaw/pull/1356))
|
||||
- *(shell)* add Low/Medium/High risk levels for graduated command approval (closes #172) ([#368](https://github.com/nearai/ironclaw/pull/368))
|
||||
- *(agent)* queue and merge messages during active turns ([#1412](https://github.com/nearai/ironclaw/pull/1412))
|
||||
- *(cli)* add `ironclaw hooks list` subcommand ([#1023](https://github.com/nearai/ironclaw/pull/1023))
|
||||
- *(extensions)* support text setup fields in web configure modal ([#496](https://github.com/nearai/ironclaw/pull/496))
|
||||
- *(llm)* add GitHub Copilot as LLM provider ([#1512](https://github.com/nearai/ironclaw/pull/1512))
|
||||
- *(workspace)* layered memory with sensitivity-based privacy redirect ([#1112](https://github.com/nearai/ironclaw/pull/1112))
|
||||
- *(webhooks)* add public webhook trigger endpoint for routines ([#736](https://github.com/nearai/ironclaw/pull/736))
|
||||
- *(llm)* Add OpenAI Codex (ChatGPT subscription) as LLM provider ([#1461](https://github.com/nearai/ironclaw/pull/1461))
|
||||
- *(web)* add light theme with dark/light/system toggle ([#1457](https://github.com/nearai/ironclaw/pull/1457))
|
||||
- *(agent)* activate stuck_threshold for time-based stuck job detection ([#1234](https://github.com/nearai/ironclaw/pull/1234))
|
||||
- chat onboarding and routine advisor ([#927](https://github.com/nearai/ironclaw/pull/927))
|
||||
|
||||
### Fixed
|
||||
|
||||
- ensure LLM calls always end with user message (closes #763) ([#1259](https://github.com/nearai/ironclaw/pull/1259))
|
||||
- restore owner-scoped gateway startup ([#1625](https://github.com/nearai/ironclaw/pull/1625))
|
||||
- remove stale stream_token gate from channel-relay activation ([#1623](https://github.com/nearai/ironclaw/pull/1623))
|
||||
- *(agent)* case-insensitive channel match and user_id filter for event triggers ([#1211](https://github.com/nearai/ironclaw/pull/1211))
|
||||
- *(routines)* normalize status display across web and CLI ([#1469](https://github.com/nearai/ironclaw/pull/1469))
|
||||
- *(tunnel)* managed tunnels target wrong port and die from SIGPIPE ([#1093](https://github.com/nearai/ironclaw/pull/1093))
|
||||
- *(agent)* persist /model selection to .env, TOML, and DB ([#1581](https://github.com/nearai/ironclaw/pull/1581))
|
||||
- post-merge review sweep — 8 fixes across security, perf, and correctness ([#1550](https://github.com/nearai/ironclaw/pull/1550))
|
||||
- generate Mistral-compatible 9-char alphanumeric tool call IDs ([#1242](https://github.com/nearai/ironclaw/pull/1242))
|
||||
- *(mcp)* handle empty 202 notification acknowledgements ([#1539](https://github.com/nearai/ironclaw/pull/1539))
|
||||
- *(tests)* eliminate env mutex poison cascade ([#1558](https://github.com/nearai/ironclaw/pull/1558))
|
||||
- *(safety)* escape tool output XML content and remove misleading sanitized attr ([#1067](https://github.com/nearai/ironclaw/pull/1067))
|
||||
- *(oauth)* reject malformed ic2.* states in decode_hosted_oauth_state ([#1441](https://github.com/nearai/ironclaw/pull/1441)) ([#1454](https://github.com/nearai/ironclaw/pull/1454))
|
||||
- parameter coercion and validation for oneOf/anyOf/allOf schemas ([#1397](https://github.com/nearai/ironclaw/pull/1397))
|
||||
- persist startup-loaded MCP clients in ExtensionManager ([#1509](https://github.com/nearai/ironclaw/pull/1509))
|
||||
- *(deps)* patch rustls-webpki vulnerability (RUSTSEC-2026-0049)
|
||||
- *(routines)* add missing extension_manager field in trigger_manual EngineContext
|
||||
- *(ci)* serialize env-mutating OAuth wildcard tests with ENV_MUTEX ([#1280](https://github.com/nearai/ironclaw/pull/1280)) ([#1468](https://github.com/nearai/ironclaw/pull/1468))
|
||||
- *(setup)* remove redundant LLM config and API keys from bootstrap .env ([#1448](https://github.com/nearai/ironclaw/pull/1448))
|
||||
- resolve wasm broadcast merge conflicts with staging ([#395](https://github.com/nearai/ironclaw/pull/395)) ([#1460](https://github.com/nearai/ironclaw/pull/1460))
|
||||
- skip credential validation for Bedrock backend ([#1011](https://github.com/nearai/ironclaw/pull/1011))
|
||||
- register sandbox jobs in ContextManager for query tool visibility ([#1426](https://github.com/nearai/ironclaw/pull/1426))
|
||||
- prefer execution-local message routing metadata ([#1449](https://github.com/nearai/ironclaw/pull/1449))
|
||||
- *(security)* validate embedding base URLs to prevent SSRF ([#1221](https://github.com/nearai/ironclaw/pull/1221))
|
||||
- f32→f64 precision artifact in temperature causes provider 400 errors ([#1450](https://github.com/nearai/ironclaw/pull/1450))
|
||||
- *(routines)* surface errors when sandbox unavailable for full_job routines ([#769](https://github.com/nearai/ironclaw/pull/769))
|
||||
- restore libSQL vector search with dynamic dimensions ([#1393](https://github.com/nearai/ironclaw/pull/1393))
|
||||
- staging CI triage — consolidate retry parsing, fix flaky tests, add docs ([#1427](https://github.com/nearai/ironclaw/pull/1427))
|
||||
|
||||
### Other
|
||||
|
||||
- Merge branch 'main' into staging-promote/455f543b-23329172268
|
||||
- Merge pull request #1655 from nearai/codex/fix-staging-promotion-1451-version-bumps
|
||||
- Merge pull request #1499 from nearai/staging-promote/9603fefd-23364438978
|
||||
- Fix libsql prompt scope regressions ([#1651](https://github.com/nearai/ironclaw/pull/1651))
|
||||
- Normalize cron schedules on routine create ([#1648](https://github.com/nearai/ironclaw/pull/1648))
|
||||
- Fix MCP lifecycle trace user scope ([#1646](https://github.com/nearai/ironclaw/pull/1646))
|
||||
- Fix REPL single-message hang and cap CI test duration ([#1643](https://github.com/nearai/ironclaw/pull/1643))
|
||||
- extract AppEvent to crates/ironclaw_common ([#1615](https://github.com/nearai/ironclaw/pull/1615))
|
||||
- Fix hosted OAuth refresh via proxy ([#1602](https://github.com/nearai/ironclaw/pull/1602))
|
||||
- *(agent)* optimize approval thread resolution (UUID parsing + lock contention) ([#1592](https://github.com/nearai/ironclaw/pull/1592))
|
||||
- *(tools)* auto-compact WASM tool schemas, add descriptions, improve credential prompts ([#1525](https://github.com/nearai/ironclaw/pull/1525))
|
||||
- Default new lightweight routines to tools-enabled ([#1573](https://github.com/nearai/ironclaw/pull/1573))
|
||||
- Google OAuth URL broken when initiated from Telegram channel ([#1165](https://github.com/nearai/ironclaw/pull/1165))
|
||||
- add gitcgr code graph badge ([#1563](https://github.com/nearai/ironclaw/pull/1563))
|
||||
- Fix owner-scoped message routing fallbacks ([#1574](https://github.com/nearai/ironclaw/pull/1574))
|
||||
- *(tools)* remove unconditional params clone in shared execution (fix #893) ([#926](https://github.com/nearai/ironclaw/pull/926))
|
||||
- *(llm)* move transcription module into src/llm/ ([#1559](https://github.com/nearai/ironclaw/pull/1559))
|
||||
- *(agent)* avoid preview allocations for non-truncated strings (fix #894) ([#924](https://github.com/nearai/ironclaw/pull/924))
|
||||
- Expand AGENTS.md with coding agents guidance ([#1392](https://github.com/nearai/ironclaw/pull/1392))
|
||||
- Fix CI approval flows and stale fixtures ([#1478](https://github.com/nearai/ironclaw/pull/1478))
|
||||
- Use live owner tool scope for autonomous routines and jobs ([#1453](https://github.com/nearai/ironclaw/pull/1453))
|
||||
- use Arc in embedding cache to avoid clones on miss path ([#1438](https://github.com/nearai/ironclaw/pull/1438))
|
||||
- Add owner-scoped permissions for full-job routines ([#1440](https://github.com/nearai/ironclaw/pull/1440))
|
||||
|
||||
## [0.21.0](https://github.com/nearai/ironclaw/compare/v0.20.0...v0.21.0) - 2026-03-20
|
||||
|
||||
### Added
|
||||
|
||||
- structured fallback deliverables for failed/stuck jobs ([#236](https://github.com/nearai/ironclaw/pull/236))
|
||||
- LRU embedding cache for workspace search ([#1423](https://github.com/nearai/ironclaw/pull/1423))
|
||||
- receive relay events via webhook callbacks ([#1254](https://github.com/nearai/ironclaw/pull/1254))
|
||||
|
||||
### Fixed
|
||||
|
||||
- bump Feishu channel version for promotion
|
||||
- *(approval)* make "always" auto-approve work for credentialed HTTP requests ([#1257](https://github.com/nearai/ironclaw/pull/1257))
|
||||
- skip NEAR AI session check when backend is not nearai ([#1413](https://github.com/nearai/ironclaw/pull/1413))
|
||||
|
||||
### Other
|
||||
|
||||
- Make hosted OAuth and MCP auth generic ([#1375](https://github.com/nearai/ironclaw/pull/1375))
|
||||
|
||||
## [0.20.0](https://github.com/nearai/ironclaw/compare/v0.19.0...v0.20.0) - 2026-03-19
|
||||
|
||||
### Added
|
||||
|
||||
- *(self-repair)* wire stuck_threshold, store, and builder ([#712](https://github.com/nearai/ironclaw/pull/712))
|
||||
- *(testing)* add FaultInjector framework for StubLlm ([#1233](https://github.com/nearai/ironclaw/pull/1233))
|
||||
- *(gateway)* unified settings page with subtabs ([#1191](https://github.com/nearai/ironclaw/pull/1191))
|
||||
- upgrade MiniMax default model to M2.7 ([#1357](https://github.com/nearai/ironclaw/pull/1357))
|
||||
|
||||
### Fixed
|
||||
|
||||
- navigate telegram E2E tests to channels subtab ([#1408](https://github.com/nearai/ironclaw/pull/1408))
|
||||
- add missing `builder` field and update E2E extensions tab navigation ([#1400](https://github.com/nearai/ironclaw/pull/1400))
|
||||
- remove debug_assert guards that panic on valid error paths ([#1385](https://github.com/nearai/ironclaw/pull/1385))
|
||||
- address valid review comments from PR #1359 ([#1380](https://github.com/nearai/ironclaw/pull/1380))
|
||||
- full_job routine runs stay running until linked job completion ([#1374](https://github.com/nearai/ironclaw/pull/1374))
|
||||
- full_job routine concurrency tracks linked job lifetime ([#1372](https://github.com/nearai/ironclaw/pull/1372))
|
||||
- remove -x from coverage pytest to prevent suite-blocking failures ([#1360](https://github.com/nearai/ironclaw/pull/1360))
|
||||
- add debug_assert invariant guards to critical code paths ([#1312](https://github.com/nearai/ironclaw/pull/1312))
|
||||
- *(mcp)* retry after missing session id errors ([#1355](https://github.com/nearai/ironclaw/pull/1355))
|
||||
- *(telegram)* preserve polling after secret-blocked updates ([#1353](https://github.com/nearai/ironclaw/pull/1353))
|
||||
- *(llm)* cap retry-after delays ([#1351](https://github.com/nearai/ironclaw/pull/1351))
|
||||
- *(setup)* remove nonexistent webhook secret command hint ([#1349](https://github.com/nearai/ironclaw/pull/1349))
|
||||
- Rate limiter returns retry after None instead of a duration ([#1269](https://github.com/nearai/ironclaw/pull/1269))
|
||||
|
||||
### Other
|
||||
|
||||
- bump telegram channel version to 0.2.5 ([#1410](https://github.com/nearai/ironclaw/pull/1410))
|
||||
- *(ci)* enforce test requirement for state machine and resilience changes ([#1230](https://github.com/nearai/ironclaw/pull/1230)) ([#1304](https://github.com/nearai/ironclaw/pull/1304))
|
||||
- Fix duplicate LLM responses for matched event routines ([#1275](https://github.com/nearai/ironclaw/pull/1275))
|
||||
- add Japanese README ([#1306](https://github.com/nearai/ironclaw/pull/1306))
|
||||
- *(ci)* add coverage gates via codecov.yml ([#1228](https://github.com/nearai/ironclaw/pull/1228)) ([#1291](https://github.com/nearai/ironclaw/pull/1291))
|
||||
- Redesign routine create requests for LLMs ([#1147](https://github.com/nearai/ironclaw/pull/1147))
|
||||
|
||||
## [0.19.0](https://github.com/nearai/ironclaw/compare/v0.18.0...v0.19.0) - 2026-03-17
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+156
-26
@@ -121,6 +121,15 @@ version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
|
||||
|
||||
[[package]]
|
||||
name = "ansi_term"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.21"
|
||||
@@ -157,7 +166,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -168,7 +177,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -392,6 +401,17 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "atty"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
|
||||
dependencies = [
|
||||
"hermit-abi 0.1.19",
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
@@ -941,6 +961,29 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.59.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bd2a9a458e8f4304c52c43ebb0cfbd520289f8379a52e329a38afda99bf8eb8"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"clap 2.34.0",
|
||||
"env_logger",
|
||||
"lazy_static",
|
||||
"lazycell",
|
||||
"log",
|
||||
"peeking_take_while",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash 1.1.0",
|
||||
"shlex",
|
||||
"which",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.66.1"
|
||||
@@ -1360,6 +1403,21 @@ dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "2.34.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c"
|
||||
dependencies = [
|
||||
"ansi_term",
|
||||
"atty",
|
||||
"bitflags 1.3.2",
|
||||
"strsim 0.8.0",
|
||||
"textwrap",
|
||||
"unicode-width 0.1.14",
|
||||
"vec_map",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.60"
|
||||
@@ -1379,7 +1437,7 @@ dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
"strsim 0.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1388,7 +1446,7 @@ version = "4.5.66"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c757a3b7e39161a4e56f9365141ada2a6c915a8622c408ab6bb4b5d047371031"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap 4.5.60",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1697,7 +1755,7 @@ dependencies = [
|
||||
"anes",
|
||||
"cast",
|
||||
"ciborium",
|
||||
"clap",
|
||||
"clap 4.5.60",
|
||||
"criterion-plot",
|
||||
"is-terminal",
|
||||
"itertools 0.10.5",
|
||||
@@ -1940,7 +1998,7 @@ dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"strsim 0.11.1",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
@@ -2136,7 +2194,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2310,6 +2368,19 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7"
|
||||
dependencies = [
|
||||
"atty",
|
||||
"humantime",
|
||||
"log",
|
||||
"regex",
|
||||
"termcolor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -2323,7 +2394,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2848,6 +2919,15 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
@@ -3008,6 +3088,12 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "humantime"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "0.14.32"
|
||||
@@ -3150,7 +3236,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.3",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
@@ -3390,8 +3476,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ironclaw"
|
||||
version = "0.19.0"
|
||||
version = "0.22.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -3406,7 +3493,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"clap",
|
||||
"clap 4.5.60",
|
||||
"clap_complete",
|
||||
"criterion",
|
||||
"cron",
|
||||
@@ -3428,6 +3515,7 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"iana-time-zone",
|
||||
"insta",
|
||||
"ironclaw_common",
|
||||
"ironclaw_safety",
|
||||
"json5",
|
||||
"libsql",
|
||||
@@ -3457,6 +3545,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_yml",
|
||||
"sha2",
|
||||
"silk-rs",
|
||||
"subtle",
|
||||
"tar",
|
||||
"tempfile",
|
||||
@@ -3486,8 +3575,16 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ironclaw_safety"
|
||||
name = "ironclaw_common"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ironclaw_safety"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"regex",
|
||||
@@ -3512,9 +3609,9 @@ version = "0.4.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"hermit-abi 0.5.2",
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3748,7 +3845,7 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f2a50a585a1184a43621a9133b7702ba5cb7a87ca5e704056b19d8005de6faf"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"bindgen 0.66.1",
|
||||
"cc",
|
||||
]
|
||||
|
||||
@@ -4134,7 +4231,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4222,7 +4319,7 @@ version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"hermit-abi 0.5.2",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -4700,7 +4797,7 @@ checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"hermit-abi",
|
||||
"hermit-abi 0.5.2",
|
||||
"pin-project-lite",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -4920,7 +5017,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash 2.1.1",
|
||||
"rustls 0.23.37",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.3",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -4957,9 +5054,9 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.3",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5472,7 +5569,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6098,6 +6195,18 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "silk-rs"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "014e6619f35a385ff848570e73a0b8c36b31031e0ee11cac70192b50097b1cfe"
|
||||
dependencies = [
|
||||
"bindgen 0.59.2",
|
||||
"bytes",
|
||||
"cc",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.8"
|
||||
@@ -6154,7 +6263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6226,6 +6335,12 @@ dependencies = [
|
||||
"unicode-properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -6379,7 +6494,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6466,6 +6581,15 @@ dependencies = [
|
||||
"testcontainers",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "textwrap"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
|
||||
dependencies = [
|
||||
"unicode-width 0.1.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -7179,7 +7303,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7332,6 +7456,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vec_map"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
@@ -8029,7 +8159,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+8
-3
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = [".", "crates/ironclaw_safety"]
|
||||
members = [".", "crates/ironclaw_common", "crates/ironclaw_safety"]
|
||||
exclude = [
|
||||
"channels-src/discord",
|
||||
"channels-src/telegram",
|
||||
@@ -20,7 +20,7 @@ exclude = [
|
||||
|
||||
[package]
|
||||
name = "ironclaw"
|
||||
version = "0.19.0"
|
||||
version = "0.22.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.92"
|
||||
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
||||
@@ -100,8 +100,11 @@ tower-http = { version = "0.6", features = ["trace", "cors", "set-header"] }
|
||||
# Cron scheduling for routines
|
||||
cron = "0.13"
|
||||
|
||||
# Shared types
|
||||
ironclaw_common = { path = "crates/ironclaw_common", version = "0.1.0" }
|
||||
|
||||
# Safety/sanitization
|
||||
ironclaw_safety = { path = "crates/ironclaw_safety", version = "0.1.0" }
|
||||
ironclaw_safety = { path = "crates/ironclaw_safety", version = "0.2.0" }
|
||||
regex = "1"
|
||||
aho-corasick = "1"
|
||||
|
||||
@@ -135,6 +138,7 @@ wasmtime-wasi = "28" # WASI support for component model
|
||||
wasmparser = "0.220" # WASM binary parsing for validation
|
||||
|
||||
# Cryptography for secrets management
|
||||
aes = "0.8"
|
||||
aes-gcm = "0.10"
|
||||
hkdf = "0.12"
|
||||
hmac = "0.12"
|
||||
@@ -171,6 +175,7 @@ base64 = "0.22.1"
|
||||
mime_guess = "2.0.5"
|
||||
clap_complete = "4.5.0"
|
||||
lru = "0.16.3"
|
||||
silk-rs = "0.2.0"
|
||||
|
||||
# HTML to Markdown conversion (feature gated)
|
||||
html-to-markdown-rs = { version = "2.3", optional = true }
|
||||
|
||||
@@ -77,6 +77,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Linq | ✅ | ❌ | P3 | Real iMessage via API, no Mac required |
|
||||
| Feishu/Lark | ✅ | 🚧 | P3 | WASM channel with Event Subscription v2.0; Bitable/Docx tools planned |
|
||||
| LINE | ✅ | ❌ | P3 | |
|
||||
| WeChat (iLink bot) | ✅ | 🚧 | P2 | Extension-first channel (`channels-src/wechat`), single-account DM flow with QR login, typing, image send/receive, inbound file extraction, and inbound voice handling with SILK-to-WAV fallback; multi-account plus video and outbound file parity follow-up |
|
||||
| WebChat | ✅ | ✅ | - | Web gateway chat |
|
||||
| Matrix | ✅ | ❌ | P3 | E2EE support |
|
||||
| Mattermost | ✅ | ❌ | P3 | Emoji reactions, interactive buttons, model picker |
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
},
|
||||
{
|
||||
"name": "feishu_app_secret",
|
||||
"prompt": "Enter your Feishu/Lark App Secret",
|
||||
"prompt": "Enter your Feishu/Lark App Secret (from your app settings at open.feishu.cn)",
|
||||
"optional": false
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
/*.wasm
|
||||
Generated
+568
@@ -0,0 +1,568 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.16.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.183"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "spdx"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e913f9242315ca39eff82aee0e19ee7a372155717ff0eb082c741e435ce25ed1"
|
||||
dependencies = [
|
||||
"leb128",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "185dfcd27fa5db2e6a23906b54c28199935f71d9a27a1a27b3a88d6fee2afae7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"spdx",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d07b6a3b550fefa1a914b6d54fc175dd11c3392da11eee604e6ffc759805d25"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bitflags",
|
||||
"hashbrown 0.14.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wechat-channel"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
"cipher",
|
||||
"md-5",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a2b3e15cd6068f233926e7d8c7c588b2ec4fb7cc7bf3824115e7c7e2a8485a3"
|
||||
dependencies = [
|
||||
"wit-bindgen-rt",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b632a5a0fa2409489bd49c9e6d99fcc61bb3d4ce9d1907d44662e75a28c71172"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rt"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7947d0131c7c9da3f01dfde0ab8bd4c4cf3c5bd49b6dba0ae640f1fa752572ea"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4329de4186ee30e2ef30a0533f9b3c123c019a237a7c82d692807bf1b3ee2697"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "177fb7ee1484d113b4792cc480b1ba57664bbc951b42a4beebe573502135b1fc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b505603761ed400c90ed30261f44a768317348e49f1864e82ecdc3b2744e5627"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae2a7999ed18efe59be8de2db9cb2b7f84d88b27818c79353dfc53131840fe1a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "wechat-channel"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "WeChat iLink Bot channel for IronClaw"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "0.36"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
base64 = "0.22"
|
||||
aes = "0.8"
|
||||
cipher = "0.4"
|
||||
md-5 = "0.10"
|
||||
rand = "0.8"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
|
||||
[workspace]
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "Building WeChat channel WASM component..."
|
||||
|
||||
cargo build --release --target wasm32-wasip2
|
||||
|
||||
WASM_PATH="target/wasm32-wasip2/release/wechat_channel.wasm"
|
||||
|
||||
if [ -f "$WASM_PATH" ]; then
|
||||
if command -v wasm-tools >/dev/null 2>&1; then
|
||||
wasm-tools component new "$WASM_PATH" -o wechat.wasm 2>/dev/null || cp "$WASM_PATH" wechat.wasm
|
||||
wasm-tools strip wechat.wasm -o wechat.wasm
|
||||
else
|
||||
cp "$WASM_PATH" wechat.wasm
|
||||
echo "wasm-tools not found; copied raw wasm output without component conversion/strip"
|
||||
fi
|
||||
|
||||
echo "Built: wechat.wasm ($(du -h wechat.wasm | cut -f1))"
|
||||
echo ""
|
||||
echo "To install:"
|
||||
echo " mkdir -p ~/.ironclaw/channels"
|
||||
echo " cp wechat.wasm wechat.capabilities.json ~/.ironclaw/channels/"
|
||||
else
|
||||
echo "Error: WASM output not found at $WASM_PATH"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,277 @@
|
||||
use base64::Engine as _;
|
||||
|
||||
use crate::near::agent::channel_host;
|
||||
use crate::types::{
|
||||
BaseInfo, GetConfigRequest, GetConfigResponse, GetUpdatesRequest, GetUpdatesResponse,
|
||||
GetUploadUrlRequest, GetUploadUrlResponse, MessageItem, OutboundWechatMessage,
|
||||
SendMessageRequest, SendTypingRequest, SendTypingResponse, TextItem, WechatConfig,
|
||||
MESSAGE_ITEM_TEXT, MESSAGE_STATE_FINISH, MESSAGE_TYPE_BOT,
|
||||
};
|
||||
|
||||
pub fn base_info() -> BaseInfo {
|
||||
BaseInfo {
|
||||
channel_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_trailing_slash(base_url: &str) -> String {
|
||||
if base_url.ends_with('/') {
|
||||
base_url.to_string()
|
||||
} else {
|
||||
format!("{base_url}/")
|
||||
}
|
||||
}
|
||||
|
||||
fn random_wechat_uin() -> String {
|
||||
let seed = (channel_host::now_millis() % u32::MAX as u64) as u32;
|
||||
base64::engine::general_purpose::STANDARD.encode(seed.to_string())
|
||||
}
|
||||
|
||||
fn request_headers(body: &[u8]) -> String {
|
||||
serde_json::json!({
|
||||
"Content-Type": "application/json",
|
||||
"AuthorizationType": "ilink_bot_token",
|
||||
"Authorization": "Bearer {WECHAT_BOT_TOKEN}",
|
||||
"Content-Length": body.len().to_string(),
|
||||
"X-WECHAT-UIN": random_wechat_uin(),
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn summarize_body_preview(bytes: &[u8], limit: usize) -> String {
|
||||
let preview = String::from_utf8_lossy(&bytes[..bytes.len().min(limit)]);
|
||||
let normalized = preview.replace(['\n', '\r'], " ");
|
||||
if bytes.len() > limit {
|
||||
format!("{normalized}...")
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_updates(
|
||||
config: &WechatConfig,
|
||||
get_updates_buf: &str,
|
||||
) -> Result<GetUpdatesResponse, String> {
|
||||
get_updates_with_timeout(config, get_updates_buf, config.long_poll_timeout_ms)
|
||||
}
|
||||
|
||||
pub fn get_updates_with_timeout(
|
||||
config: &WechatConfig,
|
||||
get_updates_buf: &str,
|
||||
timeout_ms: u32,
|
||||
) -> Result<GetUpdatesResponse, String> {
|
||||
let body = serde_json::to_vec(&GetUpdatesRequest {
|
||||
get_updates_buf: get_updates_buf.to_string(),
|
||||
base_info: base_info(),
|
||||
})
|
||||
.map_err(|e| format!("Failed to encode getUpdates request: {e}"))?;
|
||||
let headers = request_headers(&body);
|
||||
let url = format!(
|
||||
"{}ilink/bot/getupdates",
|
||||
ensure_trailing_slash(&config.base_url)
|
||||
);
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!(
|
||||
"WeChat getUpdates request: cursor_len={} timeout_ms={}",
|
||||
get_updates_buf.len(),
|
||||
config.long_poll_timeout_ms
|
||||
),
|
||||
);
|
||||
let response =
|
||||
channel_host::http_request("POST", &url, &headers, Some(&body), Some(timeout_ms))
|
||||
.map_err(|e| format!("getUpdates request failed: {e}"))?;
|
||||
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!(
|
||||
"WeChat getUpdates response: status={} bytes={} has_image_marker={} has_aeskey_marker={} preview={}",
|
||||
response.status,
|
||||
response.body.len(),
|
||||
response
|
||||
.body
|
||||
.windows(b"image_item".len())
|
||||
.any(|window| window == b"image_item"),
|
||||
response
|
||||
.body
|
||||
.windows(b"aeskey".len())
|
||||
.any(|window| window == b"aeskey"),
|
||||
summarize_body_preview(&response.body, 160)
|
||||
),
|
||||
);
|
||||
|
||||
if response.status != 200 {
|
||||
let body = String::from_utf8_lossy(&response.body);
|
||||
return Err(format!("getUpdates returned {}: {}", response.status, body));
|
||||
}
|
||||
|
||||
let parsed: GetUpdatesResponse = serde_json::from_slice(&response.body)
|
||||
.map_err(|e| format!("Failed to parse getUpdates response: {e}"))?;
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!(
|
||||
"WeChat getUpdates parsed: ret={:?} errcode={:?} msg_count={} next_cursor_len={}",
|
||||
parsed.ret,
|
||||
parsed.errcode,
|
||||
parsed.msgs.len(),
|
||||
parsed.get_updates_buf.as_deref().unwrap_or_default().len()
|
||||
),
|
||||
);
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
pub fn send_text_message(
|
||||
config: &WechatConfig,
|
||||
to_user_id: &str,
|
||||
text: &str,
|
||||
context_token: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let message = SendMessageRequest {
|
||||
msg: OutboundWechatMessage {
|
||||
from_user_id: String::new(),
|
||||
to_user_id: to_user_id.to_string(),
|
||||
client_id: format!("wechat-{}", channel_host::now_millis()),
|
||||
message_type: MESSAGE_TYPE_BOT,
|
||||
message_state: MESSAGE_STATE_FINISH,
|
||||
item_list: vec![MessageItem {
|
||||
r#type: Some(MESSAGE_ITEM_TEXT),
|
||||
text_item: Some(TextItem {
|
||||
text: text.to_string(),
|
||||
}),
|
||||
image_item: None,
|
||||
voice_item: None,
|
||||
file_item: None,
|
||||
}],
|
||||
context_token: context_token.map(str::to_string),
|
||||
},
|
||||
base_info: base_info(),
|
||||
};
|
||||
|
||||
send_message_request(config, &message)
|
||||
}
|
||||
|
||||
pub fn send_message_request(
|
||||
config: &WechatConfig,
|
||||
message: &SendMessageRequest,
|
||||
) -> Result<(), String> {
|
||||
let body = serde_json::to_vec(message)
|
||||
.map_err(|e| format!("Failed to encode sendMessage request: {e}"))?;
|
||||
let headers = request_headers(&body);
|
||||
let url = format!(
|
||||
"{}ilink/bot/sendmessage",
|
||||
ensure_trailing_slash(&config.base_url)
|
||||
);
|
||||
|
||||
let response = channel_host::http_request("POST", &url, &headers, Some(&body), Some(15_000))
|
||||
.map_err(|e| format!("sendMessage request failed: {e}"))?;
|
||||
|
||||
if response.status != 200 {
|
||||
let body = String::from_utf8_lossy(&response.body);
|
||||
return Err(format!(
|
||||
"sendMessage returned {}: {}",
|
||||
response.status, body
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_upload_url(
|
||||
config: &WechatConfig,
|
||||
request: &GetUploadUrlRequest,
|
||||
) -> Result<GetUploadUrlResponse, String> {
|
||||
let body = serde_json::to_vec(request)
|
||||
.map_err(|e| format!("Failed to encode getUploadUrl request: {e}"))?;
|
||||
let headers = request_headers(&body);
|
||||
let url = format!(
|
||||
"{}ilink/bot/getuploadurl",
|
||||
ensure_trailing_slash(&config.base_url)
|
||||
);
|
||||
|
||||
let response = channel_host::http_request("POST", &url, &headers, Some(&body), Some(15_000))
|
||||
.map_err(|e| format!("getUploadUrl request failed: {e}"))?;
|
||||
|
||||
if response.status != 200 {
|
||||
let body = String::from_utf8_lossy(&response.body);
|
||||
return Err(format!(
|
||||
"getUploadUrl returned {}: {}",
|
||||
response.status, body
|
||||
));
|
||||
}
|
||||
|
||||
serde_json::from_slice(&response.body)
|
||||
.map_err(|e| format!("Failed to parse getUploadUrl response: {e}"))
|
||||
}
|
||||
|
||||
pub fn get_config(
|
||||
config: &WechatConfig,
|
||||
ilink_user_id: &str,
|
||||
context_token: Option<&str>,
|
||||
) -> Result<GetConfigResponse, String> {
|
||||
let body = serde_json::to_vec(&GetConfigRequest {
|
||||
ilink_user_id: ilink_user_id.to_string(),
|
||||
context_token: context_token.map(str::to_string),
|
||||
base_info: base_info(),
|
||||
})
|
||||
.map_err(|e| format!("Failed to encode getConfig request: {e}"))?;
|
||||
let headers = request_headers(&body);
|
||||
let url = format!(
|
||||
"{}ilink/bot/getconfig",
|
||||
ensure_trailing_slash(&config.base_url)
|
||||
);
|
||||
|
||||
let response = channel_host::http_request("POST", &url, &headers, Some(&body), Some(10_000))
|
||||
.map_err(|e| format!("getConfig request failed: {e}"))?;
|
||||
|
||||
if response.status != 200 {
|
||||
let body = String::from_utf8_lossy(&response.body);
|
||||
return Err(format!("getConfig returned {}: {}", response.status, body));
|
||||
}
|
||||
|
||||
serde_json::from_slice(&response.body)
|
||||
.map_err(|e| format!("Failed to parse getConfig response: {e}"))
|
||||
}
|
||||
|
||||
pub fn send_typing(
|
||||
config: &WechatConfig,
|
||||
ilink_user_id: &str,
|
||||
typing_ticket: &str,
|
||||
status: i32,
|
||||
) -> Result<(), String> {
|
||||
let body = serde_json::to_vec(&SendTypingRequest {
|
||||
ilink_user_id: ilink_user_id.to_string(),
|
||||
typing_ticket: typing_ticket.to_string(),
|
||||
status,
|
||||
base_info: base_info(),
|
||||
})
|
||||
.map_err(|e| format!("Failed to encode sendTyping request: {e}"))?;
|
||||
let headers = request_headers(&body);
|
||||
let url = format!(
|
||||
"{}ilink/bot/sendtyping",
|
||||
ensure_trailing_slash(&config.base_url)
|
||||
);
|
||||
|
||||
let response = channel_host::http_request("POST", &url, &headers, Some(&body), Some(10_000))
|
||||
.map_err(|e| format!("sendTyping request failed: {e}"))?;
|
||||
|
||||
if response.status != 200 {
|
||||
let body = String::from_utf8_lossy(&response.body);
|
||||
return Err(format!("sendTyping returned {}: {}", response.status, body));
|
||||
}
|
||||
|
||||
let parsed: SendTypingResponse = serde_json::from_slice(&response.body)
|
||||
.map_err(|e| format!("Failed to parse sendTyping response: {e}"))?;
|
||||
|
||||
if parsed.ret.unwrap_or(0) != 0 {
|
||||
let errmsg = parsed
|
||||
.errmsg
|
||||
.as_deref()
|
||||
.unwrap_or("unknown WeChat sendTyping error");
|
||||
return Err(format!(
|
||||
"sendTyping returned ret={} errmsg={errmsg}",
|
||||
parsed.ret.unwrap_or(-1)
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub const TOKEN_SECRET_NAME: &str = "wechat_bot_token";
|
||||
pub const CONFIG_PATH: &str = "config.json";
|
||||
pub const GET_UPDATES_BUF_PATH: &str = "state/get_updates_buf.json";
|
||||
pub const CONTEXT_TOKENS_PATH: &str = "state/context_tokens.json";
|
||||
pub const TYPING_TICKETS_PATH: &str = "state/typing_tickets.json";
|
||||
pub const PENDING_INBOUND_PATH: &str = "state/pending_inbound.json";
|
||||
@@ -0,0 +1,942 @@
|
||||
wit_bindgen::generate!({
|
||||
world: "sandboxed-channel",
|
||||
path: "../../wit/channel.wit",
|
||||
});
|
||||
|
||||
mod api;
|
||||
mod auth;
|
||||
mod media;
|
||||
mod state;
|
||||
mod types;
|
||||
|
||||
use exports::near::agent::channel::{
|
||||
AgentResponse, ChannelConfig, Guest, PollConfig, StatusType, StatusUpdate,
|
||||
};
|
||||
use near::agent::channel_host::{self, EmittedMessage};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::auth::TOKEN_SECRET_NAME;
|
||||
use crate::state::{
|
||||
load_config, load_context_tokens, load_get_updates_buf, load_pending_inbound_bundles,
|
||||
load_typing_tickets, persist_config, persist_context_tokens, persist_get_updates_buf,
|
||||
persist_pending_inbound_bundles, persist_typing_tickets, PendingInboundBundle,
|
||||
StoredInboundAttachment, TypingTicketEntry,
|
||||
};
|
||||
use crate::types::{
|
||||
OutboundMetadata, WechatConfig, WechatMessage, MESSAGE_ITEM_TEXT, MESSAGE_TYPE_USER,
|
||||
TYPING_STATUS_CANCEL, TYPING_STATUS_TYPING,
|
||||
};
|
||||
|
||||
const TYPING_TICKET_TTL_MS: u64 = 24 * 60 * 60 * 1000;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum WechatStatusAction {
|
||||
Typing,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
struct WechatChannel;
|
||||
|
||||
impl Guest for WechatChannel {
|
||||
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
|
||||
let config = serde_json::from_str::<WechatConfig>(&config_json)
|
||||
.map_err(|e| format!("Failed to parse WeChat config: {e}"))?;
|
||||
persist_config(&config)?;
|
||||
|
||||
Ok(ChannelConfig {
|
||||
display_name: "WeChat".to_string(),
|
||||
http_endpoints: Vec::new(),
|
||||
poll: Some(PollConfig {
|
||||
interval_ms: config.poll_interval_ms.max(30_000),
|
||||
enabled: true,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
fn on_http_request(
|
||||
_req: exports::near::agent::channel::IncomingHttpRequest,
|
||||
) -> exports::near::agent::channel::OutgoingHttpResponse {
|
||||
exports::near::agent::channel::OutgoingHttpResponse {
|
||||
status: 404,
|
||||
headers_json: "{}".to_string(),
|
||||
body: b"{\"error\":\"wechat channel does not expose webhooks\"}".to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_poll() {
|
||||
if !channel_host::secret_exists(TOKEN_SECRET_NAME) {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Warn,
|
||||
"WeChat bot token is missing; skipping poll",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let config = load_config();
|
||||
let cursor = load_get_updates_buf();
|
||||
let mut current_cursor = cursor.clone();
|
||||
let mut context_tokens = load_context_tokens();
|
||||
let mut pending_inbound = match load_pending_inbound_bundles() {
|
||||
Ok(bundles) => bundles,
|
||||
Err(error) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Failed to load WeChat pending inbound bundles: {error}"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut pending_inbound_changed = false;
|
||||
|
||||
for bundle in take_due_pending_bundles(&mut pending_inbound, channel_host::now_millis()) {
|
||||
pending_inbound_changed = true;
|
||||
emit_buffered_bundle(bundle);
|
||||
}
|
||||
|
||||
match api::get_updates(&config, ¤t_cursor) {
|
||||
Ok(response) => {
|
||||
if response.errcode == Some(-14) {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
"WeChat getUpdates returned errcode=-14; reconnect the channel",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if response.ret.unwrap_or(0) != 0 {
|
||||
let errmsg = response
|
||||
.errmsg
|
||||
.as_deref()
|
||||
.unwrap_or("unknown WeChat polling error");
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Warn,
|
||||
&format!(
|
||||
"WeChat getUpdates returned ret={} errmsg={errmsg}",
|
||||
response.ret.unwrap_or(-1)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(next_cursor) = response.get_updates_buf.as_deref() {
|
||||
if next_cursor != current_cursor {
|
||||
current_cursor = next_cursor.to_string();
|
||||
if let Err(error) = persist_get_updates_buf(next_cursor) {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Warn,
|
||||
&format!("Failed to persist WeChat polling cursor: {error}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut context_tokens_changed = false;
|
||||
for message in response.msgs {
|
||||
if let Some(from_user_id) = message.from_user_id.as_deref() {
|
||||
if let Some(context_token) = message.context_token.as_deref() {
|
||||
let changed = context_tokens
|
||||
.insert(from_user_id.to_string(), context_token.to_string())
|
||||
.as_deref()
|
||||
!= Some(context_token);
|
||||
context_tokens_changed |= changed;
|
||||
}
|
||||
}
|
||||
match incoming_bundle_from_message(&config, message) {
|
||||
Ok(Some(bundle)) => {
|
||||
let emitted = process_incoming_bundle(
|
||||
&mut pending_inbound,
|
||||
bundle,
|
||||
&mut pending_inbound_changed,
|
||||
channel_host::now_millis(),
|
||||
u64::from(config.inbound_merge_window_ms),
|
||||
);
|
||||
for emitted_bundle in emitted {
|
||||
emit_buffered_bundle(emitted_bundle);
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Failed to map WeChat inbound message: {error}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect_follow_up_bundles(
|
||||
&config,
|
||||
&mut current_cursor,
|
||||
&mut context_tokens,
|
||||
&mut context_tokens_changed,
|
||||
&mut pending_inbound,
|
||||
&mut pending_inbound_changed,
|
||||
);
|
||||
|
||||
for bundle in
|
||||
take_due_pending_bundles(&mut pending_inbound, channel_host::now_millis())
|
||||
{
|
||||
pending_inbound_changed = true;
|
||||
emit_buffered_bundle(bundle);
|
||||
}
|
||||
|
||||
if context_tokens_changed {
|
||||
if let Err(error) = persist_context_tokens(&context_tokens) {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Warn,
|
||||
&format!("Failed to persist WeChat context tokens: {error}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if pending_inbound_changed {
|
||||
if let Err(error) = persist_pending_inbound_bundles(&pending_inbound) {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Warn,
|
||||
&format!("Failed to persist WeChat pending inbound bundles: {error}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("WeChat polling failed: {error}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_respond(response: AgentResponse) -> Result<(), String> {
|
||||
let metadata = serde_json::from_str::<OutboundMetadata>(&response.metadata_json)
|
||||
.map_err(|e| format!("Invalid WeChat response metadata: {e}"))?;
|
||||
let config = load_config();
|
||||
let context_tokens = load_context_tokens();
|
||||
let context_token = metadata
|
||||
.context_token
|
||||
.clone()
|
||||
.or_else(|| context_tokens.get(&metadata.from_user_id).cloned());
|
||||
if let Err(error) = send_typing_indicator(
|
||||
&config,
|
||||
&metadata,
|
||||
context_token.as_deref(),
|
||||
TYPING_STATUS_CANCEL,
|
||||
false,
|
||||
) {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Debug,
|
||||
&format!("Failed to cancel WeChat typing indicator before reply: {error}"),
|
||||
);
|
||||
}
|
||||
|
||||
send_response(&config, &metadata, &response, context_token.as_deref())
|
||||
}
|
||||
|
||||
fn on_status(update: StatusUpdate) {
|
||||
let Some(action) = classify_status_update(&update) else {
|
||||
return;
|
||||
};
|
||||
let metadata = match serde_json::from_str::<OutboundMetadata>(&update.metadata_json) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(_) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Debug,
|
||||
"on_status: no valid WeChat metadata, skipping typing update",
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let config = load_config();
|
||||
let context_tokens = load_context_tokens();
|
||||
let context_token = resolve_context_token(&metadata, &context_tokens);
|
||||
|
||||
let (typing_status, allow_ticket_fetch) = match action {
|
||||
WechatStatusAction::Typing => (TYPING_STATUS_TYPING, true),
|
||||
WechatStatusAction::Cancel => (TYPING_STATUS_CANCEL, false),
|
||||
};
|
||||
|
||||
if let Err(error) = send_typing_indicator(
|
||||
&config,
|
||||
&metadata,
|
||||
context_token.as_deref(),
|
||||
typing_status,
|
||||
allow_ticket_fetch,
|
||||
) {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Debug,
|
||||
&format!("WeChat typing update failed: {error}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_broadcast(_user_id: String, _response: AgentResponse) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_shutdown() {}
|
||||
}
|
||||
|
||||
fn incoming_bundle_from_message(
|
||||
config: &WechatConfig,
|
||||
message: WechatMessage,
|
||||
) -> Result<Option<PendingInboundBundle>, String> {
|
||||
if message.message_type != Some(MESSAGE_TYPE_USER) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let from_user_id = match message.from_user_id.as_deref() {
|
||||
Some(user_id) => user_id,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let text = extract_text(&message);
|
||||
let attachments = media::extract_inbound_attachments(config, &message)?
|
||||
.into_iter()
|
||||
.map(StoredInboundAttachment::from)
|
||||
.collect::<Vec<_>>();
|
||||
if text.trim().is_empty() && attachments.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(PendingInboundBundle {
|
||||
from_user_id: from_user_id.to_string(),
|
||||
to_user_id: message.to_user_id,
|
||||
session_id: message.session_id,
|
||||
context_token: message.context_token,
|
||||
message_id: message.message_id,
|
||||
flush_at_ms: 0,
|
||||
text,
|
||||
attachments,
|
||||
}))
|
||||
}
|
||||
|
||||
fn process_incoming_bundle(
|
||||
pending_inbound: &mut std::collections::HashMap<String, PendingInboundBundle>,
|
||||
mut bundle: PendingInboundBundle,
|
||||
pending_inbound_changed: &mut bool,
|
||||
now_ms: u64,
|
||||
inbound_merge_window_ms: u64,
|
||||
) -> Vec<PendingInboundBundle> {
|
||||
let key = bundle.from_user_id.clone();
|
||||
let bundle_has_text = !bundle.text.trim().is_empty();
|
||||
let bundle_has_attachments = !bundle.attachments.is_empty();
|
||||
|
||||
if let Some(mut pending) = pending_inbound.remove(&key) {
|
||||
*pending_inbound_changed = true;
|
||||
|
||||
if bundle_has_text {
|
||||
let incoming_metadata = bundle.clone();
|
||||
pending.text = merge_text(&pending.text, &bundle.text);
|
||||
pending.attachments.extend(bundle.attachments);
|
||||
merge_bundle_metadata(&mut pending, &incoming_metadata);
|
||||
return vec![pending];
|
||||
}
|
||||
|
||||
let incoming_metadata = bundle.clone();
|
||||
pending.attachments.extend(bundle.attachments);
|
||||
merge_bundle_metadata(&mut pending, &incoming_metadata);
|
||||
pending.flush_at_ms = next_flush_deadline(now_ms, inbound_merge_window_ms);
|
||||
pending_inbound.insert(key, pending);
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
if bundle_has_attachments && !bundle_has_text {
|
||||
*pending_inbound_changed = true;
|
||||
bundle.flush_at_ms = next_flush_deadline(now_ms, inbound_merge_window_ms);
|
||||
pending_inbound.insert(key, bundle);
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![bundle]
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_follow_up_bundles(
|
||||
config: &WechatConfig,
|
||||
current_cursor: &mut String,
|
||||
context_tokens: &mut std::collections::HashMap<String, String>,
|
||||
context_tokens_changed: &mut bool,
|
||||
pending_inbound: &mut std::collections::HashMap<String, PendingInboundBundle>,
|
||||
pending_inbound_changed: &mut bool,
|
||||
) {
|
||||
while !pending_inbound.is_empty() {
|
||||
let now_ms = channel_host::now_millis();
|
||||
let Some(timeout_ms) = next_follow_up_timeout_ms(pending_inbound, now_ms) else {
|
||||
break;
|
||||
};
|
||||
if timeout_ms == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let timeout_ms_u32 = timeout_ms.min(u64::from(u32::MAX)) as u32;
|
||||
let response = match api::get_updates_with_timeout(config, current_cursor, timeout_ms_u32) {
|
||||
Ok(response) => response,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
if response.errcode == Some(-14) {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
"WeChat getUpdates returned errcode=-14 during follow-up merge window; reconnect the channel",
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if response.ret.unwrap_or(0) != 0 {
|
||||
let errmsg = response
|
||||
.errmsg
|
||||
.as_deref()
|
||||
.unwrap_or("unknown WeChat polling error");
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Warn,
|
||||
&format!(
|
||||
"WeChat getUpdates returned ret={} errmsg={errmsg} during follow-up merge window",
|
||||
response.ret.unwrap_or(-1)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(next_cursor) = response.get_updates_buf.as_deref() {
|
||||
if next_cursor != current_cursor {
|
||||
*current_cursor = next_cursor.to_string();
|
||||
if let Err(error) = persist_get_updates_buf(next_cursor) {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Warn,
|
||||
&format!("Failed to persist WeChat polling cursor: {error}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut saw_relevant_message = false;
|
||||
for message in response.msgs {
|
||||
if let Some(from_user_id) = message.from_user_id.as_deref() {
|
||||
if let Some(context_token) = message.context_token.as_deref() {
|
||||
let changed = context_tokens
|
||||
.insert(from_user_id.to_string(), context_token.to_string())
|
||||
.as_deref()
|
||||
!= Some(context_token);
|
||||
*context_tokens_changed |= changed;
|
||||
}
|
||||
}
|
||||
match incoming_bundle_from_message(config, message) {
|
||||
Ok(Some(bundle)) => {
|
||||
let emitted = process_incoming_bundle(
|
||||
pending_inbound,
|
||||
bundle,
|
||||
pending_inbound_changed,
|
||||
channel_host::now_millis(),
|
||||
u64::from(config.inbound_merge_window_ms),
|
||||
);
|
||||
for emitted_bundle in emitted {
|
||||
saw_relevant_message = true;
|
||||
emit_buffered_bundle(emitted_bundle);
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Failed to map WeChat inbound message: {error}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !saw_relevant_message && pending_inbound.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn next_flush_deadline(now_ms: u64, inbound_merge_window_ms: u64) -> u64 {
|
||||
now_ms.saturating_add(inbound_merge_window_ms)
|
||||
}
|
||||
|
||||
fn next_follow_up_timeout_ms(
|
||||
pending_inbound: &std::collections::HashMap<String, PendingInboundBundle>,
|
||||
now_ms: u64,
|
||||
) -> Option<u64> {
|
||||
pending_inbound
|
||||
.values()
|
||||
.map(|bundle| bundle.flush_at_ms.saturating_sub(now_ms))
|
||||
.min()
|
||||
}
|
||||
|
||||
fn take_due_pending_bundles(
|
||||
pending_inbound: &mut std::collections::HashMap<String, PendingInboundBundle>,
|
||||
now_ms: u64,
|
||||
) -> Vec<PendingInboundBundle> {
|
||||
let due_keys = pending_inbound
|
||||
.iter()
|
||||
.filter_map(|(key, bundle)| (bundle.flush_at_ms <= now_ms).then_some(key.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
due_keys
|
||||
.into_iter()
|
||||
.filter_map(|key| pending_inbound.remove(&key))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn emit_buffered_bundle(bundle: PendingInboundBundle) {
|
||||
let metadata = json!({
|
||||
"from_user_id": bundle.from_user_id,
|
||||
"to_user_id": bundle.to_user_id,
|
||||
"message_id": bundle.message_id,
|
||||
"session_id": bundle.session_id,
|
||||
"context_token": bundle.context_token,
|
||||
});
|
||||
|
||||
channel_host::emit_message(&EmittedMessage {
|
||||
user_id: bundle.from_user_id.clone(),
|
||||
user_name: None,
|
||||
content: bundle.text,
|
||||
thread_id: Some(format!("wechat:{}", bundle.from_user_id)),
|
||||
metadata_json: metadata.to_string(),
|
||||
attachments: bundle.attachments.into_iter().map(Into::into).collect(),
|
||||
});
|
||||
}
|
||||
|
||||
fn merge_bundle_metadata(target: &mut PendingInboundBundle, incoming: &PendingInboundBundle) {
|
||||
if incoming.to_user_id.is_some() {
|
||||
target.to_user_id = incoming.to_user_id.clone();
|
||||
}
|
||||
if incoming.session_id.is_some() {
|
||||
target.session_id = incoming.session_id.clone();
|
||||
}
|
||||
if incoming.context_token.is_some() {
|
||||
target.context_token = incoming.context_token.clone();
|
||||
}
|
||||
if incoming.message_id.is_some() {
|
||||
target.message_id = incoming.message_id;
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_text(existing: &str, incoming: &str) -> String {
|
||||
let existing = existing.trim();
|
||||
let incoming = incoming.trim();
|
||||
match (existing.is_empty(), incoming.is_empty()) {
|
||||
(true, true) => String::new(),
|
||||
(true, false) => incoming.to_string(),
|
||||
(false, true) => existing.to_string(),
|
||||
(false, false) => format!("{existing}\n\n{incoming}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_response(
|
||||
config: &WechatConfig,
|
||||
metadata: &OutboundMetadata,
|
||||
response: &AgentResponse,
|
||||
context_token: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let mut remaining_text = response.content.trim().to_string();
|
||||
let mut sent_attachment = false;
|
||||
|
||||
for attachment in &response.attachments {
|
||||
if !attachment.mime_type.starts_with("image/") {
|
||||
return Err(format!(
|
||||
"WeChat currently supports image attachments only, got {} ({})",
|
||||
attachment.filename, attachment.mime_type
|
||||
));
|
||||
}
|
||||
|
||||
let caption = if sent_attachment {
|
||||
""
|
||||
} else {
|
||||
remaining_text.as_str()
|
||||
};
|
||||
media::send_image_attachment(
|
||||
config,
|
||||
&metadata.from_user_id,
|
||||
attachment,
|
||||
context_token,
|
||||
caption,
|
||||
)?;
|
||||
sent_attachment = true;
|
||||
remaining_text.clear();
|
||||
}
|
||||
|
||||
if !remaining_text.is_empty() || !sent_attachment {
|
||||
api::send_text_message(
|
||||
config,
|
||||
&metadata.from_user_id,
|
||||
&remaining_text,
|
||||
context_token,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_text(message: &WechatMessage) -> String {
|
||||
message
|
||||
.item_list
|
||||
.iter()
|
||||
.find_map(|item| {
|
||||
if item.r#type == Some(MESSAGE_ITEM_TEXT) {
|
||||
item.text_item.as_ref().map(|item| item.text.clone())
|
||||
} else if item.r#type == Some(crate::types::MESSAGE_ITEM_VOICE) {
|
||||
item.voice_item
|
||||
.as_ref()
|
||||
.and_then(|item| item.text.as_ref())
|
||||
.cloned()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn is_terminal_text_status(message: &str) -> bool {
|
||||
let trimmed = message.trim();
|
||||
trimmed.eq_ignore_ascii_case("done")
|
||||
|| trimmed.eq_ignore_ascii_case("interrupted")
|
||||
|| trimmed.eq_ignore_ascii_case("awaiting approval")
|
||||
|| trimmed.eq_ignore_ascii_case("rejected")
|
||||
}
|
||||
|
||||
fn classify_status_update(update: &StatusUpdate) -> Option<WechatStatusAction> {
|
||||
match update.status {
|
||||
StatusType::Thinking => Some(WechatStatusAction::Typing),
|
||||
StatusType::Done
|
||||
| StatusType::Interrupted
|
||||
| StatusType::ApprovalNeeded
|
||||
| StatusType::AuthRequired => Some(WechatStatusAction::Cancel),
|
||||
StatusType::Status if is_terminal_text_status(&update.message) => {
|
||||
Some(WechatStatusAction::Cancel)
|
||||
}
|
||||
StatusType::ToolStarted
|
||||
| StatusType::ToolCompleted
|
||||
| StatusType::ToolResult
|
||||
| StatusType::Status
|
||||
| StatusType::JobStarted
|
||||
| StatusType::AuthCompleted => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_context_token(
|
||||
metadata: &OutboundMetadata,
|
||||
context_tokens: &std::collections::HashMap<String, String>,
|
||||
) -> Option<String> {
|
||||
metadata
|
||||
.context_token
|
||||
.clone()
|
||||
.or_else(|| context_tokens.get(&metadata.from_user_id).cloned())
|
||||
}
|
||||
|
||||
fn cached_typing_ticket(user_id: &str) -> Option<String> {
|
||||
let tickets = load_typing_tickets();
|
||||
let ticket = tickets.get(user_id)?;
|
||||
let trimmed = ticket.ticket.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let age_ms = channel_host::now_millis().saturating_sub(ticket.fetched_at_ms);
|
||||
if age_ms >= TYPING_TICKET_TTL_MS {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn persist_typing_ticket(user_id: &str, ticket: &str) -> Result<(), String> {
|
||||
let mut tickets = load_typing_tickets();
|
||||
tickets.insert(
|
||||
user_id.to_string(),
|
||||
TypingTicketEntry {
|
||||
ticket: ticket.to_string(),
|
||||
fetched_at_ms: channel_host::now_millis(),
|
||||
},
|
||||
);
|
||||
persist_typing_tickets(&tickets)
|
||||
}
|
||||
|
||||
fn clear_typing_ticket(user_id: &str) -> Result<(), String> {
|
||||
let mut tickets = load_typing_tickets();
|
||||
if tickets.remove(user_id).is_some() {
|
||||
persist_typing_tickets(&tickets)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_typing_ticket(
|
||||
config: &WechatConfig,
|
||||
user_id: &str,
|
||||
context_token: Option<&str>,
|
||||
) -> Result<Option<String>, String> {
|
||||
if let Some(ticket) = cached_typing_ticket(user_id) {
|
||||
return Ok(Some(ticket));
|
||||
}
|
||||
|
||||
let response = api::get_config(config, user_id, context_token)?;
|
||||
if response.ret.unwrap_or(0) != 0 {
|
||||
let errmsg = response
|
||||
.errmsg
|
||||
.as_deref()
|
||||
.unwrap_or("unknown WeChat getConfig error");
|
||||
return Err(format!(
|
||||
"WeChat getConfig returned ret={} errmsg={errmsg}",
|
||||
response.ret.unwrap_or(-1)
|
||||
));
|
||||
}
|
||||
|
||||
let Some(ticket) = response
|
||||
.typing_ticket
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|ticket| !ticket.is_empty())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if let Err(error) = persist_typing_ticket(user_id, ticket) {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Warn,
|
||||
&format!("Failed to persist WeChat typing ticket: {error}"),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Some(ticket.to_string()))
|
||||
}
|
||||
|
||||
fn send_typing_indicator(
|
||||
config: &WechatConfig,
|
||||
metadata: &OutboundMetadata,
|
||||
context_token: Option<&str>,
|
||||
status: i32,
|
||||
allow_ticket_fetch: bool,
|
||||
) -> Result<(), String> {
|
||||
let ticket = if allow_ticket_fetch {
|
||||
resolve_typing_ticket(config, &metadata.from_user_id, context_token)?
|
||||
} else {
|
||||
cached_typing_ticket(&metadata.from_user_id)
|
||||
};
|
||||
|
||||
let Some(ticket) = ticket else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if let Err(error) = api::send_typing(config, &metadata.from_user_id, &ticket, status) {
|
||||
let _ = clear_typing_ticket(&metadata.from_user_id);
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
export!(WechatChannel);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{
|
||||
classify_status_update, extract_text, merge_text, process_incoming_bundle,
|
||||
take_due_pending_bundles, PendingInboundBundle, StoredInboundAttachment,
|
||||
WechatStatusAction,
|
||||
};
|
||||
use crate::exports::near::agent::channel::{StatusType, StatusUpdate};
|
||||
use crate::types::{MessageItem, VoiceItem, WechatMessage, MESSAGE_ITEM_VOICE};
|
||||
|
||||
fn make_bundle(user_id: &str, text: &str, image_count: usize) -> PendingInboundBundle {
|
||||
PendingInboundBundle {
|
||||
from_user_id: user_id.to_string(),
|
||||
to_user_id: Some("bot".to_string()),
|
||||
session_id: Some("session-1".to_string()),
|
||||
context_token: Some("ctx-1".to_string()),
|
||||
message_id: Some(1),
|
||||
flush_at_ms: 0,
|
||||
text: text.to_string(),
|
||||
attachments: (0..image_count)
|
||||
.map(|index| StoredInboundAttachment {
|
||||
id: format!("att-{index}"),
|
||||
mime_type: "image/jpeg".to_string(),
|
||||
filename: Some(format!("photo-{index}.jpg")),
|
||||
size_bytes: Some(128),
|
||||
source_url: Some("https://example.com/image.jpg".to_string()),
|
||||
storage_key: None,
|
||||
extracted_text: None,
|
||||
extras_json: "{}".to_string(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_status_update_thinking_starts_typing() {
|
||||
let update = StatusUpdate {
|
||||
status: StatusType::Thinking,
|
||||
message: "Thinking...".to_string(),
|
||||
metadata_json: "{}".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
classify_status_update(&update),
|
||||
Some(WechatStatusAction::Typing)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_status_update_done_cancels_typing() {
|
||||
let update = StatusUpdate {
|
||||
status: StatusType::Done,
|
||||
message: "Done".to_string(),
|
||||
metadata_json: "{}".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
classify_status_update(&update),
|
||||
Some(WechatStatusAction::Cancel)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_status_update_approval_needed_cancels_typing() {
|
||||
let update = StatusUpdate {
|
||||
status: StatusType::ApprovalNeeded,
|
||||
message: "Approval needed".to_string(),
|
||||
metadata_json: "{}".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
classify_status_update(&update),
|
||||
Some(WechatStatusAction::Cancel)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_status_update_tool_started_is_ignored() {
|
||||
let update = StatusUpdate {
|
||||
status: StatusType::ToolStarted,
|
||||
message: "Tool started".to_string(),
|
||||
metadata_json: "{}".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(classify_status_update(&update), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_status_update_terminal_text_status_cancels_typing() {
|
||||
let update = StatusUpdate {
|
||||
status: StatusType::Status,
|
||||
message: "Awaiting approval".to_string(),
|
||||
metadata_json: "{}".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
classify_status_update(&update),
|
||||
Some(WechatStatusAction::Cancel)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_status_update_progress_status_is_ignored() {
|
||||
let update = StatusUpdate {
|
||||
status: StatusType::Status,
|
||||
message: "Context compaction started".to_string(),
|
||||
metadata_json: "{}".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(classify_status_update(&update), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_text_joins_non_empty_segments() {
|
||||
assert_eq!(merge_text("", "hello"), "hello");
|
||||
assert_eq!(merge_text("look", "what is this"), "look\n\nwhat is this");
|
||||
assert_eq!(merge_text("look", ""), "look");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_text_uses_voice_transcript_when_present() {
|
||||
let message = WechatMessage {
|
||||
message_id: Some(1),
|
||||
from_user_id: Some("user-1".to_string()),
|
||||
to_user_id: Some("bot-1".to_string()),
|
||||
session_id: None,
|
||||
message_type: None,
|
||||
context_token: None,
|
||||
item_list: vec![MessageItem {
|
||||
r#type: Some(MESSAGE_ITEM_VOICE),
|
||||
text_item: None,
|
||||
image_item: None,
|
||||
voice_item: Some(VoiceItem {
|
||||
media: None,
|
||||
encode_type: Some(6),
|
||||
playtime: Some(1500),
|
||||
text: Some("voice transcript".to_string()),
|
||||
}),
|
||||
file_item: None,
|
||||
}],
|
||||
};
|
||||
|
||||
assert_eq!(extract_text(&message), "voice transcript");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_incoming_bundle_merges_buffered_image_with_follow_up_text() {
|
||||
let mut pending = HashMap::new();
|
||||
let mut changed = false;
|
||||
|
||||
let emitted = process_incoming_bundle(
|
||||
&mut pending,
|
||||
make_bundle("u1", "", 1),
|
||||
&mut changed,
|
||||
100,
|
||||
5_000,
|
||||
);
|
||||
assert!(emitted.is_empty());
|
||||
assert!(changed);
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending["u1"].flush_at_ms, 5100);
|
||||
|
||||
changed = false;
|
||||
let emitted = process_incoming_bundle(
|
||||
&mut pending,
|
||||
make_bundle("u1", "What is in this image?", 0),
|
||||
&mut changed,
|
||||
200,
|
||||
5_000,
|
||||
);
|
||||
assert!(changed);
|
||||
assert!(pending.is_empty());
|
||||
assert_eq!(emitted.len(), 1);
|
||||
assert_eq!(emitted[0].text, "What is in this image?");
|
||||
assert_eq!(emitted[0].attachments.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_incoming_bundle_emits_text_and_images_together_without_buffering() {
|
||||
let mut pending = HashMap::new();
|
||||
let mut changed = false;
|
||||
|
||||
let emitted = process_incoming_bundle(
|
||||
&mut pending,
|
||||
make_bundle("u1", "Look at this image", 1),
|
||||
&mut changed,
|
||||
100,
|
||||
5_000,
|
||||
);
|
||||
assert!(!changed);
|
||||
assert!(pending.is_empty());
|
||||
assert_eq!(emitted.len(), 1);
|
||||
assert_eq!(emitted[0].text, "Look at this image");
|
||||
assert_eq!(emitted[0].attachments.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_take_due_pending_bundles_emits_only_expired_entries() {
|
||||
let mut pending = HashMap::new();
|
||||
let mut expired = make_bundle("u1", "", 1);
|
||||
expired.flush_at_ms = 100;
|
||||
let mut fresh = make_bundle("u2", "", 1);
|
||||
fresh.flush_at_ms = 300;
|
||||
pending.insert(expired.from_user_id.clone(), expired);
|
||||
pending.insert(fresh.from_user_id.clone(), fresh);
|
||||
|
||||
let due = take_due_pending_bundles(&mut pending, 200);
|
||||
assert_eq!(due.len(), 1);
|
||||
assert_eq!(due[0].from_user_id, "u1");
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert!(pending.contains_key("u2"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit};
|
||||
use aes::Aes128;
|
||||
use base64::Engine as _;
|
||||
use md5::{Digest, Md5};
|
||||
use rand::RngCore;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::exports::near::agent::channel::Attachment;
|
||||
use crate::near::agent::channel_host::{self, InboundAttachment};
|
||||
use crate::types::{
|
||||
CdnMedia, FileItem, ImageItem, MessageItem, SendMessageRequest, WechatConfig,
|
||||
MESSAGE_ITEM_FILE, MESSAGE_ITEM_IMAGE, MESSAGE_ITEM_VOICE, MESSAGE_STATE_FINISH,
|
||||
MESSAGE_TYPE_BOT, UPLOAD_MEDIA_TYPE_IMAGE,
|
||||
};
|
||||
|
||||
const AES_BLOCK_SIZE: usize = 16;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UploadImage {
|
||||
pub download_encrypted_query_param: String,
|
||||
pub aes_key_base64: String,
|
||||
pub file_size_ciphertext: u64,
|
||||
}
|
||||
|
||||
pub fn extract_inbound_attachments(
|
||||
config: &WechatConfig,
|
||||
message: &crate::types::WechatMessage,
|
||||
) -> Result<Vec<InboundAttachment>, String> {
|
||||
message
|
||||
.item_list
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, item)| {
|
||||
map_inbound_attachment(config, message, item, index).transpose()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn send_image_attachment(
|
||||
config: &WechatConfig,
|
||||
to_user_id: &str,
|
||||
attachment: &Attachment,
|
||||
context_token: Option<&str>,
|
||||
text: &str,
|
||||
) -> Result<(), String> {
|
||||
if attachment.data.is_empty() {
|
||||
return Err(format!(
|
||||
"WeChat image attachment '{}' has no data",
|
||||
attachment.filename
|
||||
));
|
||||
}
|
||||
|
||||
let upload = upload_image(config, to_user_id, attachment)?;
|
||||
if !text.trim().is_empty() {
|
||||
crate::api::send_text_message(config, to_user_id, text.trim(), context_token)?;
|
||||
}
|
||||
let request = SendMessageRequest {
|
||||
msg: crate::types::OutboundWechatMessage {
|
||||
from_user_id: String::new(),
|
||||
to_user_id: to_user_id.to_string(),
|
||||
client_id: format!("wechat-{}", channel_host::now_millis()),
|
||||
message_type: MESSAGE_TYPE_BOT,
|
||||
message_state: MESSAGE_STATE_FINISH,
|
||||
item_list: vec![MessageItem {
|
||||
r#type: Some(MESSAGE_ITEM_IMAGE),
|
||||
text_item: None,
|
||||
image_item: Some(ImageItem {
|
||||
media: Some(CdnMedia {
|
||||
encrypt_query_param: Some(upload.download_encrypted_query_param.clone()),
|
||||
aes_key: Some(upload.aes_key_base64.clone()),
|
||||
encrypt_type: Some(1),
|
||||
}),
|
||||
aeskey: None,
|
||||
mid_size: Some(upload.file_size_ciphertext),
|
||||
}),
|
||||
voice_item: None,
|
||||
file_item: None,
|
||||
}],
|
||||
context_token: context_token.map(str::to_string),
|
||||
},
|
||||
base_info: crate::api::base_info(),
|
||||
};
|
||||
|
||||
crate::api::send_message_request(config, &request)
|
||||
}
|
||||
|
||||
fn map_inbound_attachment(
|
||||
config: &WechatConfig,
|
||||
message: &crate::types::WechatMessage,
|
||||
item: &MessageItem,
|
||||
index: usize,
|
||||
) -> Result<Option<InboundAttachment>, String> {
|
||||
if item.r#type == Some(MESSAGE_ITEM_IMAGE) {
|
||||
return map_image_attachment(config, message, item, index);
|
||||
}
|
||||
if item.r#type == Some(MESSAGE_ITEM_VOICE) {
|
||||
return map_voice_attachment(config, message, item, index);
|
||||
}
|
||||
if item.r#type == Some(MESSAGE_ITEM_FILE) {
|
||||
return map_file_attachment(config, message, item, index);
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn map_image_attachment(
|
||||
config: &WechatConfig,
|
||||
message: &crate::types::WechatMessage,
|
||||
item: &MessageItem,
|
||||
index: usize,
|
||||
) -> Result<Option<InboundAttachment>, String> {
|
||||
if item.r#type != Some(MESSAGE_ITEM_IMAGE) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let image = item.image_item.as_ref().ok_or_else(|| {
|
||||
format!(
|
||||
"WeChat image message {:?} is missing image_item payload",
|
||||
message.message_id
|
||||
)
|
||||
})?;
|
||||
let media = image.media.as_ref().ok_or_else(|| {
|
||||
format!(
|
||||
"WeChat image message {:?} is missing media payload",
|
||||
message.message_id
|
||||
)
|
||||
})?;
|
||||
let encrypt_query_param = media.encrypt_query_param.as_deref().ok_or_else(|| {
|
||||
format!(
|
||||
"WeChat image message {:?} is missing encrypt_query_param",
|
||||
message.message_id
|
||||
)
|
||||
})?;
|
||||
let message_id = message
|
||||
.message_id
|
||||
.ok_or_else(|| "WeChat image message is missing message_id".to_string())?;
|
||||
let aes_key = preferred_image_aes_key(image, media).map(str::to_string);
|
||||
|
||||
Ok(Some(InboundAttachment {
|
||||
id: format!("wechat-image-{}-{}", message_id, index),
|
||||
mime_type: "image/jpeg".to_string(),
|
||||
filename: Some(format!("wechat-image-{}-{}.jpg", message_id, index)),
|
||||
size_bytes: image.mid_size,
|
||||
source_url: Some(build_cdn_download_url(
|
||||
&config.cdn_base_url,
|
||||
encrypt_query_param,
|
||||
)),
|
||||
storage_key: None,
|
||||
extracted_text: None,
|
||||
extras_json: json!({ "wechat_aes_key": aes_key }).to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_file_attachment(
|
||||
config: &WechatConfig,
|
||||
message: &crate::types::WechatMessage,
|
||||
item: &MessageItem,
|
||||
index: usize,
|
||||
) -> Result<Option<InboundAttachment>, String> {
|
||||
if item.r#type != Some(MESSAGE_ITEM_FILE) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let file = item.file_item.as_ref().ok_or_else(|| {
|
||||
format!(
|
||||
"WeChat file message {:?} is missing file_item payload",
|
||||
message.message_id
|
||||
)
|
||||
})?;
|
||||
let media = file.media.as_ref().ok_or_else(|| {
|
||||
format!(
|
||||
"WeChat file message {:?} is missing media payload",
|
||||
message.message_id
|
||||
)
|
||||
})?;
|
||||
let encrypt_query_param = media.encrypt_query_param.as_deref().ok_or_else(|| {
|
||||
format!(
|
||||
"WeChat file message {:?} is missing encrypt_query_param",
|
||||
message.message_id
|
||||
)
|
||||
})?;
|
||||
let aes_key = media
|
||||
.aes_key
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"WeChat file message {:?} is missing aes_key",
|
||||
message.message_id
|
||||
)
|
||||
})?;
|
||||
let message_id = message
|
||||
.message_id
|
||||
.ok_or_else(|| "WeChat file message is missing message_id".to_string())?;
|
||||
let filename = inbound_file_name(file, message_id, index);
|
||||
let size_bytes = file.len.as_deref().and_then(parse_file_size);
|
||||
|
||||
Ok(Some(InboundAttachment {
|
||||
id: format!("wechat-file-{}-{}", message_id, index),
|
||||
mime_type: infer_file_mime_type(&filename),
|
||||
filename: Some(filename),
|
||||
size_bytes,
|
||||
source_url: Some(build_cdn_download_url(
|
||||
&config.cdn_base_url,
|
||||
encrypt_query_param,
|
||||
)),
|
||||
storage_key: None,
|
||||
extracted_text: None,
|
||||
extras_json: json!({ "wechat_aes_key": aes_key }).to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_voice_attachment(
|
||||
config: &WechatConfig,
|
||||
message: &crate::types::WechatMessage,
|
||||
item: &MessageItem,
|
||||
index: usize,
|
||||
) -> Result<Option<InboundAttachment>, String> {
|
||||
if item.r#type != Some(MESSAGE_ITEM_VOICE) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let voice = item.voice_item.as_ref().ok_or_else(|| {
|
||||
format!(
|
||||
"WeChat voice message {:?} is missing voice_item payload",
|
||||
message.message_id
|
||||
)
|
||||
})?;
|
||||
let media = voice.media.as_ref().ok_or_else(|| {
|
||||
format!(
|
||||
"WeChat voice message {:?} is missing media payload",
|
||||
message.message_id
|
||||
)
|
||||
})?;
|
||||
let encrypt_query_param = media.encrypt_query_param.as_deref().ok_or_else(|| {
|
||||
format!(
|
||||
"WeChat voice message {:?} is missing encrypt_query_param",
|
||||
message.message_id
|
||||
)
|
||||
})?;
|
||||
let aes_key = media
|
||||
.aes_key
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"WeChat voice message {:?} is missing aes_key",
|
||||
message.message_id
|
||||
)
|
||||
})?;
|
||||
let message_id = message
|
||||
.message_id
|
||||
.ok_or_else(|| "WeChat voice message is missing message_id".to_string())?;
|
||||
let (mime_type, extension) = infer_voice_media_type(voice.encode_type);
|
||||
let duration_secs = voice.playtime.map(|millis| (millis / 1000) as u32);
|
||||
|
||||
Ok(Some(InboundAttachment {
|
||||
id: format!("wechat-voice-{}-{}", message_id, index),
|
||||
mime_type: mime_type.to_string(),
|
||||
filename: Some(format!(
|
||||
"wechat-voice-{}-{}.{}",
|
||||
message_id, index, extension
|
||||
)),
|
||||
size_bytes: None,
|
||||
source_url: Some(build_cdn_download_url(
|
||||
&config.cdn_base_url,
|
||||
encrypt_query_param,
|
||||
)),
|
||||
storage_key: None,
|
||||
extracted_text: voice
|
||||
.text
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string),
|
||||
extras_json: build_voice_extras_json(aes_key, duration_secs),
|
||||
}))
|
||||
}
|
||||
|
||||
fn preferred_image_aes_key<'a>(image: &'a ImageItem, media: &'a CdnMedia) -> Option<&'a str> {
|
||||
image
|
||||
.aeskey
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| {
|
||||
media
|
||||
.aes_key
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
fn inbound_file_name(file: &FileItem, message_id: i64, index: usize) -> String {
|
||||
file.file_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("wechat-file-{}-{}.bin", message_id, index))
|
||||
}
|
||||
|
||||
fn parse_file_size(raw: &str) -> Option<u64> {
|
||||
raw.trim().parse::<u64>().ok()
|
||||
}
|
||||
|
||||
fn infer_voice_media_type(encode_type: Option<i32>) -> (&'static str, &'static str) {
|
||||
match encode_type {
|
||||
Some(7) => ("audio/mpeg", "mp3"),
|
||||
Some(8) => ("audio/ogg", "ogg"),
|
||||
Some(5) => ("audio/amr", "amr"),
|
||||
Some(6) => ("audio/silk", "silk"),
|
||||
_ => ("audio/silk", "silk"),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_voice_extras_json(aes_key: &str, duration_secs: Option<u32>) -> String {
|
||||
let mut extras = serde_json::Map::new();
|
||||
extras.insert("wechat_aes_key".to_string(), json!(aes_key));
|
||||
if let Some(duration_secs) = duration_secs {
|
||||
extras.insert("duration_secs".to_string(), json!(duration_secs));
|
||||
}
|
||||
serde_json::Value::Object(extras).to_string()
|
||||
}
|
||||
|
||||
fn infer_file_mime_type(filename: &str) -> String {
|
||||
let extension = filename
|
||||
.rsplit_once('.')
|
||||
.map(|(_, ext)| ext.trim().to_ascii_lowercase());
|
||||
|
||||
match extension.as_deref() {
|
||||
Some("pdf") => "application/pdf",
|
||||
Some("doc") => "application/msword",
|
||||
Some("docx") => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
Some("xls") => "application/vnd.ms-excel",
|
||||
Some("xlsx") => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
Some("ppt") => "application/vnd.ms-powerpoint",
|
||||
Some("pptx") => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
Some("txt") => "text/plain",
|
||||
Some("csv") => "text/csv",
|
||||
Some("json") => "application/json",
|
||||
Some("xml") => "application/xml",
|
||||
Some("md") => "text/markdown",
|
||||
Some("zip") => "application/zip",
|
||||
Some("tar") => "application/x-tar",
|
||||
Some("gz") => "application/gzip",
|
||||
Some("mp3") => "audio/mpeg",
|
||||
Some("ogg") => "audio/ogg",
|
||||
Some("wav") => "audio/wav",
|
||||
Some("mp4") => "video/mp4",
|
||||
Some("mov") => "video/quicktime",
|
||||
Some("webm") => "video/webm",
|
||||
Some("mkv") => "video/x-matroska",
|
||||
Some("avi") => "video/x-msvideo",
|
||||
Some("png") => "image/png",
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("gif") => "image/gif",
|
||||
Some("webp") => "image/webp",
|
||||
Some("bmp") => "image/bmp",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn upload_image(
|
||||
config: &WechatConfig,
|
||||
to_user_id: &str,
|
||||
attachment: &Attachment,
|
||||
) -> Result<UploadImage, String> {
|
||||
let plaintext = &attachment.data;
|
||||
let raw_size = plaintext.len() as u64;
|
||||
let raw_md5 = hex_lower(md5_bytes(plaintext));
|
||||
let file_size_ciphertext = padded_size(raw_size);
|
||||
let filekey = hex_lower(random_bytes(16)?);
|
||||
let aes_key = random_bytes(16)?;
|
||||
let aes_key_hex = hex_lower(aes_key.clone());
|
||||
|
||||
let upload_url = crate::api::get_upload_url(
|
||||
config,
|
||||
&crate::types::GetUploadUrlRequest {
|
||||
filekey: filekey.clone(),
|
||||
media_type: UPLOAD_MEDIA_TYPE_IMAGE,
|
||||
to_user_id: to_user_id.to_string(),
|
||||
rawsize: raw_size,
|
||||
rawfilemd5: raw_md5,
|
||||
filesize: file_size_ciphertext,
|
||||
no_need_thumb: true,
|
||||
aeskey: aes_key_hex,
|
||||
base_info: crate::api::base_info(),
|
||||
},
|
||||
)?;
|
||||
|
||||
let upload_param = upload_url
|
||||
.upload_param
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| "WeChat getUploadUrl returned no upload_param".to_string())?;
|
||||
|
||||
if upload_url.thumb_upload_param.is_some() {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Debug,
|
||||
"WeChat image upload returned thumb_upload_param; ignoring for single-image flow",
|
||||
);
|
||||
}
|
||||
|
||||
let ciphertext = encrypt_aes_ecb_pkcs7(plaintext, &aes_key)?;
|
||||
let upload_response = channel_host::http_request(
|
||||
"POST",
|
||||
&build_cdn_upload_url(&config.cdn_base_url, upload_param, &filekey),
|
||||
r#"{"Content-Type":"application/octet-stream"}"#,
|
||||
Some(&ciphertext),
|
||||
Some(15_000),
|
||||
)
|
||||
.map_err(|e| format!("WeChat CDN upload failed: {e}"))?;
|
||||
|
||||
if upload_response.status != 200 {
|
||||
let body = String::from_utf8_lossy(&upload_response.body);
|
||||
return Err(format!(
|
||||
"WeChat CDN upload returned {}: {}",
|
||||
upload_response.status, body
|
||||
));
|
||||
}
|
||||
|
||||
let headers: std::collections::HashMap<String, String> =
|
||||
serde_json::from_str(&upload_response.headers_json)
|
||||
.map_err(|e| format!("Failed to parse WeChat CDN upload headers: {e}"))?;
|
||||
let download_encrypted_query_param = headers
|
||||
.iter()
|
||||
.find_map(|(key, value)| {
|
||||
if key.eq_ignore_ascii_case("x-encrypted-param") {
|
||||
Some(value.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| "WeChat CDN upload response missing x-encrypted-param".to_string())?;
|
||||
|
||||
Ok(UploadImage {
|
||||
download_encrypted_query_param,
|
||||
aes_key_base64: base64::engine::general_purpose::STANDARD.encode(aes_key),
|
||||
file_size_ciphertext,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_cdn_download_url(cdn_base_url: &str, encrypted_query_param: &str) -> String {
|
||||
format!(
|
||||
"{}/download?encrypted_query_param={}",
|
||||
cdn_base_url.trim_end_matches('/'),
|
||||
percent_encode(encrypted_query_param)
|
||||
)
|
||||
}
|
||||
|
||||
fn build_cdn_upload_url(cdn_base_url: &str, upload_param: &str, filekey: &str) -> String {
|
||||
format!(
|
||||
"{}/upload?encrypted_query_param={}&filekey={}",
|
||||
cdn_base_url.trim_end_matches('/'),
|
||||
percent_encode(upload_param),
|
||||
percent_encode(filekey)
|
||||
)
|
||||
}
|
||||
|
||||
fn percent_encode(value: &str) -> String {
|
||||
let mut encoded = String::with_capacity(value.len());
|
||||
for byte in value.bytes() {
|
||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
|
||||
encoded.push(byte as char);
|
||||
} else {
|
||||
encoded.push('%');
|
||||
encoded.push(nibble_to_hex(byte >> 4));
|
||||
encoded.push(nibble_to_hex(byte & 0x0F));
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn nibble_to_hex(nibble: u8) -> char {
|
||||
match nibble {
|
||||
0..=9 => (b'0' + nibble) as char,
|
||||
10..=15 => (b'A' + (nibble - 10)) as char,
|
||||
_ => '0',
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_hex(bytes: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(bytes.len() * 2);
|
||||
for byte in bytes {
|
||||
out.push(nibble_to_hex(byte >> 4));
|
||||
out.push(nibble_to_hex(byte & 0x0F));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn hex_lower(bytes: Vec<u8>) -> String {
|
||||
encode_hex(&bytes).to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn encrypt_aes_ecb_pkcs7(plaintext: &[u8], key: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let cipher = Aes128::new_from_slice(key).map_err(|e| format!("Invalid AES key: {e}"))?;
|
||||
let mut padded = plaintext.to_vec();
|
||||
let pad_len = AES_BLOCK_SIZE - (padded.len() % AES_BLOCK_SIZE);
|
||||
padded.extend(std::iter::repeat_n(pad_len as u8, pad_len));
|
||||
|
||||
for chunk in padded.chunks_exact_mut(AES_BLOCK_SIZE) {
|
||||
cipher.encrypt_block(GenericArray::from_mut_slice(chunk));
|
||||
}
|
||||
|
||||
Ok(padded)
|
||||
}
|
||||
|
||||
fn md5_bytes(bytes: &[u8]) -> Vec<u8> {
|
||||
Md5::digest(bytes).to_vec()
|
||||
}
|
||||
|
||||
fn random_bytes(len: usize) -> Result<Vec<u8>, String> {
|
||||
let mut bytes = vec![0u8; len];
|
||||
rand::rngs::OsRng.fill_bytes(&mut bytes);
|
||||
if bytes.iter().all(|byte| *byte == 0) {
|
||||
return Err("OS RNG returned all-zero bytes unexpectedly".to_string());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn padded_size(raw_size: u64) -> u64 {
|
||||
((raw_size / AES_BLOCK_SIZE as u64) + 1) * AES_BLOCK_SIZE as u64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
build_voice_extras_json, encode_hex, encrypt_aes_ecb_pkcs7, infer_file_mime_type,
|
||||
infer_voice_media_type, map_file_attachment, map_image_attachment, map_voice_attachment,
|
||||
AES_BLOCK_SIZE,
|
||||
};
|
||||
use crate::types::{
|
||||
CdnMedia, FileItem, ImageItem, MessageItem, VoiceItem, WechatConfig, WechatMessage,
|
||||
MESSAGE_ITEM_FILE, MESSAGE_ITEM_IMAGE, MESSAGE_ITEM_VOICE,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_aes_ecb_pkcs7_is_block_aligned() {
|
||||
let key = [0x11u8; 16];
|
||||
let plaintext = b"wechat image payload".to_vec();
|
||||
let ciphertext = encrypt_aes_ecb_pkcs7(&plaintext, &key).unwrap();
|
||||
assert_eq!(ciphertext.len() % AES_BLOCK_SIZE, 0);
|
||||
assert_ne!(ciphertext, plaintext);
|
||||
assert_eq!(
|
||||
encode_hex(&ciphertext).to_ascii_lowercase(),
|
||||
"a7464c94a03fb2c5aa783597a1d2f5a461f1cd5d83a7bd92721e8ac1853f881f"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_image_attachment_errors_when_message_id_missing() {
|
||||
let config = WechatConfig::default();
|
||||
let message = WechatMessage {
|
||||
message_id: None,
|
||||
from_user_id: Some("user-1".to_string()),
|
||||
to_user_id: Some("bot-1".to_string()),
|
||||
session_id: None,
|
||||
message_type: None,
|
||||
context_token: None,
|
||||
item_list: vec![MessageItem {
|
||||
r#type: Some(MESSAGE_ITEM_IMAGE),
|
||||
text_item: None,
|
||||
image_item: Some(ImageItem {
|
||||
media: Some(CdnMedia {
|
||||
encrypt_query_param: Some("enc".to_string()),
|
||||
aes_key: Some("aes".to_string()),
|
||||
encrypt_type: Some(1),
|
||||
}),
|
||||
aeskey: None,
|
||||
mid_size: Some(128),
|
||||
}),
|
||||
voice_item: None,
|
||||
file_item: None,
|
||||
}],
|
||||
};
|
||||
|
||||
let error = map_image_attachment(&config, &message, &message.item_list[0], 0)
|
||||
.expect_err("missing message_id should error");
|
||||
assert!(error.contains("missing message_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_file_attachment_uses_filename_and_size_metadata() {
|
||||
let config = WechatConfig::default();
|
||||
let message = WechatMessage {
|
||||
message_id: Some(42),
|
||||
from_user_id: Some("user-1".to_string()),
|
||||
to_user_id: Some("bot-1".to_string()),
|
||||
session_id: None,
|
||||
message_type: None,
|
||||
context_token: None,
|
||||
item_list: vec![MessageItem {
|
||||
r#type: Some(MESSAGE_ITEM_FILE),
|
||||
text_item: None,
|
||||
image_item: None,
|
||||
voice_item: None,
|
||||
file_item: Some(FileItem {
|
||||
media: Some(CdnMedia {
|
||||
encrypt_query_param: Some("enc".to_string()),
|
||||
aes_key: Some("YWJjZGVmZ2hpamtsbW5vcA==".to_string()),
|
||||
encrypt_type: Some(1),
|
||||
}),
|
||||
file_name: Some("report.PDF".to_string()),
|
||||
len: Some("256".to_string()),
|
||||
}),
|
||||
}],
|
||||
};
|
||||
|
||||
let attachment = map_file_attachment(&config, &message, &message.item_list[0], 0)
|
||||
.expect("file attachment should map")
|
||||
.expect("file attachment should be present");
|
||||
assert_eq!(attachment.id, "wechat-file-42-0");
|
||||
assert_eq!(attachment.mime_type, "application/pdf");
|
||||
assert_eq!(attachment.filename.as_deref(), Some("report.PDF"));
|
||||
assert_eq!(attachment.size_bytes, Some(256));
|
||||
assert!(attachment.extras_json.contains("wechat_aes_key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_file_attachment_errors_when_message_id_missing() {
|
||||
let config = WechatConfig::default();
|
||||
let message = WechatMessage {
|
||||
message_id: None,
|
||||
from_user_id: Some("user-1".to_string()),
|
||||
to_user_id: Some("bot-1".to_string()),
|
||||
session_id: None,
|
||||
message_type: None,
|
||||
context_token: None,
|
||||
item_list: vec![MessageItem {
|
||||
r#type: Some(MESSAGE_ITEM_FILE),
|
||||
text_item: None,
|
||||
image_item: None,
|
||||
voice_item: None,
|
||||
file_item: Some(FileItem {
|
||||
media: Some(CdnMedia {
|
||||
encrypt_query_param: Some("enc".to_string()),
|
||||
aes_key: Some("aes".to_string()),
|
||||
encrypt_type: Some(1),
|
||||
}),
|
||||
file_name: Some("report.pdf".to_string()),
|
||||
len: Some("256".to_string()),
|
||||
}),
|
||||
}],
|
||||
};
|
||||
|
||||
let error = map_file_attachment(&config, &message, &message.item_list[0], 0)
|
||||
.expect_err("missing message_id should error");
|
||||
assert!(error.contains("missing message_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_infer_file_mime_type_defaults_to_octet_stream() {
|
||||
assert_eq!(
|
||||
infer_file_mime_type("archive.unknown"),
|
||||
"application/octet-stream"
|
||||
);
|
||||
assert_eq!(infer_file_mime_type("README"), "application/octet-stream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_infer_voice_media_type_defaults_to_silk() {
|
||||
assert_eq!(infer_voice_media_type(Some(6)), ("audio/silk", "silk"));
|
||||
assert_eq!(infer_voice_media_type(Some(8)), ("audio/ogg", "ogg"));
|
||||
assert_eq!(infer_voice_media_type(None), ("audio/silk", "silk"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_voice_extras_json_includes_duration() {
|
||||
let extras = build_voice_extras_json("aes-key", Some(9));
|
||||
assert!(extras.contains("wechat_aes_key"));
|
||||
assert!(extras.contains("duration_secs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_voice_attachment_sets_audio_metadata() {
|
||||
let config = WechatConfig::default();
|
||||
let message = WechatMessage {
|
||||
message_id: Some(77),
|
||||
from_user_id: Some("user-1".to_string()),
|
||||
to_user_id: Some("bot-1".to_string()),
|
||||
session_id: None,
|
||||
message_type: None,
|
||||
context_token: None,
|
||||
item_list: vec![MessageItem {
|
||||
r#type: Some(MESSAGE_ITEM_VOICE),
|
||||
text_item: None,
|
||||
image_item: None,
|
||||
voice_item: Some(VoiceItem {
|
||||
media: Some(CdnMedia {
|
||||
encrypt_query_param: Some("enc".to_string()),
|
||||
aes_key: Some("YWJjZGVmZ2hpamtsbW5vcA==".to_string()),
|
||||
encrypt_type: Some(1),
|
||||
}),
|
||||
encode_type: Some(8),
|
||||
playtime: Some(4200),
|
||||
text: Some("hello from voice".to_string()),
|
||||
}),
|
||||
file_item: None,
|
||||
}],
|
||||
};
|
||||
|
||||
let attachment = map_voice_attachment(&config, &message, &message.item_list[0], 0)
|
||||
.expect("voice attachment should map")
|
||||
.expect("voice attachment should be present");
|
||||
assert_eq!(attachment.id, "wechat-voice-77-0");
|
||||
assert_eq!(attachment.mime_type, "audio/ogg");
|
||||
assert_eq!(
|
||||
attachment.filename.as_deref(),
|
||||
Some("wechat-voice-77-0.ogg")
|
||||
);
|
||||
assert_eq!(
|
||||
attachment.extracted_text.as_deref(),
|
||||
Some("hello from voice")
|
||||
);
|
||||
assert!(attachment.extras_json.contains("duration_secs"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::auth::{
|
||||
CONFIG_PATH, CONTEXT_TOKENS_PATH, GET_UPDATES_BUF_PATH, PENDING_INBOUND_PATH,
|
||||
TYPING_TICKETS_PATH,
|
||||
};
|
||||
use crate::near::agent::channel_host;
|
||||
use crate::types::WechatConfig;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct TypingTicketEntry {
|
||||
pub ticket: String,
|
||||
pub fetched_at_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct StoredInboundAttachment {
|
||||
pub id: String,
|
||||
pub mime_type: String,
|
||||
pub filename: Option<String>,
|
||||
pub size_bytes: Option<u64>,
|
||||
pub source_url: Option<String>,
|
||||
pub storage_key: Option<String>,
|
||||
pub extracted_text: Option<String>,
|
||||
pub extras_json: String,
|
||||
}
|
||||
|
||||
impl From<channel_host::InboundAttachment> for StoredInboundAttachment {
|
||||
fn from(value: channel_host::InboundAttachment) -> Self {
|
||||
Self {
|
||||
id: value.id,
|
||||
mime_type: value.mime_type,
|
||||
filename: value.filename,
|
||||
size_bytes: value.size_bytes,
|
||||
source_url: value.source_url,
|
||||
storage_key: value.storage_key,
|
||||
extracted_text: value.extracted_text,
|
||||
extras_json: value.extras_json,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StoredInboundAttachment> for channel_host::InboundAttachment {
|
||||
fn from(value: StoredInboundAttachment) -> Self {
|
||||
Self {
|
||||
id: value.id,
|
||||
mime_type: value.mime_type,
|
||||
filename: value.filename,
|
||||
size_bytes: value.size_bytes,
|
||||
source_url: value.source_url,
|
||||
storage_key: value.storage_key,
|
||||
extracted_text: value.extracted_text,
|
||||
extras_json: value.extras_json,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct PendingInboundBundle {
|
||||
pub from_user_id: String,
|
||||
pub to_user_id: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
pub context_token: Option<String>,
|
||||
pub message_id: Option<i64>,
|
||||
pub flush_at_ms: u64,
|
||||
pub text: String,
|
||||
pub attachments: Vec<StoredInboundAttachment>,
|
||||
}
|
||||
|
||||
pub fn load_config() -> WechatConfig {
|
||||
channel_host::workspace_read(CONFIG_PATH)
|
||||
.and_then(|raw| serde_json::from_str::<WechatConfig>(&raw).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn persist_config(config: &WechatConfig) -> Result<(), String> {
|
||||
let serialized =
|
||||
serde_json::to_string(config).map_err(|e| format!("Failed to serialize config: {e}"))?;
|
||||
channel_host::workspace_write(CONFIG_PATH, &serialized).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn load_get_updates_buf() -> String {
|
||||
channel_host::workspace_read(GET_UPDATES_BUF_PATH)
|
||||
.and_then(|raw| serde_json::from_str::<String>(&raw).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn persist_get_updates_buf(value: &str) -> Result<(), String> {
|
||||
let serialized =
|
||||
serde_json::to_string(value).map_err(|e| format!("Failed to serialize cursor: {e}"))?;
|
||||
channel_host::workspace_write(GET_UPDATES_BUF_PATH, &serialized).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn load_context_tokens() -> HashMap<String, String> {
|
||||
channel_host::workspace_read(CONTEXT_TOKENS_PATH)
|
||||
.and_then(|raw| serde_json::from_str::<HashMap<String, String>>(&raw).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn persist_context_tokens(tokens: &HashMap<String, String>) -> Result<(), String> {
|
||||
let serialized =
|
||||
serde_json::to_string(tokens).map_err(|e| format!("Failed to serialize tokens: {e}"))?;
|
||||
channel_host::workspace_write(CONTEXT_TOKENS_PATH, &serialized).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn load_typing_tickets() -> HashMap<String, TypingTicketEntry> {
|
||||
channel_host::workspace_read(TYPING_TICKETS_PATH)
|
||||
.and_then(|raw| serde_json::from_str::<HashMap<String, TypingTicketEntry>>(&raw).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn persist_typing_tickets(tickets: &HashMap<String, TypingTicketEntry>) -> Result<(), String> {
|
||||
let serialized =
|
||||
serde_json::to_string(tickets).map_err(|e| format!("Failed to serialize tickets: {e}"))?;
|
||||
channel_host::workspace_write(TYPING_TICKETS_PATH, &serialized).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn load_pending_inbound_bundles() -> Result<HashMap<String, PendingInboundBundle>, String> {
|
||||
parse_pending_inbound_bundles(channel_host::workspace_read(PENDING_INBOUND_PATH).as_deref())
|
||||
}
|
||||
|
||||
pub fn persist_pending_inbound_bundles(
|
||||
bundles: &HashMap<String, PendingInboundBundle>,
|
||||
) -> Result<(), String> {
|
||||
let serialized =
|
||||
serde_json::to_string(bundles).map_err(|e| format!("Failed to serialize bundles: {e}"))?;
|
||||
channel_host::workspace_write(PENDING_INBOUND_PATH, &serialized).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn parse_pending_inbound_bundles(
|
||||
raw: Option<&str>,
|
||||
) -> Result<HashMap<String, PendingInboundBundle>, String> {
|
||||
match raw {
|
||||
None => Ok(HashMap::new()),
|
||||
Some(raw) => serde_json::from_str(raw)
|
||||
.map_err(|e| format!("Failed to parse pending inbound bundles: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_pending_inbound_bundles_missing_file_returns_empty_map() {
|
||||
let bundles = parse_pending_inbound_bundles(None).expect("missing state should be empty");
|
||||
assert!(bundles.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_pending_inbound_bundles_invalid_json_returns_error() {
|
||||
let error =
|
||||
parse_pending_inbound_bundles(Some("{not json")).expect_err("invalid json should err");
|
||||
assert!(error.contains("Failed to parse pending inbound bundles"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct WechatConfig {
|
||||
#[serde(default = "default_base_url")]
|
||||
pub base_url: String,
|
||||
#[serde(default = "default_cdn_base_url")]
|
||||
pub cdn_base_url: String,
|
||||
#[serde(default = "default_bot_type")]
|
||||
pub bot_type: String,
|
||||
#[serde(default = "default_poll_interval_ms")]
|
||||
pub poll_interval_ms: u32,
|
||||
#[serde(default = "default_long_poll_timeout_ms")]
|
||||
pub long_poll_timeout_ms: u32,
|
||||
#[serde(default = "default_inbound_merge_window_ms")]
|
||||
pub inbound_merge_window_ms: u32,
|
||||
}
|
||||
|
||||
fn default_base_url() -> String {
|
||||
"https://ilinkai.weixin.qq.com".to_string()
|
||||
}
|
||||
|
||||
fn default_cdn_base_url() -> String {
|
||||
"https://novac2c.cdn.weixin.qq.com/c2c".to_string()
|
||||
}
|
||||
|
||||
fn default_bot_type() -> String {
|
||||
"3".to_string()
|
||||
}
|
||||
|
||||
fn default_poll_interval_ms() -> u32 {
|
||||
30_000
|
||||
}
|
||||
|
||||
fn default_long_poll_timeout_ms() -> u32 {
|
||||
35_000
|
||||
}
|
||||
|
||||
fn default_inbound_merge_window_ms() -> u32 {
|
||||
5_000
|
||||
}
|
||||
|
||||
impl Default for WechatConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base_url: default_base_url(),
|
||||
cdn_base_url: default_cdn_base_url(),
|
||||
bot_type: default_bot_type(),
|
||||
poll_interval_ms: default_poll_interval_ms(),
|
||||
long_poll_timeout_ms: default_long_poll_timeout_ms(),
|
||||
inbound_merge_window_ms: default_inbound_merge_window_ms(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct BaseInfo {
|
||||
pub channel_version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct GetUploadUrlRequest {
|
||||
pub filekey: String,
|
||||
pub media_type: i32,
|
||||
pub to_user_id: String,
|
||||
pub rawsize: u64,
|
||||
pub rawfilemd5: String,
|
||||
pub filesize: u64,
|
||||
pub no_need_thumb: bool,
|
||||
pub aeskey: String,
|
||||
pub base_info: BaseInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct GetUpdatesRequest {
|
||||
pub get_updates_buf: String,
|
||||
pub base_info: BaseInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct GetConfigRequest {
|
||||
pub ilink_user_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub context_token: Option<String>,
|
||||
pub base_info: BaseInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct GetUpdatesResponse {
|
||||
pub ret: Option<i32>,
|
||||
pub errcode: Option<i32>,
|
||||
pub errmsg: Option<String>,
|
||||
#[serde(default)]
|
||||
pub msgs: Vec<WechatMessage>,
|
||||
pub get_updates_buf: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct GetUploadUrlResponse {
|
||||
pub upload_param: Option<String>,
|
||||
pub thumb_upload_param: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct SendMessageRequest {
|
||||
pub msg: OutboundWechatMessage,
|
||||
pub base_info: BaseInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct SendTypingRequest {
|
||||
pub ilink_user_id: String,
|
||||
pub typing_ticket: String,
|
||||
pub status: i32,
|
||||
pub base_info: BaseInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct OutboundWechatMessage {
|
||||
pub from_user_id: String,
|
||||
pub to_user_id: String,
|
||||
pub client_id: String,
|
||||
pub message_type: i32,
|
||||
pub message_state: i32,
|
||||
pub item_list: Vec<MessageItem>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub context_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct WechatMessage {
|
||||
pub message_id: Option<i64>,
|
||||
pub from_user_id: Option<String>,
|
||||
pub to_user_id: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
pub message_type: Option<i32>,
|
||||
pub context_token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub item_list: Vec<MessageItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct GetConfigResponse {
|
||||
pub ret: Option<i32>,
|
||||
pub errmsg: Option<String>,
|
||||
pub typing_ticket: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SendTypingResponse {
|
||||
pub ret: Option<i32>,
|
||||
pub errmsg: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct MessageItem {
|
||||
pub r#type: Option<i32>,
|
||||
pub text_item: Option<TextItem>,
|
||||
pub image_item: Option<ImageItem>,
|
||||
pub voice_item: Option<VoiceItem>,
|
||||
pub file_item: Option<FileItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct TextItem {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct CdnMedia {
|
||||
pub encrypt_query_param: Option<String>,
|
||||
pub aes_key: Option<String>,
|
||||
pub encrypt_type: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ImageItem {
|
||||
pub media: Option<CdnMedia>,
|
||||
pub aeskey: Option<String>,
|
||||
pub mid_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct VoiceItem {
|
||||
pub media: Option<CdnMedia>,
|
||||
pub encode_type: Option<i32>,
|
||||
pub playtime: Option<u64>,
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct FileItem {
|
||||
pub media: Option<CdnMedia>,
|
||||
pub file_name: Option<String>,
|
||||
pub len: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct OutboundMetadata {
|
||||
pub from_user_id: String,
|
||||
pub to_user_id: Option<String>,
|
||||
pub message_id: Option<i64>,
|
||||
pub session_id: Option<String>,
|
||||
pub context_token: Option<String>,
|
||||
}
|
||||
|
||||
pub const MESSAGE_TYPE_USER: i32 = 1;
|
||||
pub const MESSAGE_TYPE_BOT: i32 = 2;
|
||||
pub const MESSAGE_STATE_FINISH: i32 = 2;
|
||||
pub const MESSAGE_ITEM_TEXT: i32 = 1;
|
||||
pub const MESSAGE_ITEM_IMAGE: i32 = 2;
|
||||
pub const MESSAGE_ITEM_VOICE: i32 = 3;
|
||||
pub const MESSAGE_ITEM_FILE: i32 = 4;
|
||||
pub const TYPING_STATUS_TYPING: i32 = 1;
|
||||
pub const TYPING_STATUS_CANCEL: i32 = 2;
|
||||
pub const UPLOAD_MEDIA_TYPE_IMAGE: i32 = 1;
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.3.0",
|
||||
"type": "channel",
|
||||
"name": "wechat",
|
||||
"description": "WeChat iLink Bot channel for direct-message chat via long polling",
|
||||
"setup": {
|
||||
"required_secrets": [
|
||||
{
|
||||
"name": "wechat_bot_token",
|
||||
"prompt": "Connect this channel from the WeChat setup flow. IronClaw stores the bot token after QR login succeeds.",
|
||||
"optional": false
|
||||
}
|
||||
],
|
||||
"setup_url": "https://ilinkai.weixin.qq.com"
|
||||
},
|
||||
"capabilities": {
|
||||
"http": {
|
||||
"allowlist": [
|
||||
{ "host": "ilinkai.weixin.qq.com", "path_prefix": "/" },
|
||||
{ "host": "novac2c.cdn.weixin.qq.com", "path_prefix": "/c2c/" }
|
||||
],
|
||||
"rate_limit": {
|
||||
"requests_per_minute": 60,
|
||||
"requests_per_hour": 1200
|
||||
}
|
||||
},
|
||||
"secrets": {
|
||||
"allowed_names": ["wechat_*"]
|
||||
},
|
||||
"channel": {
|
||||
"allowed_paths": [],
|
||||
"allow_polling": true,
|
||||
"min_poll_interval_ms": 30000,
|
||||
"workspace_prefix": "channels/wechat/",
|
||||
"callback_timeout_secs": 45,
|
||||
"emit_rate_limit": {
|
||||
"messages_per_minute": 100,
|
||||
"messages_per_hour": 5000
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"base_url": "https://ilinkai.weixin.qq.com",
|
||||
"cdn_base_url": "https://novac2c.cdn.weixin.qq.com/c2c",
|
||||
"bot_type": "3",
|
||||
"poll_interval_ms": 30000,
|
||||
"long_poll_timeout_ms": 35000,
|
||||
"inbound_merge_window_ms": 5000
|
||||
}
|
||||
}
|
||||
Generated
+1
-1
@@ -269,7 +269,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "whatsapp-channel"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "ironclaw_common"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.92"
|
||||
description = "Shared types and utilities for the IronClaw workspace"
|
||||
authors = ["NEAR AI <[email protected]>"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
homepage = "https://github.com/nearai/ironclaw"
|
||||
repository = "https://github.com/nearai/ironclaw"
|
||||
|
||||
[package.metadata.dist]
|
||||
dist = false
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,393 @@
|
||||
//! Application-wide event types.
|
||||
//!
|
||||
//! `AppEvent` is the real-time event protocol used across the entire
|
||||
//! application. The web gateway serialises these to SSE / WebSocket
|
||||
//! frames, but other subsystems (agent loop, orchestrator, extensions)
|
||||
//! produce and consume them too.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A single tool decision in a reasoning update (SSE DTO).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolDecisionDto {
|
||||
pub tool_name: String,
|
||||
pub rationale: String,
|
||||
}
|
||||
|
||||
impl ToolDecisionDto {
|
||||
/// Parse a list of tool decisions from a JSON array value.
|
||||
pub fn from_json_array(value: &serde_json::Value) -> Vec<Self> {
|
||||
value
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|d| {
|
||||
Some(Self {
|
||||
tool_name: d.get("tool_name")?.as_str()?.to_string(),
|
||||
rationale: d.get("rationale")?.as_str()?.to_string(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum AppEvent {
|
||||
#[serde(rename = "response")]
|
||||
Response { content: String, thread_id: String },
|
||||
#[serde(rename = "thinking")]
|
||||
Thinking {
|
||||
message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
#[serde(rename = "tool_started")]
|
||||
ToolStarted {
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
#[serde(rename = "tool_completed")]
|
||||
ToolCompleted {
|
||||
name: String,
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
parameters: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
#[serde(rename = "tool_result")]
|
||||
ToolResult {
|
||||
name: String,
|
||||
preview: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
#[serde(rename = "stream_chunk")]
|
||||
StreamChunk {
|
||||
content: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
#[serde(rename = "status")]
|
||||
Status {
|
||||
message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
#[serde(rename = "job_started")]
|
||||
JobStarted {
|
||||
job_id: String,
|
||||
title: String,
|
||||
browse_url: String,
|
||||
},
|
||||
#[serde(rename = "approval_needed")]
|
||||
ApprovalNeeded {
|
||||
request_id: String,
|
||||
tool_name: String,
|
||||
description: String,
|
||||
parameters: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
/// Whether the "always" auto-approve option should be shown.
|
||||
allow_always: bool,
|
||||
},
|
||||
#[serde(rename = "auth_required")]
|
||||
AuthRequired {
|
||||
extension_name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
instructions: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
auth_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
setup_url: Option<String>,
|
||||
},
|
||||
#[serde(rename = "auth_completed")]
|
||||
AuthCompleted {
|
||||
extension_name: String,
|
||||
success: bool,
|
||||
message: String,
|
||||
},
|
||||
#[serde(rename = "error")]
|
||||
Error {
|
||||
message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
#[serde(rename = "heartbeat")]
|
||||
Heartbeat,
|
||||
|
||||
// Sandbox job streaming events (worker + Claude Code bridge)
|
||||
#[serde(rename = "job_message")]
|
||||
JobMessage {
|
||||
job_id: String,
|
||||
role: String,
|
||||
content: String,
|
||||
},
|
||||
#[serde(rename = "job_tool_use")]
|
||||
JobToolUse {
|
||||
job_id: String,
|
||||
tool_name: String,
|
||||
input: serde_json::Value,
|
||||
},
|
||||
#[serde(rename = "job_tool_result")]
|
||||
JobToolResult {
|
||||
job_id: String,
|
||||
tool_name: String,
|
||||
output: String,
|
||||
},
|
||||
#[serde(rename = "job_status")]
|
||||
JobStatus { job_id: String, message: String },
|
||||
#[serde(rename = "job_result")]
|
||||
JobResult {
|
||||
job_id: String,
|
||||
status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
session_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
fallback_deliverable: Option<serde_json::Value>,
|
||||
},
|
||||
|
||||
/// An image was generated by a tool.
|
||||
#[serde(rename = "image_generated")]
|
||||
ImageGenerated {
|
||||
data_url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
|
||||
/// Suggested follow-up messages for the user.
|
||||
#[serde(rename = "suggestions")]
|
||||
Suggestions {
|
||||
suggestions: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
|
||||
/// Per-turn token usage and cost summary.
|
||||
#[serde(rename = "turn_cost")]
|
||||
TurnCost {
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
cost_usd: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
|
||||
/// Extension activation status change (WASM channels).
|
||||
#[serde(rename = "extension_status")]
|
||||
ExtensionStatus {
|
||||
extension_name: String,
|
||||
status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
message: Option<String>,
|
||||
},
|
||||
|
||||
/// Agent reasoning update (why it chose specific tools).
|
||||
#[serde(rename = "reasoning_update")]
|
||||
ReasoningUpdate {
|
||||
narrative: String,
|
||||
decisions: Vec<ToolDecisionDto>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
|
||||
/// Reasoning update for a sandbox job.
|
||||
#[serde(rename = "job_reasoning")]
|
||||
JobReasoning {
|
||||
job_id: String,
|
||||
narrative: String,
|
||||
decisions: Vec<ToolDecisionDto>,
|
||||
},
|
||||
}
|
||||
|
||||
impl AppEvent {
|
||||
/// The wire-format event type string (matches the `#[serde(rename)]` value).
|
||||
pub fn event_type(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Response { .. } => "response",
|
||||
Self::Thinking { .. } => "thinking",
|
||||
Self::ToolStarted { .. } => "tool_started",
|
||||
Self::ToolCompleted { .. } => "tool_completed",
|
||||
Self::ToolResult { .. } => "tool_result",
|
||||
Self::StreamChunk { .. } => "stream_chunk",
|
||||
Self::Status { .. } => "status",
|
||||
Self::JobStarted { .. } => "job_started",
|
||||
Self::ApprovalNeeded { .. } => "approval_needed",
|
||||
Self::AuthRequired { .. } => "auth_required",
|
||||
Self::AuthCompleted { .. } => "auth_completed",
|
||||
Self::Error { .. } => "error",
|
||||
Self::Heartbeat => "heartbeat",
|
||||
Self::JobMessage { .. } => "job_message",
|
||||
Self::JobToolUse { .. } => "job_tool_use",
|
||||
Self::JobToolResult { .. } => "job_tool_result",
|
||||
Self::JobStatus { .. } => "job_status",
|
||||
Self::JobResult { .. } => "job_result",
|
||||
Self::ImageGenerated { .. } => "image_generated",
|
||||
Self::Suggestions { .. } => "suggestions",
|
||||
Self::TurnCost { .. } => "turn_cost",
|
||||
Self::ExtensionStatus { .. } => "extension_status",
|
||||
Self::ReasoningUpdate { .. } => "reasoning_update",
|
||||
Self::JobReasoning { .. } => "job_reasoning",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Verify that `event_type()` returns the same string as the serde
|
||||
/// `"type"` field for every variant. This catches drift between the
|
||||
/// `#[serde(rename)]` attributes and the manual match arms.
|
||||
#[test]
|
||||
fn event_type_matches_serde_type_field() {
|
||||
let variants: Vec<AppEvent> = vec![
|
||||
AppEvent::Response {
|
||||
content: String::new(),
|
||||
thread_id: String::new(),
|
||||
},
|
||||
AppEvent::Thinking {
|
||||
message: String::new(),
|
||||
thread_id: None,
|
||||
},
|
||||
AppEvent::ToolStarted {
|
||||
name: String::new(),
|
||||
thread_id: None,
|
||||
},
|
||||
AppEvent::ToolCompleted {
|
||||
name: String::new(),
|
||||
success: true,
|
||||
error: None,
|
||||
parameters: None,
|
||||
thread_id: None,
|
||||
},
|
||||
AppEvent::ToolResult {
|
||||
name: String::new(),
|
||||
preview: String::new(),
|
||||
thread_id: None,
|
||||
},
|
||||
AppEvent::StreamChunk {
|
||||
content: String::new(),
|
||||
thread_id: None,
|
||||
},
|
||||
AppEvent::Status {
|
||||
message: String::new(),
|
||||
thread_id: None,
|
||||
},
|
||||
AppEvent::JobStarted {
|
||||
job_id: String::new(),
|
||||
title: String::new(),
|
||||
browse_url: String::new(),
|
||||
},
|
||||
AppEvent::ApprovalNeeded {
|
||||
request_id: String::new(),
|
||||
tool_name: String::new(),
|
||||
description: String::new(),
|
||||
parameters: String::new(),
|
||||
thread_id: None,
|
||||
allow_always: false,
|
||||
},
|
||||
AppEvent::AuthRequired {
|
||||
extension_name: String::new(),
|
||||
instructions: None,
|
||||
auth_url: None,
|
||||
setup_url: None,
|
||||
},
|
||||
AppEvent::AuthCompleted {
|
||||
extension_name: String::new(),
|
||||
success: true,
|
||||
message: String::new(),
|
||||
},
|
||||
AppEvent::Error {
|
||||
message: String::new(),
|
||||
thread_id: None,
|
||||
},
|
||||
AppEvent::Heartbeat,
|
||||
AppEvent::JobMessage {
|
||||
job_id: String::new(),
|
||||
role: String::new(),
|
||||
content: String::new(),
|
||||
},
|
||||
AppEvent::JobToolUse {
|
||||
job_id: String::new(),
|
||||
tool_name: String::new(),
|
||||
input: serde_json::Value::Null,
|
||||
},
|
||||
AppEvent::JobToolResult {
|
||||
job_id: String::new(),
|
||||
tool_name: String::new(),
|
||||
output: String::new(),
|
||||
},
|
||||
AppEvent::JobStatus {
|
||||
job_id: String::new(),
|
||||
message: String::new(),
|
||||
},
|
||||
AppEvent::JobResult {
|
||||
job_id: String::new(),
|
||||
status: String::new(),
|
||||
session_id: None,
|
||||
fallback_deliverable: None,
|
||||
},
|
||||
AppEvent::ImageGenerated {
|
||||
data_url: String::new(),
|
||||
path: None,
|
||||
thread_id: None,
|
||||
},
|
||||
AppEvent::Suggestions {
|
||||
suggestions: vec![],
|
||||
thread_id: None,
|
||||
},
|
||||
AppEvent::TurnCost {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cost_usd: String::new(),
|
||||
thread_id: None,
|
||||
},
|
||||
AppEvent::ExtensionStatus {
|
||||
extension_name: String::new(),
|
||||
status: String::new(),
|
||||
message: None,
|
||||
},
|
||||
AppEvent::ReasoningUpdate {
|
||||
narrative: String::new(),
|
||||
decisions: vec![],
|
||||
thread_id: None,
|
||||
},
|
||||
AppEvent::JobReasoning {
|
||||
job_id: String::new(),
|
||||
narrative: String::new(),
|
||||
decisions: vec![],
|
||||
},
|
||||
];
|
||||
|
||||
for variant in &variants {
|
||||
let json: serde_json::Value = serde_json::to_value(variant).unwrap();
|
||||
let serde_type = json["type"].as_str().unwrap();
|
||||
assert_eq!(
|
||||
variant.event_type(),
|
||||
serde_type,
|
||||
"event_type() mismatch for variant: {:?}",
|
||||
variant
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_deserialize() {
|
||||
let original = AppEvent::Response {
|
||||
content: "hello".to_string(),
|
||||
thread_id: "t1".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: AppEvent = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.event_type(), "response");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Shared types and utilities for the IronClaw workspace.
|
||||
|
||||
mod event;
|
||||
mod util;
|
||||
|
||||
pub use event::{AppEvent, ToolDecisionDto};
|
||||
pub use util::truncate_preview;
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Shared utility functions.
|
||||
|
||||
/// Truncate a string to at most `max_bytes` bytes at a char boundary, appending "...".
|
||||
///
|
||||
/// If the input is wrapped in `<tool_output ...>...</tool_output>` and truncation
|
||||
/// removes the closing tag, the tag is re-appended so downstream XML parsers
|
||||
/// never see an unclosed element.
|
||||
pub fn truncate_preview(s: &str, max_bytes: usize) -> String {
|
||||
if s.len() <= max_bytes {
|
||||
return s.to_string();
|
||||
}
|
||||
// Walk backwards from max_bytes to find a valid char boundary
|
||||
let mut end = max_bytes;
|
||||
while end > 0 && !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
let mut result = format!("{}...", &s[..end]);
|
||||
|
||||
// Re-close <tool_output> if truncation cut through the closing tag.
|
||||
if s.starts_with("<tool_output") && !result.ends_with("</tool_output>") {
|
||||
result.push_str("\n</tool_output>");
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_short_string() {
|
||||
assert_eq!(truncate_preview("hello", 10), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_exact_boundary() {
|
||||
assert_eq!(truncate_preview("hello", 5), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_truncates_ascii() {
|
||||
assert_eq!(truncate_preview("hello world", 5), "hello...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_empty_string() {
|
||||
assert_eq!(truncate_preview("", 10), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_multibyte_char_boundary() {
|
||||
let s = "a\u{20AC}b";
|
||||
let result = truncate_preview(s, 3);
|
||||
assert_eq!(result, "a...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_emoji() {
|
||||
let s = "hi\u{1F980}";
|
||||
let result = truncate_preview(s, 4);
|
||||
assert_eq!(result, "hi...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_cjk() {
|
||||
let s = "\u{4F60}\u{597D}\u{4E16}\u{754C}";
|
||||
let result = truncate_preview(s, 7);
|
||||
assert_eq!(result, "\u{4F60}\u{597D}...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_zero_max_bytes() {
|
||||
assert_eq!(truncate_preview("hello", 0), "...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_closes_tool_output_tag() {
|
||||
let s = "<tool_output name=\"search\">\nSome very long content here\n</tool_output>";
|
||||
let result = truncate_preview(s, 60);
|
||||
assert!(result.ends_with("</tool_output>"));
|
||||
assert!(result.contains("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_no_extra_close_when_intact() {
|
||||
let s = "<tool_output name=\"echo\">\nshort\n</tool_output>";
|
||||
let result = truncate_preview(s, 500);
|
||||
assert_eq!(result, s);
|
||||
assert_eq!(result.matches("</tool_output>").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_non_xml_unaffected() {
|
||||
let s = "Just a plain long string that gets truncated";
|
||||
let result = truncate_preview(s, 10);
|
||||
assert_eq!(result, "Just a pla...");
|
||||
assert!(!result.contains("</tool_output>"));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ironclaw_safety"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.92"
|
||||
description = "Prompt injection defense, input validation, secret leak detection, and safety policy enforcement"
|
||||
@@ -8,7 +8,6 @@ authors = ["NEAR AI <[email protected]>"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
homepage = "https://github.com/nearai/ironclaw"
|
||||
repository = "https://github.com/nearai/ironclaw"
|
||||
publish = false
|
||||
|
||||
[package.metadata.dist]
|
||||
dist = false
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
# WeChat Integration Design
|
||||
|
||||
**Date:** 2026-03-25
|
||||
**Status:** Ready for implementation
|
||||
**Goal:** Add WeChat support to IronClaw using the same upstream iLink Bot protocol as `@tencent-weixin/openclaw-weixin`, while keeping the implementation aligned with IronClaw's extension-first channel architecture.
|
||||
|
||||
---
|
||||
|
||||
## Upstream Baseline
|
||||
|
||||
The current upstream npm package is `@tencent-weixin/openclaw-weixin` version `2.0.1`.
|
||||
|
||||
From the package README and source, the upstream WeChat channel does all of the following:
|
||||
|
||||
- logs in by QR code against `https://ilinkai.weixin.qq.com`
|
||||
- receives inbound messages by long-polling `ilink/bot/getupdates`
|
||||
- sends outbound messages through `ilink/bot/sendmessage`
|
||||
- uses `ilink/bot/getconfig` and `ilink/bot/sendtyping` for typing indicators
|
||||
- uses `ilink/bot/getuploadurl` for media uploads
|
||||
- persists `get_updates_buf` for long-poll resume
|
||||
- persists `context_token` so replies stay attached to the right WeChat session
|
||||
- supports multiple logged-in WeChat bot accounts
|
||||
- treats WeChat as a direct-message-only channel
|
||||
- block-sends replies instead of token streaming
|
||||
|
||||
This design treats that upstream behavior as the capability boundary. We should not add scope based on features the upstream plugin does not have.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Direction
|
||||
|
||||
IronClaw should **not** try to load the upstream OpenClaw plugin directly.
|
||||
|
||||
Instead, IronClaw should implement a **native channel extension** under `channels-src/wechat/` and only extend the host/runtime where that support is generic and reusable.
|
||||
|
||||
### Why not host the npm plugin directly
|
||||
|
||||
- The upstream package depends on `openclaw/plugin-sdk/*` APIs and runtime contracts that IronClaw does not have.
|
||||
- It assumes OpenClaw-specific lifecycle concepts such as `gateway.startAccount`.
|
||||
- Recreating an OpenClaw-compatible Node plugin host inside IronClaw would be more work and more fragile than implementing the protocol directly.
|
||||
|
||||
### Why `channels-src/wechat/`
|
||||
|
||||
- It matches the existing layering used by other platform channels.
|
||||
- It keeps platform protocol logic out of host-owned core modules.
|
||||
- It leaves room for the channel to move outside this repo later without changing the host model.
|
||||
|
||||
Recommended layout:
|
||||
|
||||
```text
|
||||
channels-src/
|
||||
wechat/
|
||||
Cargo.toml
|
||||
build.sh
|
||||
wechat.capabilities.json
|
||||
src/
|
||||
lib.rs
|
||||
api.rs
|
||||
auth.rs
|
||||
state.rs
|
||||
types.rs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 Scope
|
||||
|
||||
Phase 1 is a **single-account** implementation of the upstream WeChat channel.
|
||||
|
||||
The point of this phase is to keep the channel aligned with upstream behavior while removing the one biggest source of host/runtime complexity: multi-account lifecycle.
|
||||
|
||||
### Must-have in Phase 1
|
||||
|
||||
- QR code login
|
||||
- one connected WeChat bot account
|
||||
- direct-message text receive/send
|
||||
- `getupdates` long-poll loop
|
||||
- `sendmessage` outbound replies
|
||||
- typing indicators via `getconfig` and `sendtyping`
|
||||
- inbound image download/decrypt for vision
|
||||
- outbound image upload/send via `getuploadurl`
|
||||
- `context_token` persistence
|
||||
- `get_updates_buf` persistence
|
||||
- login persistence across restart
|
||||
- extension-first packaging under `channels-src/wechat/`
|
||||
|
||||
### Explicit simplification from upstream
|
||||
|
||||
- multi-account support is deferred
|
||||
|
||||
### Follow-up after Phase 1
|
||||
|
||||
These are upstream features, so they belong on the roadmap, but they do not need to block the first implementation cut:
|
||||
|
||||
- broader media parity beyond the current image + inbound-file + inbound-voice path (outbound files and video)
|
||||
|
||||
We should not spend time listing non-goals that come from outside the upstream capability boundary.
|
||||
|
||||
---
|
||||
|
||||
## Proposed Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Core WASM channel host"] --> B["WeChat channel extension"]
|
||||
C["Generic login UI/API"] --> D["QR login session"]
|
||||
D --> E["Secret storage"]
|
||||
E --> B
|
||||
B --> F["getupdates long-poll"]
|
||||
F --> G["IncomingMessage"]
|
||||
G --> H["ChannelManager -> Agent"]
|
||||
H --> I["sendmessage reply"]
|
||||
```
|
||||
|
||||
### Extension responsibilities
|
||||
|
||||
`channels-src/wechat/` should own:
|
||||
|
||||
- iLink API request/response types
|
||||
- QR login protocol calls
|
||||
- long-polling `getupdates`
|
||||
- `context_token` storage and lookup
|
||||
- outbound `sendmessage`
|
||||
- WeChat-specific status/error mapping
|
||||
|
||||
### Host responsibilities
|
||||
|
||||
IronClaw core should only own reusable pieces:
|
||||
|
||||
- installing and activating the WASM channel
|
||||
- generic secret persistence
|
||||
- generic QR/device-login session handling for channels
|
||||
- exposing login flow through authenticated UI/API
|
||||
- starting and polling the channel runtime
|
||||
|
||||
---
|
||||
|
||||
## Data And State Model
|
||||
|
||||
Phase 1 is single-account, so state should stay simple.
|
||||
|
||||
### Secrets
|
||||
|
||||
- `wechat_bot_token`
|
||||
|
||||
This is written after QR login succeeds and reused on restart.
|
||||
|
||||
### Channel state
|
||||
|
||||
Under the channel workspace prefix, persist:
|
||||
|
||||
- `state/get_updates_buf.json`
|
||||
- `state/context_tokens.json`
|
||||
|
||||
`context_tokens.json` maps the WeChat peer to its latest `context_token`.
|
||||
|
||||
### Inbound message mapping
|
||||
|
||||
For each inbound WeChat DM:
|
||||
|
||||
- `channel = "wechat"`
|
||||
- `user_id = <wechat sender id>` or owner scope if it is the bound owner
|
||||
- `thread_id = Some("wechat:<sender_id>")`
|
||||
- `conversation_scope_id = Some("wechat:<sender_id>")`
|
||||
|
||||
`metadata_json` should include:
|
||||
|
||||
- `from_user_id`
|
||||
- `to_user_id`
|
||||
- `message_id`
|
||||
- `context_token`
|
||||
|
||||
That is enough for `on_respond()` to send the reply back to the right peer.
|
||||
|
||||
---
|
||||
|
||||
## Minimal Host Uplift
|
||||
|
||||
The current extension host is close, but Phase 1 still needs one important addition: a generic interactive login flow for channels.
|
||||
|
||||
Minimum host support needed:
|
||||
|
||||
1. Start a channel login session.
|
||||
2. Return QR payload plus a session identifier.
|
||||
3. Poll login session status.
|
||||
4. On success, write the returned token to channel secrets.
|
||||
5. Reload or reactivate the channel so polling starts automatically.
|
||||
|
||||
This should be added as a generic channel-auth capability, not as WeChat-specific core logic.
|
||||
|
||||
---
|
||||
|
||||
## User Flow
|
||||
|
||||
Phase 1 should be **web-first**, because the target user is a normal WeChat user rather than a CLI-only operator.
|
||||
|
||||
1. Install or enable the `wechat` channel extension.
|
||||
2. Click "Connect WeChat".
|
||||
3. Web UI requests a login session from the host.
|
||||
4. Web UI displays the QR code.
|
||||
5. User scans and confirms on their phone.
|
||||
6. Host stores `wechat_bot_token`.
|
||||
7. Channel reloads and starts polling.
|
||||
8. User sends a DM in WeChat and receives IronClaw replies there.
|
||||
|
||||
CLI support can still exist for development, but it should not be the primary Phase 1 UX.
|
||||
|
||||
---
|
||||
|
||||
## Message Handling Semantics
|
||||
|
||||
### Inbound
|
||||
|
||||
On each poll:
|
||||
|
||||
1. load `get_updates_buf`
|
||||
2. call `getupdates`
|
||||
3. persist the new cursor if present
|
||||
4. normalize inbound text messages into `IncomingMessage`
|
||||
5. persist the latest `context_token` for that peer
|
||||
6. emit the message to the agent
|
||||
|
||||
### Outbound
|
||||
|
||||
On response:
|
||||
|
||||
1. read peer routing info from `metadata_json`
|
||||
2. load the latest `context_token`
|
||||
3. convert the response to plain text if needed
|
||||
4. send one coalesced text reply via `sendmessage`
|
||||
|
||||
This matches the upstream channel's block-send behavior.
|
||||
|
||||
---
|
||||
|
||||
## Testing Plan
|
||||
|
||||
### Unit tests
|
||||
|
||||
- QR login response parsing
|
||||
- `get_updates_buf` round-trip
|
||||
- `context_token` round-trip
|
||||
- inbound message normalization
|
||||
- outbound metadata routing
|
||||
|
||||
### Integration tests
|
||||
|
||||
Use a mock iLink server to cover:
|
||||
|
||||
- QR login success and expiry
|
||||
- restart without re-login
|
||||
- inbound poll -> agent -> outbound text reply
|
||||
- cursor resume after restart
|
||||
|
||||
---
|
||||
|
||||
## Phase 2
|
||||
|
||||
After Phase 1 is stable, add the upstream features we intentionally deferred:
|
||||
|
||||
- multi-account support
|
||||
- media upload/send
|
||||
@@ -15,13 +15,14 @@
|
||||
},
|
||||
"messaging": {
|
||||
"display_name": "Messaging Channels",
|
||||
"description": "Discord, Telegram, Slack, and WhatsApp channels",
|
||||
"description": "Discord, Telegram, Slack, WhatsApp, and WeChat channels",
|
||||
"extensions": [
|
||||
"channels/discord",
|
||||
"channels/telegram",
|
||||
"channels/slack",
|
||||
"channels/whatsapp",
|
||||
"channels/feishu"
|
||||
"channels/feishu",
|
||||
"channels/wechat"
|
||||
],
|
||||
"shared_auth": null
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "feishu",
|
||||
"display_name": "Feishu / Lark Channel",
|
||||
"kind": "channel",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.3",
|
||||
"wit_version": "0.3.0",
|
||||
"description": "Talk to your agent through a Feishu or Lark bot",
|
||||
"keywords": [
|
||||
@@ -19,8 +19,8 @@
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"sha256": "5fca74022264d1c8e78a0853766276f7ffa3cf0d8065b2f51ca10985acad4714",
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-feishu-0.1.1-wasm32-wasip2.tar.gz"
|
||||
"sha256": "a66ff0dafb67d2216d8161bb7e96e724a94acb0ab993b85d2782d30412f8fe94",
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/channel-feishu-0.1.3-wasm32-wasip2.tar.gz"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-telegram-0.2.4-wasm32-wasip2.tar.gz",
|
||||
"sha256": "a7cb300ec1c946831cfceaa95c1dc8f30d0f42a3924f3cb5de8098821573f4b8"
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.20.0/channel-telegram-0.2.5-wasm32-wasip2.tar.gz",
|
||||
"sha256": "1ef20a538f55b379e049356e4d6758006251846bc3365ceaa1c87eba8379a329"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "wechat",
|
||||
"display_name": "WeChat Channel",
|
||||
"kind": "channel",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.3.0",
|
||||
"description": "Talk to your agent through a WeChat iLink bot account",
|
||||
"keywords": [
|
||||
"messaging",
|
||||
"chat",
|
||||
"wechat",
|
||||
"wechat",
|
||||
"qr"
|
||||
],
|
||||
"source": {
|
||||
"dir": "channels-src/wechat",
|
||||
"capabilities": "wechat.capabilities.json",
|
||||
"crate_name": "wechat-channel"
|
||||
},
|
||||
"auth_summary": {
|
||||
"method": "interactive",
|
||||
"provider": "WeChat",
|
||||
"secrets": [
|
||||
"wechat_bot_token"
|
||||
],
|
||||
"shared_auth": null,
|
||||
"setup_url": "https://ilinkai.weixin.qq.com"
|
||||
},
|
||||
"tags": [
|
||||
"messaging"
|
||||
]
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "github",
|
||||
"display_name": "GitHub",
|
||||
"kind": "tool",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"wit_version": "0.3.0",
|
||||
"description": "GitHub integration for issues, PRs, repos, and code search",
|
||||
"keywords": [
|
||||
@@ -19,8 +19,8 @@
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-github-0.2.1-wasm32-wasip2.tar.gz",
|
||||
"sha256": "92c530b3ad172e2372d819744b5233f1d8f65768e26eb5a6c213eba3ce1de758"
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-github-0.2.2-wasm32-wasip2.tar.gz",
|
||||
"sha256": "70b55af593193d8fa495c0f702ea23284d83a624124f8a5f7564916ec5032c3f"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "gmail",
|
||||
"display_name": "Gmail",
|
||||
"kind": "tool",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"wit_version": "0.3.0",
|
||||
"description": "Read, send, and manage Gmail messages and threads",
|
||||
"keywords": [
|
||||
@@ -18,8 +18,8 @@
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/gmail-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"sha256": "ee9574e02e92bc1d481f1310eb88afd99ee52bf6971074ab33bd76bf99b34b1d"
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-gmail-0.2.1-wasm32-wasip2.tar.gz",
|
||||
"sha256": "79025b40ee70ce1120acc4320bae50da095d7afb0ef67bd56d99b064b72ea779"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "google-calendar",
|
||||
"display_name": "Google Calendar",
|
||||
"kind": "tool",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"wit_version": "0.3.0",
|
||||
"description": "Create, read, update, and delete Google Calendar events",
|
||||
"keywords": [
|
||||
@@ -18,8 +18,8 @@
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/google-calendar-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"sha256": "2fa47150ea222e787c122182ad6f4dfa2ffaf5fe490d05e8de887a76445f8d2d"
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-google-calendar-0.2.1-wasm32-wasip2.tar.gz",
|
||||
"sha256": "86bcc075010b08f5ab2f98f504cec1c6c9e0ca144857d185cbecf72a11f504bf"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "google-docs",
|
||||
"display_name": "Google Docs",
|
||||
"kind": "tool",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"wit_version": "0.3.0",
|
||||
"description": "Create and edit Google Docs documents",
|
||||
"keywords": [
|
||||
@@ -18,8 +18,8 @@
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/google-docs-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"sha256": "40e134a1c1564f832ca861c3396895d4e33ec67b99313fc1f97baf8d971423a9"
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-google-docs-0.2.1-wasm32-wasip2.tar.gz",
|
||||
"sha256": "39d476029764949498a53a6a223f9952b5f4df151be7b8b19bf3fe4d401a57cd"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "google-drive",
|
||||
"display_name": "Google Drive",
|
||||
"kind": "tool",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"wit_version": "0.3.0",
|
||||
"description": "Upload, download, search, and manage Google Drive files and folders",
|
||||
"keywords": [
|
||||
@@ -18,8 +18,8 @@
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/google-drive-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"sha256": "002a341a1d58125563a7c69561b26fbc2629b04ea723cade744102bdc0fbb71f"
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-google-drive-0.2.1-wasm32-wasip2.tar.gz",
|
||||
"sha256": "6e9a700fab93865c852af718666af64c5b534ad6a419fb4b736e07740188f494"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "google-sheets",
|
||||
"display_name": "Google Sheets",
|
||||
"kind": "tool",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"wit_version": "0.3.0",
|
||||
"description": "Read and write Google Sheets spreadsheet data",
|
||||
"keywords": [
|
||||
@@ -18,8 +18,8 @@
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/google-sheets-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"sha256": "8aa2c9d52f033edea3a6c2311b0ec694ccb6d0a54ef07e94d72bf8be1ce8009a"
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-google-sheets-0.2.1-wasm32-wasip2.tar.gz",
|
||||
"sha256": "1f8c381799a916be83263cac9d497d52946e21b1b588592a3a42ca94a73b7051"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "google-slides",
|
||||
"display_name": "Google Slides",
|
||||
"kind": "tool",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"wit_version": "0.3.0",
|
||||
"description": "Create and edit Google Slides presentations",
|
||||
"keywords": [
|
||||
@@ -17,8 +17,8 @@
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/google-slides-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"sha256": "e931a97d4fd0b0b938e464dc7c7f2be6ea6b4d1508f5ea3cd931d44db23f05f5"
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-google-slides-0.2.1-wasm32-wasip2.tar.gz",
|
||||
"sha256": "e2528be5da02f1b8cfc8ee9b0cdd849516c53d412e2f75c6175b3bded7f512cb"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "llm-context",
|
||||
"display_name": "LLM Context",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"wit_version": "0.3.0",
|
||||
"description": "Fetch pre-extracted web content from Brave Search for grounding LLM answers (RAG, fact-checking)",
|
||||
"keywords": [
|
||||
@@ -21,8 +21,8 @@
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-llm-context-0.1.0-wasm32-wasip2.tar.gz",
|
||||
"sha256": "d9ced2b1226b879135891e0ee40e072c7c95412e1b2462925a23853e1f92497e"
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-llm-context-0.1.1-wasm32-wasip2.tar.gz",
|
||||
"sha256": "9b19e2fd05dbbbe3c8bd55309a91db09124e8415eb0f767828b6e10b55771e63"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "slack-tool",
|
||||
"display_name": "Slack Tool",
|
||||
"kind": "tool",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"wit_version": "0.3.0",
|
||||
"description": "Your agent uses Slack to post and read messages in your workspace",
|
||||
"keywords": [
|
||||
@@ -17,8 +17,8 @@
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-slack-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"sha256": "ccfb0415d7a04f9497726c712d15216de36e86f498b849101283c017f5ab4efb"
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-slack-0.2.1-wasm32-wasip2.tar.gz",
|
||||
"sha256": "927519e5b7734beeb022d3b8bbd152e0e6b9f67c9452a8ad47809d3c4221a137"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "telegram-mtproto",
|
||||
"display_name": "Telegram Tool",
|
||||
"kind": "tool",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"wit_version": "0.3.0",
|
||||
"description": "Your agent uses your Telegram account to read and send messages",
|
||||
"keywords": [
|
||||
@@ -18,8 +18,8 @@
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-telegram-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"sha256": "c17065ca41fae5f2a7c43b36144686718cd310a2f22442313bb1aa82bbad0ae4"
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-telegram-0.2.1-wasm32-wasip2.tar.gz",
|
||||
"sha256": "1e57d0755fc9c7b3ec013d079f30168898b484a6919f9edd105f0cd80131c1cd"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "web-search",
|
||||
"display_name": "Web Search",
|
||||
"kind": "tool",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"wit_version": "0.3.0",
|
||||
"description": "Search the web using Brave Search API",
|
||||
"keywords": [
|
||||
@@ -18,8 +18,8 @@
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-web-search-0.2.1-wasm32-wasip2.tar.gz",
|
||||
"sha256": "bad275ca4ec314adea5241d6b92c44ccf9cebcbca8e30ba2493cc0bcb4b57218"
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-web-search-0.2.2-wasm32-wasip2.tar.gz",
|
||||
"sha256": "47382b50c1ea7525b20d59dc02fab04e336d018665826c2f24710bdf460779ae"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -1,7 +1,2 @@
|
||||
[workspace]
|
||||
git_release_enable = false
|
||||
|
||||
[[package]]
|
||||
name = "ironclaw_safety"
|
||||
publish = false
|
||||
release = false
|
||||
|
||||
+226
-48
@@ -13,9 +13,10 @@ use futures::StreamExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::agent::context_monitor::ContextMonitor;
|
||||
use crate::agent::heartbeat::spawn_heartbeat;
|
||||
use crate::agent::heartbeat::{spawn_heartbeat, spawn_multi_user_heartbeat};
|
||||
use crate::agent::routine_engine::{RoutineEngine, spawn_cron_ticker};
|
||||
use crate::agent::self_repair::{DefaultSelfRepair, RepairResult, SelfRepair};
|
||||
use crate::agent::session::ThreadState;
|
||||
use crate::agent::session_manager::SessionManager;
|
||||
use crate::agent::submission::{Submission, SubmissionParser, SubmissionResult};
|
||||
use crate::agent::{HeartbeatConfig as AgentHeartbeatConfig, Router, Scheduler, SchedulerDeps};
|
||||
@@ -84,6 +85,15 @@ fn resolve_owner_scope_notification_user(
|
||||
trimmed_option(explicit_user).or_else(|| trimmed_option(owner_fallback))
|
||||
}
|
||||
|
||||
fn is_single_message_repl(message: &IncomingMessage) -> bool {
|
||||
message.channel == "repl"
|
||||
&& message
|
||||
.metadata
|
||||
.get("single_message_mode")
|
||||
.and_then(|value| value.as_bool())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn resolve_channel_notification_user(
|
||||
extension_manager: Option<&Arc<ExtensionManager>>,
|
||||
channel: Option<&str>,
|
||||
@@ -157,8 +167,8 @@ pub struct AgentDeps {
|
||||
pub hooks: Arc<HookRegistry>,
|
||||
/// Cost enforcement guardrails (daily budget, hourly rate limits).
|
||||
pub cost_guard: Arc<crate::agent::cost_guard::CostGuard>,
|
||||
/// SSE broadcast sender for live job event streaming to the web gateway.
|
||||
pub sse_tx: Option<tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>>,
|
||||
/// SSE manager for live job event streaming to the web gateway.
|
||||
pub sse_tx: Option<Arc<crate::channels::web::sse::SseManager>>,
|
||||
/// HTTP interceptor for trace recording/replay.
|
||||
pub http_interceptor: Option<Arc<dyn crate::llm::recording::HttpInterceptor>>,
|
||||
/// Audio transcription middleware for voice messages.
|
||||
@@ -169,6 +179,11 @@ pub struct AgentDeps {
|
||||
pub sandbox_readiness: crate::agent::routine_engine::SandboxReadiness,
|
||||
/// Software builder for self-repair tool rebuilding.
|
||||
pub builder: Option<Arc<dyn crate::tools::SoftwareBuilder>>,
|
||||
/// Resolved LLM backend identifier (e.g., "nearai", "openai", "groq").
|
||||
/// Used by `/model` persistence to determine which env var to update.
|
||||
pub llm_backend: String,
|
||||
/// Per-tenant rate limiting registry (lazily creates rate state per user).
|
||||
pub tenant_rates: Arc<crate::tenant::TenantRateRegistry>,
|
||||
}
|
||||
|
||||
/// The main agent that coordinates all components.
|
||||
@@ -231,12 +246,15 @@ impl Agent {
|
||||
SchedulerDeps {
|
||||
tools: deps.tools.clone(),
|
||||
extension_manager: deps.extension_manager.clone(),
|
||||
store: deps.store.clone(),
|
||||
store: deps
|
||||
.store
|
||||
.as_ref()
|
||||
.map(|db| crate::tenant::AdminScope::new(Arc::clone(db))),
|
||||
hooks: deps.hooks.clone(),
|
||||
},
|
||||
);
|
||||
if let Some(ref tx) = deps.sse_tx {
|
||||
scheduler.set_sse_sender(tx.clone());
|
||||
if let Some(ref sse) = deps.sse_tx {
|
||||
scheduler.set_sse_sender(Arc::clone(sse));
|
||||
}
|
||||
if let Some(ref interceptor) = deps.http_interceptor {
|
||||
scheduler.set_http_interceptor(Arc::clone(interceptor));
|
||||
@@ -312,6 +330,50 @@ impl Agent {
|
||||
&self.deps.cost_guard
|
||||
}
|
||||
|
||||
/// Build a tenant-scoped execution context for the given user.
|
||||
///
|
||||
/// This is the standard entry point for per-user operations. The returned
|
||||
/// [`TenantCtx`] provides a [`TenantScope`] that auto-binds `user_id` on
|
||||
/// every database operation and a per-user rate limiter.
|
||||
pub(super) async fn tenant_ctx(&self, user_id: &str) -> crate::tenant::TenantCtx {
|
||||
let rate = self.deps.tenant_rates.get_or_create(user_id).await;
|
||||
|
||||
let store = self
|
||||
.deps
|
||||
.store
|
||||
.as_ref()
|
||||
.map(|db| crate::tenant::TenantScope::new(user_id, Arc::clone(db)));
|
||||
|
||||
// Reuse the owner workspace if user matches, otherwise create per-user.
|
||||
let workspace = match &self.deps.workspace {
|
||||
Some(ws) if ws.user_id() == user_id => Some(Arc::clone(ws)),
|
||||
_ => self
|
||||
.deps
|
||||
.store
|
||||
.as_ref()
|
||||
.map(|db| Arc::new(Workspace::new_with_db(user_id, Arc::clone(db)))),
|
||||
};
|
||||
|
||||
crate::tenant::TenantCtx::new(
|
||||
user_id,
|
||||
store,
|
||||
workspace,
|
||||
Arc::clone(&self.deps.cost_guard),
|
||||
rate,
|
||||
)
|
||||
}
|
||||
|
||||
/// Get an admin-scoped database accessor for cross-tenant operations.
|
||||
///
|
||||
/// Only for system-level components (heartbeat, routine engine, self-repair,
|
||||
/// scheduler). Handler code should use [`tenant_ctx()`](Self::tenant_ctx) instead.
|
||||
pub(super) fn admin_store(&self) -> Option<crate::tenant::AdminScope> {
|
||||
self.deps
|
||||
.store
|
||||
.as_ref()
|
||||
.map(|db| crate::tenant::AdminScope::new(Arc::clone(db)))
|
||||
}
|
||||
|
||||
pub(super) fn skill_registry(&self) -> Option<&Arc<std::sync::RwLock<SkillRegistry>>> {
|
||||
self.deps.skill_registry.as_ref()
|
||||
}
|
||||
@@ -397,8 +459,8 @@ impl Agent {
|
||||
self.config.stuck_threshold,
|
||||
self.config.max_repair_attempts,
|
||||
);
|
||||
if let Some(ref store) = self.deps.store {
|
||||
self_repair = self_repair.with_store(Arc::clone(store));
|
||||
if let Some(admin) = self.admin_store() {
|
||||
self_repair = self_repair.with_store(admin);
|
||||
}
|
||||
if let Some(ref builder) = self.deps.builder {
|
||||
self_repair = self_repair.with_builder(Arc::clone(builder), Arc::clone(self.tools()));
|
||||
@@ -505,6 +567,7 @@ impl Agent {
|
||||
.with_interval(std::time::Duration::from_secs(hb_config.interval_secs));
|
||||
config.quiet_hours_start = hb_config.quiet_hours_start;
|
||||
config.quiet_hours_end = hb_config.quiet_hours_end;
|
||||
config.multi_tenant = hb_config.multi_tenant;
|
||||
config.timezone = hb_config
|
||||
.timezone
|
||||
.clone()
|
||||
@@ -534,30 +597,52 @@ impl Agent {
|
||||
.await;
|
||||
let notify_user = heartbeat_notify_user;
|
||||
let channels = self.channels.clone();
|
||||
let is_multi_tenant = hb_config.multi_tenant;
|
||||
tokio::spawn(async move {
|
||||
while let Some(response) = notify_rx.recv().await {
|
||||
// In multi-tenant mode, extract the owning user_id from
|
||||
// the response metadata so notifications reach the
|
||||
// correct user rather than the agent's owner.
|
||||
// This intentionally overrides the configured notify_target
|
||||
// because each user's heartbeat should notify that user.
|
||||
let effective_user = if is_multi_tenant {
|
||||
response
|
||||
.metadata
|
||||
.get("owner_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Try the configured channel first, fall back to
|
||||
// broadcasting on all channels.
|
||||
let targeted_ok = if let Some(ref channel) = notify_channel
|
||||
&& let Some(ref user) = notify_target
|
||||
{
|
||||
channels
|
||||
.broadcast(channel, user, response.clone())
|
||||
.await
|
||||
.is_ok()
|
||||
let targeted_ok = if let Some(ref channel) = notify_channel {
|
||||
let target = effective_user.as_deref().or(notify_target.as_deref());
|
||||
if let Some(user) = target {
|
||||
channels
|
||||
.broadcast(channel, user, response.clone())
|
||||
.await
|
||||
.is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !targeted_ok && let Some(ref user) = notify_user {
|
||||
let results = channels.broadcast_all(user, response).await;
|
||||
for (ch, result) in results {
|
||||
if let Err(e) = result {
|
||||
tracing::warn!(
|
||||
"Failed to broadcast heartbeat to {}: {}",
|
||||
ch,
|
||||
e
|
||||
);
|
||||
if !targeted_ok {
|
||||
let fallback = effective_user.as_deref().or(notify_user.as_deref());
|
||||
if let Some(user) = fallback {
|
||||
let results = channels.broadcast_all(user, response).await;
|
||||
for (ch, result) in results {
|
||||
if let Err(e) = result {
|
||||
tracing::warn!(
|
||||
"Failed to broadcast heartbeat to {}: {}",
|
||||
ch,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -570,14 +655,29 @@ impl Agent {
|
||||
.map(|h| h.to_workspace_config())
|
||||
.unwrap_or_default();
|
||||
|
||||
Some(spawn_heartbeat(
|
||||
config,
|
||||
hygiene,
|
||||
workspace.clone(),
|
||||
self.cheap_llm().clone(),
|
||||
Some(notify_tx),
|
||||
self.store().map(Arc::clone),
|
||||
))
|
||||
if config.multi_tenant {
|
||||
if let Some(admin) = self.admin_store() {
|
||||
Some(spawn_multi_user_heartbeat(
|
||||
config,
|
||||
hygiene,
|
||||
self.cheap_llm().clone(),
|
||||
Some(notify_tx),
|
||||
admin,
|
||||
))
|
||||
} else {
|
||||
tracing::warn!("Multi-tenant heartbeat requires a database store");
|
||||
None
|
||||
}
|
||||
} else {
|
||||
Some(spawn_heartbeat(
|
||||
config,
|
||||
hygiene,
|
||||
workspace.clone(),
|
||||
self.cheap_llm().clone(),
|
||||
Some(notify_tx),
|
||||
self.admin_store(),
|
||||
))
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("Heartbeat enabled but no workspace available");
|
||||
None
|
||||
@@ -599,7 +699,7 @@ impl Agent {
|
||||
|
||||
let engine = Arc::new(RoutineEngine::new(
|
||||
rt_config.clone(),
|
||||
Arc::clone(store),
|
||||
crate::tenant::AdminScope::new(Arc::clone(store)),
|
||||
self.llm().clone(),
|
||||
Arc::clone(workspace),
|
||||
notify_tx,
|
||||
@@ -1052,10 +1152,11 @@ impl Agent {
|
||||
} else {
|
||||
drop(sess);
|
||||
self.session_manager
|
||||
.resolve_thread(
|
||||
.resolve_thread_with_parsed_uuid(
|
||||
&message.user_id,
|
||||
&message.channel,
|
||||
message.conversation_scope(),
|
||||
approval_thread_uuid,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -1136,9 +1237,14 @@ impl Agent {
|
||||
&& let Submission::UserInput { ref content } = submission
|
||||
&& let Some(engine) = self.routine_engine().await
|
||||
{
|
||||
let fired = engine
|
||||
.check_event_triggers(&message.user_id, &message.channel, content)
|
||||
.await;
|
||||
let single_message_repl = is_single_message_repl(message);
|
||||
// Use post-hook content so that BeforeInbound hooks that rewrite
|
||||
// input are respected by event trigger matching.
|
||||
let fired = if single_message_repl {
|
||||
engine.check_event_triggers_and_wait(message, content).await
|
||||
} else {
|
||||
engine.check_event_triggers(message, content).await
|
||||
};
|
||||
if fired > 0 {
|
||||
tracing::debug!(
|
||||
channel = %message.channel,
|
||||
@@ -1146,15 +1252,30 @@ impl Agent {
|
||||
fired,
|
||||
"Consumed inbound user message with matching event-triggered routine(s)"
|
||||
);
|
||||
return Ok(Some(String::new()));
|
||||
return if single_message_repl {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(String::new()))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Build per-tenant execution context once; threaded through all handlers.
|
||||
let tenant = self.tenant_ctx(&message.user_id).await;
|
||||
|
||||
let session_for_empty_exit = Arc::clone(&session);
|
||||
|
||||
// Process based on submission type
|
||||
let result = match submission {
|
||||
Submission::UserInput { content } => {
|
||||
let mut result = self
|
||||
.process_user_input(message, session.clone(), thread_id, &content)
|
||||
.process_user_input(
|
||||
message,
|
||||
tenant.clone(),
|
||||
session.clone(),
|
||||
thread_id,
|
||||
&content,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Drain any messages queued during processing.
|
||||
@@ -1221,7 +1342,13 @@ impl Agent {
|
||||
let mut queued_msg = message.clone();
|
||||
queued_msg.attachments.clear();
|
||||
result = self
|
||||
.process_user_input(&queued_msg, session.clone(), thread_id, &next_content)
|
||||
.process_user_input(
|
||||
&queued_msg,
|
||||
tenant.clone(),
|
||||
session.clone(),
|
||||
thread_id,
|
||||
&next_content,
|
||||
)
|
||||
.await;
|
||||
|
||||
// If processing failed, re-queue the drained content so it
|
||||
@@ -1246,8 +1373,30 @@ impl Agent {
|
||||
command,
|
||||
message.channel
|
||||
);
|
||||
// /reasoning is special-cased here (not in handle_system_command)
|
||||
// because it needs the session + thread_id to read turn reasoning
|
||||
// data, which handle_system_command's signature doesn't provide.
|
||||
if command == "reasoning" {
|
||||
let result = self
|
||||
.handle_reasoning_command(&args, &session, thread_id)
|
||||
.await;
|
||||
return match result {
|
||||
SubmissionResult::Response { content } => Ok(Some(content)),
|
||||
SubmissionResult::Ok { message } => Ok(message),
|
||||
SubmissionResult::Error { message } => {
|
||||
Ok(Some(format!("Error: {}", message)))
|
||||
}
|
||||
_ => {
|
||||
if is_single_message_repl(message) {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(String::new()))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
// Authorization checks (including restart channel check) are enforced in handle_system_command
|
||||
self.handle_system_command(&command, &args, &message.channel)
|
||||
self.handle_system_command(&command, &args, &message.channel, &tenant)
|
||||
.await
|
||||
}
|
||||
Submission::Undo => self.process_undo(session, thread_id).await,
|
||||
@@ -1260,12 +1409,9 @@ impl Agent {
|
||||
Submission::Summarize => self.process_summarize(session, thread_id).await,
|
||||
Submission::Suggest => self.process_suggest(session, thread_id).await,
|
||||
Submission::JobStatus { job_id } => {
|
||||
self.process_job_status(&message.user_id, job_id.as_deref())
|
||||
.await
|
||||
}
|
||||
Submission::JobCancel { job_id } => {
|
||||
self.process_job_cancel(&message.user_id, &job_id).await
|
||||
self.process_job_status(&tenant, job_id.as_deref()).await
|
||||
}
|
||||
Submission::JobCancel { job_id } => self.process_job_cancel(&tenant, &job_id).await,
|
||||
Submission::Quit => return Ok(None),
|
||||
Submission::SwitchThread { thread_id: target } => {
|
||||
self.process_switch_thread(message, target).await
|
||||
@@ -1305,7 +1451,26 @@ impl Agent {
|
||||
Ok(Some(content))
|
||||
}
|
||||
}
|
||||
SubmissionResult::Ok { message } => Ok(message),
|
||||
SubmissionResult::Ok {
|
||||
message: output_message,
|
||||
} => {
|
||||
let should_exit =
|
||||
if output_message.as_deref() == Some("") && is_single_message_repl(message) {
|
||||
let sess = session_for_empty_exit.lock().await;
|
||||
sess.threads
|
||||
.get(&thread_id)
|
||||
.map(|thread| thread.state != ThreadState::AwaitingApproval)
|
||||
.unwrap_or(true)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if should_exit {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(output_message)
|
||||
}
|
||||
}
|
||||
SubmissionResult::Error { message } => Ok(Some(format!("Error: {}", message))),
|
||||
SubmissionResult::Interrupted => Ok(Some("Interrupted.".into())),
|
||||
SubmissionResult::NeedApproval { .. } => {
|
||||
@@ -1321,7 +1486,7 @@ impl Agent {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
chat_tool_execution_metadata, resolve_routine_notification_user,
|
||||
chat_tool_execution_metadata, is_single_message_repl, resolve_routine_notification_user,
|
||||
should_fallback_routine_notification, truncate_for_preview,
|
||||
};
|
||||
use crate::channels::IncomingMessage;
|
||||
@@ -1483,4 +1648,17 @@ mod tests {
|
||||
|
||||
assert!(should_fallback_routine_notification(&error)); // safety: test-only assertion
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_message_repl_detection_requires_repl_channel_and_metadata_flag() {
|
||||
let repl = IncomingMessage::new("repl", "owner-scope", "hello")
|
||||
.with_metadata(serde_json::json!({ "single_message_mode": true }));
|
||||
let gateway = IncomingMessage::new("gateway", "owner-scope", "hello")
|
||||
.with_metadata(serde_json::json!({ "single_message_mode": true }));
|
||||
let plain_repl = IncomingMessage::new("repl", "owner-scope", "hello");
|
||||
|
||||
assert!(is_single_message_repl(&repl)); // safety: test-only assertion
|
||||
assert!(!is_single_message_repl(&gateway)); // safety: test-only assertion
|
||||
assert!(!is_single_message_repl(&plain_repl)); // safety: test-only assertion
|
||||
}
|
||||
}
|
||||
|
||||
+126
-1
@@ -10,7 +10,7 @@ use std::borrow::Cow;
|
||||
|
||||
use crate::agent::session::PendingApproval;
|
||||
use crate::error::Error;
|
||||
use crate::llm::{ChatMessage, Reasoning, ReasoningContext, RespondResult};
|
||||
use crate::llm::{ChatMessage, FinishReason, Reasoning, ReasoningContext, RespondResult};
|
||||
|
||||
/// Signal from the delegate indicating how the loop should proceed.
|
||||
pub enum LoopSignal {
|
||||
@@ -134,6 +134,9 @@ pub async fn run_agentic_loop(
|
||||
config: &AgenticLoopConfig,
|
||||
) -> Result<LoopOutcome, Error> {
|
||||
let mut consecutive_tool_intent_nudges: u32 = 0;
|
||||
// Accumulates across all iterations (not reset by text responses) so
|
||||
// non-consecutive truncations still escalate to force_text.
|
||||
let mut truncation_count: u32 = 0;
|
||||
|
||||
for iteration in 1..=config.max_iterations {
|
||||
// Check for external signals (stop, cancellation, user messages)
|
||||
@@ -215,7 +218,35 @@ pub async fn run_agentic_loop(
|
||||
tool_calls,
|
||||
content,
|
||||
} => {
|
||||
// If the response was truncated, tool call parameters are likely
|
||||
// incomplete. Discard them and tell the LLM to try a different
|
||||
// approach rather than executing malformed tool calls.
|
||||
if output.finish_reason == FinishReason::Length {
|
||||
truncation_count += 1;
|
||||
let names: Vec<&str> = tool_calls.iter().map(|tc| tc.name.as_str()).collect();
|
||||
tracing::warn!(
|
||||
iteration,
|
||||
tools = ?names,
|
||||
truncation_count,
|
||||
"Discarding truncated tool calls (finish_reason=Length)"
|
||||
);
|
||||
if let Some(ref text) = content {
|
||||
reason_ctx.messages.push(ChatMessage::assistant(text));
|
||||
}
|
||||
reason_ctx
|
||||
.messages
|
||||
.push(ChatMessage::user(crate::llm::TRUNCATED_TOOL_CALL_NOTICE));
|
||||
// After repeated truncations, force text-only mode so the LLM
|
||||
// stops attempting tool calls it can't fit in the output budget.
|
||||
if truncation_count >= 3 {
|
||||
reason_ctx.force_text = true;
|
||||
}
|
||||
delegate.after_iteration(iteration).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
consecutive_tool_intent_nudges = 0;
|
||||
truncation_count = 0;
|
||||
|
||||
if let Some(outcome) = delegate
|
||||
.execute_tool_calls(tool_calls, content, reason_ctx)
|
||||
@@ -271,6 +302,7 @@ mod tests {
|
||||
RespondOutput {
|
||||
result: RespondResult::Text(text.to_string()),
|
||||
usage: zero_usage(),
|
||||
finish_reason: FinishReason::Stop,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,6 +313,7 @@ mod tests {
|
||||
content: None,
|
||||
},
|
||||
usage: zero_usage(),
|
||||
finish_reason: FinishReason::ToolUse,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,6 +447,7 @@ mod tests {
|
||||
id: "call_1".to_string(),
|
||||
name: "echo".to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
reasoning: None,
|
||||
};
|
||||
let delegate = MockDelegate::new(vec![
|
||||
tool_calls_output(vec![tool_call]),
|
||||
@@ -621,4 +655,95 @@ mod tests {
|
||||
let result = truncate_for_preview("café", 4);
|
||||
assert_eq!(result, "caf...");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_truncated_tool_calls_discarded_on_length() {
|
||||
let truncated_tool_call = ToolCall {
|
||||
id: "call_1".to_string(),
|
||||
name: "memory_write".to_string(),
|
||||
arguments: serde_json::json!({}), // empty — truncated
|
||||
reasoning: None,
|
||||
};
|
||||
let truncated_output = RespondOutput {
|
||||
result: RespondResult::ToolCalls {
|
||||
tool_calls: vec![truncated_tool_call],
|
||||
content: Some("I'll write the report.".to_string()),
|
||||
},
|
||||
usage: zero_usage(),
|
||||
finish_reason: FinishReason::Length, // response was truncated
|
||||
};
|
||||
let delegate = MockDelegate::new(vec![truncated_output, text_output("Summarized it.")]);
|
||||
let reasoning = stub_reasoning();
|
||||
let mut ctx = ReasoningContext::new();
|
||||
let config = AgenticLoopConfig {
|
||||
max_iterations: 5,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Tool calls should NOT have been executed
|
||||
assert_eq!(delegate.tool_exec_count.load(Ordering::SeqCst), 0);
|
||||
// The loop should have continued and returned the text response
|
||||
assert!(matches!(outcome, LoopOutcome::Response(ref t) if t == "Summarized it."));
|
||||
// A truncation notice should have been injected into context
|
||||
assert!(
|
||||
ctx.messages
|
||||
.iter()
|
||||
.any(|m| m.role == crate::llm::Role::User && m.content.contains("truncated")),
|
||||
"Should inject truncation notice into context"
|
||||
);
|
||||
// The partial assistant content should have been preserved
|
||||
assert!(
|
||||
ctx.messages
|
||||
.iter()
|
||||
.any(|m| m.role == crate::llm::Role::Assistant
|
||||
&& m.content.contains("write the report")),
|
||||
"Should preserve partial assistant content"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_repeated_truncations_force_text_mode() {
|
||||
let make_truncated = || RespondOutput {
|
||||
result: RespondResult::ToolCalls {
|
||||
tool_calls: vec![ToolCall {
|
||||
id: "call_1".to_string(),
|
||||
name: "memory_write".to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
reasoning: None,
|
||||
}],
|
||||
content: None,
|
||||
},
|
||||
usage: zero_usage(),
|
||||
finish_reason: FinishReason::Length,
|
||||
};
|
||||
// Three truncated responses, then a text response
|
||||
let delegate = MockDelegate::new(vec![
|
||||
make_truncated(),
|
||||
make_truncated(),
|
||||
make_truncated(),
|
||||
text_output("Gave up on tool calls."),
|
||||
]);
|
||||
let reasoning = stub_reasoning();
|
||||
let mut ctx = ReasoningContext::new();
|
||||
let config = AgenticLoopConfig {
|
||||
max_iterations: 5,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(outcome, LoopOutcome::Response(_)));
|
||||
assert_eq!(delegate.tool_exec_count.load(Ordering::SeqCst), 0);
|
||||
// After 3 truncations, force_text should be set
|
||||
assert!(
|
||||
ctx.force_text,
|
||||
"Should escalate to force_text after repeated truncations"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+224
-54
@@ -33,6 +33,7 @@ impl Agent {
|
||||
&self,
|
||||
intent: MessageIntent,
|
||||
message: &IncomingMessage,
|
||||
tenant: &crate::tenant::TenantCtx,
|
||||
) -> Result<SubmissionResult, Error> {
|
||||
// Send thinking status for non-trivial operations
|
||||
if let MessageIntent::CreateJob { .. } = &intent {
|
||||
@@ -52,24 +53,18 @@ impl Agent {
|
||||
description,
|
||||
category,
|
||||
} => {
|
||||
self.handle_create_job(&message.user_id, title, description, category)
|
||||
self.handle_create_job(tenant, title, description, category)
|
||||
.await?
|
||||
}
|
||||
MessageIntent::CheckJobStatus { job_id } => {
|
||||
self.handle_check_status(&message.user_id, job_id).await?
|
||||
}
|
||||
MessageIntent::CancelJob { job_id } => {
|
||||
self.handle_cancel_job(&message.user_id, &job_id).await?
|
||||
}
|
||||
MessageIntent::ListJobs { filter } => {
|
||||
self.handle_list_jobs(&message.user_id, filter).await?
|
||||
}
|
||||
MessageIntent::HelpJob { job_id } => {
|
||||
self.handle_help_job(&message.user_id, &job_id).await?
|
||||
self.handle_check_status(tenant, job_id).await?
|
||||
}
|
||||
MessageIntent::CancelJob { job_id } => self.handle_cancel_job(tenant, &job_id).await?,
|
||||
MessageIntent::ListJobs { filter } => self.handle_list_jobs(tenant, filter).await?,
|
||||
MessageIntent::HelpJob { job_id } => self.handle_help_job(tenant, &job_id).await?,
|
||||
MessageIntent::Command { command, args } => {
|
||||
match self
|
||||
.handle_command(&command, &args, &message.channel)
|
||||
.handle_command(&command, &args, &message.channel, tenant)
|
||||
.await?
|
||||
{
|
||||
Some(s) => s,
|
||||
@@ -83,14 +78,14 @@ impl Agent {
|
||||
|
||||
async fn handle_create_job(
|
||||
&self,
|
||||
user_id: &str,
|
||||
tenant: &crate::tenant::TenantCtx,
|
||||
title: String,
|
||||
description: String,
|
||||
category: Option<String>,
|
||||
) -> Result<String, Error> {
|
||||
let job_id = self
|
||||
.scheduler
|
||||
.dispatch_job(user_id, &title, &description, None)
|
||||
.dispatch_job(tenant.user_id(), &title, &description, None)
|
||||
.await?;
|
||||
|
||||
// Set the dedicated category field (not stored in metadata)
|
||||
@@ -113,7 +108,7 @@ impl Agent {
|
||||
|
||||
async fn handle_check_status(
|
||||
&self,
|
||||
user_id: &str,
|
||||
tenant: &crate::tenant::TenantCtx,
|
||||
job_id: Option<String>,
|
||||
) -> Result<String, Error> {
|
||||
match job_id {
|
||||
@@ -122,7 +117,8 @@ impl Agent {
|
||||
.map_err(|_| crate::error::JobError::NotFound { id: Uuid::nil() })?;
|
||||
|
||||
// Try DB first for persistent state, fall back to ContextManager.
|
||||
if let Some(store) = self.store()
|
||||
// TenantScope.get_job() auto-filters by ownership — no manual check needed.
|
||||
if let Some(store) = tenant.store()
|
||||
&& let Ok(Some(ctx)) = store.get_job(uuid).await
|
||||
{
|
||||
return Ok(format!(
|
||||
@@ -138,7 +134,7 @@ impl Agent {
|
||||
}
|
||||
|
||||
let ctx = self.context_manager.get_context(uuid).await?;
|
||||
if ctx.user_id != user_id {
|
||||
if ctx.user_id != tenant.user_id() {
|
||||
return Err(crate::error::JobError::NotFound { id: uuid }.into());
|
||||
}
|
||||
|
||||
@@ -155,7 +151,8 @@ impl Agent {
|
||||
}
|
||||
None => {
|
||||
// Show summary from DB for consistency with Jobs tab.
|
||||
if let Some(store) = self.store() {
|
||||
// TenantScope methods auto-scope to user — no user_id parameter needed.
|
||||
if let Some(store) = tenant.store() {
|
||||
let mut total = 0;
|
||||
let mut in_progress = 0;
|
||||
let mut completed = 0;
|
||||
@@ -183,7 +180,7 @@ impl Agent {
|
||||
}
|
||||
|
||||
// Fallback to ContextManager if no DB.
|
||||
let summary = self.context_manager.summary_for(user_id).await;
|
||||
let summary = self.context_manager.summary_for(tenant.user_id()).await;
|
||||
Ok(format!(
|
||||
"Jobs summary: Total: {} In Progress: {} Completed: {} Failed: {} Stuck: {}",
|
||||
summary.total,
|
||||
@@ -196,19 +193,24 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_cancel_job(&self, user_id: &str, job_id: &str) -> Result<String, Error> {
|
||||
async fn handle_cancel_job(
|
||||
&self,
|
||||
tenant: &crate::tenant::TenantCtx,
|
||||
job_id: &str,
|
||||
) -> Result<String, Error> {
|
||||
let uuid = Uuid::parse_str(job_id)
|
||||
.map_err(|_| crate::error::JobError::NotFound { id: Uuid::nil() })?;
|
||||
|
||||
let ctx = self.context_manager.get_context(uuid).await?;
|
||||
if ctx.user_id != user_id {
|
||||
if ctx.user_id != tenant.user_id() {
|
||||
return Err(crate::error::JobError::NotFound { id: uuid }.into());
|
||||
}
|
||||
|
||||
self.scheduler.stop(uuid).await?;
|
||||
|
||||
// Also update DB so the Jobs tab reflects cancellation immediately.
|
||||
if let Some(store) = self.store()
|
||||
// Use TenantScope — ownership already verified above.
|
||||
if let Some(store) = tenant.store()
|
||||
&& let Err(e) = store
|
||||
.update_job_status(uuid, JobState::Cancelled, Some("Cancelled by user"))
|
||||
.await
|
||||
@@ -221,11 +223,12 @@ impl Agent {
|
||||
|
||||
async fn handle_list_jobs(
|
||||
&self,
|
||||
user_id: &str,
|
||||
tenant: &crate::tenant::TenantCtx,
|
||||
_filter: Option<String>,
|
||||
) -> Result<String, Error> {
|
||||
// List from DB for consistency with Jobs tab.
|
||||
if let Some(store) = self.store() {
|
||||
// TenantScope methods auto-scope to user.
|
||||
if let Some(store) = tenant.store() {
|
||||
let agent_jobs = match store.list_agent_jobs().await {
|
||||
Ok(jobs) => jobs,
|
||||
Err(e) => {
|
||||
@@ -256,7 +259,7 @@ impl Agent {
|
||||
}
|
||||
|
||||
// Fallback to ContextManager if no DB.
|
||||
let jobs = self.context_manager.all_jobs_for(user_id).await;
|
||||
let jobs = self.context_manager.all_jobs_for(tenant.user_id()).await;
|
||||
if jobs.is_empty() {
|
||||
return Ok("No jobs found.".to_string());
|
||||
}
|
||||
@@ -270,12 +273,16 @@ impl Agent {
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
async fn handle_help_job(&self, user_id: &str, job_id: &str) -> Result<String, Error> {
|
||||
async fn handle_help_job(
|
||||
&self,
|
||||
tenant: &crate::tenant::TenantCtx,
|
||||
job_id: &str,
|
||||
) -> Result<String, Error> {
|
||||
let uuid = Uuid::parse_str(job_id)
|
||||
.map_err(|_| crate::error::JobError::NotFound { id: Uuid::nil() })?;
|
||||
|
||||
let ctx = self.context_manager.get_context(uuid).await?;
|
||||
if ctx.user_id != user_id {
|
||||
if ctx.user_id != tenant.user_id() {
|
||||
return Err(crate::error::JobError::NotFound { id: uuid }.into());
|
||||
}
|
||||
|
||||
@@ -308,11 +315,11 @@ impl Agent {
|
||||
/// Show job status inline — either all jobs (no id) or a specific job.
|
||||
pub(super) async fn process_job_status(
|
||||
&self,
|
||||
user_id: &str,
|
||||
tenant: &crate::tenant::TenantCtx,
|
||||
job_id: Option<&str>,
|
||||
) -> Result<SubmissionResult, Error> {
|
||||
match self
|
||||
.handle_check_status(user_id, job_id.map(|s| s.to_string()))
|
||||
.handle_check_status(tenant, job_id.map(|s| s.to_string()))
|
||||
.await
|
||||
{
|
||||
Ok(text) => Ok(SubmissionResult::response(text)),
|
||||
@@ -323,10 +330,10 @@ impl Agent {
|
||||
/// Cancel a job by ID.
|
||||
pub(super) async fn process_job_cancel(
|
||||
&self,
|
||||
user_id: &str,
|
||||
tenant: &crate::tenant::TenantCtx,
|
||||
job_id: &str,
|
||||
) -> Result<SubmissionResult, Error> {
|
||||
match self.handle_cancel_job(user_id, job_id).await {
|
||||
match self.handle_cancel_job(tenant, job_id).await {
|
||||
Ok(text) => Ok(SubmissionResult::response(text)),
|
||||
Err(e) => Ok(SubmissionResult::error(format!("Cancel error: {}", e))),
|
||||
}
|
||||
@@ -465,12 +472,101 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle `/reasoning [N|all]` — show reasoning history for the active thread.
|
||||
pub(super) async fn handle_reasoning_command(
|
||||
&self,
|
||||
args: &[String],
|
||||
session: &Arc<Mutex<Session>>,
|
||||
thread_id: Uuid,
|
||||
) -> SubmissionResult {
|
||||
// Clone the turn data we need, then drop the session lock.
|
||||
let turns_snapshot: Vec<(
|
||||
usize,
|
||||
Option<String>,
|
||||
Vec<crate::agent::session::TurnToolCall>,
|
||||
)>;
|
||||
{
|
||||
let sess = session.lock().await;
|
||||
let thread = match sess.threads.get(&thread_id) {
|
||||
Some(t) => t,
|
||||
None => return SubmissionResult::error("No active thread."),
|
||||
};
|
||||
|
||||
if thread.turns.is_empty() {
|
||||
return SubmissionResult::ok_with_message("No turns yet.");
|
||||
}
|
||||
|
||||
// Parse argument: default=last turn, "all"=all turns, N=specific turn (1-based).
|
||||
let selected: Vec<&crate::agent::session::Turn> = match args.first().map(|s| s.as_str())
|
||||
{
|
||||
Some("all") => thread.turns.iter().collect(),
|
||||
Some(n) => match n.parse::<usize>() {
|
||||
Ok(0) => return SubmissionResult::error("Turn numbers start at 1."),
|
||||
Ok(num) if num > thread.turns.len() => {
|
||||
return SubmissionResult::error(format!(
|
||||
"Turn {} does not exist (max: {}).",
|
||||
num,
|
||||
thread.turns.len()
|
||||
));
|
||||
}
|
||||
Ok(num) => vec![&thread.turns[num - 1]],
|
||||
Err(_) => return SubmissionResult::error("Usage: /reasoning [N|all]"),
|
||||
},
|
||||
None => {
|
||||
// Default: last turn that has tool calls
|
||||
match thread.turns.iter().rev().find(|t| !t.tool_calls.is_empty()) {
|
||||
Some(t) => vec![t],
|
||||
None => {
|
||||
return SubmissionResult::ok_with_message("No turns with tool calls.");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
turns_snapshot = selected
|
||||
.into_iter()
|
||||
.map(|t| (t.turn_number, t.narrative.clone(), t.tool_calls.clone()))
|
||||
.collect();
|
||||
}
|
||||
// Session lock is now dropped — format output without holding it.
|
||||
|
||||
let mut output = String::new();
|
||||
for (turn_number, narrative, tool_calls) in &turns_snapshot {
|
||||
output.push_str(&format!("--- Turn {} ---\n", turn_number + 1));
|
||||
if let Some(narrative) = narrative {
|
||||
output.push_str(&format!("Reasoning: {}\n", narrative));
|
||||
}
|
||||
if tool_calls.is_empty() {
|
||||
output.push_str(" (no tool calls)\n");
|
||||
} else {
|
||||
for tc in tool_calls {
|
||||
let status = if tc.error.is_some() {
|
||||
"error"
|
||||
} else if tc.result.is_some() {
|
||||
"ok"
|
||||
} else {
|
||||
"pending"
|
||||
};
|
||||
output.push_str(&format!(" {} [{}]", tc.name, status));
|
||||
if let Some(ref rationale) = tc.rationale {
|
||||
output.push_str(&format!(" — {}", rationale));
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
|
||||
SubmissionResult::response(output.trim_end())
|
||||
}
|
||||
|
||||
/// Handle system commands that bypass thread-state checks entirely.
|
||||
pub(super) async fn handle_system_command(
|
||||
&self,
|
||||
command: &str,
|
||||
args: &[String],
|
||||
channel: &str,
|
||||
tenant: &crate::tenant::TenantCtx,
|
||||
) -> Result<SubmissionResult, Error> {
|
||||
match command {
|
||||
"help" => Ok(SubmissionResult::response(concat!(
|
||||
@@ -480,6 +576,7 @@ impl Agent {
|
||||
" /version Show version info\n",
|
||||
" /tools List available tools\n",
|
||||
" /debug Toggle debug mode\n",
|
||||
" /reasoning [N|all] Show agent reasoning for turns\n",
|
||||
" /ping Connectivity check\n",
|
||||
"\n",
|
||||
"Jobs:\n",
|
||||
@@ -663,19 +760,32 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
match self.llm().set_model(requested) {
|
||||
Ok(()) => {
|
||||
// Persist the model choice so it survives restarts.
|
||||
self.persist_selected_model(requested).await;
|
||||
Ok(SubmissionResult::response(format!(
|
||||
"Switched model to: {}",
|
||||
requested
|
||||
)))
|
||||
if self.config.multi_tenant {
|
||||
// Multi-tenant: only persist to per-user DB settings.
|
||||
// Do NOT call set_model() on the shared provider — that
|
||||
// would change the default for all users. The per-request
|
||||
// model_override in the dispatcher reads from the same
|
||||
// "selected_model" setting and applies it per-user.
|
||||
self.persist_selected_model(tenant, requested).await;
|
||||
Ok(SubmissionResult::response(format!(
|
||||
"Model preference set to: {} (per-user)",
|
||||
requested
|
||||
)))
|
||||
} else {
|
||||
match self.llm().set_model(requested) {
|
||||
Ok(()) => {
|
||||
// Persist the model choice so it survives restarts.
|
||||
self.persist_selected_model(tenant, requested).await;
|
||||
Ok(SubmissionResult::response(format!(
|
||||
"Switched model to: {}",
|
||||
requested
|
||||
)))
|
||||
}
|
||||
Err(e) => Ok(SubmissionResult::error(format!(
|
||||
"Failed to switch model: {}",
|
||||
e
|
||||
))),
|
||||
}
|
||||
Err(e) => Ok(SubmissionResult::error(format!(
|
||||
"Failed to switch model: {}",
|
||||
e
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -817,10 +927,14 @@ impl Agent {
|
||||
command: &str,
|
||||
args: &[String],
|
||||
channel: &str,
|
||||
tenant: &crate::tenant::TenantCtx,
|
||||
) -> Result<Option<String>, Error> {
|
||||
// System commands are now handled directly via Submission::SystemCommand,
|
||||
// but the router may still send us unknown /commands.
|
||||
match self.handle_system_command(command, args, channel).await? {
|
||||
match self
|
||||
.handle_system_command(command, args, channel, tenant)
|
||||
.await?
|
||||
{
|
||||
SubmissionResult::Response { content } => Ok(Some(content)),
|
||||
SubmissionResult::Ok { message } => Ok(message),
|
||||
SubmissionResult::Error { message } => Ok(Some(format!("Error: {}", message))),
|
||||
@@ -832,21 +946,69 @@ impl Agent {
|
||||
///
|
||||
/// Best-effort: logs warnings on failure but does not propagate errors,
|
||||
/// since the in-memory model switch already succeeded.
|
||||
async fn persist_selected_model(&self, model: &str) {
|
||||
// 1. Persist to DB if available.
|
||||
if let Some(store) = self.store() {
|
||||
///
|
||||
/// In multi-tenant mode, only the per-user DB setting is written — global
|
||||
/// .env and TOML files are shared across users and must not be mutated.
|
||||
async fn persist_selected_model(&self, tenant: &crate::tenant::TenantCtx, model: &str) {
|
||||
// 1. Persist to DB if available (per-user scoped via TenantScope).
|
||||
if let Some(store) = tenant.store() {
|
||||
let value = serde_json::Value::String(model.to_string());
|
||||
if let Err(e) = store
|
||||
.set_setting(self.owner_id(), "selected_model", &value)
|
||||
.await
|
||||
{
|
||||
if let Err(e) = store.set_setting("selected_model", &value).await {
|
||||
tracing::warn!("Failed to persist model to DB: {}", e);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
user_id = tenant.user_id(),
|
||||
"Persisted selected_model to DB: {}",
|
||||
model
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("No database store available — model choice will not persist to DB");
|
||||
}
|
||||
|
||||
// 2. Update TOML config file if it exists (sync I/O in spawn_blocking).
|
||||
// 2. In multi-tenant mode, skip .env/TOML writes — these are global
|
||||
// files shared by all users. The per-user DB setting is sufficient.
|
||||
if self.config.multi_tenant {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Update .env and TOML config file (sync I/O in spawn_blocking).
|
||||
let model_owned = model.to_string();
|
||||
let backend = self.deps.llm_backend.clone();
|
||||
if let Err(e) = tokio::task::spawn_blocking(move || {
|
||||
// 2a. Update the backend-specific model env var in ~/.ironclaw/.env.
|
||||
//
|
||||
// Env vars have the HIGHEST priority in LlmConfig::resolve_model()
|
||||
// (env var > TOML > DB > default). If the .env file has e.g.
|
||||
// NEARAI_MODEL=old-model, it shadows everything else. We must
|
||||
// update this var or the /model change is invisible on restart.
|
||||
let registry = crate::llm::ProviderRegistry::load();
|
||||
let model_env = registry.model_env_var(&backend);
|
||||
let env_var_prefix = format!("{}=", model_env);
|
||||
|
||||
// Only update the .env file if the var is actually set there
|
||||
// (avoid injecting new vars the user never configured).
|
||||
let env_path = crate::bootstrap::ironclaw_env_path();
|
||||
let env_has_var = std::fs::read_to_string(&env_path)
|
||||
.ok()
|
||||
.is_some_and(|content| {
|
||||
content.lines().any(|line| {
|
||||
let trimmed = line.trim_start();
|
||||
!trimmed.starts_with('#') && trimmed.starts_with(&env_var_prefix)
|
||||
})
|
||||
});
|
||||
if env_has_var {
|
||||
if let Err(e) = crate::bootstrap::upsert_bootstrap_var(model_env, &model_owned) {
|
||||
tracing::warn!("Failed to update {} in .env: {}", model_env, e);
|
||||
} else {
|
||||
tracing::debug!("Updated {} in .env to {}", model_env, model_owned);
|
||||
}
|
||||
}
|
||||
|
||||
// 2b. Update (or create) the TOML config file.
|
||||
//
|
||||
// The TOML overlay has higher priority than DB settings on
|
||||
// startup, so it MUST stay in sync with the DB.
|
||||
let toml_path = crate::settings::Settings::default_toml_path();
|
||||
match crate::settings::Settings::load_toml(&toml_path) {
|
||||
Ok(Some(mut settings)) => {
|
||||
@@ -856,7 +1018,15 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// No config file on disk; nothing to update.
|
||||
// No config file yet — create one so the model choice
|
||||
// survives restarts even when the DB is unavailable.
|
||||
let settings = crate::settings::Settings {
|
||||
selected_model: Some(model_owned),
|
||||
..Default::default()
|
||||
};
|
||||
if let Err(e) = settings.save_toml(&toml_path) {
|
||||
tracing::warn!("Failed to create config.toml for model persistence: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to load config.toml for model persistence: {}", e);
|
||||
@@ -865,7 +1035,7 @@ impl Agent {
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Model TOML persistence task failed: {}", e);
|
||||
tracing::warn!("Model persistence task failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+236
-3
@@ -21,6 +21,9 @@ pub struct CostGuardConfig {
|
||||
pub max_cost_per_day_cents: Option<u64>,
|
||||
/// Maximum LLM calls per hour. None = unlimited.
|
||||
pub max_actions_per_hour: Option<u64>,
|
||||
/// Maximum spend per user per day in cents. None = unlimited.
|
||||
/// Applied independently per user alongside the global budget.
|
||||
pub max_cost_per_user_per_day_cents: Option<u64>,
|
||||
}
|
||||
|
||||
/// Error returned when a cost limit is exceeded.
|
||||
@@ -30,6 +33,12 @@ pub enum CostLimitExceeded {
|
||||
DailyBudget { spent_cents: u64, limit_cents: u64 },
|
||||
/// Hourly action rate limit reached.
|
||||
HourlyRate { actions: u64, limit: u64 },
|
||||
/// Per-user daily spending cap reached.
|
||||
UserDailyBudget {
|
||||
user_id: String,
|
||||
spent_cents: u64,
|
||||
limit_cents: u64,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CostLimitExceeded {
|
||||
@@ -49,6 +58,17 @@ impl std::fmt::Display for CostLimitExceeded {
|
||||
"Hourly action limit exceeded: {} actions of {} allowed per hour",
|
||||
actions, limit
|
||||
),
|
||||
Self::UserDailyBudget {
|
||||
user_id,
|
||||
spent_cents,
|
||||
limit_cents,
|
||||
} => write!(
|
||||
f,
|
||||
"User '{}' daily cost limit exceeded: spent ${:.2} of ${:.2} allowed",
|
||||
user_id,
|
||||
*spent_cents as f64 / 100.0,
|
||||
*limit_cents as f64 / 100.0
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +98,9 @@ pub struct CostGuard {
|
||||
|
||||
/// Per-model token usage since startup.
|
||||
model_tokens: Mutex<HashMap<String, ModelTokens>>,
|
||||
|
||||
/// Per-user daily cost tracking. Each entry resets independently at midnight UTC.
|
||||
per_user_daily_cost: Mutex<HashMap<String, DailyCost>>,
|
||||
}
|
||||
|
||||
struct DailyCost {
|
||||
@@ -97,6 +120,7 @@ impl CostGuard {
|
||||
action_window: Mutex::new(VecDeque::new()),
|
||||
budget_exceeded: AtomicBool::new(false),
|
||||
model_tokens: Mutex::new(HashMap::new()),
|
||||
per_user_daily_cost: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +227,11 @@ impl CostGuard {
|
||||
daily.reset_date = today;
|
||||
self.budget_exceeded.store(false, Ordering::Relaxed);
|
||||
tracing::info!("Cost guard: daily counter reset for {}", today);
|
||||
|
||||
// Prune per-user entries from previous days to prevent
|
||||
// unbounded HashMap growth in long-lived deployments.
|
||||
let mut per_user = self.per_user_daily_cost.lock().await;
|
||||
per_user.retain(|_, entry| entry.reset_date == today);
|
||||
}
|
||||
daily.total += cost;
|
||||
|
||||
@@ -248,6 +277,85 @@ impl CostGuard {
|
||||
cost
|
||||
}
|
||||
|
||||
/// Record an LLM call with per-user attribution.
|
||||
///
|
||||
/// Delegates to `record_llm_call` for global tracking, then additionally
|
||||
/// records the cost against the user's daily budget.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn record_llm_call_for_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
model: &str,
|
||||
input_tokens: u32,
|
||||
output_tokens: u32,
|
||||
cache_read_input_tokens: u32,
|
||||
cache_creation_input_tokens: u32,
|
||||
cache_read_discount: Decimal,
|
||||
cache_write_multiplier: Decimal,
|
||||
cost_per_token: Option<(Decimal, Decimal)>,
|
||||
) -> Decimal {
|
||||
let cost = self
|
||||
.record_llm_call(
|
||||
model,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cache_read_input_tokens,
|
||||
cache_creation_input_tokens,
|
||||
cache_read_discount,
|
||||
cache_write_multiplier,
|
||||
cost_per_token,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Track per-user daily cost
|
||||
{
|
||||
let today = chrono::Utc::now().date_naive();
|
||||
let mut per_user = self.per_user_daily_cost.lock().await;
|
||||
let entry = per_user
|
||||
.entry(user_id.to_string())
|
||||
.or_insert_with(|| DailyCost {
|
||||
total: Decimal::ZERO,
|
||||
reset_date: today,
|
||||
});
|
||||
if today != entry.reset_date {
|
||||
entry.total = Decimal::ZERO;
|
||||
entry.reset_date = today;
|
||||
}
|
||||
entry.total += cost;
|
||||
}
|
||||
|
||||
cost
|
||||
}
|
||||
|
||||
/// Check whether the next action is allowed for a specific user.
|
||||
///
|
||||
/// Checks the global limits first (via `check_allowed`), then additionally
|
||||
/// checks the per-user daily budget if configured.
|
||||
pub async fn check_allowed_for_user(&self, user_id: &str) -> Result<(), CostLimitExceeded> {
|
||||
// Check global limits first
|
||||
self.check_allowed().await?;
|
||||
|
||||
// Check per-user daily budget
|
||||
if let Some(limit_cents) = self.config.max_cost_per_user_per_day_cents {
|
||||
let today = chrono::Utc::now().date_naive();
|
||||
let per_user = self.per_user_daily_cost.lock().await;
|
||||
if let Some(entry) = per_user.get(user_id)
|
||||
&& entry.reset_date == today
|
||||
{
|
||||
let spent_cents = to_cents(entry.total);
|
||||
if spent_cents >= limit_cents {
|
||||
return Err(CostLimitExceeded::UserDailyBudget {
|
||||
user_id: user_id.to_string(),
|
||||
spent_cents,
|
||||
limit_cents,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Current daily spend in USD (as Decimal).
|
||||
pub async fn daily_spend(&self) -> Decimal {
|
||||
let daily = self.daily_cost.lock().await;
|
||||
@@ -259,6 +367,16 @@ impl CostGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// Current daily spend for a specific user in USD (as Decimal).
|
||||
pub async fn daily_spend_for_user(&self, user_id: &str) -> Decimal {
|
||||
let today = chrono::Utc::now().date_naive();
|
||||
let per_user = self.per_user_daily_cost.lock().await;
|
||||
match per_user.get(user_id) {
|
||||
Some(entry) if entry.reset_date == today => entry.total,
|
||||
_ => Decimal::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of actions in the current hourly window.
|
||||
pub async fn actions_this_hour(&self) -> u64 {
|
||||
let mut window = self.action_window.lock().await;
|
||||
@@ -314,7 +432,7 @@ mod tests {
|
||||
async fn test_daily_budget_enforcement() {
|
||||
let guard = CostGuard::new(CostGuardConfig {
|
||||
max_cost_per_day_cents: Some(1), // $0.01 limit
|
||||
max_actions_per_hour: None,
|
||||
..CostGuardConfig::default()
|
||||
});
|
||||
|
||||
// First call allowed
|
||||
@@ -350,8 +468,8 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_hourly_rate_enforcement() {
|
||||
let guard = CostGuard::new(CostGuardConfig {
|
||||
max_cost_per_day_cents: None,
|
||||
max_actions_per_hour: Some(3),
|
||||
..CostGuardConfig::default()
|
||||
});
|
||||
|
||||
// First 3 actions allowed
|
||||
@@ -633,8 +751,8 @@ mod tests {
|
||||
// A fresh CostGuard with rate limits should not panic even if
|
||||
// checked_sub returns None (simulating short uptime).
|
||||
let guard = CostGuard::new(CostGuardConfig {
|
||||
max_cost_per_day_cents: None,
|
||||
max_actions_per_hour: Some(100),
|
||||
..CostGuardConfig::default()
|
||||
});
|
||||
|
||||
// These must not panic regardless of system uptime
|
||||
@@ -656,4 +774,119 @@ mod tests {
|
||||
let result = Instant::now().checked_sub(std::time::Duration::MAX);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_per_user_daily_budget_enforcement() {
|
||||
let guard = CostGuard::new(CostGuardConfig {
|
||||
max_cost_per_day_cents: None,
|
||||
max_actions_per_hour: None,
|
||||
max_cost_per_user_per_day_cents: Some(1), // $0.01 per user
|
||||
});
|
||||
|
||||
// Both users initially allowed
|
||||
assert!(guard.check_allowed_for_user("alice").await.is_ok());
|
||||
assert!(guard.check_allowed_for_user("bob").await.is_ok());
|
||||
|
||||
// Alice makes an expensive call
|
||||
guard
|
||||
.record_llm_call_for_user(
|
||||
"alice",
|
||||
"gpt-4o",
|
||||
10_000,
|
||||
10_000,
|
||||
0,
|
||||
0,
|
||||
Decimal::ONE,
|
||||
Decimal::ONE,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Alice should be blocked, Bob should still be allowed
|
||||
let result = guard.check_allowed_for_user("alice").await;
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
CostLimitExceeded::UserDailyBudget {
|
||||
user_id,
|
||||
limit_cents,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(user_id, "alice");
|
||||
assert_eq!(limit_cents, 1);
|
||||
}
|
||||
other => panic!("Expected UserDailyBudget, got {:?}", other),
|
||||
}
|
||||
assert!(guard.check_allowed_for_user("bob").await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_per_user_daily_spend_tracking() {
|
||||
let guard = CostGuard::new(CostGuardConfig::default());
|
||||
|
||||
assert_eq!(guard.daily_spend_for_user("alice").await, Decimal::ZERO);
|
||||
assert_eq!(guard.daily_spend_for_user("bob").await, Decimal::ZERO);
|
||||
|
||||
let cost = guard
|
||||
.record_llm_call_for_user(
|
||||
"alice",
|
||||
"gpt-4o",
|
||||
1000,
|
||||
500,
|
||||
0,
|
||||
0,
|
||||
Decimal::ONE,
|
||||
Decimal::ONE,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(guard.daily_spend_for_user("alice").await, cost);
|
||||
assert_eq!(guard.daily_spend_for_user("bob").await, Decimal::ZERO);
|
||||
// Global spend should also be tracked
|
||||
assert_eq!(guard.daily_spend().await, cost);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_per_user_budget_independent_of_global() {
|
||||
let guard = CostGuard::new(CostGuardConfig {
|
||||
max_cost_per_day_cents: Some(100_000), // $1000 global limit
|
||||
max_actions_per_hour: None,
|
||||
max_cost_per_user_per_day_cents: Some(1), // $0.01 per user
|
||||
});
|
||||
|
||||
// User hits their personal limit
|
||||
guard
|
||||
.record_llm_call_for_user(
|
||||
"alice",
|
||||
"gpt-4o",
|
||||
10_000,
|
||||
10_000,
|
||||
0,
|
||||
0,
|
||||
Decimal::ONE,
|
||||
Decimal::ONE,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Alice blocked by per-user limit, not global
|
||||
assert!(guard.check_allowed_for_user("alice").await.is_err());
|
||||
// Global limit is far from reached
|
||||
assert!(guard.check_allowed().await.is_ok());
|
||||
// Bob is unaffected
|
||||
assert!(guard.check_allowed_for_user("bob").await.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_cost_limit_display() {
|
||||
let limit = CostLimitExceeded::UserDailyBudget {
|
||||
user_id: "alice".to_string(),
|
||||
spent_cents: 150,
|
||||
limit_cents: 100,
|
||||
};
|
||||
let msg = limit.to_string();
|
||||
assert!(msg.contains("alice"));
|
||||
assert!(msg.contains("$1.50"));
|
||||
assert!(msg.contains("$1.00"));
|
||||
}
|
||||
}
|
||||
|
||||
+165
-20
@@ -42,6 +42,7 @@ impl Agent {
|
||||
pub(super) async fn run_agentic_loop(
|
||||
&self,
|
||||
message: &IncomingMessage,
|
||||
tenant: crate::tenant::TenantCtx,
|
||||
session: Arc<Mutex<Session>>,
|
||||
thread_id: Uuid,
|
||||
initial_messages: Vec<ChatMessage>,
|
||||
@@ -63,7 +64,12 @@ impl Agent {
|
||||
);
|
||||
|
||||
let system_prompt = if let Some(ws) = self.workspace() {
|
||||
match ws
|
||||
let scoped_workspace = if ws.user_id() == message.user_id {
|
||||
Arc::clone(ws)
|
||||
} else {
|
||||
Arc::new(ws.scoped_to_user(&message.user_id))
|
||||
};
|
||||
match scoped_workspace
|
||||
.system_prompt_for_context_tz(is_group_chat, user_tz)
|
||||
.await
|
||||
{
|
||||
@@ -163,6 +169,7 @@ impl Agent {
|
||||
|
||||
let delegate = ChatDelegate {
|
||||
agent: self,
|
||||
tenant,
|
||||
session: session.clone(),
|
||||
thread_id,
|
||||
message,
|
||||
@@ -235,6 +242,7 @@ impl Agent {
|
||||
/// auth intercept, and cost tracking.
|
||||
struct ChatDelegate<'a> {
|
||||
agent: &'a Agent,
|
||||
tenant: crate::tenant::TenantCtx,
|
||||
session: Arc<Mutex<Session>>,
|
||||
thread_id: Uuid,
|
||||
message: &'a IncomingMessage,
|
||||
@@ -295,9 +303,10 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
||||
} else {
|
||||
tool_defs
|
||||
};
|
||||
|
||||
// Update context for this iteration
|
||||
reason_ctx.available_tools = tool_defs;
|
||||
// Preserve force_text if already set (e.g. by truncation escalation).
|
||||
let force_text = force_text || reason_ctx.force_text;
|
||||
reason_ctx.system_prompt = Some(if force_text {
|
||||
self.cached_prompt_no_tools.clone()
|
||||
} else {
|
||||
@@ -331,8 +340,8 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
||||
reason_ctx: &mut ReasoningContext,
|
||||
iteration: usize,
|
||||
) -> Result<crate::llm::RespondOutput, Error> {
|
||||
// Enforce cost guardrails before the LLM call
|
||||
if let Err(limit) = self.agent.cost_guard().check_allowed().await {
|
||||
// Enforce cost guardrails before the LLM call (global + per-user)
|
||||
if let Err(limit) = self.tenant.check_cost_allowed().await {
|
||||
return Err(crate::error::LlmError::InvalidResponse {
|
||||
provider: "agent".to_string(),
|
||||
reason: limit.to_string(),
|
||||
@@ -340,6 +349,21 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
||||
.into());
|
||||
}
|
||||
|
||||
// Apply per-user model override from settings (first iteration only
|
||||
// to avoid repeated DB lookups within the same agentic loop).
|
||||
// Uses "selected_model" — the same key the /model command persists to
|
||||
// via SettingsStore (per-user scoped via TenantScope).
|
||||
if iteration == 0
|
||||
&& let Some(store) = self.tenant.store()
|
||||
&& let Ok(Some(value)) = store.get_setting("selected_model").await
|
||||
&& let Some(model) = value.as_str()
|
||||
{
|
||||
let model = model.trim();
|
||||
if !model.is_empty() {
|
||||
reason_ctx.model_override = Some(model.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let output = match reasoning.respond_with_tools(reason_ctx).await {
|
||||
Ok(output) => output,
|
||||
Err(crate::error::LlmError::ContextLengthExceeded { used, limit }) => {
|
||||
@@ -374,13 +398,22 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
// Record cost and track token usage
|
||||
let model_name = self.agent.llm().active_model_name();
|
||||
// Record cost and track token usage (global + per-user).
|
||||
// When a model override is active, use the override name for attribution
|
||||
// and let CostGuard look up pricing via costs::model_cost() instead of
|
||||
// using the default provider's cost_per_token (which reflects the wrong model).
|
||||
let (model_name, cost_per_token) = if let Some(ref ovr) = reason_ctx.model_override {
|
||||
(ovr.clone(), None)
|
||||
} else {
|
||||
(
|
||||
self.agent.llm().active_model_name(),
|
||||
Some(self.agent.llm().cost_per_token()),
|
||||
)
|
||||
};
|
||||
let read_discount = self.agent.llm().cache_read_discount();
|
||||
let write_multiplier = self.agent.llm().cache_write_multiplier();
|
||||
let call_cost = self
|
||||
.agent
|
||||
.cost_guard()
|
||||
.tenant
|
||||
.record_llm_call(
|
||||
&model_name,
|
||||
output.usage.input_tokens,
|
||||
@@ -389,7 +422,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
||||
output.usage.cache_creation_input_tokens,
|
||||
read_discount,
|
||||
write_multiplier,
|
||||
Some(self.agent.llm().cost_per_token()),
|
||||
cost_per_token,
|
||||
)
|
||||
.await;
|
||||
tracing::debug!(
|
||||
@@ -420,6 +453,19 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
||||
content: Option<String>,
|
||||
reason_ctx: &mut ReasoningContext,
|
||||
) -> Result<Option<LoopOutcome>, Error> {
|
||||
// Extract and sanitize the narrative before consuming `content`.
|
||||
let narrative = content
|
||||
.as_deref()
|
||||
.filter(|c| !c.trim().is_empty())
|
||||
.map(|c| {
|
||||
let sanitized = self
|
||||
.agent
|
||||
.safety()
|
||||
.sanitize_tool_output("agent_narrative", c);
|
||||
sanitized.content
|
||||
})
|
||||
.filter(|c| !c.trim().is_empty());
|
||||
|
||||
// Add the assistant message with tool_calls to context.
|
||||
// OpenAI protocol requires this before tool-result messages.
|
||||
reason_ctx
|
||||
@@ -440,6 +486,41 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
||||
)
|
||||
.await;
|
||||
|
||||
// Build per-tool decisions for the reasoning update.
|
||||
// Sanitize each rationale through SafetyLayer (parity with JobDelegate).
|
||||
let decisions: Vec<crate::channels::ToolDecision> = tool_calls
|
||||
.iter()
|
||||
.filter_map(|tc| {
|
||||
tc.reasoning.as_ref().map(|r| {
|
||||
let sanitized = self
|
||||
.agent
|
||||
.safety()
|
||||
.sanitize_tool_output("tool_rationale", r)
|
||||
.content;
|
||||
crate::channels::ToolDecision {
|
||||
tool_name: tc.name.clone(),
|
||||
rationale: sanitized,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Emit reasoning update to channels.
|
||||
if narrative.is_some() || !decisions.is_empty() {
|
||||
let _ = self
|
||||
.agent
|
||||
.channels
|
||||
.send_status(
|
||||
&self.message.channel,
|
||||
StatusUpdate::ReasoningUpdate {
|
||||
narrative: narrative.clone().unwrap_or_default(),
|
||||
decisions: decisions.clone(),
|
||||
},
|
||||
&self.message.metadata,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Record tool calls in the thread with sensitive params redacted.
|
||||
{
|
||||
let mut redacted_args: Vec<serde_json::Value> = Vec::with_capacity(tool_calls.len());
|
||||
@@ -455,8 +536,23 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
||||
if let Some(thread) = sess.threads.get_mut(&self.thread_id)
|
||||
&& let Some(turn) = thread.last_turn_mut()
|
||||
{
|
||||
// Set turn-level narrative.
|
||||
if turn.narrative.is_none() {
|
||||
turn.narrative = narrative;
|
||||
}
|
||||
for (tc, safe_args) in tool_calls.iter().zip(redacted_args) {
|
||||
turn.record_tool_call(&tc.name, safe_args);
|
||||
let sanitized_rationale = tc.reasoning.as_ref().map(|r| {
|
||||
self.agent
|
||||
.safety()
|
||||
.sanitize_tool_output("tool_rationale", r)
|
||||
.content
|
||||
});
|
||||
turn.record_tool_call_with_reasoning(
|
||||
&tc.name,
|
||||
safe_args,
|
||||
sanitized_rationale,
|
||||
Some(tc.id.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -726,7 +822,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
||||
if let Some(thread) = sess.threads.get_mut(&self.thread_id)
|
||||
&& let Some(turn) = thread.last_turn_mut()
|
||||
{
|
||||
turn.record_tool_error(error_msg.clone());
|
||||
turn.record_tool_error_for(&tc.id, error_msg.clone());
|
||||
}
|
||||
}
|
||||
reason_ctx
|
||||
@@ -852,16 +948,19 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
||||
Err(e) => format!("Tool '{}' failed: {}", tc.name, e),
|
||||
};
|
||||
|
||||
// Record sanitized result in thread
|
||||
// Record sanitized result in thread (identity-based matching).
|
||||
{
|
||||
let mut sess = self.session.lock().await;
|
||||
if let Some(thread) = sess.threads.get_mut(&self.thread_id)
|
||||
&& let Some(turn) = thread.last_turn_mut()
|
||||
{
|
||||
if is_tool_error {
|
||||
turn.record_tool_error(result_content.clone());
|
||||
turn.record_tool_error_for(&tc.id, result_content.clone());
|
||||
} else {
|
||||
turn.record_tool_result(serde_json::json!(result_content));
|
||||
turn.record_tool_result_for(
|
||||
&tc.id,
|
||||
serde_json::json!(result_content),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1098,15 +1197,23 @@ pub(crate) fn extract_suggestions(text: &str) -> (String, Vec<String>) {
|
||||
Regex::new(r"(?s)<suggestions>\s*(.*?)\s*</suggestions>").expect("valid regex") // safety: constant pattern
|
||||
});
|
||||
|
||||
// Find the position of the last closing code fence to avoid matching inside code blocks
|
||||
let last_code_fence = text.rfind("```").unwrap_or(0);
|
||||
// Build a sorted list of code fence positions to determine open/close pairing.
|
||||
// A position is "inside" a fenced block when it falls between an odd-numbered
|
||||
// fence (opening) and the next even-numbered fence (closing).
|
||||
let fence_positions: Vec<usize> = text.match_indices("```").map(|(pos, _)| pos).collect();
|
||||
|
||||
// Find all matches, take the last one that's after the last code fence
|
||||
let is_inside_fence = |pos: usize| -> bool {
|
||||
// Count how many fences appear before `pos`. If odd, we're inside a fence.
|
||||
let count = fence_positions.iter().take_while(|&&fp| fp <= pos).count();
|
||||
count % 2 == 1
|
||||
};
|
||||
|
||||
// Find all matches, take the last one that's outside any code fence
|
||||
let mut best_match: Option<regex::Match<'_>> = None;
|
||||
let mut best_capture: Option<String> = None;
|
||||
for caps in RE.captures_iter(text) {
|
||||
if let (Some(full), Some(inner)) = (caps.get(0), caps.get(1))
|
||||
&& full.start() >= last_code_fence
|
||||
&& !is_inside_fence(full.start())
|
||||
{
|
||||
best_match = Some(full);
|
||||
best_capture = Some(inner.as_str().to_string());
|
||||
@@ -1225,6 +1332,8 @@ mod tests {
|
||||
document_extraction: None,
|
||||
sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
|
||||
builder: None,
|
||||
llm_backend: "nearai".to_string(),
|
||||
tenant_rates: Arc::new(crate::tenant::TenantRateRegistry::new(4, 3)),
|
||||
};
|
||||
|
||||
Agent::new(
|
||||
@@ -1240,10 +1349,14 @@ mod tests {
|
||||
allow_local_tools: false,
|
||||
max_cost_per_day_cents: None,
|
||||
max_actions_per_hour: None,
|
||||
max_cost_per_user_per_day_cents: None,
|
||||
max_tool_iterations: 50,
|
||||
auto_approve_tools: false,
|
||||
default_timezone: "UTC".to_string(),
|
||||
max_tokens_per_job: 0,
|
||||
multi_tenant: false,
|
||||
max_llm_concurrent_per_user: None,
|
||||
max_jobs_concurrent_per_user: None,
|
||||
},
|
||||
deps,
|
||||
Arc::new(ChannelManager::new()),
|
||||
@@ -1453,11 +1566,13 @@ mod tests {
|
||||
id: "call_2".to_string(),
|
||||
name: "http".to_string(),
|
||||
arguments: serde_json::json!({"url": "https://example.com"}),
|
||||
reasoning: None,
|
||||
},
|
||||
ToolCall {
|
||||
id: "call_3".to_string(),
|
||||
name: "echo".to_string(),
|
||||
arguments: serde_json::json!({"message": "done"}),
|
||||
reasoning: None,
|
||||
},
|
||||
],
|
||||
user_timezone: None,
|
||||
@@ -1643,6 +1758,7 @@ mod tests {
|
||||
id: "call_1".to_string(),
|
||||
name: "echo".to_string(),
|
||||
arguments: serde_json::json!({"message": "hi"}),
|
||||
reasoning: None,
|
||||
}],
|
||||
),
|
||||
ChatMessage::tool_result("call_1", "echo", "hi"),
|
||||
@@ -1735,11 +1851,13 @@ mod tests {
|
||||
id: "c1".to_string(),
|
||||
name: "http".to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
reasoning: None,
|
||||
},
|
||||
ToolCall {
|
||||
id: "c2".to_string(),
|
||||
name: "echo".to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
reasoning: None,
|
||||
},
|
||||
],
|
||||
),
|
||||
@@ -1773,6 +1891,7 @@ mod tests {
|
||||
id: "c1".to_string(),
|
||||
name: "echo".to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
reasoning: None,
|
||||
}],
|
||||
),
|
||||
ChatMessage::tool_result("c1", "echo", "done"),
|
||||
@@ -1903,6 +2022,7 @@ mod tests {
|
||||
id: crate::llm::generate_tool_call_id(0, 0),
|
||||
name: "echo".to_string(),
|
||||
arguments: serde_json::json!({"message": "looping"}),
|
||||
reasoning: None,
|
||||
}],
|
||||
input_tokens: 0,
|
||||
output_tokens: 5,
|
||||
@@ -2056,6 +2176,7 @@ mod tests {
|
||||
id: crate::llm::generate_tool_call_id(0, 0),
|
||||
name: "nonexistent_tool".to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
reasoning: None,
|
||||
}],
|
||||
input_tokens: 0,
|
||||
output_tokens: 5,
|
||||
@@ -2092,6 +2213,8 @@ mod tests {
|
||||
document_extraction: None,
|
||||
sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
|
||||
builder: None,
|
||||
llm_backend: "nearai".to_string(),
|
||||
tenant_rates: Arc::new(crate::tenant::TenantRateRegistry::new(4, 3)),
|
||||
};
|
||||
|
||||
Agent::new(
|
||||
@@ -2107,10 +2230,14 @@ mod tests {
|
||||
allow_local_tools: false,
|
||||
max_cost_per_day_cents: None,
|
||||
max_actions_per_hour: None,
|
||||
max_cost_per_user_per_day_cents: None,
|
||||
max_tool_iterations,
|
||||
auto_approve_tools: true,
|
||||
default_timezone: "UTC".to_string(),
|
||||
max_tokens_per_job: 0,
|
||||
multi_tenant: false,
|
||||
max_llm_concurrent_per_user: None,
|
||||
max_jobs_concurrent_per_user: None,
|
||||
},
|
||||
deps,
|
||||
Arc::new(ChannelManager::new()),
|
||||
@@ -2145,13 +2272,14 @@ mod tests {
|
||||
|
||||
let message = IncomingMessage::new("test", "test-user", "do something");
|
||||
let initial_messages = vec![ChatMessage::user("do something")];
|
||||
let tenant = agent.tenant_ctx("test-user").await;
|
||||
|
||||
// The dispatcher must terminate within 5 seconds. If there is an
|
||||
// infinite loop bug (e.g., index not advancing on tool failure), the
|
||||
// timeout will fire and the test will fail.
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
agent.run_agentic_loop(&message, session, thread_id, initial_messages),
|
||||
agent.run_agentic_loop(&message, tenant, session, thread_id, initial_messages),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -2212,6 +2340,8 @@ mod tests {
|
||||
document_extraction: None,
|
||||
sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
|
||||
builder: None,
|
||||
llm_backend: "nearai".to_string(),
|
||||
tenant_rates: Arc::new(crate::tenant::TenantRateRegistry::new(4, 3)),
|
||||
};
|
||||
|
||||
Agent::new(
|
||||
@@ -2227,10 +2357,14 @@ mod tests {
|
||||
allow_local_tools: false,
|
||||
max_cost_per_day_cents: None,
|
||||
max_actions_per_hour: None,
|
||||
max_cost_per_user_per_day_cents: None,
|
||||
max_tool_iterations: max_iter,
|
||||
auto_approve_tools: true,
|
||||
default_timezone: "UTC".to_string(),
|
||||
max_tokens_per_job: 0,
|
||||
multi_tenant: false,
|
||||
max_llm_concurrent_per_user: None,
|
||||
max_jobs_concurrent_per_user: None,
|
||||
},
|
||||
deps,
|
||||
Arc::new(ChannelManager::new()),
|
||||
@@ -2250,13 +2384,14 @@ mod tests {
|
||||
|
||||
let message = IncomingMessage::new("test", "test-user", "keep calling tools");
|
||||
let initial_messages = vec![ChatMessage::user("keep calling tools")];
|
||||
let tenant = agent.tenant_ctx("test-user").await;
|
||||
|
||||
// Even with an LLM that always wants to call tools, the dispatcher
|
||||
// must terminate within the timeout thanks to force_text at
|
||||
// max_tool_iterations.
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
agent.run_agentic_loop(&message, session, thread_id, initial_messages),
|
||||
agent.run_agentic_loop(&message, tenant, session, thread_id, initial_messages),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -2345,6 +2480,16 @@ mod tests {
|
||||
assert!(suggestions.is_empty()); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_suggestions_inside_unclosed_code_fence() {
|
||||
// Regression: odd number of fences (unclosed fence) must still be
|
||||
// treated as "inside a code block".
|
||||
let input = "```\ncode\n<suggestions>[\"bar\"]</suggestions>";
|
||||
let (text, suggestions) = super::extract_suggestions(input);
|
||||
assert_eq!(text, input); // safety: test
|
||||
assert!(suggestions.is_empty()); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_suggestions_after_code_fence() {
|
||||
let input = "```\ncode\n```\nAnswer.\n<suggestions>[\"foo\"]</suggestions>";
|
||||
|
||||
+184
-7
@@ -31,8 +31,8 @@ use chrono_tz::Tz;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::channels::OutgoingResponse;
|
||||
use crate::db::Database;
|
||||
use crate::llm::{ChatMessage, CompletionRequest, LlmProvider, Reasoning};
|
||||
use crate::tenant::AdminScope;
|
||||
use crate::workspace::Workspace;
|
||||
use crate::workspace::hygiene::HygieneConfig;
|
||||
|
||||
@@ -57,6 +57,9 @@ pub struct HeartbeatConfig {
|
||||
pub quiet_hours_end: Option<u32>,
|
||||
/// Timezone for fire_at and quiet hours evaluation (IANA name).
|
||||
pub timezone: Option<String>,
|
||||
/// When true, cycle through all users with routines instead of
|
||||
/// running heartbeat for a single user. Requires a database store.
|
||||
pub multi_tenant: bool,
|
||||
}
|
||||
|
||||
impl Default for HeartbeatConfig {
|
||||
@@ -71,6 +74,7 @@ impl Default for HeartbeatConfig {
|
||||
quiet_hours_start: None,
|
||||
quiet_hours_end: None,
|
||||
timezone: None,
|
||||
multi_tenant: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,7 +182,7 @@ pub struct HeartbeatRunner {
|
||||
workspace: Arc<Workspace>,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
store: Option<AdminScope>,
|
||||
consecutive_failures: u32,
|
||||
}
|
||||
|
||||
@@ -207,8 +211,8 @@ impl HeartbeatRunner {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the database store for persistent heartbeat conversations.
|
||||
pub fn with_store(mut self, store: Arc<dyn Database>) -> Self {
|
||||
/// Set the admin-scoped database store for persistent heartbeat conversations.
|
||||
pub fn with_store(mut self, store: AdminScope) -> Self {
|
||||
self.store = Some(store);
|
||||
self
|
||||
}
|
||||
@@ -396,7 +400,7 @@ impl HeartbeatRunner {
|
||||
}
|
||||
|
||||
/// Send a notification about heartbeat findings.
|
||||
async fn send_notification(&self, message: &str) {
|
||||
pub(crate) async fn send_notification(&self, message: &str) {
|
||||
let Some(ref tx) = self.response_tx else {
|
||||
tracing::debug!("No response channel configured for heartbeat notifications");
|
||||
return;
|
||||
@@ -493,7 +497,7 @@ pub fn spawn_heartbeat(
|
||||
workspace: Arc<Workspace>,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
store: Option<AdminScope>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
let mut runner = HeartbeatRunner::new(config, hygiene_config, workspace, llm);
|
||||
if let Some(tx) = response_tx {
|
||||
@@ -508,6 +512,179 @@ pub fn spawn_heartbeat(
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn a multi-user heartbeat runner that cycles through all users that
|
||||
/// own routines (enabled or not). Each tick, it queries the DB for distinct
|
||||
/// user_ids, creates a per-user workspace, and runs a heartbeat check for
|
||||
/// each user concurrently. Per-user failure counts are tracked independently.
|
||||
pub fn spawn_multi_user_heartbeat(
|
||||
config: HeartbeatConfig,
|
||||
hygiene_config: HygieneConfig,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
|
||||
store: AdminScope,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
if !config.enabled {
|
||||
tracing::info!("Multi-user heartbeat is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut tick_interval = if config.fire_at.is_none() {
|
||||
let mut iv = tokio::time::interval(config.interval);
|
||||
iv.tick().await; // skip immediate tick
|
||||
Some(iv)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Track consecutive failures per user so we can disable heartbeat
|
||||
// for persistently-failing users (same semantics as single-user mode).
|
||||
let mut user_failures: std::collections::HashMap<String, u32> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
tracing::info!("Starting multi-user heartbeat loop");
|
||||
|
||||
loop {
|
||||
if let Some(fire_at) = config.fire_at {
|
||||
let sleep_dur = duration_until_next_fire(fire_at, config.resolved_tz());
|
||||
tokio::time::sleep(sleep_dur).await;
|
||||
} else if let Some(ref mut iv) = tick_interval {
|
||||
iv.tick().await;
|
||||
}
|
||||
|
||||
if config.is_quiet_hours() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get distinct user_ids from routines
|
||||
let user_ids = match store.list_all_routines().await {
|
||||
Ok(routines) => {
|
||||
let mut ids: Vec<String> = routines
|
||||
.iter()
|
||||
.map(|r| r.user_id.clone())
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
ids.sort();
|
||||
ids
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Multi-user heartbeat: failed to list routines: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Run user heartbeats concurrently so one slow LLM call doesn't
|
||||
// block others. Cap concurrency to avoid flooding the LLM provider.
|
||||
const MAX_CONCURRENT_HEARTBEATS: usize = 8;
|
||||
let mut join_set = tokio::task::JoinSet::new();
|
||||
|
||||
for user_id in &user_ids {
|
||||
// Skip users that have exceeded max_failures
|
||||
let failures = user_failures.get(user_id).copied().unwrap_or(0);
|
||||
if failures >= config.max_failures {
|
||||
continue;
|
||||
}
|
||||
|
||||
let workspace = Arc::new(Workspace::new_with_db(user_id, Arc::clone(store.db())));
|
||||
|
||||
// Run memory hygiene per user (same as single-user heartbeat).
|
||||
let hygiene_ws = Arc::clone(&workspace);
|
||||
let hygiene_cfg = hygiene_config.clone();
|
||||
let hygiene_user = user_id.clone();
|
||||
tokio::spawn(async move {
|
||||
let report =
|
||||
crate::workspace::hygiene::run_if_due(&hygiene_ws, &hygiene_cfg).await;
|
||||
if report.had_work() {
|
||||
tracing::info!(
|
||||
user_id = hygiene_user,
|
||||
daily_logs_deleted = report.daily_logs_deleted,
|
||||
conversation_docs_deleted = report.conversation_docs_deleted,
|
||||
"multi-user heartbeat: memory hygiene deleted stale documents"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Drain completed tasks to stay within the concurrency cap.
|
||||
while join_set.len() >= MAX_CONCURRENT_HEARTBEATS {
|
||||
if let Some(join_result) = join_set.join_next().await {
|
||||
collect_heartbeat_result(join_result, &mut user_failures, &config);
|
||||
}
|
||||
}
|
||||
|
||||
let uid = user_id.clone();
|
||||
let cfg = config.clone();
|
||||
let hyg = hygiene_config.clone();
|
||||
let llm_clone = llm.clone();
|
||||
let tx = response_tx.clone();
|
||||
let admin = store.clone();
|
||||
|
||||
join_set.spawn(async move {
|
||||
let mut runner = HeartbeatRunner::new(cfg, hyg, workspace, llm_clone);
|
||||
if let Some(tx) = tx {
|
||||
runner = runner.with_response_channel(tx);
|
||||
}
|
||||
runner = runner.with_store(admin);
|
||||
|
||||
let result = runner.check_heartbeat().await;
|
||||
if let HeartbeatResult::NeedsAttention(msg) = &result {
|
||||
runner.send_notification(msg).await;
|
||||
}
|
||||
(uid, result)
|
||||
});
|
||||
}
|
||||
|
||||
// Collect remaining results and update failure counts
|
||||
while let Some(join_result) = join_set.join_next().await {
|
||||
collect_heartbeat_result(join_result, &mut user_failures, &config);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Process a single JoinSet result from the multi-user heartbeat loop.
|
||||
fn collect_heartbeat_result(
|
||||
join_result: Result<(String, HeartbeatResult), tokio::task::JoinError>,
|
||||
user_failures: &mut std::collections::HashMap<String, u32>,
|
||||
config: &HeartbeatConfig,
|
||||
) {
|
||||
let (uid, result) = match join_result {
|
||||
Ok(pair) => pair,
|
||||
Err(e) => {
|
||||
tracing::error!("Multi-user heartbeat task panicked: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
match result {
|
||||
HeartbeatResult::Ok => {
|
||||
tracing::trace!(user_id = uid, "Multi-user heartbeat OK");
|
||||
user_failures.remove(&uid);
|
||||
}
|
||||
HeartbeatResult::NeedsAttention(_) => {
|
||||
tracing::info!(user_id = uid, "Multi-user heartbeat needs attention");
|
||||
user_failures.remove(&uid);
|
||||
}
|
||||
HeartbeatResult::Skipped => {}
|
||||
HeartbeatResult::Failed(err) => {
|
||||
let count = user_failures.entry(uid.clone()).or_insert(0);
|
||||
*count += 1;
|
||||
tracing::error!(
|
||||
user_id = uid,
|
||||
consecutive_failures = *count,
|
||||
"Multi-user heartbeat failed: {}",
|
||||
err
|
||||
);
|
||||
if *count >= config.max_failures {
|
||||
tracing::error!(
|
||||
user_id = uid,
|
||||
"Multi-user heartbeat disabled for user after {} consecutive failures",
|
||||
count
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -726,7 +903,7 @@ mod tests {
|
||||
Arc<crate::workspace::Workspace>,
|
||||
Arc<dyn crate::llm::LlmProvider>,
|
||||
Option<tokio::sync::mpsc::Sender<crate::channels::OutgoingResponse>>,
|
||||
Option<Arc<dyn crate::db::Database>>,
|
||||
Option<AdminScope>,
|
||||
) -> tokio::task::JoinHandle<()> = spawn_heartbeat;
|
||||
let _ = _fn_ptr;
|
||||
}
|
||||
|
||||
+35
-25
@@ -21,8 +21,8 @@ use tokio::task::JoinHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::channels::IncomingMessage;
|
||||
use crate::channels::web::types::SseEvent;
|
||||
use crate::context::{ContextManager, JobState};
|
||||
use ironclaw_common::AppEvent;
|
||||
|
||||
/// Route context for forwarding job monitor events back to the user's channel.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -36,15 +36,15 @@ pub struct JobMonitorRoute {
|
||||
/// injects assistant messages into the agent loop.
|
||||
///
|
||||
/// The monitor forwards:
|
||||
/// - `SseEvent::JobMessage` (assistant role): injected as incoming messages so
|
||||
/// - `AppEvent::JobMessage` (assistant role): injected as incoming messages so
|
||||
/// the main agent can read and relay to the user.
|
||||
/// - `SseEvent::JobResult`: injected as a completion notice, then the task exits.
|
||||
/// - `AppEvent::JobResult`: injected as a completion notice, then the task exits.
|
||||
///
|
||||
/// Tool use/result and status events are intentionally skipped (too noisy for
|
||||
/// the main agent's context window).
|
||||
pub fn spawn_job_monitor(
|
||||
job_id: Uuid,
|
||||
event_rx: broadcast::Receiver<(Uuid, SseEvent)>,
|
||||
event_rx: broadcast::Receiver<(Uuid, String, AppEvent)>,
|
||||
inject_tx: mpsc::Sender<IncomingMessage>,
|
||||
route: JobMonitorRoute,
|
||||
) -> JoinHandle<()> {
|
||||
@@ -56,7 +56,7 @@ pub fn spawn_job_monitor(
|
||||
/// jobs don't stay `InProgress` forever in the `ContextManager`.
|
||||
pub fn spawn_job_monitor_with_context(
|
||||
job_id: Uuid,
|
||||
mut event_rx: broadcast::Receiver<(Uuid, SseEvent)>,
|
||||
mut event_rx: broadcast::Receiver<(Uuid, String, AppEvent)>,
|
||||
inject_tx: mpsc::Sender<IncomingMessage>,
|
||||
route: JobMonitorRoute,
|
||||
context_manager: Option<Arc<ContextManager>>,
|
||||
@@ -68,13 +68,13 @@ pub fn spawn_job_monitor_with_context(
|
||||
|
||||
loop {
|
||||
match event_rx.recv().await {
|
||||
Ok((ev_job_id, event)) => {
|
||||
Ok((ev_job_id, _user_id, event)) => {
|
||||
if ev_job_id != job_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
match event {
|
||||
SseEvent::JobMessage { role, content, .. } if role == "assistant" => {
|
||||
AppEvent::JobMessage { role, content, .. } if role == "assistant" => {
|
||||
let mut msg = IncomingMessage::new(
|
||||
route.channel.clone(),
|
||||
route.user_id.clone(),
|
||||
@@ -92,7 +92,7 @@ pub fn spawn_job_monitor_with_context(
|
||||
break;
|
||||
}
|
||||
}
|
||||
SseEvent::JobResult { status, .. } => {
|
||||
AppEvent::JobResult { status, .. } => {
|
||||
// Transition in-memory state so the job frees its
|
||||
// max_jobs slot and query tools show the final state.
|
||||
if let Some(ref cm) = context_manager {
|
||||
@@ -162,7 +162,7 @@ pub fn spawn_job_monitor_with_context(
|
||||
/// inject messages into) but we still need to free the `max_jobs` slot.
|
||||
pub fn spawn_completion_watcher(
|
||||
job_id: Uuid,
|
||||
mut event_rx: broadcast::Receiver<(Uuid, SseEvent)>,
|
||||
mut event_rx: broadcast::Receiver<(Uuid, String, AppEvent)>,
|
||||
context_manager: Arc<ContextManager>,
|
||||
) -> JoinHandle<()> {
|
||||
let short_id = job_id.to_string()[..8].to_string();
|
||||
@@ -170,7 +170,9 @@ pub fn spawn_completion_watcher(
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match event_rx.recv().await {
|
||||
Ok((ev_job_id, SseEvent::JobResult { status, .. })) if ev_job_id == job_id => {
|
||||
Ok((ev_job_id, _user_id, AppEvent::JobResult { status, .. }))
|
||||
if ev_job_id == job_id =>
|
||||
{
|
||||
let target = if status == "completed" {
|
||||
JobState::Completed
|
||||
} else {
|
||||
@@ -227,7 +229,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_monitor_forwards_assistant_messages() {
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, String, AppEvent)>(16);
|
||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
@@ -237,7 +239,8 @@ mod tests {
|
||||
event_tx
|
||||
.send((
|
||||
job_id,
|
||||
SseEvent::JobMessage {
|
||||
"test-user".to_string(),
|
||||
AppEvent::JobMessage {
|
||||
job_id: job_id.to_string(),
|
||||
role: "assistant".to_string(),
|
||||
content: "I found a bug".to_string(),
|
||||
@@ -259,7 +262,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_monitor_ignores_other_jobs() {
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, String, AppEvent)>(16);
|
||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
@@ -270,7 +273,8 @@ mod tests {
|
||||
event_tx
|
||||
.send((
|
||||
other_job_id,
|
||||
SseEvent::JobMessage {
|
||||
"test-user".to_string(),
|
||||
AppEvent::JobMessage {
|
||||
job_id: other_job_id.to_string(),
|
||||
role: "assistant".to_string(),
|
||||
content: "wrong job".to_string(),
|
||||
@@ -289,7 +293,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_monitor_exits_on_job_result() {
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, String, AppEvent)>(16);
|
||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
@@ -299,7 +303,8 @@ mod tests {
|
||||
event_tx
|
||||
.send((
|
||||
job_id,
|
||||
SseEvent::JobResult {
|
||||
"test-user".to_string(),
|
||||
AppEvent::JobResult {
|
||||
job_id: job_id.to_string(),
|
||||
status: "completed".to_string(),
|
||||
session_id: None,
|
||||
@@ -324,7 +329,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_monitor_skips_tool_events() {
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, String, AppEvent)>(16);
|
||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
@@ -334,7 +339,8 @@ mod tests {
|
||||
event_tx
|
||||
.send((
|
||||
job_id,
|
||||
SseEvent::JobToolUse {
|
||||
"test-user".to_string(),
|
||||
AppEvent::JobToolUse {
|
||||
job_id: job_id.to_string(),
|
||||
tool_name: "shell".to_string(),
|
||||
input: serde_json::json!({"command": "ls"}),
|
||||
@@ -346,7 +352,8 @@ mod tests {
|
||||
event_tx
|
||||
.send((
|
||||
job_id,
|
||||
SseEvent::JobMessage {
|
||||
"test-user".to_string(),
|
||||
AppEvent::JobMessage {
|
||||
job_id: job_id.to_string(),
|
||||
role: "user".to_string(),
|
||||
content: "user prompt".to_string(),
|
||||
@@ -402,7 +409,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, String, AppEvent)>(16);
|
||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||
|
||||
let handle = spawn_job_monitor_with_context(
|
||||
@@ -417,7 +424,8 @@ mod tests {
|
||||
event_tx
|
||||
.send((
|
||||
job_id,
|
||||
SseEvent::JobResult {
|
||||
"test-user".to_string(),
|
||||
AppEvent::JobResult {
|
||||
job_id: job_id.to_string(),
|
||||
status: "completed".to_string(),
|
||||
session_id: None,
|
||||
@@ -450,7 +458,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, String, AppEvent)>(16);
|
||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||
|
||||
let handle = spawn_job_monitor_with_context(
|
||||
@@ -465,7 +473,8 @@ mod tests {
|
||||
event_tx
|
||||
.send((
|
||||
job_id,
|
||||
SseEvent::JobResult {
|
||||
"test-user".to_string(),
|
||||
AppEvent::JobResult {
|
||||
job_id: job_id.to_string(),
|
||||
status: "failed".to_string(),
|
||||
session_id: None,
|
||||
@@ -498,13 +507,14 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, String, AppEvent)>(16);
|
||||
let handle = spawn_completion_watcher(job_id, event_tx.subscribe(), Arc::clone(&cm));
|
||||
|
||||
event_tx
|
||||
.send((
|
||||
job_id,
|
||||
SseEvent::JobResult {
|
||||
"test-user".to_string(),
|
||||
AppEvent::JobResult {
|
||||
job_id: job_id.to_string(),
|
||||
status: "completed".to_string(),
|
||||
session_id: None,
|
||||
|
||||
+3
-1
@@ -36,7 +36,9 @@ pub(crate) use agent_loop::truncate_for_preview;
|
||||
pub use agent_loop::{Agent, AgentDeps};
|
||||
pub use compaction::{CompactionResult, ContextCompactor};
|
||||
pub use context_monitor::{CompactionStrategy, ContextBreakdown, ContextMonitor};
|
||||
pub use heartbeat::{HeartbeatConfig, HeartbeatResult, HeartbeatRunner, spawn_heartbeat};
|
||||
pub use heartbeat::{
|
||||
HeartbeatConfig, HeartbeatResult, HeartbeatRunner, spawn_heartbeat, spawn_multi_user_heartbeat,
|
||||
};
|
||||
pub use router::{MessageIntent, Router};
|
||||
pub use routine::{Routine, RoutineAction, RoutineRun, Trigger};
|
||||
pub use routine_engine::{RoutineEngine, SandboxReadiness};
|
||||
|
||||
+297
-39
@@ -18,21 +18,22 @@ use std::time::Duration;
|
||||
use chrono::Utc;
|
||||
use regex::Regex;
|
||||
use tokio::sync::{RwLock, mpsc};
|
||||
use tokio::task::JoinHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::agent::Scheduler;
|
||||
use crate::agent::routine::{
|
||||
NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, next_cron_fire,
|
||||
};
|
||||
use crate::channels::OutgoingResponse;
|
||||
use crate::channels::{IncomingMessage, OutgoingResponse};
|
||||
use crate::config::RoutineConfig;
|
||||
use crate::context::{JobContext, JobState};
|
||||
use crate::db::Database;
|
||||
use crate::error::RoutineError;
|
||||
use crate::extensions::ExtensionManager;
|
||||
use crate::llm::{
|
||||
ChatMessage, CompletionRequest, FinishReason, LlmProvider, ToolCall, ToolCompletionRequest,
|
||||
};
|
||||
use crate::tenant::AdminScope;
|
||||
use crate::tools::{
|
||||
ToolError, ToolRegistry, autonomous_allowed_tool_names, autonomous_unavailable_message,
|
||||
prepare_tool_params,
|
||||
@@ -45,6 +46,11 @@ enum EventMatcher {
|
||||
System { routine: Routine },
|
||||
}
|
||||
|
||||
struct TriggeredRoutine {
|
||||
routine: Routine,
|
||||
detail: String,
|
||||
}
|
||||
|
||||
/// Distinguishes why sandbox is unavailable so error messages are accurate.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SandboxReadiness {
|
||||
@@ -56,10 +62,44 @@ pub enum SandboxReadiness {
|
||||
DockerUnavailable,
|
||||
}
|
||||
|
||||
/// Check whether an event-triggered routine's user/channel filters match an
|
||||
/// incoming message.
|
||||
///
|
||||
/// Returns `true` if:
|
||||
/// - The routine has an `Event` trigger (non-Event routines always return `false`)
|
||||
/// - The routine's `user_id` matches the message's user scope
|
||||
/// - The routine's channel filter (if any) matches the message channel
|
||||
/// case-insensitively
|
||||
///
|
||||
/// This is a pure function extracted from `check_event_triggers` so the
|
||||
/// filter logic can be unit-tested without async infrastructure.
|
||||
pub(crate) fn routine_matches_message(routine: &Routine, message: &IncomingMessage) -> bool {
|
||||
// Only Event-triggered routines can match incoming messages.
|
||||
if !matches!(routine.trigger, Trigger::Event { .. }) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// User ownership filter — only fire routines scoped to this user.
|
||||
if routine.user_id != message.user_id {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Channel filter (case-insensitive, matching emit_system_event behavior)
|
||||
if let Trigger::Event {
|
||||
channel: Some(ch), ..
|
||||
} = &routine.trigger
|
||||
&& !ch.eq_ignore_ascii_case(&message.channel)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// The routine execution engine.
|
||||
pub struct RoutineEngine {
|
||||
config: RoutineConfig,
|
||||
store: Arc<dyn Database>,
|
||||
store: AdminScope,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
workspace: Arc<Workspace>,
|
||||
/// Sender for notifications (routed to channel manager).
|
||||
@@ -88,7 +128,7 @@ impl RoutineEngine {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
config: RoutineConfig,
|
||||
store: Arc<dyn Database>,
|
||||
store: AdminScope,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
workspace: Arc<Workspace>,
|
||||
notify_tx: mpsc::Sender<OutgoingResponse>,
|
||||
@@ -167,10 +207,45 @@ impl RoutineEngine {
|
||||
}
|
||||
|
||||
/// Check incoming message against event triggers. Returns number of routines fired.
|
||||
pub async fn check_event_triggers(&self, message: &IncomingMessage, content: &str) -> usize {
|
||||
let triggered = self.matching_event_triggers(message, content).await;
|
||||
let fired = triggered.len();
|
||||
for triggered in triggered {
|
||||
std::mem::drop(self.spawn_fire(triggered.routine, "event", Some(triggered.detail)));
|
||||
}
|
||||
fired
|
||||
}
|
||||
|
||||
/// Fire matching event-triggered routines and wait for them to complete.
|
||||
///
|
||||
/// Accepts only the three fields needed for matching (user scope, channel,
|
||||
/// message content) so callers never need to clone a full `IncomingMessage`.
|
||||
pub async fn check_event_triggers(&self, user_id: &str, channel: &str, content: &str) -> usize {
|
||||
/// Used by single-message REPL mode so the process does not exit before
|
||||
/// background event-triggered routines finish.
|
||||
pub async fn check_event_triggers_and_wait(
|
||||
&self,
|
||||
message: &IncomingMessage,
|
||||
content: &str,
|
||||
) -> usize {
|
||||
let triggered = self.matching_event_triggers(message, content).await;
|
||||
let fired = triggered.len();
|
||||
let handles: Vec<JoinHandle<()>> = triggered
|
||||
.into_iter()
|
||||
.map(|triggered| self.spawn_fire(triggered.routine, "event", Some(triggered.detail)))
|
||||
.collect();
|
||||
|
||||
for handle in handles {
|
||||
if let Err(e) = handle.await {
|
||||
tracing::warn!(error = %e, "Event-triggered routine task failed");
|
||||
}
|
||||
}
|
||||
|
||||
fired
|
||||
}
|
||||
|
||||
async fn matching_event_triggers(
|
||||
&self,
|
||||
message: &IncomingMessage,
|
||||
content: &str,
|
||||
) -> Vec<TriggeredRoutine> {
|
||||
let cache = self.event_cache.read().await;
|
||||
|
||||
// Early return if there are no message matchers at all.
|
||||
@@ -178,10 +253,9 @@ impl RoutineEngine {
|
||||
.iter()
|
||||
.any(|m| matches!(m, EventMatcher::Message { .. }))
|
||||
{
|
||||
return 0;
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut fired = 0;
|
||||
let mut triggered = Vec::new();
|
||||
|
||||
// Collect routine IDs for batch query
|
||||
let routine_ids: Vec<Uuid> = cache
|
||||
@@ -193,13 +267,13 @@ impl RoutineEngine {
|
||||
.collect();
|
||||
|
||||
if routine_ids.is_empty() {
|
||||
return 0;
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Single batch query instead of N queries
|
||||
let concurrent_counts = match self.batch_concurrent_counts(&routine_ids).await {
|
||||
Some(counts) => counts,
|
||||
None => return 0,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
for matcher in cache.iter() {
|
||||
@@ -208,16 +282,24 @@ impl RoutineEngine {
|
||||
EventMatcher::System { .. } => continue,
|
||||
};
|
||||
|
||||
if routine.user_id != user_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Channel filter
|
||||
if let Trigger::Event {
|
||||
channel: Some(ch), ..
|
||||
} = &routine.trigger
|
||||
&& ch != channel
|
||||
{
|
||||
// User ownership + channel filter (extracted for testability).
|
||||
if !routine_matches_message(routine, message) {
|
||||
// User mismatch is expected for multi-user setups — keep at
|
||||
// trace to avoid one log per routine per inbound message.
|
||||
if routine.user_id != message.user_id {
|
||||
tracing::trace!(
|
||||
routine = %routine.name,
|
||||
routine_user = %routine.user_id,
|
||||
message_user = %message.user_id,
|
||||
"Skipped: user scope mismatch"
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
routine = %routine.name,
|
||||
channel = %message.channel,
|
||||
"Skipped: channel mismatch"
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -228,14 +310,14 @@ impl RoutineEngine {
|
||||
|
||||
// Cooldown check
|
||||
if !self.check_cooldown(routine) {
|
||||
tracing::trace!(routine = %routine.name, "Skipped: cooldown active");
|
||||
tracing::debug!(routine = %routine.name, "Skipped: cooldown active");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Concurrent run check (using batch-loaded counts)
|
||||
let running_count = concurrent_counts.get(&routine.id).copied().unwrap_or(0);
|
||||
if running_count >= routine.guardrails.max_concurrent as i64 {
|
||||
tracing::trace!(routine = %routine.name, "Skipped: max concurrent reached");
|
||||
tracing::debug!(routine = %routine.name, "Skipped: max concurrent reached");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -246,11 +328,13 @@ impl RoutineEngine {
|
||||
}
|
||||
|
||||
let detail = truncate(content, 200);
|
||||
self.spawn_fire(routine.clone(), "event", Some(detail));
|
||||
fired += 1;
|
||||
triggered.push(TriggeredRoutine {
|
||||
routine: routine.clone(),
|
||||
detail,
|
||||
});
|
||||
}
|
||||
|
||||
fired
|
||||
triggered
|
||||
}
|
||||
|
||||
/// Emit a structured event to system-event routines.
|
||||
@@ -698,12 +782,22 @@ impl RoutineEngine {
|
||||
});
|
||||
}
|
||||
|
||||
// Per-user workspace (same pattern as spawn_fire).
|
||||
let routine_workspace = if routine.user_id == self.workspace.user_id() {
|
||||
self.workspace.clone()
|
||||
} else {
|
||||
Arc::new(Workspace::new_with_db(
|
||||
&routine.user_id,
|
||||
Arc::clone(self.store.db()),
|
||||
))
|
||||
};
|
||||
|
||||
// Execute inline for manual triggers (caller wants to wait)
|
||||
let engine = EngineContext {
|
||||
config: self.config.clone(),
|
||||
store: self.store.clone(),
|
||||
llm: self.llm.clone(),
|
||||
workspace: self.workspace.clone(),
|
||||
workspace: routine_workspace,
|
||||
notify_tx: self.notify_tx.clone(),
|
||||
running_count: self.running_count.clone(),
|
||||
scheduler: self.scheduler.clone(),
|
||||
@@ -806,7 +900,12 @@ impl RoutineEngine {
|
||||
}
|
||||
|
||||
/// Spawn a fire in a background task.
|
||||
fn spawn_fire(&self, routine: Routine, trigger_type: &str, trigger_detail: Option<String>) {
|
||||
fn spawn_fire(
|
||||
&self,
|
||||
routine: Routine,
|
||||
trigger_type: &str,
|
||||
trigger_detail: Option<String>,
|
||||
) -> JoinHandle<()> {
|
||||
let run = RoutineRun {
|
||||
id: Uuid::new_v4(),
|
||||
routine_id: routine.id,
|
||||
@@ -821,11 +920,23 @@ impl RoutineEngine {
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
// Use per-user workspace so each routine executes in the correct
|
||||
// user's context. Fall back to the engine-wide workspace when the
|
||||
// routine belongs to the same user (avoids unnecessary allocation).
|
||||
let routine_workspace = if routine.user_id == self.workspace.user_id() {
|
||||
self.workspace.clone()
|
||||
} else {
|
||||
Arc::new(Workspace::new_with_db(
|
||||
&routine.user_id,
|
||||
Arc::clone(self.store.db()),
|
||||
))
|
||||
};
|
||||
|
||||
let engine = EngineContext {
|
||||
config: self.config.clone(),
|
||||
store: self.store.clone(),
|
||||
llm: self.llm.clone(),
|
||||
workspace: self.workspace.clone(),
|
||||
workspace: routine_workspace,
|
||||
notify_tx: self.notify_tx.clone(),
|
||||
running_count: self.running_count.clone(),
|
||||
scheduler: self.scheduler.clone(),
|
||||
@@ -843,7 +954,7 @@ impl RoutineEngine {
|
||||
return;
|
||||
}
|
||||
execute_routine(engine, routine, run).await;
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
fn check_cooldown(&self, routine: &Routine) -> bool {
|
||||
@@ -878,7 +989,7 @@ impl RoutineEngine {
|
||||
/// an active state (Pending/InProgress/Stuck). Maps the final `JobState` to
|
||||
/// a `RunStatus` for the routine run.
|
||||
struct FullJobWatcher {
|
||||
store: Arc<dyn Database>,
|
||||
store: AdminScope,
|
||||
job_id: Uuid,
|
||||
routine_name: String,
|
||||
}
|
||||
@@ -889,7 +1000,7 @@ impl FullJobWatcher {
|
||||
/// Safety ceiling: 24 hours, derived from POLL_INTERVAL.
|
||||
const MAX_POLLS: u32 = (24 * 60 * 60) / Self::POLL_INTERVAL.as_secs() as u32;
|
||||
|
||||
fn new(store: Arc<dyn Database>, job_id: Uuid, routine_name: String) -> Self {
|
||||
fn new(store: AdminScope, job_id: Uuid, routine_name: String) -> Self {
|
||||
Self {
|
||||
store,
|
||||
job_id,
|
||||
@@ -961,7 +1072,7 @@ impl FullJobWatcher {
|
||||
/// Shared context passed to the execution function.
|
||||
struct EngineContext {
|
||||
config: RoutineConfig,
|
||||
store: Arc<dyn Database>,
|
||||
store: AdminScope,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
workspace: Arc<Workspace>,
|
||||
notify_tx: mpsc::Sender<OutgoingResponse>,
|
||||
@@ -1305,6 +1416,19 @@ async fn execute_lightweight(
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize a user-controlled string before interpolation into an LLM prompt.
|
||||
/// Strips newlines (which could break prompt structure) and truncates to a
|
||||
/// reasonable length to limit abuse surface.
|
||||
fn sanitize_prompt_field(value: &str) -> String {
|
||||
const MAX_LEN: usize = 128;
|
||||
value
|
||||
.chars()
|
||||
.filter(|&c| c != '\n' && c != '\r')
|
||||
.take(MAX_LEN)
|
||||
.map(|c| if c == '`' { '\'' } else { c })
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_lightweight_prompt(
|
||||
prompt: &str,
|
||||
context_parts: &[String],
|
||||
@@ -1323,14 +1447,16 @@ fn build_lightweight_prompt(
|
||||
);
|
||||
|
||||
if let Some(channel) = notify.channel.as_deref() {
|
||||
let sanitized = sanitize_prompt_field(channel);
|
||||
full_prompt.push_str(&format!(
|
||||
"The configured delivery channel for this routine is `{channel}`.\n"
|
||||
"The configured delivery channel for this routine is `{sanitized}`.\n"
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(user) = notify.user.as_deref() {
|
||||
let sanitized = sanitize_prompt_field(user);
|
||||
full_prompt.push_str(&format!(
|
||||
"The configured delivery target for this routine is `{user}`.\n"
|
||||
"The configured delivery target for this routine is `{sanitized}`.\n"
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1440,6 +1566,7 @@ fn handle_text_response(
|
||||
/// This is a simplified version of the full dispatcher loop:
|
||||
/// - Max 3-5 iterations (configurable)
|
||||
/// - Sequential tool execution (not parallel)
|
||||
/// - Uses the owner's live autonomous tool scope when lightweight tools are enabled
|
||||
/// - Auto-approval of non-Always tools
|
||||
/// - No hooks or approval dialogs
|
||||
async fn execute_lightweight_with_tools(
|
||||
@@ -1486,7 +1613,10 @@ async fn execute_lightweight_with_tools(
|
||||
let force_text = iteration >= max_iterations;
|
||||
|
||||
if force_text {
|
||||
// Final iteration: no tools, just get text response
|
||||
// Final iteration: no tools, just get text response.
|
||||
// Claude 4.6 rejects assistant prefill; NEAR AI rejects any non-user-ending
|
||||
// conversation. Ensure the last message is user-role.
|
||||
crate::util::ensure_ends_with_user_message(&mut messages);
|
||||
let request = CompletionRequest::new(messages)
|
||||
.with_max_tokens(effective_max_tokens)
|
||||
.with_temperature(0.3);
|
||||
@@ -1765,6 +1895,13 @@ pub fn spawn_cron_ticker(
|
||||
engine.check_cron_triggers().await;
|
||||
|
||||
let mut ticker = tokio::time::interval(interval);
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
// Periodic event cache refresh so web/CLI mutations are picked up
|
||||
// without requiring tool-path code to call refresh_event_cache().
|
||||
// Uses wall-clock elapsed time so the refresh cadence is stable
|
||||
// regardless of the cron tick interval configuration.
|
||||
let refresh_interval = Duration::from_secs(60);
|
||||
let mut last_refresh = tokio::time::Instant::now();
|
||||
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
@@ -1772,7 +1909,11 @@ pub fn spawn_cron_ticker(
|
||||
// never races with FullJobWatcher instances from this process.
|
||||
engine.sync_dispatched_runs().await;
|
||||
engine.check_cron_triggers().await;
|
||||
engine.sync_dispatched_runs().await;
|
||||
|
||||
if last_refresh.elapsed() >= refresh_interval {
|
||||
engine.refresh_event_cache().await;
|
||||
last_refresh = tokio::time::Instant::now();
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1838,7 +1979,13 @@ fn strip_html_tags(s: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::agent::routine::{NotifyConfig, RunStatus};
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::agent::routine::{
|
||||
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, RunStatus, Trigger,
|
||||
};
|
||||
use crate::channels::IncomingMessage;
|
||||
use crate::config::RoutineConfig;
|
||||
|
||||
#[test]
|
||||
@@ -2036,6 +2183,117 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to build a test routine with the given user_id and trigger.
|
||||
fn make_routine(user_id: &str, trigger: Trigger) -> Routine {
|
||||
Routine {
|
||||
id: Uuid::new_v4(),
|
||||
name: "test".to_string(),
|
||||
description: String::new(),
|
||||
user_id: user_id.to_string(),
|
||||
enabled: true,
|
||||
trigger,
|
||||
action: RoutineAction::Lightweight {
|
||||
prompt: String::new(),
|
||||
context_paths: vec![],
|
||||
max_tokens: 1000,
|
||||
use_tools: false,
|
||||
max_tool_rounds: 0,
|
||||
},
|
||||
guardrails: RoutineGuardrails::default(),
|
||||
notify: Default::default(),
|
||||
last_run_at: None,
|
||||
next_fire_at: None,
|
||||
run_count: 0,
|
||||
consecutive_failures: 0,
|
||||
state: serde_json::Value::Null,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to build a test IncomingMessage.
|
||||
fn make_message(user_id: &str, channel: &str, content: &str) -> IncomingMessage {
|
||||
IncomingMessage {
|
||||
id: Uuid::new_v4(),
|
||||
channel: channel.to_string(),
|
||||
user_id: user_id.to_string(),
|
||||
owner_id: user_id.to_string(),
|
||||
sender_id: user_id.to_string(),
|
||||
user_name: None,
|
||||
content: content.to_string(),
|
||||
thread_id: None,
|
||||
conversation_scope_id: None,
|
||||
received_at: Utc::now(),
|
||||
metadata: serde_json::Value::Null,
|
||||
timezone: None,
|
||||
attachments: vec![],
|
||||
is_internal: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression test for issue #1051: event triggers used case-sensitive
|
||||
/// channel comparison, so "Telegram" != "telegram" caused silent mismatch.
|
||||
/// Tests the actual `routine_matches_message` function used in `check_event_triggers`.
|
||||
#[test]
|
||||
fn test_channel_filter_is_case_insensitive() {
|
||||
let routine = make_routine(
|
||||
"user1",
|
||||
Trigger::Event {
|
||||
pattern: ".*".to_string(),
|
||||
channel: Some("Telegram".to_string()),
|
||||
},
|
||||
);
|
||||
let msg = make_message("user1", "telegram", "hello");
|
||||
|
||||
// Case-insensitive channel match must succeed
|
||||
assert!(super::routine_matches_message(&routine, &msg));
|
||||
|
||||
// Exact case must also work
|
||||
let msg_exact = make_message("user1", "Telegram", "hello");
|
||||
assert!(super::routine_matches_message(&routine, &msg_exact));
|
||||
|
||||
// Different channel must not match
|
||||
let msg_wrong = make_message("user1", "discord", "hello");
|
||||
assert!(!super::routine_matches_message(&routine, &msg_wrong));
|
||||
}
|
||||
|
||||
/// Regression test for issue #1051: event triggers did not filter by
|
||||
/// user_id, so routines from user A could fire on messages from user B.
|
||||
/// Tests the actual `routine_matches_message` function used in `check_event_triggers`.
|
||||
#[test]
|
||||
fn test_event_trigger_requires_user_match() {
|
||||
let routine = make_routine(
|
||||
"alice",
|
||||
Trigger::Event {
|
||||
pattern: ".*".to_string(),
|
||||
channel: None,
|
||||
},
|
||||
);
|
||||
|
||||
// Different user must not match
|
||||
let msg_bob = make_message("bob", "telegram", "hello");
|
||||
assert!(!super::routine_matches_message(&routine, &msg_bob));
|
||||
|
||||
// Same user must match
|
||||
let msg_alice = make_message("alice", "telegram", "hello");
|
||||
assert!(super::routine_matches_message(&routine, &msg_alice));
|
||||
}
|
||||
|
||||
/// When no channel filter is set, any channel should match (given user matches).
|
||||
#[test]
|
||||
fn test_no_channel_filter_matches_any_channel() {
|
||||
let routine = make_routine(
|
||||
"user1",
|
||||
Trigger::Event {
|
||||
pattern: ".*".to_string(),
|
||||
channel: None,
|
||||
},
|
||||
);
|
||||
|
||||
let msg = make_message("user1", "whatever_channel", "hello");
|
||||
assert!(super::routine_matches_message(&routine, &msg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_routine_tool_denylist_blocks_self_management_tools() {
|
||||
let denylisted = vec![
|
||||
|
||||
+12
-9
@@ -9,15 +9,14 @@ use tokio::task::JoinHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::agent::task::{Task, TaskContext, TaskOutput};
|
||||
use crate::channels::web::types::SseEvent;
|
||||
use crate::config::AgentConfig;
|
||||
use crate::context::{ContextManager, JobContext, JobState};
|
||||
use crate::db::Database;
|
||||
use crate::error::{Error, JobError};
|
||||
use crate::extensions::ExtensionManager;
|
||||
use crate::hooks::HookRegistry;
|
||||
use crate::llm::LlmProvider;
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::tenant::AdminScope;
|
||||
use crate::tools::{
|
||||
ApprovalContext, ToolRegistry, autonomous_allowed_tool_names, autonomous_unavailable_error,
|
||||
prepare_tool_params,
|
||||
@@ -53,7 +52,7 @@ struct ScheduledSubtask {
|
||||
pub struct SchedulerDeps {
|
||||
pub tools: Arc<ToolRegistry>,
|
||||
pub extension_manager: Option<Arc<ExtensionManager>>,
|
||||
pub store: Option<Arc<dyn Database>>,
|
||||
pub store: Option<AdminScope>,
|
||||
pub hooks: Arc<HookRegistry>,
|
||||
}
|
||||
|
||||
@@ -65,10 +64,10 @@ pub struct Scheduler {
|
||||
safety: Arc<SafetyLayer>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
extension_manager: Option<Arc<ExtensionManager>>,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
store: Option<AdminScope>,
|
||||
hooks: Arc<HookRegistry>,
|
||||
/// SSE broadcast sender for live job event streaming.
|
||||
sse_tx: Option<tokio::sync::broadcast::Sender<SseEvent>>,
|
||||
/// SSE manager for live job event streaming.
|
||||
sse_tx: Option<Arc<crate::channels::web::sse::SseManager>>,
|
||||
/// HTTP interceptor for trace recording/replay (propagated to workers).
|
||||
http_interceptor: Option<Arc<dyn crate::llm::recording::HttpInterceptor>>,
|
||||
/// Running jobs (main LLM-driven jobs).
|
||||
@@ -102,9 +101,9 @@ impl Scheduler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the SSE broadcast sender for live job event streaming.
|
||||
pub fn set_sse_sender(&mut self, tx: tokio::sync::broadcast::Sender<SseEvent>) {
|
||||
self.sse_tx = Some(tx);
|
||||
/// Set the SSE manager for live job event streaming.
|
||||
pub fn set_sse_sender(&mut self, sse: Arc<crate::channels::web::sse::SseManager>) {
|
||||
self.sse_tx = Some(sse);
|
||||
}
|
||||
|
||||
/// Set the HTTP interceptor for trace recording/replay.
|
||||
@@ -781,10 +780,14 @@ mod tests {
|
||||
allow_local_tools: true,
|
||||
max_cost_per_day_cents: None,
|
||||
max_actions_per_hour: None,
|
||||
max_cost_per_user_per_day_cents: None,
|
||||
max_tool_iterations: 10,
|
||||
auto_approve_tools: true,
|
||||
default_timezone: "UTC".to_string(),
|
||||
max_tokens_per_job,
|
||||
multi_tenant: false,
|
||||
max_llm_concurrent_per_user: None,
|
||||
max_jobs_concurrent_per_user: None,
|
||||
};
|
||||
let cm = Arc::new(ContextManager::new(5));
|
||||
let llm: Arc<dyn LlmProvider> = Arc::new(StubLlm);
|
||||
|
||||
@@ -8,8 +8,8 @@ use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::context::{ContextManager, JobState};
|
||||
use crate::db::Database;
|
||||
use crate::error::RepairError;
|
||||
use crate::tenant::AdminScope;
|
||||
use crate::tools::{BuildRequirement, Language, SoftwareBuilder, SoftwareType, ToolRegistry};
|
||||
|
||||
/// A job that has been detected as stuck.
|
||||
@@ -69,7 +69,7 @@ pub struct DefaultSelfRepair {
|
||||
/// Jobs in `InProgress` longer than this are treated as stuck.
|
||||
stuck_threshold: Duration,
|
||||
max_repair_attempts: u32,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
store: Option<AdminScope>,
|
||||
builder: Option<Arc<dyn SoftwareBuilder>>,
|
||||
tools: Option<Arc<ToolRegistry>>,
|
||||
}
|
||||
@@ -91,8 +91,8 @@ impl DefaultSelfRepair {
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a Store for tool failure tracking.
|
||||
pub fn with_store(mut self, store: Arc<dyn Database>) -> Self {
|
||||
/// Add an admin-scoped store for tool failure tracking.
|
||||
pub fn with_store(mut self, store: AdminScope) -> Self {
|
||||
self.store = Some(store);
|
||||
self
|
||||
}
|
||||
@@ -806,7 +806,7 @@ mod tests {
|
||||
// Create self-repair with zero threshold (detect immediately),
|
||||
// wired with store, builder, and tools.
|
||||
let repair = DefaultSelfRepair::new(Arc::clone(&cm), Duration::from_secs(0), 3)
|
||||
.with_store(Arc::clone(&db))
|
||||
.with_store(crate::tenant::AdminScope::new(Arc::clone(&db)))
|
||||
.with_builder(
|
||||
Arc::clone(&builder) as Arc<dyn crate::tools::SoftwareBuilder>,
|
||||
tools,
|
||||
|
||||
+193
-2
@@ -16,8 +16,8 @@ use chrono::{DateTime, TimeDelta, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::channels::web::util::truncate_preview;
|
||||
use crate::llm::{ChatMessage, ToolCall, generate_tool_call_id};
|
||||
use ironclaw_common::truncate_preview;
|
||||
|
||||
/// A session containing one or more threads.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -449,6 +449,7 @@ impl Thread {
|
||||
id: call_id.clone(),
|
||||
name: tc.name.clone(),
|
||||
arguments: tc.parameters.clone(),
|
||||
reasoning: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -522,7 +523,12 @@ impl Thread {
|
||||
&& let Some(ref tcs) = assistant_msg.tool_calls
|
||||
{
|
||||
for tc in tcs {
|
||||
turn.record_tool_call(&tc.name, tc.arguments.clone());
|
||||
turn.record_tool_call_with_reasoning(
|
||||
&tc.name,
|
||||
tc.arguments.clone(),
|
||||
tc.reasoning.clone(),
|
||||
Some(tc.id.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,6 +608,10 @@ pub struct Turn {
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
/// Error message (if failed).
|
||||
pub error: Option<String>,
|
||||
/// Agent's reasoning narrative for this turn.
|
||||
/// Cleaned via `clean_response` and sanitized through `SafetyLayer` before storage.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub narrative: Option<String>,
|
||||
/// Transient image content parts for multimodal LLM input.
|
||||
/// Not serialized — images are only needed for the current LLM call.
|
||||
/// The text description in `user_input` persists for compaction/context.
|
||||
@@ -621,6 +631,7 @@ impl Turn {
|
||||
started_at: Utc::now(),
|
||||
completed_at: None,
|
||||
error: None,
|
||||
narrative: None,
|
||||
image_content_parts: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -656,6 +667,26 @@ impl Turn {
|
||||
parameters: params,
|
||||
result: None,
|
||||
error: None,
|
||||
rationale: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
}
|
||||
|
||||
/// Record a tool call with reasoning context.
|
||||
pub fn record_tool_call_with_reasoning(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
params: serde_json::Value,
|
||||
rationale: Option<String>,
|
||||
tool_call_id: Option<String>,
|
||||
) {
|
||||
self.tool_calls.push(TurnToolCall {
|
||||
name: name.into(),
|
||||
parameters: params,
|
||||
result: None,
|
||||
error: None,
|
||||
rationale,
|
||||
tool_call_id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -672,6 +703,60 @@ impl Turn {
|
||||
call.error = Some(error.into());
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a tool result by tool_call_id, with fallback to first pending call.
|
||||
pub fn record_tool_result_for(&mut self, tool_call_id: &str, result: serde_json::Value) {
|
||||
if let Some(call) = self
|
||||
.tool_calls
|
||||
.iter_mut()
|
||||
.find(|c| c.tool_call_id.as_deref() == Some(tool_call_id))
|
||||
{
|
||||
call.result = Some(result);
|
||||
} else if let Some(call) = self
|
||||
.tool_calls
|
||||
.iter_mut()
|
||||
.find(|c| c.result.is_none() && c.error.is_none())
|
||||
{
|
||||
tracing::debug!(
|
||||
tool_call_id = %tool_call_id,
|
||||
fallback_tool = %call.name,
|
||||
"tool_call_id not found, falling back to first pending call"
|
||||
);
|
||||
call.result = Some(result);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
tool_call_id = %tool_call_id,
|
||||
"Tool result dropped: no matching or pending tool call"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a tool error by tool_call_id, with fallback to first pending call.
|
||||
pub fn record_tool_error_for(&mut self, tool_call_id: &str, error: impl Into<String>) {
|
||||
if let Some(call) = self
|
||||
.tool_calls
|
||||
.iter_mut()
|
||||
.find(|c| c.tool_call_id.as_deref() == Some(tool_call_id))
|
||||
{
|
||||
call.error = Some(error.into());
|
||||
} else if let Some(call) = self
|
||||
.tool_calls
|
||||
.iter_mut()
|
||||
.find(|c| c.result.is_none() && c.error.is_none())
|
||||
{
|
||||
tracing::debug!(
|
||||
tool_call_id = %tool_call_id,
|
||||
fallback_tool = %call.name,
|
||||
"tool_call_id not found, falling back to first pending call"
|
||||
);
|
||||
call.error = Some(error.into());
|
||||
} else {
|
||||
tracing::warn!(
|
||||
tool_call_id = %tool_call_id,
|
||||
"Tool error dropped: no matching or pending tool call"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record of a tool call made during a turn.
|
||||
@@ -685,6 +770,12 @@ pub struct TurnToolCall {
|
||||
pub result: Option<serde_json::Value>,
|
||||
/// Error from the tool (if failed).
|
||||
pub error: Option<String>,
|
||||
/// Agent's reasoning for choosing this tool.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rationale: Option<String>,
|
||||
/// The tool_call_id from the LLM, for identity-based result matching.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1309,6 +1400,7 @@ mod tests {
|
||||
id: "call_0".to_string(),
|
||||
name: "search".to_string(),
|
||||
arguments: serde_json::json!({"q": "test"}),
|
||||
reasoning: None,
|
||||
};
|
||||
let messages = vec![
|
||||
ChatMessage::user("Find test"),
|
||||
@@ -1339,6 +1431,7 @@ mod tests {
|
||||
id: "call_0".to_string(),
|
||||
name: "http".to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
reasoning: None,
|
||||
};
|
||||
let messages = vec![
|
||||
ChatMessage::user("Fetch URL"),
|
||||
@@ -1404,11 +1497,13 @@ mod tests {
|
||||
id: "call_a".to_string(),
|
||||
name: "search".to_string(),
|
||||
arguments: serde_json::json!({"q": "data"}),
|
||||
reasoning: None,
|
||||
};
|
||||
let tc2 = ToolCall {
|
||||
id: "call_b".to_string(),
|
||||
name: "write".to_string(),
|
||||
arguments: serde_json::json!({"path": "out.txt"}),
|
||||
reasoning: None,
|
||||
};
|
||||
let messages = vec![
|
||||
ChatMessage::user("Find and save"),
|
||||
@@ -1620,4 +1715,100 @@ mod tests {
|
||||
let merged = thread.drain_pending_messages().unwrap();
|
||||
assert_eq!(merged, "failed batch\nnew msg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_tool_result_for_by_id() {
|
||||
let mut turn = Turn::new(0, "test");
|
||||
turn.record_tool_call_with_reasoning(
|
||||
"tool_a",
|
||||
serde_json::json!({}),
|
||||
None,
|
||||
Some("id_a".into()),
|
||||
);
|
||||
turn.record_tool_call_with_reasoning(
|
||||
"tool_b",
|
||||
serde_json::json!({}),
|
||||
None,
|
||||
Some("id_b".into()),
|
||||
);
|
||||
|
||||
// Record result for second tool by ID
|
||||
turn.record_tool_result_for("id_b", serde_json::json!("result_b"));
|
||||
assert!(turn.tool_calls[0].result.is_none());
|
||||
assert_eq!(
|
||||
turn.tool_calls[1].result.as_ref().unwrap(),
|
||||
&serde_json::json!("result_b")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_tool_error_for_by_id() {
|
||||
let mut turn = Turn::new(0, "test");
|
||||
turn.record_tool_call_with_reasoning(
|
||||
"tool_a",
|
||||
serde_json::json!({}),
|
||||
None,
|
||||
Some("id_a".into()),
|
||||
);
|
||||
turn.record_tool_call_with_reasoning(
|
||||
"tool_b",
|
||||
serde_json::json!({}),
|
||||
None,
|
||||
Some("id_b".into()),
|
||||
);
|
||||
|
||||
turn.record_tool_error_for("id_a", "failed");
|
||||
assert_eq!(turn.tool_calls[0].error.as_deref(), Some("failed"));
|
||||
assert!(turn.tool_calls[1].error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_tool_result_for_fallback_to_pending() {
|
||||
let mut turn = Turn::new(0, "test");
|
||||
turn.record_tool_call_with_reasoning(
|
||||
"tool_a",
|
||||
serde_json::json!({}),
|
||||
None,
|
||||
Some("id_a".into()),
|
||||
);
|
||||
turn.record_tool_call_with_reasoning(
|
||||
"tool_b",
|
||||
serde_json::json!({}),
|
||||
None,
|
||||
Some("id_b".into()),
|
||||
);
|
||||
|
||||
// First tool already has a result
|
||||
turn.tool_calls[0].result = Some(serde_json::json!("done"));
|
||||
|
||||
// Unknown ID should fall back to first pending (tool_b)
|
||||
turn.record_tool_result_for("unknown_id", serde_json::json!("fallback"));
|
||||
assert_eq!(
|
||||
turn.tool_calls[0].result.as_ref().unwrap(),
|
||||
&serde_json::json!("done")
|
||||
);
|
||||
assert_eq!(
|
||||
turn.tool_calls[1].result.as_ref().unwrap(),
|
||||
&serde_json::json!("fallback")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_tool_result_for_no_pending_is_noop() {
|
||||
let mut turn = Turn::new(0, "test");
|
||||
turn.record_tool_call_with_reasoning(
|
||||
"tool_a",
|
||||
serde_json::json!({}),
|
||||
None,
|
||||
Some("id_a".into()),
|
||||
);
|
||||
turn.tool_calls[0].result = Some(serde_json::json!("done"));
|
||||
|
||||
// No pending calls, unknown ID — should be a no-op
|
||||
turn.record_tool_result_for("unknown_id", serde_json::json!("lost"));
|
||||
assert_eq!(
|
||||
turn.tool_calls[0].result.as_ref().unwrap(),
|
||||
&serde_json::json!("done")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+189
-34
@@ -102,11 +102,30 @@ impl SessionManager {
|
||||
/// Resolve an external thread ID to an internal thread.
|
||||
///
|
||||
/// Returns the session and thread ID. Creates both if they don't exist.
|
||||
/// Delegates to [`resolve_thread_with_parsed_uuid`](Self::resolve_thread_with_parsed_uuid)
|
||||
/// with `parsed_uuid: None`.
|
||||
pub async fn resolve_thread(
|
||||
&self,
|
||||
user_id: &str,
|
||||
channel: &str,
|
||||
external_thread_id: Option<&str>,
|
||||
) -> (Arc<Mutex<Session>>, Uuid) {
|
||||
self.resolve_thread_with_parsed_uuid(user_id, channel, external_thread_id, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Like [`resolve_thread`](Self::resolve_thread), but accepts a pre-parsed
|
||||
/// UUID to skip redundant parsing when the caller has already validated
|
||||
/// the external thread ID as a UUID (e.g. the approval routing path).
|
||||
///
|
||||
/// Uses a single read-lock acquisition for both the key lookup and the UUID
|
||||
/// adoption check to reduce contention under concurrent approval load.
|
||||
pub async fn resolve_thread_with_parsed_uuid(
|
||||
&self,
|
||||
user_id: &str,
|
||||
channel: &str,
|
||||
external_thread_id: Option<&str>,
|
||||
parsed_uuid: Option<Uuid>,
|
||||
) -> (Arc<Mutex<Session>>, Uuid) {
|
||||
let session = self.get_or_create_session(user_id).await;
|
||||
|
||||
@@ -116,51 +135,65 @@ impl SessionManager {
|
||||
external_thread_id: external_thread_id.map(String::from),
|
||||
};
|
||||
|
||||
// Check if we have a mapping
|
||||
{
|
||||
// Use pre-parsed UUID if available, otherwise parse from string.
|
||||
let ext_uuid = parsed_uuid
|
||||
.or_else(|| external_thread_id.and_then(|ext_tid| Uuid::parse_str(ext_tid).ok()));
|
||||
|
||||
// Validate that parsed_uuid (if provided) is consistent with external_thread_id.
|
||||
#[cfg(debug_assertions)]
|
||||
if let (Some(parsed), Some(ext_tid)) = (&parsed_uuid, external_thread_id) {
|
||||
debug_assert_eq!(
|
||||
Uuid::parse_str(ext_tid).ok().as_ref(),
|
||||
Some(parsed),
|
||||
"parsed_uuid must be the parsed form of external_thread_id"
|
||||
);
|
||||
}
|
||||
|
||||
// Single read lock for both the key lookup and UUID adoption check
|
||||
let adoptable_uuid = {
|
||||
let thread_map = self.thread_map.read().await;
|
||||
|
||||
// Fast path: exact key match
|
||||
if let Some(&thread_id) = thread_map.get(&key) {
|
||||
// Verify thread still exists in session
|
||||
let sess = session.lock().await;
|
||||
if sess.threads.contains_key(&thread_id) {
|
||||
return (Arc::clone(&session), thread_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if external_thread_id is itself a known thread UUID that
|
||||
// exists in the session but was never registered in the thread_map
|
||||
// (e.g. created by chat_new_thread_handler or hydrated from DB).
|
||||
// We only adopt it if no thread_map entry maps to this UUID —
|
||||
// otherwise it belongs to a different channel scope.
|
||||
if let Some(ext_tid) = external_thread_id
|
||||
&& let Ok(ext_uuid) = Uuid::parse_str(ext_tid)
|
||||
{
|
||||
let thread_map = self.thread_map.read().await;
|
||||
let mapped_elsewhere = thread_map.values().any(|&v| v == ext_uuid);
|
||||
drop(thread_map);
|
||||
// UUID adoption check (still under the same read lock).
|
||||
// If external_thread_id is a valid UUID not mapped elsewhere,
|
||||
// it may be a thread created by chat_new_thread_handler or
|
||||
// hydrated from DB that we can adopt.
|
||||
// Only attempt adoption when external_thread_id is Some, preserving
|
||||
// the invariant that None external_thread_id never triggers adoption.
|
||||
if external_thread_id.is_some() {
|
||||
ext_uuid.filter(|&uuid| !thread_map.values().any(|&v| v == uuid))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}; // Single read lock dropped here
|
||||
|
||||
if !mapped_elsewhere {
|
||||
let sess = session.lock().await;
|
||||
if sess.threads.contains_key(&ext_uuid) {
|
||||
drop(sess);
|
||||
// If we found an adoptable UUID, verify it exists in session and acquire write lock
|
||||
if let Some(ext_uuid) = adoptable_uuid {
|
||||
let sess = session.lock().await;
|
||||
if sess.threads.contains_key(&ext_uuid) {
|
||||
drop(sess);
|
||||
|
||||
let mut thread_map = self.thread_map.write().await;
|
||||
// Re-check after acquiring write lock to prevent race condition
|
||||
// where another task mapped this UUID between our read and write.
|
||||
if !thread_map.values().any(|&v| v == ext_uuid) {
|
||||
thread_map.insert(key, ext_uuid);
|
||||
drop(thread_map);
|
||||
// Ensure undo manager exists
|
||||
let mut undo_managers = self.undo_managers.write().await;
|
||||
undo_managers
|
||||
.entry(ext_uuid)
|
||||
.or_insert_with(|| Arc::new(Mutex::new(UndoManager::new())));
|
||||
return (session, ext_uuid);
|
||||
}
|
||||
// If it was mapped elsewhere while we were unlocked, fall through
|
||||
// to create a new thread, preserving channel isolation.
|
||||
let mut thread_map = self.thread_map.write().await;
|
||||
// Re-check after acquiring write lock to prevent race condition
|
||||
// where another task mapped this UUID between our read and write.
|
||||
if !thread_map.values().any(|&v| v == ext_uuid) {
|
||||
thread_map.insert(key, ext_uuid);
|
||||
drop(thread_map);
|
||||
// Ensure undo manager exists
|
||||
let mut undo_managers = self.undo_managers.write().await;
|
||||
undo_managers
|
||||
.entry(ext_uuid)
|
||||
.or_insert_with(|| Arc::new(Mutex::new(UndoManager::new())));
|
||||
return (session, ext_uuid);
|
||||
}
|
||||
// If mapped elsewhere while unlocked, fall through to create new thread
|
||||
}
|
||||
}
|
||||
|
||||
@@ -909,6 +942,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_thread_consolidates_read_path() {
|
||||
// Verify that resolve_thread still correctly handles:
|
||||
// 1. Fast path: key exists in thread_map
|
||||
// 2. UUID adoption: external_thread_id is a UUID in session but not in map
|
||||
// 3. New thread: neither path matches
|
||||
use crate::agent::session::Thread;
|
||||
|
||||
let manager = SessionManager::new();
|
||||
|
||||
// Case 1: Normal resolution creates thread and maps it
|
||||
let (session1, tid1) = manager
|
||||
.resolve_thread("user1", "chan1", Some("ext-1"))
|
||||
.await;
|
||||
// Resolving again with same key should return same thread (fast path)
|
||||
let (_, tid1_again) = manager
|
||||
.resolve_thread("user1", "chan1", Some("ext-1"))
|
||||
.await;
|
||||
assert_eq!(tid1, tid1_again);
|
||||
|
||||
// Case 2: UUID adoption - insert a thread directly into session
|
||||
let adopted_id = Uuid::new_v4();
|
||||
{
|
||||
let mut sess = session1.lock().await;
|
||||
let thread = Thread::with_id(adopted_id, sess.id);
|
||||
sess.threads.insert(adopted_id, thread);
|
||||
}
|
||||
// Resolve with the UUID as external_thread_id -- should adopt it
|
||||
let (_, resolved) = manager
|
||||
.resolve_thread("user1", "chan1", Some(&adopted_id.to_string()))
|
||||
.await;
|
||||
assert_eq!(resolved, adopted_id);
|
||||
|
||||
// Case 3: Different channel gets different thread
|
||||
let (_, tid2) = manager.resolve_thread("user1", "chan2", None).await;
|
||||
assert_ne!(tid1, tid2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_thread_finds_existing_session_thread_by_uuid() {
|
||||
use crate::agent::session::{Session, Thread};
|
||||
@@ -947,4 +1018,88 @@ mod tests {
|
||||
"should have exactly 1 thread, not a duplicate"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_thread_with_pre_parsed_uuid_adopts_thread() {
|
||||
use crate::agent::session::Thread;
|
||||
|
||||
let manager = SessionManager::new();
|
||||
let (session, _) = manager.resolve_thread("user1", "chan1", None).await;
|
||||
|
||||
// Manually insert a thread with a known UUID
|
||||
let known_id = Uuid::new_v4();
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(known_id, sess.id);
|
||||
sess.threads.insert(known_id, thread);
|
||||
}
|
||||
|
||||
// Resolve with pre-parsed UUID -- should adopt it without re-parsing
|
||||
let (_, resolved) = manager
|
||||
.resolve_thread_with_parsed_uuid(
|
||||
"user1",
|
||||
"chan1",
|
||||
Some(&known_id.to_string()),
|
||||
Some(known_id),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resolved, known_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_thread_with_parsed_uuid_none_delegates_to_parse() {
|
||||
use crate::agent::session::Thread;
|
||||
|
||||
let manager = SessionManager::new();
|
||||
let (session, _) = manager.resolve_thread("user2", "chan2", None).await;
|
||||
|
||||
// Insert a thread with a known UUID
|
||||
let known_id = Uuid::new_v4();
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(known_id, sess.id);
|
||||
sess.threads.insert(known_id, thread);
|
||||
}
|
||||
|
||||
// Resolve with parsed_uuid=None but a valid UUID string -- should
|
||||
// fall back to parsing the string and still adopt the thread
|
||||
let (_, resolved) = manager
|
||||
.resolve_thread_with_parsed_uuid("user2", "chan2", Some(&known_id.to_string()), None)
|
||||
.await;
|
||||
assert_eq!(resolved, known_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_thread_with_none_external_thread_id_does_not_adopt() {
|
||||
use crate::agent::session::Thread;
|
||||
|
||||
let manager = SessionManager::new();
|
||||
let (session, default_tid) = manager.resolve_thread("user3", "chan3", None).await;
|
||||
|
||||
// Manually insert a thread with a known UUID (simulating a thread
|
||||
// created by chat_new_thread_handler)
|
||||
let known_id = Uuid::new_v4();
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(known_id, sess.id);
|
||||
sess.threads.insert(known_id, thread);
|
||||
}
|
||||
|
||||
// Resolve with external_thread_id=None but parsed_uuid=Some.
|
||||
// This should NOT adopt the UUID — the old code prevented adoption
|
||||
// when external_thread_id was None, and we preserve that invariant.
|
||||
let (_, resolved) = manager
|
||||
.resolve_thread_with_parsed_uuid("user3", "chan3", None, Some(known_id))
|
||||
.await;
|
||||
|
||||
// Should return the existing default thread, not the injected UUID
|
||||
assert_eq!(
|
||||
resolved, default_tid,
|
||||
"should return existing default thread when external_thread_id is None"
|
||||
);
|
||||
assert_ne!(
|
||||
resolved, known_id,
|
||||
"should NOT adopt UUID when external_thread_id is None"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,17 @@ impl SubmissionParser {
|
||||
args: vec![],
|
||||
};
|
||||
}
|
||||
if lower == "/reasoning" || lower.starts_with("/reasoning ") {
|
||||
let args: Vec<String> = trimmed
|
||||
.split_whitespace()
|
||||
.skip(1)
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
return Submission::SystemCommand {
|
||||
command: "reasoning".to_string(),
|
||||
args,
|
||||
};
|
||||
}
|
||||
if lower == "/restart" {
|
||||
tracing::debug!("[SubmissionParser::parse] Recognized /restart command");
|
||||
return Submission::SystemCommand {
|
||||
|
||||
+172
-76
@@ -16,12 +16,12 @@ use crate::agent::dispatcher::{
|
||||
};
|
||||
use crate::agent::session::{MAX_PENDING_MESSAGES, PendingApproval, Session, ThreadState};
|
||||
use crate::agent::submission::SubmissionResult;
|
||||
use crate::channels::web::util::truncate_preview;
|
||||
use crate::channels::{IncomingMessage, StatusUpdate};
|
||||
use crate::context::JobContext;
|
||||
use crate::error::Error;
|
||||
use crate::llm::{ChatMessage, ToolCall};
|
||||
use crate::tools::redact_params;
|
||||
use ironclaw_common::truncate_preview;
|
||||
|
||||
const FORGED_THREAD_ID_ERROR: &str = "Invalid or unauthorized thread ID.";
|
||||
|
||||
@@ -31,7 +31,58 @@ fn requires_preexisting_uuid_thread(channel: &str) -> bool {
|
||||
matches!(channel, "gateway" | "test")
|
||||
}
|
||||
|
||||
fn validate_inbound_text_for_message(
|
||||
safety: &crate::safety::SafetyLayer,
|
||||
content: &str,
|
||||
attachments: &[crate::channels::IncomingAttachment],
|
||||
) -> crate::safety::ValidationResult {
|
||||
if content.trim().is_empty() && !attachments.is_empty() {
|
||||
crate::safety::ValidationResult::ok()
|
||||
} else {
|
||||
safety.validate_input(content)
|
||||
}
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
fn reject_unsafe_inbound_user_message(
|
||||
&self,
|
||||
message: &IncomingMessage,
|
||||
content: &str,
|
||||
) -> Option<SubmissionResult> {
|
||||
let validation =
|
||||
validate_inbound_text_for_message(self.safety(), content, &message.attachments);
|
||||
if !validation.is_valid {
|
||||
let details = validation
|
||||
.errors
|
||||
.iter()
|
||||
.map(|e| format!("{}: {}", e.field, e.message))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
return Some(SubmissionResult::error(format!(
|
||||
"Input rejected by safety validation: {details}",
|
||||
)));
|
||||
}
|
||||
|
||||
let violations = self.safety().check_policy(content);
|
||||
if violations
|
||||
.iter()
|
||||
.any(|rule| rule.action == crate::safety::PolicyAction::Block)
|
||||
{
|
||||
return Some(SubmissionResult::error("Input rejected by safety policy."));
|
||||
}
|
||||
|
||||
if let Some(warning) = self.safety().scan_inbound_for_secrets(content) {
|
||||
tracing::warn!(
|
||||
user = %message.user_id,
|
||||
channel = %message.channel,
|
||||
"Inbound message blocked: contains leaked secret"
|
||||
);
|
||||
return Some(SubmissionResult::error(warning));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Hydrate a historical thread from DB into memory if not already present.
|
||||
///
|
||||
/// Called before `resolve_thread` so that the session manager finds the
|
||||
@@ -175,6 +226,7 @@ impl Agent {
|
||||
pub(super) async fn process_user_input(
|
||||
&self,
|
||||
message: &IncomingMessage,
|
||||
tenant: crate::tenant::TenantCtx,
|
||||
session: Arc<Mutex<Session>>,
|
||||
thread_id: Uuid,
|
||||
content: &str,
|
||||
@@ -226,34 +278,11 @@ impl Agent {
|
||||
}
|
||||
|
||||
// Run the same safety checks that the normal path applies
|
||||
// (validation, policy, secret scan) so that blocked content
|
||||
// is never stored in pending_messages or serialized.
|
||||
let validation = self.safety().validate_input(content);
|
||||
if !validation.is_valid {
|
||||
let details = validation
|
||||
.errors
|
||||
.iter()
|
||||
.map(|e| format!("{}: {}", e.field, e.message))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
return Ok(SubmissionResult::error(format!(
|
||||
"Input rejected by safety validation: {details}",
|
||||
)));
|
||||
}
|
||||
let violations = self.safety().check_policy(content);
|
||||
if violations
|
||||
.iter()
|
||||
.any(|rule| rule.action == crate::safety::PolicyAction::Block)
|
||||
// so blocked content is never stored in pending_messages.
|
||||
if let Some(rejection) =
|
||||
self.reject_unsafe_inbound_user_message(message, content)
|
||||
{
|
||||
return Ok(SubmissionResult::error("Input rejected by safety policy."));
|
||||
}
|
||||
if let Some(warning) = self.safety().scan_inbound_for_secrets(content) {
|
||||
tracing::warn!(
|
||||
user = %message.user_id,
|
||||
channel = %message.channel,
|
||||
"Queued message blocked: contains leaked secret"
|
||||
);
|
||||
return Ok(SubmissionResult::error(warning));
|
||||
return Ok(rejection);
|
||||
}
|
||||
|
||||
if !thread.queue_message(content.to_string()) {
|
||||
@@ -307,39 +336,11 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
// Safety validation for user input
|
||||
let validation = self.safety().validate_input(content);
|
||||
if !validation.is_valid {
|
||||
let details = validation
|
||||
.errors
|
||||
.iter()
|
||||
.map(|e| format!("{}: {}", e.field, e.message))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
return Ok(SubmissionResult::error(format!(
|
||||
"Input rejected by safety validation: {}",
|
||||
details
|
||||
)));
|
||||
}
|
||||
|
||||
let violations = self.safety().check_policy(content);
|
||||
if violations
|
||||
.iter()
|
||||
.any(|rule| rule.action == crate::safety::PolicyAction::Block)
|
||||
{
|
||||
return Ok(SubmissionResult::error("Input rejected by safety policy."));
|
||||
}
|
||||
|
||||
// Scan inbound messages for secrets (API keys, tokens).
|
||||
// Catching them here prevents the LLM from echoing them back, which
|
||||
// would trigger the outbound leak detector and create error loops.
|
||||
if let Some(warning) = self.safety().scan_inbound_for_secrets(content) {
|
||||
tracing::warn!(
|
||||
user = %message.user_id,
|
||||
channel = %message.channel,
|
||||
"Inbound message blocked: contains leaked secret"
|
||||
);
|
||||
return Ok(SubmissionResult::error(warning));
|
||||
// Validate inbound content before the turn is created. Attachment-only
|
||||
// messages are allowed to pass through so multimodal channels can send
|
||||
// an empty text body alongside real image/document payloads.
|
||||
if let Some(rejection) = self.reject_unsafe_inbound_user_message(message, content) {
|
||||
return Ok(rejection);
|
||||
}
|
||||
|
||||
// Handle explicit commands (starting with /) directly
|
||||
@@ -351,7 +352,7 @@ impl Agent {
|
||||
|
||||
if let Some(intent) = self.router.route_command(&temp_message) {
|
||||
// Explicit command like /status, /job, /list - handle directly
|
||||
return self.handle_job_or_command(intent, message).await;
|
||||
return self.handle_job_or_command(intent, message, &tenant).await;
|
||||
}
|
||||
|
||||
// Natural language goes through the agentic loop
|
||||
@@ -462,7 +463,7 @@ impl Agent {
|
||||
|
||||
// Run the agentic tool execution loop
|
||||
let result = self
|
||||
.run_agentic_loop(message, session.clone(), thread_id, turn_messages)
|
||||
.run_agentic_loop(message, tenant, session.clone(), thread_id, turn_messages)
|
||||
.await;
|
||||
|
||||
// Re-acquire lock and check if interrupted
|
||||
@@ -513,10 +514,10 @@ impl Agent {
|
||||
};
|
||||
|
||||
thread.complete_turn(&response);
|
||||
let (turn_number, tool_calls) = thread
|
||||
let (turn_number, tool_calls, narrative) = thread
|
||||
.turns
|
||||
.last()
|
||||
.map(|t| (t.turn_number, t.tool_calls.clone()))
|
||||
.map(|t| (t.turn_number, t.tool_calls.clone(), t.narrative.clone()))
|
||||
.unwrap_or_default();
|
||||
let _ = self
|
||||
.channels
|
||||
@@ -534,6 +535,7 @@ impl Agent {
|
||||
&message.user_id,
|
||||
turn_number,
|
||||
&tool_calls,
|
||||
narrative.as_deref(),
|
||||
)
|
||||
.await;
|
||||
self.persist_assistant_response(
|
||||
@@ -725,7 +727,9 @@ impl Agent {
|
||||
///
|
||||
/// Stored between the user and assistant messages so that
|
||||
/// `build_turns_from_db_messages` can reconstruct the tool call history.
|
||||
/// Content is a JSON array of tool call summaries.
|
||||
/// Content is a JSON object: `{ "calls": [...], "narrative": "..." }`.
|
||||
/// The `calls` array contains tool call summaries with optional `rationale`
|
||||
/// and `tool_call_id` fields. Legacy rows may be plain JSON arrays.
|
||||
pub(super) async fn persist_tool_calls(
|
||||
&self,
|
||||
thread_id: Uuid,
|
||||
@@ -733,6 +737,7 @@ impl Agent {
|
||||
user_id: &str,
|
||||
turn_number: usize,
|
||||
tool_calls: &[crate::agent::session::TurnToolCall],
|
||||
narrative: Option<&str>,
|
||||
) {
|
||||
if tool_calls.is_empty() {
|
||||
return;
|
||||
@@ -767,11 +772,30 @@ impl Agent {
|
||||
if let Some(ref error) = tc.error {
|
||||
obj["error"] = serde_json::Value::String(truncate_preview(error, 200));
|
||||
}
|
||||
if let Some(ref rationale) = tc.rationale {
|
||||
obj["rationale"] = serde_json::Value::String(truncate_preview(rationale, 500));
|
||||
}
|
||||
if let Some(ref tool_call_id) = tc.tool_call_id {
|
||||
obj["tool_call_id"] =
|
||||
serde_json::Value::String(truncate_preview(tool_call_id, 128));
|
||||
}
|
||||
obj
|
||||
})
|
||||
.collect();
|
||||
|
||||
let content = match serde_json::to_string(&summaries) {
|
||||
// Wrap in an object with optional narrative so it can be reconstructed.
|
||||
// safety: no byte-index slicing here; comment describes JSON shape
|
||||
let wrapper = if let Some(n) = narrative {
|
||||
serde_json::json!({
|
||||
"narrative": truncate_preview(n, 1000),
|
||||
"calls": summaries,
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"calls": summaries,
|
||||
})
|
||||
};
|
||||
let content = match serde_json::to_string(&wrapper) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to serialize tool calls: {}", e);
|
||||
@@ -1104,9 +1128,12 @@ impl Agent {
|
||||
&& let Some(turn) = thread.last_turn_mut()
|
||||
{
|
||||
if is_tool_error {
|
||||
turn.record_tool_error(result_content.clone());
|
||||
turn.record_tool_error_for(&pending.tool_call_id, result_content.clone());
|
||||
} else {
|
||||
turn.record_tool_result(serde_json::json!(result_content));
|
||||
turn.record_tool_result_for(
|
||||
&pending.tool_call_id,
|
||||
serde_json::json!(result_content),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1358,9 +1385,12 @@ impl Agent {
|
||||
&& let Some(turn) = thread.last_turn_mut()
|
||||
{
|
||||
if is_deferred_error {
|
||||
turn.record_tool_error(deferred_content.clone());
|
||||
turn.record_tool_error_for(&tc.id, deferred_content.clone());
|
||||
} else {
|
||||
turn.record_tool_result(serde_json::json!(deferred_content));
|
||||
turn.record_tool_result_for(
|
||||
&tc.id,
|
||||
serde_json::json!(deferred_content),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1444,7 +1474,13 @@ impl Agent {
|
||||
|
||||
// Continue the agentic loop (a tool was already executed this turn)
|
||||
let result = self
|
||||
.run_agentic_loop(message, session.clone(), thread_id, context_messages)
|
||||
.run_agentic_loop(
|
||||
message,
|
||||
self.tenant_ctx(&message.user_id).await,
|
||||
session.clone(),
|
||||
thread_id,
|
||||
context_messages,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Handle the result
|
||||
@@ -1459,10 +1495,10 @@ impl Agent {
|
||||
let (response, suggestions) =
|
||||
crate::agent::dispatcher::extract_suggestions(&response);
|
||||
thread.complete_turn(&response);
|
||||
let (turn_number, tool_calls) = thread
|
||||
let (turn_number, tool_calls, narrative) = thread
|
||||
.turns
|
||||
.last()
|
||||
.map(|t| (t.turn_number, t.tool_calls.clone()))
|
||||
.map(|t| (t.turn_number, t.tool_calls.clone(), t.narrative.clone()))
|
||||
.unwrap_or_default();
|
||||
// User message already persisted at turn start; save tool calls then assistant response
|
||||
self.persist_tool_calls(
|
||||
@@ -1471,6 +1507,7 @@ impl Agent {
|
||||
&message.user_id,
|
||||
turn_number,
|
||||
&tool_calls,
|
||||
narrative.as_deref(),
|
||||
)
|
||||
.await;
|
||||
self.persist_assistant_response(
|
||||
@@ -1646,7 +1683,7 @@ impl Agent {
|
||||
};
|
||||
|
||||
match ext_mgr
|
||||
.configure_token(&pending.extension_name, token)
|
||||
.configure_token(&pending.extension_name, token, &message.user_id)
|
||||
.await
|
||||
{
|
||||
Ok(result) if result.activated => {
|
||||
@@ -1816,7 +1853,20 @@ fn rebuild_chat_messages_from_db(
|
||||
"assistant" => result.push(ChatMessage::assistant(&msg.content)),
|
||||
"tool_calls" => {
|
||||
// Try to parse the enriched JSON and rebuild tool messages.
|
||||
if let Ok(calls) = serde_json::from_str::<Vec<serde_json::Value>>(&msg.content) {
|
||||
// Supports two formats:
|
||||
// - Old: plain JSON array of tool call summaries
|
||||
// - New: wrapped object { "calls": [...], "narrative": "..." }
|
||||
let calls: Vec<serde_json::Value> =
|
||||
match serde_json::from_str::<serde_json::Value>(&msg.content) {
|
||||
Ok(serde_json::Value::Array(arr)) => arr,
|
||||
Ok(serde_json::Value::Object(obj)) => obj
|
||||
.get("calls")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
{
|
||||
if calls.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -1839,6 +1889,10 @@ fn rebuild_chat_messages_from_db(
|
||||
.get("parameters")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::json!({})),
|
||||
reasoning: c
|
||||
.get("rationale")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -1880,6 +1934,9 @@ fn rebuild_chat_messages_from_db(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::channels::{AttachmentKind, IncomingAttachment};
|
||||
use crate::config::SafetyConfig;
|
||||
use crate::safety::SafetyLayer;
|
||||
|
||||
#[test]
|
||||
fn test_rebuild_chat_messages_user_assistant_only() {
|
||||
@@ -2017,6 +2074,45 @@ mod tests {
|
||||
assert_eq!(result[7].content, "Written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_inbound_text_rejects_empty_text_without_attachments() {
|
||||
let safety = SafetyLayer::new(&SafetyConfig {
|
||||
max_output_length: 10_000,
|
||||
injection_check_enabled: true,
|
||||
});
|
||||
|
||||
let result = validate_inbound_text_for_message(&safety, "", &[]);
|
||||
assert!(!result.is_valid);
|
||||
assert_eq!(result.errors.len(), 1);
|
||||
assert_eq!(result.errors[0].field, "input");
|
||||
assert_eq!(result.errors[0].message, "Input cannot be empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_inbound_text_allows_empty_text_when_attachments_exist() {
|
||||
let safety = SafetyLayer::new(&SafetyConfig {
|
||||
max_output_length: 10_000,
|
||||
injection_check_enabled: true,
|
||||
});
|
||||
|
||||
let attachments = vec![IncomingAttachment {
|
||||
id: "image-1".to_string(),
|
||||
kind: AttachmentKind::Image,
|
||||
mime_type: "image/jpeg".to_string(),
|
||||
filename: Some("photo.jpg".to_string()),
|
||||
size_bytes: Some(128),
|
||||
source_url: Some("https://example.com/photo.jpg".to_string()),
|
||||
storage_key: None,
|
||||
extracted_text: None,
|
||||
data: vec![1, 2, 3],
|
||||
duration_secs: None,
|
||||
}];
|
||||
|
||||
let result = validate_inbound_text_for_message(&safety, "", &attachments);
|
||||
assert!(result.is_valid);
|
||||
assert!(result.errors.is_empty());
|
||||
}
|
||||
|
||||
fn make_db_msg(role: &str, content: &str) -> crate::history::ConversationMessage {
|
||||
crate::history::ConversationMessage {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
|
||||
+32
-9
@@ -312,13 +312,7 @@ impl AppBuilder {
|
||||
.create_provider(&self.config.llm.nearai.base_url, self.session.clone());
|
||||
|
||||
// Register memory tools if database is available
|
||||
let workspace_user_id = self
|
||||
.config
|
||||
.channels
|
||||
.gateway
|
||||
.as_ref()
|
||||
.map(|gw| gw.user_id.as_str())
|
||||
.unwrap_or("default");
|
||||
let workspace_user_id = self.config.owner_id.as_str();
|
||||
let workspace = if let Some(ref db) = self.db {
|
||||
let emb_cache_config = EmbeddingCacheConfig {
|
||||
max_entries: self.config.embeddings.cache_size,
|
||||
@@ -327,7 +321,7 @@ impl AppBuilder {
|
||||
.with_search_config(&self.config.search);
|
||||
|
||||
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)
|
||||
@@ -341,7 +335,35 @@ impl AppBuilder {
|
||||
}
|
||||
ws = ws.with_memory_layers(self.config.workspace.memory_layers.clone());
|
||||
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
|
||||
// WorkspacePool (which implements WorkspaceResolver) 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 pool = Arc::new(crate::channels::web::server::WorkspacePool::new(
|
||||
Arc::clone(db),
|
||||
embeddings.clone(),
|
||||
emb_cache_config,
|
||||
self.config.search.clone(),
|
||||
self.config.workspace.clone(),
|
||||
));
|
||||
tools.register_memory_tools_with_resolver(pool);
|
||||
tracing::info!(
|
||||
"Memory tools configured with per-user workspace resolver (multi-tenant mode)"
|
||||
);
|
||||
} else {
|
||||
tools.register_memory_tools(Arc::clone(&ws));
|
||||
}
|
||||
|
||||
Some(ws)
|
||||
} else {
|
||||
None
|
||||
@@ -858,6 +880,7 @@ impl AppBuilder {
|
||||
crate::agent::cost_guard::CostGuardConfig {
|
||||
max_cost_per_day_cents: self.config.agent.max_cost_per_day_cents,
|
||||
max_actions_per_hour: self.config.agent.max_actions_per_hour,
|
||||
max_cost_per_user_per_day_cents: self.config.agent.max_cost_per_user_per_day_cents,
|
||||
},
|
||||
));
|
||||
|
||||
|
||||
@@ -265,6 +265,15 @@ impl OutgoingResponse {
|
||||
}
|
||||
}
|
||||
|
||||
/// A single tool decision within a reasoning update.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolDecision {
|
||||
/// Tool name.
|
||||
pub tool_name: String,
|
||||
/// Agent's reasoning for choosing this tool.
|
||||
pub rationale: String,
|
||||
}
|
||||
|
||||
/// Status update types for showing agent activity.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum StatusUpdate {
|
||||
@@ -333,6 +342,13 @@ pub enum StatusUpdate {
|
||||
},
|
||||
/// Suggested follow-up messages for the user.
|
||||
Suggestions { suggestions: Vec<String> },
|
||||
/// Agent reasoning update (why it chose specific tools).
|
||||
ReasoningUpdate {
|
||||
/// Human-readable summary of the agent's decision.
|
||||
narrative: String,
|
||||
/// Per-tool decisions.
|
||||
decisions: Vec<ToolDecision>,
|
||||
},
|
||||
/// Per-turn token usage and cost summary (shown as subtle metadata).
|
||||
TurnCost {
|
||||
input_tokens: u64,
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ mod webhook_server;
|
||||
|
||||
pub use channel::{
|
||||
AttachmentKind, Channel, ChannelSecretUpdater, IncomingAttachment, IncomingMessage,
|
||||
MessageStream, OutgoingResponse, StatusUpdate, routing_target_from_metadata,
|
||||
MessageStream, OutgoingResponse, StatusUpdate, ToolDecision, routing_target_from_metadata,
|
||||
};
|
||||
pub use http::{HttpChannel, HttpChannelState};
|
||||
pub use manager::ChannelManager;
|
||||
|
||||
@@ -122,18 +122,32 @@ impl RelayClient {
|
||||
/// instance_url in chat-api. IronClaw only passes an optional CSRF nonce
|
||||
/// for validating the callback — no URLs.
|
||||
pub async fn initiate_oauth(&self, state_nonce: Option<&str>) -> Result<String, RelayError> {
|
||||
let url = format!("{}/oauth/slack/auth", self.base_url);
|
||||
tracing::debug!(relay_url = %url, "RelayClient::initiate_oauth: sending request");
|
||||
let mut query: Vec<(&str, &str)> = vec![];
|
||||
if let Some(nonce) = state_nonce {
|
||||
query.push(("state_nonce", nonce));
|
||||
}
|
||||
let resp = self
|
||||
.http
|
||||
.get(format!("{}/oauth/slack/auth", self.base_url))
|
||||
.get(&url)
|
||||
.bearer_auth(self.api_key.expose_secret())
|
||||
.query(&query)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| RelayError::Network(e.to_string()))?;
|
||||
.map_err(|e| {
|
||||
tracing::warn!(
|
||||
relay_url = %url,
|
||||
error = %e,
|
||||
"RelayClient::initiate_oauth: network request failed"
|
||||
);
|
||||
RelayError::Network(e.to_string())
|
||||
})?;
|
||||
tracing::debug!(
|
||||
relay_url = %url,
|
||||
status = %resp.status(),
|
||||
"RelayClient::initiate_oauth: received response"
|
||||
);
|
||||
|
||||
let status = resp.status();
|
||||
if status.is_redirection() {
|
||||
@@ -224,20 +238,39 @@ impl RelayClient {
|
||||
method: &str,
|
||||
body: serde_json::Value,
|
||||
) -> Result<serde_json::Value, RelayError> {
|
||||
let url = format!("{}/proxy/{}/{}", self.base_url, provider, method);
|
||||
tracing::debug!(
|
||||
relay_url = %url,
|
||||
provider = %provider,
|
||||
method = %method,
|
||||
"RelayClient::proxy_provider: sending request"
|
||||
);
|
||||
let query: Vec<(&str, &str)> = vec![("team_id", team_id)];
|
||||
let resp = self
|
||||
.http
|
||||
.post(format!("{}/proxy/{}/{}", self.base_url, provider, method))
|
||||
.post(&url)
|
||||
.bearer_auth(self.api_key.expose_secret())
|
||||
.query(&query)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| RelayError::Network(e.to_string()))?;
|
||||
.map_err(|e| {
|
||||
tracing::warn!(
|
||||
relay_url = %url,
|
||||
error = %e,
|
||||
"RelayClient::proxy_provider: network request failed"
|
||||
);
|
||||
RelayError::Network(e.to_string())
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
tracing::warn!(
|
||||
relay_url = %url,
|
||||
status = status,
|
||||
"RelayClient::proxy_provider: channel-relay returned error"
|
||||
);
|
||||
return Err(RelayError::Api {
|
||||
status,
|
||||
message: body,
|
||||
@@ -255,23 +288,45 @@ impl RelayClient {
|
||||
/// 32-byte secret. Called once at activation time; the result is cached in the
|
||||
/// extension manager so subsequent calls to `relay_signing_secret()` use it.
|
||||
pub async fn get_signing_secret(&self, team_id: &str) -> Result<Vec<u8>, RelayError> {
|
||||
let url = format!("{}/relay/signing-secret", self.base_url);
|
||||
tracing::debug!(
|
||||
relay_url = %url,
|
||||
"RelayClient::get_signing_secret: fetching signing secret"
|
||||
);
|
||||
let resp = self
|
||||
.http
|
||||
.get(format!("{}/relay/signing-secret", self.base_url))
|
||||
.get(&url)
|
||||
.bearer_auth(self.api_key.expose_secret())
|
||||
.query(&[("team_id", team_id)])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| RelayError::Network(e.to_string()))?;
|
||||
.map_err(|e| {
|
||||
tracing::warn!(
|
||||
relay_url = %url,
|
||||
error = %e,
|
||||
"RelayClient::get_signing_secret: network request failed"
|
||||
);
|
||||
RelayError::Network(e.to_string())
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
tracing::warn!(
|
||||
relay_url = %url,
|
||||
status = status,
|
||||
body = %body,
|
||||
"RelayClient::get_signing_secret: channel-relay returned error"
|
||||
);
|
||||
return Err(RelayError::Api {
|
||||
status,
|
||||
message: body,
|
||||
});
|
||||
}
|
||||
tracing::debug!(
|
||||
relay_url = %url,
|
||||
"RelayClient::get_signing_secret: received successful response"
|
||||
);
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
|
||||
+65
-9
@@ -75,6 +75,7 @@ const SLASH_COMMANDS: &[&str] = &[
|
||||
"/suggest",
|
||||
"/thread",
|
||||
"/resume",
|
||||
"/reasoning",
|
||||
];
|
||||
|
||||
/// Rustyline helper for slash-command tab completion.
|
||||
@@ -430,6 +431,18 @@ impl ReplChannel {
|
||||
let _ = execute!(stderr, terminal::Clear(terminal::ClearType::FromCursorDown));
|
||||
}
|
||||
}
|
||||
|
||||
async fn finish_single_message_turn(&self) {
|
||||
if self.single_message.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
let tx = self.msg_tx.lock().ok().and_then(|mut guard| guard.take());
|
||||
if let Some(tx) = tx {
|
||||
let msg = IncomingMessage::new("repl", &self.user_id, "/quit");
|
||||
let _ = tx.send(msg).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ReplChannel {
|
||||
@@ -479,7 +492,9 @@ impl Channel for ReplChannel {
|
||||
|
||||
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
||||
let (tx, rx) = mpsc::channel(32);
|
||||
// Store tx so send_status can inject approval responses directly
|
||||
// Approval prompts inject responses back through this sender.
|
||||
// In single-message mode we keep it until the turn finishes, then
|
||||
// drop it after enqueuing /quit so the receiver stream can close.
|
||||
if let Ok(mut guard) = self.msg_tx.lock() {
|
||||
*guard = Some(tx.clone());
|
||||
}
|
||||
@@ -495,11 +510,10 @@ impl Channel for ReplChannel {
|
||||
|
||||
// Single message mode: send it and return
|
||||
if let Some(msg) = single_message {
|
||||
let incoming = IncomingMessage::new("repl", &user_id, &msg).with_timezone(&sys_tz);
|
||||
let incoming = IncomingMessage::new("repl", &user_id, &msg)
|
||||
.with_metadata(serde_json::json!({ "single_message_mode": true }))
|
||||
.with_timezone(&sys_tz);
|
||||
let _ = tx.blocking_send(incoming);
|
||||
// Ensure the agent exits after handling exactly one turn in -m mode,
|
||||
// even when other channels (gateway/http) are enabled.
|
||||
let _ = tx.blocking_send(IncomingMessage::new("repl", &user_id, "/quit"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -662,6 +676,7 @@ impl Channel for ReplChannel {
|
||||
println!();
|
||||
println!();
|
||||
self.stdin_locked.store(false, Ordering::Relaxed);
|
||||
self.finish_single_message_turn().await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -680,6 +695,7 @@ impl Channel for ReplChannel {
|
||||
println!();
|
||||
// Unlock stdin so readline can resume
|
||||
self.stdin_locked.store(false, Ordering::Relaxed);
|
||||
self.finish_single_message_turn().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -779,6 +795,7 @@ impl Channel for ReplChannel {
|
||||
let msg_tx = Arc::clone(&self.msg_tx);
|
||||
let user_id = self.user_id.clone();
|
||||
let lock_flag = Arc::clone(&self.stdin_locked);
|
||||
let single_message_mode = self.single_message.is_some();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let action = run_approval_selector(allow_always).unwrap_or("n");
|
||||
// Unlock stdin so readline can resume after approval
|
||||
@@ -787,7 +804,12 @@ impl Channel for ReplChannel {
|
||||
return;
|
||||
};
|
||||
if let Some(tx) = guard.as_ref() {
|
||||
let msg = IncomingMessage::new("repl", &user_id, action);
|
||||
let msg = if single_message_mode {
|
||||
IncomingMessage::new("repl", &user_id, action)
|
||||
.with_metadata(serde_json::json!({ "single_message_mode": true }))
|
||||
} else {
|
||||
IncomingMessage::new("repl", &user_id, action)
|
||||
};
|
||||
let _ = tx.blocking_send(msg);
|
||||
}
|
||||
});
|
||||
@@ -841,6 +863,19 @@ impl Channel for ReplChannel {
|
||||
StatusUpdate::Suggestions { .. } => {
|
||||
// Suggestions are only rendered by the web gateway
|
||||
}
|
||||
StatusUpdate::ReasoningUpdate {
|
||||
narrative,
|
||||
decisions,
|
||||
} => {
|
||||
if !narrative.is_empty() {
|
||||
let display = truncate_for_preview(&narrative, CLI_STATUS_MAX);
|
||||
eprintln!(" \x1b[94m\u{25B6} {display}\x1b[0m");
|
||||
}
|
||||
for d in &decisions {
|
||||
let display = truncate_for_preview(&d.rationale, CLI_STATUS_MAX);
|
||||
eprintln!(" \x1b[90m\u{2192} {}: {display}\x1b[0m", d.tool_name);
|
||||
}
|
||||
}
|
||||
StatusUpdate::TurnCost { .. } => {
|
||||
// Cost display is handled by the TUI channel
|
||||
}
|
||||
@@ -875,6 +910,7 @@ impl Channel for ReplChannel {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use futures::StreamExt;
|
||||
use tokio::time::{Duration, timeout};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -883,16 +919,36 @@ mod tests {
|
||||
let repl = ReplChannel::with_message("hi".to_string());
|
||||
let mut stream = repl.start().await.expect("repl start should succeed");
|
||||
|
||||
let first = stream.next().await.expect("first message missing");
|
||||
let first = timeout(Duration::from_secs(1), stream.next())
|
||||
.await
|
||||
.expect("timed out waiting for first message")
|
||||
.expect("first message missing");
|
||||
assert_eq!(first.channel, "repl");
|
||||
assert_eq!(first.content, "hi");
|
||||
|
||||
let second = stream.next().await.expect("quit message missing");
|
||||
assert!(
|
||||
timeout(Duration::from_millis(100), stream.next())
|
||||
.await
|
||||
.is_err(),
|
||||
"single-message mode should wait for the turn to finish before quitting"
|
||||
);
|
||||
|
||||
repl.respond(&first, OutgoingResponse::text("done"))
|
||||
.await
|
||||
.expect("respond should succeed");
|
||||
|
||||
let second = timeout(Duration::from_secs(1), stream.next())
|
||||
.await
|
||||
.expect("timed out waiting for quit message")
|
||||
.expect("quit message missing");
|
||||
assert_eq!(second.channel, "repl");
|
||||
assert_eq!(second.content, "/quit");
|
||||
|
||||
assert!(
|
||||
stream.next().await.is_none(),
|
||||
timeout(Duration::from_secs(1), stream.next())
|
||||
.await
|
||||
.expect("timed out waiting for stream to close")
|
||||
.is_none(),
|
||||
"stream should end after /quit"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockDecrypt, KeyInit, generic_array::GenericArray};
|
||||
use base64::Engine as _;
|
||||
use serde::Deserialize;
|
||||
use silk_rs::decode_silk;
|
||||
|
||||
use crate::channels::wasm::capabilities::ChannelCapabilities;
|
||||
use crate::channels::wasm::host::{Attachment, ChannelHostState};
|
||||
|
||||
const AES_BLOCK_SIZE: usize = 16;
|
||||
const MAX_ATTACHMENT_BYTES: usize = 20 * 1024 * 1024;
|
||||
const WECHAT_CHANNEL_NAME: &str = "wechat";
|
||||
const WECHAT_SILK_SAMPLE_RATE_HZ: i32 = 24_000;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WechatAttachmentExtras {
|
||||
wechat_aes_key: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) async fn hydrate_attachment_for_channel(
|
||||
channel_name: &str,
|
||||
capabilities: &ChannelCapabilities,
|
||||
attachment: &mut Attachment,
|
||||
) {
|
||||
if !should_hydrate_wechat_attachment(channel_name, attachment) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(source_url) = attachment.source_url.as_deref() else {
|
||||
return;
|
||||
};
|
||||
let Some(encoded_aes_key) = wechat_aes_key(&attachment.extras_json) else {
|
||||
tracing::warn!(
|
||||
channel = %channel_name,
|
||||
attachment_id = %attachment.id,
|
||||
"Skipping WeChat attachment hydration: missing AES key metadata"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
match download_wechat_attachment_bytes(channel_name, capabilities, source_url).await {
|
||||
Ok(ciphertext) => match decrypt_wechat_attachment_bytes(&ciphertext, &encoded_aes_key) {
|
||||
Ok(plaintext) => {
|
||||
attachment.size_bytes = Some(plaintext.len() as u64);
|
||||
attachment.data = plaintext;
|
||||
if attachment.mime_type.starts_with("image/") {
|
||||
attachment.mime_type = detect_image_mime(&attachment.data).to_string();
|
||||
} else if is_wechat_silk_attachment(attachment) {
|
||||
if let Err(error) = maybe_transcode_wechat_silk_attachment(attachment) {
|
||||
tracing::warn!(
|
||||
channel = %channel_name,
|
||||
attachment_id = %attachment.id,
|
||||
error = %error,
|
||||
"Failed to transcode WeChat SILK attachment; preserving raw SILK"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
channel = %channel_name,
|
||||
attachment_id = %attachment.id,
|
||||
error = %error,
|
||||
"Failed to decrypt WeChat attachment"
|
||||
);
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
channel = %channel_name,
|
||||
attachment_id = %attachment.id,
|
||||
error = %error,
|
||||
"Failed to download WeChat attachment"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_wechat_silk_attachment(attachment: &Attachment) -> bool {
|
||||
attachment.mime_type.eq_ignore_ascii_case("audio/silk")
|
||||
|| attachment
|
||||
.filename
|
||||
.as_deref()
|
||||
.and_then(|filename| filename.rsplit_once('.').map(|(_, ext)| ext))
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("silk"))
|
||||
}
|
||||
|
||||
fn should_hydrate_wechat_attachment(channel_name: &str, attachment: &Attachment) -> bool {
|
||||
channel_name == WECHAT_CHANNEL_NAME
|
||||
&& attachment.data.is_empty()
|
||||
&& attachment.source_url.is_some()
|
||||
}
|
||||
|
||||
fn wechat_aes_key(extras_json: &str) -> Option<String> {
|
||||
if extras_json.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
serde_json::from_str::<WechatAttachmentExtras>(extras_json)
|
||||
.ok()
|
||||
.and_then(|extras| extras.wechat_aes_key)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
async fn download_wechat_attachment_bytes(
|
||||
channel_name: &str,
|
||||
capabilities: &ChannelCapabilities,
|
||||
source_url: &str,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let host_state = ChannelHostState::new(channel_name, capabilities.clone());
|
||||
host_state.check_http_allowed(source_url, "GET")?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
|
||||
|
||||
let response = client
|
||||
.get(source_url)
|
||||
.timeout(Duration::from_secs(15))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("WeChat CDN download failed: {e}"))?;
|
||||
|
||||
if response.status() != reqwest::StatusCode::OK {
|
||||
return Err(format!(
|
||||
"WeChat CDN download returned {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read WeChat CDN response body: {e}"))?
|
||||
.to_vec();
|
||||
|
||||
if bytes.is_empty() {
|
||||
return Err("WeChat CDN download returned an empty body".to_string());
|
||||
}
|
||||
if bytes.len() > MAX_ATTACHMENT_BYTES {
|
||||
return Err(format!(
|
||||
"WeChat attachment exceeds {MAX_ATTACHMENT_BYTES} bytes"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn decrypt_wechat_attachment_bytes(
|
||||
ciphertext: &[u8],
|
||||
encoded_aes_key: &str,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let key = parse_aes_key(encoded_aes_key)?;
|
||||
decrypt_aes_ecb_pkcs7(ciphertext, &key)
|
||||
}
|
||||
|
||||
fn parse_aes_key(encoded: &str) -> Result<Vec<u8>, String> {
|
||||
let decoded = if encoded.len() == 32 && encoded.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
decode_hex(encoded)?
|
||||
} else {
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.map_err(|e| format!("Failed to decode WeChat AES key: {e}"))?
|
||||
};
|
||||
|
||||
if decoded.len() == AES_BLOCK_SIZE {
|
||||
return Ok(decoded);
|
||||
}
|
||||
|
||||
if decoded.len() == 32 && decoded.iter().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return decode_hex(
|
||||
std::str::from_utf8(&decoded)
|
||||
.map_err(|e| format!("WeChat AES key hex payload is not valid UTF-8: {e}"))?,
|
||||
);
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"WeChat AES key must decode to 16 bytes or a 32-char hex string, got {} bytes",
|
||||
decoded.len()
|
||||
))
|
||||
}
|
||||
|
||||
fn decode_hex(input: &str) -> Result<Vec<u8>, String> {
|
||||
if !input.len().is_multiple_of(2) {
|
||||
return Err("hex input length must be even".to_string());
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(input.len() / 2);
|
||||
let chars: Vec<u8> = input.as_bytes().to_vec();
|
||||
for idx in (0..chars.len()).step_by(2) {
|
||||
let high = from_hex_digit(chars[idx])?;
|
||||
let low = from_hex_digit(chars[idx + 1])?;
|
||||
bytes.push((high << 4) | low);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn from_hex_digit(value: u8) -> Result<u8, String> {
|
||||
match value {
|
||||
b'0'..=b'9' => Ok(value - b'0'),
|
||||
b'a'..=b'f' => Ok(value - b'a' + 10),
|
||||
b'A'..=b'F' => Ok(value - b'A' + 10),
|
||||
_ => Err(format!("invalid hex digit '{}'", value as char)),
|
||||
}
|
||||
}
|
||||
|
||||
fn decrypt_aes_ecb_pkcs7(ciphertext: &[u8], key: &[u8]) -> Result<Vec<u8>, String> {
|
||||
if !ciphertext.len().is_multiple_of(AES_BLOCK_SIZE) {
|
||||
return Err("ciphertext length is not a multiple of 16 bytes".to_string());
|
||||
}
|
||||
|
||||
let cipher = Aes128::new_from_slice(key).map_err(|e| format!("Invalid AES key: {e}"))?;
|
||||
let mut plaintext = ciphertext.to_vec();
|
||||
for chunk in plaintext.chunks_exact_mut(AES_BLOCK_SIZE) {
|
||||
cipher.decrypt_block(GenericArray::from_mut_slice(chunk));
|
||||
}
|
||||
|
||||
let pad_len = *plaintext
|
||||
.last()
|
||||
.ok_or_else(|| "ciphertext decrypted to an empty buffer".to_string())?
|
||||
as usize;
|
||||
if pad_len == 0 || pad_len > AES_BLOCK_SIZE || pad_len > plaintext.len() {
|
||||
return Err("invalid PKCS7 padding".to_string());
|
||||
}
|
||||
if !plaintext[plaintext.len() - pad_len..]
|
||||
.iter()
|
||||
.all(|byte| *byte as usize == pad_len)
|
||||
{
|
||||
return Err("invalid PKCS7 padding bytes".to_string());
|
||||
}
|
||||
plaintext.truncate(plaintext.len() - pad_len);
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
fn detect_image_mime(bytes: &[u8]) -> &'static str {
|
||||
if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
|
||||
"image/png"
|
||||
} else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||||
"image/jpeg"
|
||||
} else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
|
||||
"image/gif"
|
||||
} else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
|
||||
"image/webp"
|
||||
} else {
|
||||
"image/jpeg"
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_transcode_wechat_silk_attachment(attachment: &mut Attachment) -> Result<(), String> {
|
||||
if attachment.data.is_empty() {
|
||||
return Err("SILK attachment has no data".to_string());
|
||||
}
|
||||
|
||||
let pcm = decode_silk(&attachment.data, WECHAT_SILK_SAMPLE_RATE_HZ)
|
||||
.map_err(|error| format!("SILK decode failed: {error}"))?;
|
||||
if pcm.is_empty() {
|
||||
return Err("SILK decoder returned empty PCM".to_string());
|
||||
}
|
||||
|
||||
let wav = pcm_s16le_to_wav(&pcm, WECHAT_SILK_SAMPLE_RATE_HZ as u32)?;
|
||||
attachment.data = wav;
|
||||
attachment.size_bytes = Some(attachment.data.len() as u64);
|
||||
attachment.mime_type = "audio/wav".to_string();
|
||||
if let Some(filename) = attachment.filename.as_mut() {
|
||||
replace_attachment_extension(filename, "wav");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pcm_s16le_to_wav(pcm: &[u8], sample_rate_hz: u32) -> Result<Vec<u8>, String> {
|
||||
if !pcm.len().is_multiple_of(2) {
|
||||
return Err("PCM buffer length must be even for 16-bit mono audio".to_string());
|
||||
}
|
||||
|
||||
let data_len = u32::try_from(pcm.len())
|
||||
.map_err(|_| "PCM buffer exceeds WAV container size limits".to_string())?;
|
||||
let total_len = 44u32
|
||||
.checked_add(data_len)
|
||||
.ok_or_else(|| "WAV container size overflowed".to_string())?;
|
||||
let byte_rate = sample_rate_hz
|
||||
.checked_mul(2)
|
||||
.ok_or_else(|| "WAV byte rate overflowed".to_string())?;
|
||||
|
||||
let mut wav = Vec::with_capacity(total_len as usize);
|
||||
wav.extend_from_slice(b"RIFF");
|
||||
wav.extend_from_slice(&(total_len - 8).to_le_bytes());
|
||||
wav.extend_from_slice(b"WAVE");
|
||||
wav.extend_from_slice(b"fmt ");
|
||||
wav.extend_from_slice(&16u32.to_le_bytes());
|
||||
wav.extend_from_slice(&1u16.to_le_bytes());
|
||||
wav.extend_from_slice(&1u16.to_le_bytes());
|
||||
wav.extend_from_slice(&sample_rate_hz.to_le_bytes());
|
||||
wav.extend_from_slice(&byte_rate.to_le_bytes());
|
||||
wav.extend_from_slice(&2u16.to_le_bytes());
|
||||
wav.extend_from_slice(&16u16.to_le_bytes());
|
||||
wav.extend_from_slice(b"data");
|
||||
wav.extend_from_slice(&data_len.to_le_bytes());
|
||||
wav.extend_from_slice(pcm);
|
||||
Ok(wav)
|
||||
}
|
||||
|
||||
fn replace_attachment_extension(filename: &mut String, replacement: &str) {
|
||||
if let Some((stem, _)) = filename.rsplit_once('.') {
|
||||
*filename = format!("{stem}.{replacement}");
|
||||
} else {
|
||||
filename.push('.');
|
||||
filename.push_str(replacement);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn encrypt_aes_ecb_pkcs7(plaintext: &[u8], key: &[u8]) -> Result<Vec<u8>, String> {
|
||||
use aes::cipher::BlockEncrypt;
|
||||
|
||||
let cipher = Aes128::new_from_slice(key).map_err(|e| format!("Invalid AES key: {e}"))?;
|
||||
let mut padded = plaintext.to_vec();
|
||||
let pad_len = AES_BLOCK_SIZE - (padded.len() % AES_BLOCK_SIZE);
|
||||
padded.extend(std::iter::repeat_n(pad_len as u8, pad_len));
|
||||
|
||||
for chunk in padded.chunks_exact_mut(AES_BLOCK_SIZE) {
|
||||
cipher.encrypt_block(GenericArray::from_mut_slice(chunk));
|
||||
}
|
||||
|
||||
Ok(padded)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
Attachment, decrypt_wechat_attachment_bytes, detect_image_mime, encrypt_aes_ecb_pkcs7,
|
||||
hydrate_attachment_for_channel, maybe_transcode_wechat_silk_attachment, pcm_s16le_to_wav,
|
||||
should_hydrate_wechat_attachment,
|
||||
};
|
||||
use crate::channels::wasm::ChannelCapabilities;
|
||||
use base64::Engine as _;
|
||||
|
||||
fn make_attachment() -> Attachment {
|
||||
Attachment {
|
||||
id: "wechat-image-1".to_string(),
|
||||
mime_type: "image/jpeg".to_string(),
|
||||
filename: Some("wechat-image.jpg".to_string()),
|
||||
size_bytes: None,
|
||||
source_url: Some(
|
||||
"https://novac2c.cdn.weixin.qq.com/c2c/download?encrypted_query_param=test"
|
||||
.to_string(),
|
||||
),
|
||||
storage_key: None,
|
||||
extracted_text: None,
|
||||
extras_json: String::new(),
|
||||
data: Vec::new(),
|
||||
duration_secs: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_test_extras_json(aes_key: &str) -> String {
|
||||
serde_json::json!({ "wechat_aes_key": aes_key }).to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_wechat_image_bytes_round_trips() {
|
||||
let key = [7u8; 16];
|
||||
let plaintext = vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x11];
|
||||
let ciphertext = encrypt_aes_ecb_pkcs7(&plaintext, &key).unwrap();
|
||||
let encoded_key = base64::engine::general_purpose::STANDARD.encode(key);
|
||||
let decrypted = decrypt_wechat_attachment_bytes(&ciphertext, &encoded_key).unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_image_mime_prefers_magic_bytes() {
|
||||
assert_eq!(detect_image_mime(&[0xFF, 0xD8, 0xFF, 0x00]), "image/jpeg");
|
||||
assert_eq!(
|
||||
detect_image_mime(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]),
|
||||
"image/png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wechat_attachment_hydration_applies_to_wechat_encrypted_media() {
|
||||
let mut attachment = make_attachment();
|
||||
attachment.extras_json = encode_test_extras_json("ZmFrZS1rZXk=");
|
||||
assert!(should_hydrate_wechat_attachment("wechat", &attachment));
|
||||
assert!(!should_hydrate_wechat_attachment("telegram", &attachment));
|
||||
|
||||
attachment.mime_type = "application/pdf".to_string();
|
||||
assert!(should_hydrate_wechat_attachment("wechat", &attachment));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hydration_skips_when_metadata_is_missing() {
|
||||
let mut attachment = make_attachment();
|
||||
let caps = ChannelCapabilities::for_channel("wechat");
|
||||
hydrate_attachment_for_channel("wechat", &caps, &mut attachment).await;
|
||||
assert!(attachment.data.is_empty());
|
||||
assert_eq!(attachment.size_bytes, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pcm_s16le_to_wav_wraps_pcm_with_expected_header() {
|
||||
let wav = pcm_s16le_to_wav(&[0x00, 0x00, 0x01, 0x00], 24_000).expect("wav wrapping");
|
||||
assert!(wav.starts_with(b"RIFF"));
|
||||
assert_eq!(&wav[8..12], b"WAVE");
|
||||
assert_eq!(&wav[12..16], b"fmt ");
|
||||
assert_eq!(&wav[36..40], b"data");
|
||||
assert_eq!(&wav[40..44], &(4u32).to_le_bytes());
|
||||
assert_eq!(&wav[44..], &[0x00, 0x00, 0x01, 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silk_transcode_failure_preserves_raw_silk_path_for_callers() {
|
||||
let mut attachment = Attachment {
|
||||
id: "wechat-voice-1".to_string(),
|
||||
mime_type: "audio/silk".to_string(),
|
||||
filename: Some("wechat-voice-1.silk".to_string()),
|
||||
size_bytes: Some(3),
|
||||
source_url: None,
|
||||
storage_key: None,
|
||||
extracted_text: None,
|
||||
extras_json: encode_test_extras_json("ZmFrZS1rZXk="),
|
||||
data: vec![1, 2, 3],
|
||||
duration_secs: Some(1),
|
||||
};
|
||||
|
||||
let original = attachment.data.clone();
|
||||
let error =
|
||||
maybe_transcode_wechat_silk_attachment(&mut attachment).expect_err("invalid SILK");
|
||||
assert!(error.contains("SILK decode failed"));
|
||||
assert_eq!(attachment.mime_type, "audio/silk");
|
||||
assert_eq!(attachment.filename.as_deref(), Some("wechat-voice-1.silk"));
|
||||
assert_eq!(attachment.data, original);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,8 @@ pub struct Attachment {
|
||||
pub storage_key: Option<String>,
|
||||
/// Extracted text content (e.g., OCR result, PDF text, audio transcript).
|
||||
pub extracted_text: Option<String>,
|
||||
/// Extensible metadata from the channel payload.
|
||||
pub extras_json: String,
|
||||
/// Raw file bytes (for small files downloaded by the channel).
|
||||
pub data: Vec<u8>,
|
||||
/// Duration in seconds (for audio/video).
|
||||
@@ -995,6 +997,7 @@ mod tests {
|
||||
source_url: None,
|
||||
storage_key: None,
|
||||
extracted_text: None,
|
||||
extras_json: String::new(),
|
||||
data: Vec::new(),
|
||||
duration_secs: None,
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
mod attachment_hydration;
|
||||
mod bundled;
|
||||
mod capabilities;
|
||||
mod error;
|
||||
|
||||
@@ -159,6 +159,11 @@ impl WasmChannelRouter {
|
||||
self.channels.read().await.get(channel_name).cloned()
|
||||
}
|
||||
|
||||
/// Get a registered channel directly by name.
|
||||
pub async fn get_channel_by_name(&self, channel_name: &str) -> Option<Arc<WasmChannel>> {
|
||||
self.channels.read().await.get(channel_name).cloned()
|
||||
}
|
||||
|
||||
/// Validate a secret for a channel.
|
||||
pub async fn validate_secret(&self, channel_name: &str, provided: &str) -> bool {
|
||||
let secrets = self.secrets.read().await;
|
||||
@@ -333,6 +338,9 @@ async fn webhook_handler(
|
||||
|
||||
let channel_name = channel.channel_name();
|
||||
|
||||
// Track whether any authentication was performed and passed.
|
||||
let mut did_authenticate = false;
|
||||
|
||||
// Check if secret is required
|
||||
if state.router.requires_secret(channel_name).await {
|
||||
// Get the secret header name for this channel (from capabilities or default)
|
||||
@@ -382,6 +390,7 @@ async fn webhook_handler(
|
||||
);
|
||||
}
|
||||
tracing::debug!(channel = %channel_name, "Webhook secret validated");
|
||||
did_authenticate = true;
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
@@ -433,6 +442,7 @@ async fn webhook_handler(
|
||||
);
|
||||
}
|
||||
tracing::debug!(channel = %channel_name, "Ed25519 signature verified");
|
||||
did_authenticate = true;
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
@@ -484,6 +494,7 @@ async fn webhook_handler(
|
||||
);
|
||||
}
|
||||
tracing::debug!(channel = %channel_name, "HMAC-SHA256 signature verified");
|
||||
did_authenticate = true;
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
@@ -510,8 +521,9 @@ async fn webhook_handler(
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Call the WASM channel
|
||||
let secret_validated = state.router.requires_secret(channel_name).await;
|
||||
// Call the WASM channel. `did_authenticate` was set above by whichever
|
||||
// auth guard (secret / Ed25519 / HMAC) successfully validated the request.
|
||||
let secret_validated = did_authenticate;
|
||||
|
||||
tracing::info!(
|
||||
channel = %channel_name,
|
||||
@@ -703,6 +715,10 @@ mod tests {
|
||||
// Should not find non-existent path
|
||||
let not_found = router.get_channel_for_path("/webhook/telegram").await;
|
||||
assert!(not_found.is_none());
|
||||
|
||||
let found_by_name = router.get_channel_by_name("slack").await;
|
||||
assert!(found_by_name.is_some());
|
||||
assert_eq!(found_by_name.unwrap().channel_name(), "slack");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -139,6 +139,13 @@ impl ChannelCapabilitiesFile {
|
||||
serde_json::to_string(&self.config).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
/// Whether this channel declares owner/pairing gating in its config.
|
||||
pub fn requires_binding(&self) -> bool {
|
||||
["owner_id", "dm_policy", "allow_from"]
|
||||
.iter()
|
||||
.any(|key| self.config.contains_key(*key))
|
||||
}
|
||||
|
||||
/// Get the webhook secret header name for this channel.
|
||||
///
|
||||
/// Returns the configured header name from capabilities, or a sensible default.
|
||||
@@ -569,6 +576,34 @@ mod tests {
|
||||
assert_eq!(caps.workspace_prefix, "integrations/custom/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_requires_binding_detects_dm_owner_fields() {
|
||||
let telegram = ChannelCapabilitiesFile::from_json(
|
||||
r#"{
|
||||
"name": "telegram",
|
||||
"config": {
|
||||
"owner_id": null,
|
||||
"dm_policy": "pairing",
|
||||
"allow_from": []
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(telegram.requires_binding());
|
||||
|
||||
let wechat = ChannelCapabilitiesFile::from_json(
|
||||
r#"{
|
||||
"name": "wechat",
|
||||
"config": {
|
||||
"base_url": "https://ilinkai.weixin.qq.com",
|
||||
"bot_type": "3"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!wechat.requires_binding());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emit_rate_limit() {
|
||||
let json = r#"{
|
||||
|
||||
+107
-2
@@ -190,7 +190,20 @@ async fn register_channel(
|
||||
// The credential injection system only replaces placeholders in URLs
|
||||
// and headers, so channels like Feishu that exchange app_id + app_secret
|
||||
// for a tenant token need the raw values in their config.
|
||||
inject_channel_secrets_into_config(&channel_name, secrets_store, &mut config_updates).await;
|
||||
inject_channel_secrets_into_config(
|
||||
&channel_name,
|
||||
&config.owner_id,
|
||||
secrets_store,
|
||||
&mut config_updates,
|
||||
)
|
||||
.await;
|
||||
inject_channel_settings_into_config(
|
||||
&channel_name,
|
||||
&config.owner_id,
|
||||
settings_store,
|
||||
&mut config_updates,
|
||||
)
|
||||
.await;
|
||||
|
||||
if !config_updates.is_empty() {
|
||||
channel_arc.update_config(config_updates).await;
|
||||
@@ -396,6 +409,7 @@ pub async fn inject_channel_credentials(
|
||||
/// `feishu_app_secret` are injected as config keys `app_id` and `app_secret`.
|
||||
async fn inject_channel_secrets_into_config(
|
||||
channel_name: &str,
|
||||
owner_id: &str,
|
||||
secrets_store: &Option<Arc<dyn SecretsStore + Send + Sync>>,
|
||||
config_updates: &mut std::collections::HashMap<String, serde_json::Value>,
|
||||
) {
|
||||
@@ -413,7 +427,7 @@ async fn inject_channel_secrets_into_config(
|
||||
};
|
||||
|
||||
for &(config_key, secret_name) in secret_config_mappings {
|
||||
match secrets.get_decrypted("default", secret_name).await {
|
||||
match secrets.get_decrypted(owner_id, secret_name).await {
|
||||
Ok(decrypted) => {
|
||||
config_updates.insert(
|
||||
config_key.to_string(),
|
||||
@@ -442,3 +456,94 @@ async fn inject_channel_secrets_into_config(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject channel-specific settings into config for channels that persist
|
||||
/// runtime-discovered values (for example a custom API base URL after login).
|
||||
async fn inject_channel_settings_into_config(
|
||||
channel_name: &str,
|
||||
owner_id: &str,
|
||||
settings_store: Option<&Arc<dyn crate::db::SettingsStore>>,
|
||||
config_updates: &mut std::collections::HashMap<String, serde_json::Value>,
|
||||
) {
|
||||
let Some(store) = settings_store else {
|
||||
return;
|
||||
};
|
||||
|
||||
let setting_mappings: &[(&str, &str)] = match channel_name {
|
||||
"wechat" => &[("base_url", "extensions.wechat.base_url")],
|
||||
_ => return,
|
||||
};
|
||||
|
||||
for &(config_key, setting_path) in setting_mappings {
|
||||
if let Ok(Some(serde_json::Value::String(value))) =
|
||||
store.get_setting(owner_id, setting_path).await
|
||||
{
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
config_updates.insert(
|
||||
config_key.to_string(),
|
||||
serde_json::Value::String(trimmed.to_string()),
|
||||
);
|
||||
tracing::debug!(
|
||||
channel = %channel_name,
|
||||
config_key = %config_key,
|
||||
setting_path = %setting_path,
|
||||
"Injected setting into channel config"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::db::{Database, SettingsStore};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_inject_channel_settings_uses_owner_scope() -> Result<(), String> {
|
||||
let dir = tempfile::tempdir().map_err(|e| format!("tempdir failed: {e}"))?;
|
||||
let db_path = dir.path().join("wechat-settings.db");
|
||||
let db = Arc::new(
|
||||
crate::db::libsql::LibSqlBackend::new_local(&db_path)
|
||||
.await
|
||||
.map_err(|e| format!("create local libsql backend failed: {e}"))?,
|
||||
);
|
||||
db.run_migrations()
|
||||
.await
|
||||
.map_err(|e| format!("run libsql migrations failed: {e}"))?;
|
||||
|
||||
db.set_setting(
|
||||
"default",
|
||||
"extensions.wechat.base_url",
|
||||
&serde_json::json!("https://default.example"),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("persist default setting failed: {e}"))?;
|
||||
db.set_setting(
|
||||
"owner-123",
|
||||
"extensions.wechat.base_url",
|
||||
&serde_json::json!("https://owner.example"),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("persist owner setting failed: {e}"))?;
|
||||
|
||||
let settings_store: Arc<dyn crate::db::SettingsStore> = db;
|
||||
let mut config_updates = std::collections::HashMap::new();
|
||||
super::inject_channel_settings_into_config(
|
||||
"wechat",
|
||||
"owner-123",
|
||||
Some(&settings_store),
|
||||
&mut config_updates,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
config_updates.get("base_url"),
|
||||
Some(&serde_json::json!("https://owner.example"))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+159
-78
@@ -573,6 +573,7 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
||||
source_url: a.source_url,
|
||||
storage_key: a.storage_key,
|
||||
extracted_text: a.extracted_text,
|
||||
extras_json: a.extras_json,
|
||||
data,
|
||||
duration_secs,
|
||||
}
|
||||
@@ -1181,22 +1182,32 @@ impl WasmChannel {
|
||||
)
|
||||
}
|
||||
|
||||
fn log_on_start_host_state(&self, host_state: &mut ChannelHostState) {
|
||||
fn log_host_state_entries(channel_name: &str, host_state: &mut ChannelHostState) {
|
||||
for entry in host_state.take_logs() {
|
||||
match entry.level {
|
||||
crate::tools::wasm::LogLevel::Trace => {
|
||||
tracing::trace!(channel = %channel_name, "{}", entry.message);
|
||||
}
|
||||
crate::tools::wasm::LogLevel::Debug => {
|
||||
tracing::debug!(channel = %channel_name, "{}", entry.message);
|
||||
}
|
||||
crate::tools::wasm::LogLevel::Info => {
|
||||
tracing::info!(channel = %channel_name, "{}", entry.message);
|
||||
}
|
||||
crate::tools::wasm::LogLevel::Error => {
|
||||
tracing::error!(channel = %self.name, "{}", entry.message);
|
||||
tracing::error!(channel = %channel_name, "{}", entry.message);
|
||||
}
|
||||
crate::tools::wasm::LogLevel::Warn => {
|
||||
tracing::warn!(channel = %self.name, "{}", entry.message);
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!(channel = %self.name, "{}", entry.message);
|
||||
tracing::warn!(channel = %channel_name, "{}", entry.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn log_on_start_host_state(&self, host_state: &mut ChannelHostState) {
|
||||
Self::log_host_state_entries(&self.name, host_state);
|
||||
}
|
||||
|
||||
async fn execute_on_start_with_state(
|
||||
&self,
|
||||
) -> Result<(Result<ChannelConfig, WasmChannelError>, ChannelHostState), WasmChannelError> {
|
||||
@@ -1480,18 +1491,20 @@ impl WasmChannel {
|
||||
|
||||
// Call on_poll using the generated typed interface
|
||||
let channel_iface = instance.near_agent_channel();
|
||||
channel_iface
|
||||
let poll_result = channel_iface
|
||||
.call_on_poll(&mut store)
|
||||
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?;
|
||||
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel));
|
||||
|
||||
let mut host_state =
|
||||
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
|
||||
|
||||
// Commit pending workspace writes to the persistent store
|
||||
let pending_writes = host_state.take_pending_writes();
|
||||
workspace_store.commit_writes(&pending_writes);
|
||||
if poll_result.is_ok() {
|
||||
// Commit pending workspace writes only after a successful callback.
|
||||
let pending_writes = host_state.take_pending_writes();
|
||||
workspace_store.commit_writes(&pending_writes);
|
||||
}
|
||||
|
||||
Ok(((), host_state))
|
||||
Ok((poll_result, host_state))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| WasmChannelError::ExecutionPanicked {
|
||||
@@ -1503,7 +1516,10 @@ impl WasmChannel {
|
||||
|
||||
let channel_name = self.name.clone();
|
||||
match result {
|
||||
Ok(Ok(((), mut host_state))) => {
|
||||
Ok(Ok((poll_result, mut host_state))) => {
|
||||
Self::log_host_state_entries(&channel_name, &mut host_state);
|
||||
poll_result?;
|
||||
|
||||
// Process emitted messages
|
||||
let emitted = host_state.take_emitted_messages();
|
||||
self.process_emitted_messages(emitted).await?;
|
||||
@@ -2181,6 +2197,16 @@ impl WasmChannel {
|
||||
};
|
||||
|
||||
for emitted in messages {
|
||||
let EmittedMessage {
|
||||
user_id,
|
||||
user_name,
|
||||
content,
|
||||
thread_id,
|
||||
metadata_json,
|
||||
attachments,
|
||||
..
|
||||
} = emitted;
|
||||
|
||||
// Check rate limit — acquire and release the write lock before send().await
|
||||
{
|
||||
let mut rate_limiter = self.rate_limiter.write().await;
|
||||
@@ -2198,55 +2224,41 @@ impl WasmChannel {
|
||||
let (resolved_user_id, is_owner_sender) = resolve_message_scope(
|
||||
&self.owner_scope_id,
|
||||
self.owner_actor_id.as_deref(),
|
||||
&emitted.user_id,
|
||||
&user_id,
|
||||
);
|
||||
|
||||
// Convert to IncomingMessage
|
||||
let mut msg = IncomingMessage::new(&self.name, &resolved_user_id, &emitted.content)
|
||||
let mut msg = IncomingMessage::new(&self.name, &resolved_user_id, &content)
|
||||
.with_owner_id(&self.owner_scope_id)
|
||||
.with_sender_id(&emitted.user_id);
|
||||
.with_sender_id(&user_id);
|
||||
|
||||
if let Some(name) = emitted.user_name {
|
||||
if let Some(name) = user_name {
|
||||
msg = msg.with_user_name(name);
|
||||
}
|
||||
|
||||
if let Some(thread_id) = emitted.thread_id {
|
||||
if let Some(thread_id) = thread_id {
|
||||
msg = msg.with_thread(thread_id);
|
||||
}
|
||||
|
||||
// Convert attachments
|
||||
if !emitted.attachments.is_empty() {
|
||||
let incoming_attachments = emitted
|
||||
.attachments
|
||||
.iter()
|
||||
.map(|a| crate::channels::IncomingAttachment {
|
||||
id: a.id.clone(),
|
||||
kind: crate::channels::AttachmentKind::from_mime_type(&a.mime_type),
|
||||
mime_type: a.mime_type.clone(),
|
||||
filename: a.filename.clone(),
|
||||
size_bytes: a.size_bytes,
|
||||
source_url: a.source_url.clone(),
|
||||
storage_key: a.storage_key.clone(),
|
||||
extracted_text: a.extracted_text.clone(),
|
||||
data: a.data.clone(),
|
||||
duration_secs: a.duration_secs,
|
||||
})
|
||||
.collect();
|
||||
if !attachments.is_empty() {
|
||||
let incoming_attachments =
|
||||
convert_emitted_attachments(&self.name, &self.capabilities, attachments).await;
|
||||
msg = msg.with_attachments(incoming_attachments);
|
||||
}
|
||||
|
||||
// Parse metadata JSON
|
||||
msg = apply_emitted_metadata(msg, &emitted.metadata_json);
|
||||
msg = apply_emitted_metadata(msg, &metadata_json);
|
||||
if is_owner_sender {
|
||||
// Store for owner-target routing (chat_id etc.).
|
||||
self.update_broadcast_metadata(&emitted.metadata_json).await;
|
||||
self.update_broadcast_metadata(&metadata_json).await;
|
||||
}
|
||||
|
||||
// Send to stream — no locks held across this await
|
||||
tracing::info!(
|
||||
channel = %self.name,
|
||||
user_id = %emitted.user_id,
|
||||
content_len = emitted.content.len(),
|
||||
user_id = %user_id,
|
||||
content_len = content.len(),
|
||||
attachment_count = msg.attachments.len(),
|
||||
"Sending emitted message to agent"
|
||||
);
|
||||
@@ -2331,6 +2343,7 @@ impl WasmChannel {
|
||||
&& let Err(e) = Self::dispatch_emitted_messages(
|
||||
EmitDispatchContext {
|
||||
channel_name: &channel_name,
|
||||
capabilities: &capabilities,
|
||||
owner_scope_id: &owner_scope_id,
|
||||
owner_actor_id: owner_actor_id.as_deref(),
|
||||
message_tx: &message_tx,
|
||||
@@ -2416,18 +2429,20 @@ impl WasmChannel {
|
||||
|
||||
// Call on_poll using the generated typed interface
|
||||
let channel_iface = instance.near_agent_channel();
|
||||
channel_iface
|
||||
let poll_result = channel_iface
|
||||
.call_on_poll(&mut store)
|
||||
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?;
|
||||
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel));
|
||||
|
||||
let mut host_state =
|
||||
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
|
||||
|
||||
// Commit pending workspace writes to the persistent store
|
||||
let pending_writes = host_state.take_pending_writes();
|
||||
workspace_store.commit_writes(&pending_writes);
|
||||
if poll_result.is_ok() {
|
||||
// Commit pending workspace writes only after a successful callback.
|
||||
let pending_writes = host_state.take_pending_writes();
|
||||
workspace_store.commit_writes(&pending_writes);
|
||||
}
|
||||
|
||||
Ok(host_state)
|
||||
Ok((poll_result, host_state))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| WasmChannelError::ExecutionPanicked {
|
||||
@@ -2438,7 +2453,10 @@ impl WasmChannel {
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(mut host_state)) => {
|
||||
Ok(Ok((poll_result, mut host_state))) => {
|
||||
Self::log_host_state_entries(channel_name, &mut host_state);
|
||||
poll_result?;
|
||||
|
||||
let emitted = host_state.take_emitted_messages();
|
||||
tracing::debug!(
|
||||
channel = %channel_name,
|
||||
@@ -2484,6 +2502,16 @@ impl WasmChannel {
|
||||
};
|
||||
|
||||
for emitted in messages {
|
||||
let EmittedMessage {
|
||||
user_id,
|
||||
user_name,
|
||||
content,
|
||||
thread_id,
|
||||
metadata_json,
|
||||
attachments,
|
||||
..
|
||||
} = emitted;
|
||||
|
||||
// Check rate limit — acquire and release the write lock before send().await
|
||||
{
|
||||
let mut limiter = dispatch.rate_limiter.write().await;
|
||||
@@ -2498,54 +2526,40 @@ impl WasmChannel {
|
||||
}
|
||||
}
|
||||
|
||||
let (resolved_user_id, is_owner_sender) = resolve_message_scope(
|
||||
dispatch.owner_scope_id,
|
||||
dispatch.owner_actor_id,
|
||||
&emitted.user_id,
|
||||
);
|
||||
let (resolved_user_id, is_owner_sender) =
|
||||
resolve_message_scope(dispatch.owner_scope_id, dispatch.owner_actor_id, &user_id);
|
||||
|
||||
// Convert to IncomingMessage
|
||||
let mut msg =
|
||||
IncomingMessage::new(dispatch.channel_name, &resolved_user_id, &emitted.content)
|
||||
.with_owner_id(dispatch.owner_scope_id)
|
||||
.with_sender_id(&emitted.user_id);
|
||||
let mut msg = IncomingMessage::new(dispatch.channel_name, &resolved_user_id, &content)
|
||||
.with_owner_id(dispatch.owner_scope_id)
|
||||
.with_sender_id(&user_id);
|
||||
|
||||
if let Some(name) = emitted.user_name {
|
||||
if let Some(name) = user_name {
|
||||
msg = msg.with_user_name(name);
|
||||
}
|
||||
|
||||
if let Some(thread_id) = emitted.thread_id {
|
||||
if let Some(thread_id) = thread_id {
|
||||
msg = msg.with_thread(thread_id);
|
||||
}
|
||||
|
||||
// Convert attachments
|
||||
if !emitted.attachments.is_empty() {
|
||||
let incoming_attachments = emitted
|
||||
.attachments
|
||||
.iter()
|
||||
.map(|a| crate::channels::IncomingAttachment {
|
||||
id: a.id.clone(),
|
||||
kind: crate::channels::AttachmentKind::from_mime_type(&a.mime_type),
|
||||
mime_type: a.mime_type.clone(),
|
||||
filename: a.filename.clone(),
|
||||
size_bytes: a.size_bytes,
|
||||
source_url: a.source_url.clone(),
|
||||
storage_key: a.storage_key.clone(),
|
||||
extracted_text: a.extracted_text.clone(),
|
||||
data: a.data.clone(),
|
||||
duration_secs: a.duration_secs,
|
||||
})
|
||||
.collect();
|
||||
if !attachments.is_empty() {
|
||||
let incoming_attachments = convert_emitted_attachments(
|
||||
dispatch.channel_name,
|
||||
dispatch.capabilities,
|
||||
attachments,
|
||||
)
|
||||
.await;
|
||||
msg = msg.with_attachments(incoming_attachments);
|
||||
}
|
||||
|
||||
msg = apply_emitted_metadata(msg, &emitted.metadata_json);
|
||||
msg = apply_emitted_metadata(msg, &metadata_json);
|
||||
if is_owner_sender {
|
||||
// Store for owner-target routing (chat_id etc.)
|
||||
do_update_broadcast_metadata(
|
||||
dispatch.channel_name,
|
||||
dispatch.owner_scope_id,
|
||||
&emitted.metadata_json,
|
||||
&metadata_json,
|
||||
dispatch.last_broadcast_metadata,
|
||||
dispatch.settings_store,
|
||||
)
|
||||
@@ -2555,8 +2569,8 @@ impl WasmChannel {
|
||||
// Send to stream — no locks held across this await
|
||||
tracing::info!(
|
||||
channel = %dispatch.channel_name,
|
||||
user_id = %emitted.user_id,
|
||||
content_len = emitted.content.len(),
|
||||
user_id = %user_id,
|
||||
content_len = content.len(),
|
||||
attachment_count = msg.attachments.len(),
|
||||
"Sending polled message to agent"
|
||||
);
|
||||
@@ -2581,6 +2595,7 @@ impl WasmChannel {
|
||||
|
||||
struct EmitDispatchContext<'a> {
|
||||
channel_name: &'a str,
|
||||
capabilities: &'a ChannelCapabilities,
|
||||
owner_scope_id: &'a str,
|
||||
owner_actor_id: Option<&'a str>,
|
||||
message_tx: &'a RwLock<Option<mpsc::Sender<IncomingMessage>>>,
|
||||
@@ -3061,6 +3076,20 @@ fn status_to_wit(
|
||||
},
|
||||
// Suggestions and turn cost are web-gateway-only; skip for WASM channels
|
||||
StatusUpdate::Suggestions { .. } | StatusUpdate::TurnCost { .. } => return None,
|
||||
StatusUpdate::ReasoningUpdate {
|
||||
narrative,
|
||||
decisions,
|
||||
} => {
|
||||
let mut msg = narrative.clone();
|
||||
for d in decisions {
|
||||
msg.push_str(&format!("\n → {}: {}", d.tool_name, d.rationale));
|
||||
}
|
||||
wit_channel::StatusUpdate {
|
||||
status: wit_channel::StatusType::Status,
|
||||
message: msg,
|
||||
metadata_json,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3243,6 +3272,38 @@ async fn resolve_channel_host_credentials(
|
||||
/// Maximum total attachment size (50 MB).
|
||||
const MAX_TOTAL_ATTACHMENT_BYTES: u64 = 50 * 1024 * 1024;
|
||||
|
||||
async fn convert_emitted_attachments(
|
||||
channel_name: &str,
|
||||
capabilities: &ChannelCapabilities,
|
||||
attachments: Vec<crate::channels::wasm::host::Attachment>,
|
||||
) -> Vec<crate::channels::IncomingAttachment> {
|
||||
let mut hydrated = attachments;
|
||||
for attachment in &mut hydrated {
|
||||
crate::channels::wasm::attachment_hydration::hydrate_attachment_for_channel(
|
||||
channel_name,
|
||||
capabilities,
|
||||
attachment,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
hydrated
|
||||
.into_iter()
|
||||
.map(|attachment| crate::channels::IncomingAttachment {
|
||||
id: attachment.id,
|
||||
kind: crate::channels::AttachmentKind::from_mime_type(&attachment.mime_type),
|
||||
mime_type: attachment.mime_type,
|
||||
filename: attachment.filename,
|
||||
size_bytes: attachment.size_bytes,
|
||||
source_url: attachment.source_url,
|
||||
storage_key: attachment.storage_key,
|
||||
extracted_text: attachment.extracted_text,
|
||||
data: attachment.data,
|
||||
duration_secs: attachment.duration_secs,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Detect MIME type from file extension using the `mime_guess` crate.
|
||||
fn mime_from_extension(path: &str) -> String {
|
||||
mime_guess::from_path(path)
|
||||
@@ -3455,6 +3516,8 @@ mod tests {
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
|
||||
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
|
||||
let capabilities =
|
||||
crate::channels::wasm::capabilities::ChannelCapabilities::for_channel("test-channel");
|
||||
|
||||
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
|
||||
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
|
||||
@@ -3471,6 +3534,7 @@ mod tests {
|
||||
let result = WasmChannel::dispatch_emitted_messages(
|
||||
EmitDispatchContext {
|
||||
channel_name: "test-channel",
|
||||
capabilities: &capabilities,
|
||||
owner_scope_id: "default",
|
||||
owner_actor_id: None,
|
||||
message_tx: &message_tx,
|
||||
@@ -3503,6 +3567,8 @@ mod tests {
|
||||
|
||||
// No sender available (channel not started)
|
||||
let message_tx = Arc::new(tokio::sync::RwLock::new(None));
|
||||
let capabilities =
|
||||
crate::channels::wasm::capabilities::ChannelCapabilities::for_channel("test-channel");
|
||||
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
|
||||
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
|
||||
crate::channels::wasm::capabilities::EmitRateLimitConfig::default(),
|
||||
@@ -3516,6 +3582,7 @@ mod tests {
|
||||
let result = WasmChannel::dispatch_emitted_messages(
|
||||
EmitDispatchContext {
|
||||
channel_name: "test-channel",
|
||||
capabilities: &capabilities,
|
||||
owner_scope_id: "default",
|
||||
owner_actor_id: None,
|
||||
message_tx: &message_tx,
|
||||
@@ -4506,6 +4573,8 @@ mod tests {
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
|
||||
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
|
||||
let capabilities =
|
||||
crate::channels::wasm::capabilities::ChannelCapabilities::for_channel("test-channel");
|
||||
|
||||
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
|
||||
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
|
||||
@@ -4522,6 +4591,7 @@ mod tests {
|
||||
source_url: Some("https://api.telegram.org/file/photo123".to_string()),
|
||||
storage_key: None,
|
||||
extracted_text: None,
|
||||
extras_json: String::new(),
|
||||
data: Vec::new(),
|
||||
duration_secs: None,
|
||||
},
|
||||
@@ -4533,6 +4603,7 @@ mod tests {
|
||||
source_url: None,
|
||||
storage_key: Some("store/doc456".to_string()),
|
||||
extracted_text: Some("Report contents...".to_string()),
|
||||
extras_json: String::new(),
|
||||
data: Vec::new(),
|
||||
duration_secs: None,
|
||||
},
|
||||
@@ -4545,6 +4616,7 @@ mod tests {
|
||||
let result = WasmChannel::dispatch_emitted_messages(
|
||||
EmitDispatchContext {
|
||||
channel_name: "test-channel",
|
||||
capabilities: &capabilities,
|
||||
owner_scope_id: "default",
|
||||
owner_actor_id: None,
|
||||
message_tx: &message_tx,
|
||||
@@ -4591,6 +4663,8 @@ mod tests {
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
|
||||
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
|
||||
let capabilities =
|
||||
crate::channels::wasm::capabilities::ChannelCapabilities::for_channel("telegram");
|
||||
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
|
||||
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
|
||||
crate::channels::wasm::capabilities::EmitRateLimitConfig::default(),
|
||||
@@ -4606,6 +4680,7 @@ mod tests {
|
||||
let result = WasmChannel::dispatch_emitted_messages(
|
||||
EmitDispatchContext {
|
||||
channel_name: "telegram",
|
||||
capabilities: &capabilities,
|
||||
owner_scope_id: "owner-scope",
|
||||
owner_actor_id: Some("telegram-owner"),
|
||||
message_tx: &message_tx,
|
||||
@@ -4634,6 +4709,8 @@ mod tests {
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
|
||||
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
|
||||
let capabilities =
|
||||
crate::channels::wasm::capabilities::ChannelCapabilities::for_channel("telegram");
|
||||
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
|
||||
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
|
||||
crate::channels::wasm::capabilities::EmitRateLimitConfig::default(),
|
||||
@@ -4648,6 +4725,7 @@ mod tests {
|
||||
let result = WasmChannel::dispatch_emitted_messages(
|
||||
EmitDispatchContext {
|
||||
channel_name: "telegram",
|
||||
capabilities: &capabilities,
|
||||
owner_scope_id: "owner-scope",
|
||||
owner_actor_id: Some("telegram-owner"),
|
||||
message_tx: &message_tx,
|
||||
@@ -4717,6 +4795,8 @@ mod tests {
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
|
||||
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
|
||||
let capabilities =
|
||||
crate::channels::wasm::capabilities::ChannelCapabilities::for_channel("test-channel");
|
||||
|
||||
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
|
||||
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
|
||||
@@ -4730,6 +4810,7 @@ mod tests {
|
||||
let result = WasmChannel::dispatch_emitted_messages(
|
||||
EmitDispatchContext {
|
||||
channel_name: "test-channel",
|
||||
capabilities: &capabilities,
|
||||
owner_scope_id: "default",
|
||||
owner_actor_id: None,
|
||||
message_tx: &message_tx,
|
||||
|
||||
+383
-22
@@ -1,17 +1,133 @@
|
||||
//! 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::{
|
||||
extract::{Request, State},
|
||||
http::{HeaderMap, Method, StatusCode},
|
||||
extract::{FromRequestParts, Request, State},
|
||||
http::{HeaderMap, Method, StatusCode, request::Parts},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
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)]
|
||||
pub struct AuthState {
|
||||
pub token: String,
|
||||
pub struct MultiAuthState {
|
||||
/// 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.
|
||||
@@ -51,29 +167,34 @@ fn query_token(request: &Request) -> Option<String> {
|
||||
/// Auth middleware that validates bearer token from header or query param.
|
||||
///
|
||||
/// 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(
|
||||
State(auth): State<AuthState>,
|
||||
State(auth): State<MultiAuthState>,
|
||||
headers: HeaderMap,
|
||||
request: Request,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
// Try Authorization header first (constant-time comparison).
|
||||
// Try Authorization header first.
|
||||
// RFC 6750 Section 2.1: auth-scheme comparison is case-insensitive.
|
||||
if let Some(auth_header) = headers.get("authorization")
|
||||
&& let Ok(value) = auth_header.to_str()
|
||||
&& value.len() > 7
|
||||
&& 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;
|
||||
}
|
||||
|
||||
// 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)
|
||||
&& 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;
|
||||
}
|
||||
|
||||
@@ -83,15 +204,61 @@ pub async fn auth_middleware(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testing::credentials::{TEST_AUTH_SECRET_TOKEN, TEST_BEARER_TOKEN};
|
||||
use crate::testing::credentials::TEST_AUTH_SECRET_TOKEN;
|
||||
|
||||
#[test]
|
||||
fn test_auth_state_clone() {
|
||||
let state = AuthState {
|
||||
token: TEST_BEARER_TOKEN.to_string(),
|
||||
};
|
||||
let cloned = state.clone();
|
||||
assert_eq!(cloned.token, TEST_BEARER_TOKEN);
|
||||
fn test_multi_auth_state_single() {
|
||||
let state = MultiAuthState::single("tok-123".to_string(), "alice".to_string());
|
||||
let identity = state.authenticate("tok-123");
|
||||
assert!(identity.is_some());
|
||||
assert_eq!(identity.unwrap().user_id, "alice");
|
||||
}
|
||||
|
||||
#[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;
|
||||
@@ -107,9 +274,7 @@ mod tests {
|
||||
/// Router with streaming endpoints (query auth allowed) and regular
|
||||
/// endpoints (query auth rejected).
|
||||
fn test_app(token: &str) -> Router {
|
||||
let state = AuthState {
|
||||
token: token.to_string(),
|
||||
};
|
||||
let state = MultiAuthState::single(token.to_string(), "test-user".to_string());
|
||||
Router::new()
|
||||
.route("/api/chat/events", get(dummy_handler))
|
||||
.route("/api/logs/events", get(dummy_handler))
|
||||
@@ -306,4 +471,200 @@ mod tests {
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
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 crate::channels::IncomingMessage;
|
||||
use crate::channels::web::auth::AuthenticatedUser;
|
||||
use crate::channels::web::server::GatewayState;
|
||||
use crate::channels::web::types::*;
|
||||
use crate::channels::web::util::{build_turns_from_db_messages, truncate_preview};
|
||||
|
||||
pub async fn chat_send_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(identity): AuthenticatedUser,
|
||||
Json(req): Json<SendMessageRequest>,
|
||||
) -> Result<(StatusCode, Json<SendMessageResponse>), (StatusCode, String)> {
|
||||
if !state.chat_rate_limiter.check() {
|
||||
if !state.chat_rate_limiter.check(&identity.user_id) {
|
||||
return Err((
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"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 {
|
||||
msg = msg.with_thread(thread_id);
|
||||
@@ -74,6 +76,7 @@ pub async fn chat_send_handler(
|
||||
|
||||
pub async fn chat_approval_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(identity): AuthenticatedUser,
|
||||
Json(req): Json<ApprovalRequest>,
|
||||
) -> Result<(StatusCode, Json<SendMessageResponse>), (StatusCode, String)> {
|
||||
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 {
|
||||
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.
|
||||
pub async fn chat_auth_token_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Json(req): Json<AuthTokenRequest>,
|
||||
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
||||
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
||||
@@ -158,7 +162,7 @@ pub async fn chat_auth_token_handler(
|
||||
))?;
|
||||
|
||||
match ext_mgr
|
||||
.configure_token(&req.extension_name, &req.token)
|
||||
.configure_token(&req.extension_name, &req.token, &user.user_id)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
@@ -169,20 +173,26 @@ pub async fn chat_auth_token_handler(
|
||||
resp.instructions = result.verification.as_ref().map(|v| v.instructions.clone());
|
||||
|
||||
if result.verification.is_some() {
|
||||
state.sse.broadcast(SseEvent::AuthRequired {
|
||||
extension_name: req.extension_name.clone(),
|
||||
instructions: Some(result.message),
|
||||
auth_url: None,
|
||||
setup_url: None,
|
||||
});
|
||||
state.sse.broadcast_for_user(
|
||||
&user.user_id,
|
||||
AppEvent::AuthRequired {
|
||||
extension_name: req.extension_name.clone(),
|
||||
instructions: Some(result.message),
|
||||
auth_url: None,
|
||||
setup_url: None,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
clear_auth_mode(&state).await;
|
||||
clear_auth_mode(&state, &user.user_id).await;
|
||||
|
||||
state.sse.broadcast(SseEvent::AuthCompleted {
|
||||
extension_name: req.extension_name.clone(),
|
||||
success: true,
|
||||
message: result.message,
|
||||
});
|
||||
state.sse.broadcast_for_user(
|
||||
&user.user_id,
|
||||
AppEvent::AuthCompleted {
|
||||
extension_name: req.extension_name.clone(),
|
||||
success: true,
|
||||
message: result.message,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Json(resp))
|
||||
@@ -190,12 +200,15 @@ pub async fn chat_auth_token_handler(
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
|
||||
state.sse.broadcast(SseEvent::AuthRequired {
|
||||
extension_name: req.extension_name.clone(),
|
||||
instructions: Some(msg.clone()),
|
||||
auth_url: None,
|
||||
setup_url: None,
|
||||
});
|
||||
state.sse.broadcast_for_user(
|
||||
&user.user_id,
|
||||
AppEvent::AuthRequired {
|
||||
extension_name: req.extension_name.clone(),
|
||||
instructions: Some(msg.clone()),
|
||||
auth_url: None,
|
||||
setup_url: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(Json(ActionResponse::fail(msg)))
|
||||
}
|
||||
@@ -205,16 +218,17 @@ pub async fn chat_auth_token_handler(
|
||||
/// Cancel an in-progress auth flow.
|
||||
pub async fn chat_auth_cancel_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(identity): AuthenticatedUser,
|
||||
Json(_req): Json<AuthCancelRequest>,
|
||||
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
||||
clear_auth_mode(&state).await;
|
||||
clear_auth_mode(&state, &identity.user_id).await;
|
||||
Ok(Json(ActionResponse::ok("Auth cancelled")))
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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;
|
||||
if let Some(thread_id) = sess.active_thread
|
||||
&& 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(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
state.sse.subscribe().ok_or((
|
||||
state.sse.subscribe(Some(user.user_id)).ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Too many connections".to_string(),
|
||||
))
|
||||
@@ -237,6 +252,7 @@ pub async fn chat_ws_handler(
|
||||
headers: axum::http::HeaderMap,
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(identity): AuthenticatedUser,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
// Validate Origin header to prevent cross-site WebSocket hijacking.
|
||||
let origin = headers
|
||||
@@ -262,7 +278,9 @@ pub async fn chat_ws_handler(
|
||||
"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)]
|
||||
@@ -274,6 +292,7 @@ pub struct HistoryQuery {
|
||||
|
||||
pub async fn chat_history_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(identity): AuthenticatedUser,
|
||||
Query(query): Query<HistoryQuery>,
|
||||
) -> Result<Json<HistoryResponse>, (StatusCode, String)> {
|
||||
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(),
|
||||
))?;
|
||||
|
||||
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 before_cursor = query
|
||||
@@ -314,7 +335,7 @@ pub async fn chat_history_handler(
|
||||
&& let Some(ref store) = state.store
|
||||
{
|
||||
let owned = store
|
||||
.conversation_belongs_to_user(thread_id, &state.user_id)
|
||||
.conversation_belongs_to_user(thread_id, &identity.user_id)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !owned {
|
||||
@@ -377,8 +398,10 @@ pub async fn chat_history_handler(
|
||||
truncate_preview(&s, 500)
|
||||
}),
|
||||
error: tc.error.clone(),
|
||||
rationale: tc.rationale.clone(),
|
||||
})
|
||||
.collect(),
|
||||
narrative: t.narrative.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -434,24 +457,27 @@ pub async fn chat_history_handler(
|
||||
|
||||
pub async fn chat_threads_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(identity): AuthenticatedUser,
|
||||
) -> Result<Json<ThreadListResponse>, (StatusCode, String)> {
|
||||
let session_manager = state.session_manager.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"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
|
||||
if let Some(ref store) = state.store {
|
||||
// Auto-create assistant thread if it doesn't exist
|
||||
let assistant_id = store
|
||||
.get_or_create_assistant_conversation(&state.user_id, "gateway")
|
||||
.get_or_create_assistant_conversation(&identity.user_id, "gateway")
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if let Ok(summaries) = store
|
||||
.list_conversations_all_channels(&state.user_id, 50)
|
||||
.list_conversations_all_channels(&identity.user_id, 50)
|
||||
.await
|
||||
{
|
||||
let mut assistant_thread = None;
|
||||
@@ -534,13 +560,16 @@ pub async fn chat_threads_handler(
|
||||
|
||||
pub async fn chat_new_thread_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(identity): AuthenticatedUser,
|
||||
) -> Result<Json<ThreadInfo>, (StatusCode, String)> {
|
||||
let session_manager = state.session_manager.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"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 mut sess = session.lock().await;
|
||||
let thread = sess.create_thread();
|
||||
@@ -562,12 +591,12 @@ pub async fn chat_new_thread_handler(
|
||||
// so that the subsequent loadThreads() call from the frontend sees it.
|
||||
if let Some(ref store) = state.store {
|
||||
match store
|
||||
.ensure_conversation(thread_id, "gateway", &state.user_id, None)
|
||||
.ensure_conversation(thread_id, "gateway", &identity.user_id, None)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {}
|
||||
Ok(false) => tracing::warn!(
|
||||
user = %state.user_id,
|
||||
user = %identity.user_id,
|
||||
thread_id = %thread_id,
|
||||
"Skipped persisting new thread due to ownership/channel conflict"
|
||||
),
|
||||
|
||||
@@ -8,11 +8,13 @@ use axum::{
|
||||
http::StatusCode,
|
||||
};
|
||||
|
||||
use crate::channels::web::auth::AuthenticatedUser;
|
||||
use crate::channels::web::server::GatewayState;
|
||||
use crate::channels::web::types::*;
|
||||
|
||||
pub async fn extensions_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
) -> Result<Json<ExtensionListResponse>, (StatusCode, String)> {
|
||||
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
@@ -20,7 +22,7 @@ pub async fn extensions_list_handler(
|
||||
))?;
|
||||
|
||||
let installed = ext_mgr
|
||||
.list(None, false)
|
||||
.list(None, false, &user.user_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
@@ -45,6 +47,7 @@ pub async fn extensions_list_handler(
|
||||
&ext,
|
||||
has_paired,
|
||||
owner_bound_channels.contains(&ext.name),
|
||||
ext.requires_binding,
|
||||
)
|
||||
} else if ext.kind == crate::extensions::ExtensionKind::ChannelRelay {
|
||||
Some(if ext.active {
|
||||
@@ -80,6 +83,7 @@ pub async fn extensions_list_handler(
|
||||
|
||||
pub async fn extensions_tools_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(_user): AuthenticatedUser,
|
||||
) -> Result<Json<ToolListResponse>, (StatusCode, String)> {
|
||||
let registry = state.tool_registry.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
@@ -100,6 +104,7 @@ pub async fn extensions_tools_handler(
|
||||
|
||||
pub async fn extensions_install_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Json(req): Json<InstallExtensionRequest>,
|
||||
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
||||
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
||||
@@ -116,7 +121,7 @@ pub async fn extensions_install_handler(
|
||||
});
|
||||
|
||||
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
|
||||
{
|
||||
Ok(result) => Ok(Json(ActionResponse::ok(result.message))),
|
||||
@@ -126,6 +131,7 @@ pub async fn extensions_install_handler(
|
||||
|
||||
pub async fn extensions_remove_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
|
||||
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
||||
@@ -133,7 +139,7 @@ pub async fn extensions_remove_handler(
|
||||
"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))),
|
||||
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
|
||||
}
|
||||
|
||||
+400
-277
@@ -11,11 +11,13 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::channels::web::auth::AuthenticatedUser;
|
||||
use crate::channels::web::server::GatewayState;
|
||||
use crate::channels::web::types::*;
|
||||
|
||||
pub async fn jobs_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
) -> Result<Json<JobListResponse>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
@@ -25,8 +27,8 @@ pub async fn jobs_list_handler(
|
||||
let mut jobs: Vec<JobInfo> = Vec::new();
|
||||
let mut seen_ids: HashSet<Uuid> = HashSet::new();
|
||||
|
||||
// Fetch sandbox jobs from database.
|
||||
match store.list_sandbox_jobs().await {
|
||||
// Fetch sandbox jobs scoped to this user.
|
||||
match store.list_sandbox_jobs_for_user(&user.user_id).await {
|
||||
Ok(sandbox_jobs) => {
|
||||
for j in &sandbox_jobs {
|
||||
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.
|
||||
match store.list_agent_jobs().await {
|
||||
// Fetch agent (non-sandbox) jobs scoped to this user, deduplicating by ID.
|
||||
match store.list_agent_jobs_for_user(&user.user_id).await {
|
||||
Ok(agent_jobs) => {
|
||||
for j in &agent_jobs {
|
||||
if seen_ids.contains(&j.id) {
|
||||
@@ -80,6 +82,7 @@ pub async fn jobs_list_handler(
|
||||
|
||||
pub async fn jobs_summary_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
) -> Result<Json<JobSummaryResponse>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
@@ -93,8 +96,8 @@ pub async fn jobs_summary_handler(
|
||||
let mut failed = 0;
|
||||
let mut stuck = 0;
|
||||
|
||||
// Sandbox job counts.
|
||||
match store.sandbox_job_summary().await {
|
||||
// Sandbox job counts scoped to this user.
|
||||
match store.sandbox_job_summary_for_user(&user.user_id).await {
|
||||
Ok(s) => {
|
||||
total += s.total;
|
||||
pending += s.creating;
|
||||
@@ -107,8 +110,8 @@ pub async fn jobs_summary_handler(
|
||||
}
|
||||
}
|
||||
|
||||
// Agent job counts.
|
||||
match store.agent_job_summary().await {
|
||||
// Agent job counts scoped to this user.
|
||||
match store.agent_job_summary_for_user(&user.user_id).await {
|
||||
Ok(s) => {
|
||||
total += s.total;
|
||||
pending += s.pending;
|
||||
@@ -134,6 +137,7 @@ pub async fn jobs_summary_handler(
|
||||
|
||||
pub async fn jobs_detail_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<JobDetailResponse>, (StatusCode, String)> {
|
||||
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()))?;
|
||||
|
||||
// Try sandbox job from DB first.
|
||||
if let Ok(Some(job)) = store.get_sandbox_job(job_id).await {
|
||||
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());
|
||||
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()));
|
||||
}
|
||||
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() {
|
||||
"creating" => "pending",
|
||||
"running" => "in_progress",
|
||||
s => s,
|
||||
};
|
||||
let ui_state = match job.status.as_str() {
|
||||
"creating" => "pending",
|
||||
"running" => "in_progress",
|
||||
s => s,
|
||||
};
|
||||
|
||||
let elapsed_secs = job.started_at.map(|start| {
|
||||
let end = job.completed_at.unwrap_or_else(chrono::Utc::now);
|
||||
(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,
|
||||
let elapsed_secs = job.started_at.map(|start| {
|
||||
let end = job.completed_at.unwrap_or_else(chrono::Utc::now);
|
||||
(end - start).num_seconds().max(0) as u64
|
||||
});
|
||||
}
|
||||
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();
|
||||
let is_claude_code = mode.as_deref() == Some("claude_code");
|
||||
// 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(),
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(Json(JobDetailResponse {
|
||||
id: job.id,
|
||||
title: job.task.clone(),
|
||||
description: String::new(),
|
||||
state: ui_state.to_string(),
|
||||
user_id: job.user_id.clone(),
|
||||
created_at: job.created_at.to_rfc3339(),
|
||||
started_at: job.started_at.map(|dt| dt.to_rfc3339()),
|
||||
completed_at: job.completed_at.map(|dt| dt.to_rfc3339()),
|
||||
elapsed_secs,
|
||||
project_dir: Some(job.project_dir.clone()),
|
||||
browse_url: Some(format!("/projects/{}/", browse_id)),
|
||||
job_mode: mode.filter(|m| m != "worker"),
|
||||
transitions,
|
||||
can_restart: state.job_manager.is_some(),
|
||||
can_prompt: is_claude_code && state.prompt_queue.is_some(),
|
||||
job_kind: Some("sandbox".to_string()),
|
||||
}));
|
||||
let mode = store.get_sandbox_job_mode(job.id).await.ok().flatten();
|
||||
let is_claude_code = mode.as_deref() == Some("claude_code");
|
||||
|
||||
return Ok(Json(JobDetailResponse {
|
||||
id: job.id,
|
||||
title: job.task.clone(),
|
||||
description: String::new(),
|
||||
state: ui_state.to_string(),
|
||||
user_id: job.user_id.clone(),
|
||||
created_at: job.created_at.to_rfc3339(),
|
||||
started_at: job.started_at.map(|dt| dt.to_rfc3339()),
|
||||
completed_at: job.completed_at.map(|dt| dt.to_rfc3339()),
|
||||
elapsed_secs,
|
||||
project_dir: Some(job.project_dir.clone()),
|
||||
browse_url: Some(format!("/projects/{}/", browse_id)),
|
||||
job_mode: mode.filter(|m| m != "worker"),
|
||||
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.
|
||||
if let Ok(Some(ctx)) = store.get_job(job_id).await {
|
||||
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
|
||||
});
|
||||
match store.get_job(job_id).await {
|
||||
Ok(Some(ctx)) => {
|
||||
if ctx.user_id != user.user_id {
|
||||
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).
|
||||
// Stuck jobs have no active worker loop, so messages would be silently dropped.
|
||||
let is_promptable = matches!(
|
||||
ctx.state,
|
||||
crate::context::JobState::Pending | crate::context::JobState::InProgress
|
||||
);
|
||||
return Ok(Json(JobDetailResponse {
|
||||
id: ctx.job_id,
|
||||
title: ctx.title.clone(),
|
||||
description: ctx.description.clone(),
|
||||
state: ctx.state.to_string(),
|
||||
user_id: ctx.user_id.clone(),
|
||||
created_at: ctx.created_at.to_rfc3339(),
|
||||
started_at: ctx.started_at.map(|dt| dt.to_rfc3339()),
|
||||
completed_at: ctx.completed_at.map(|dt| dt.to_rfc3339()),
|
||||
elapsed_secs,
|
||||
project_dir: None,
|
||||
browse_url: None,
|
||||
job_mode: None,
|
||||
transitions: Vec::new(),
|
||||
can_restart: state.scheduler.is_some(),
|
||||
can_prompt: is_promptable && state.scheduler.is_some(),
|
||||
job_kind: Some("agent".to_string()),
|
||||
}));
|
||||
// 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.
|
||||
let is_promptable = matches!(
|
||||
ctx.state,
|
||||
crate::context::JobState::Pending | crate::context::JobState::InProgress
|
||||
);
|
||||
Ok(Json(JobDetailResponse {
|
||||
id: ctx.job_id,
|
||||
title: ctx.title.clone(),
|
||||
description: ctx.description.clone(),
|
||||
state: ctx.state.to_string(),
|
||||
user_id: ctx.user_id.clone(),
|
||||
created_at: ctx.created_at.to_rfc3339(),
|
||||
started_at: ctx.started_at.map(|dt| dt.to_rfc3339()),
|
||||
completed_at: ctx.completed_at.map(|dt| dt.to_rfc3339()),
|
||||
elapsed_secs,
|
||||
project_dir: None,
|
||||
browse_url: None,
|
||||
job_mode: None,
|
||||
transitions: Vec::new(),
|
||||
can_restart: state.scheduler.is_some(),
|
||||
can_prompt: is_promptable && state.scheduler.is_some(),
|
||||
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(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let job_id = Uuid::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
// Try sandbox job cancellation.
|
||||
if let Some(ref store) = state.store
|
||||
&& let Ok(Some(job)) = store.get_sandbox_job(job_id).await
|
||||
{
|
||||
if job.status == "running" || job.status == "creating" {
|
||||
// Stop the container if we have a job manager.
|
||||
if let Some(ref jm) = state.job_manager
|
||||
&& let Err(e) = jm.stop_job(job_id).await
|
||||
{
|
||||
tracing::warn!(job_id = %job_id, error = %e, "Failed to stop container during cancellation");
|
||||
if let Some(ref store) = state.store {
|
||||
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()));
|
||||
}
|
||||
if job.status == "running" || job.status == "creating" {
|
||||
if let Some(ref jm) = state.job_manager
|
||||
&& 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
|
||||
// (which updates the in-memory ContextManager AND aborts the task handle),
|
||||
// then persist the status to the DB as a fallback.
|
||||
if let Some(ref store) = state.store
|
||||
&& let Ok(Some(job)) = store.get_job(job_id).await
|
||||
{
|
||||
if job.state.is_active() {
|
||||
// Try to stop via scheduler (aborts the worker task + updates
|
||||
// in-memory ContextManager). This is best-effort — the job may
|
||||
// not be in the scheduler map if it already finished.
|
||||
if let Some(ref slot) = state.scheduler
|
||||
&& let Some(ref scheduler) = *slot.read().await
|
||||
{
|
||||
let _ = scheduler.stop(job_id).await;
|
||||
}
|
||||
if let Some(ref store) = state.store {
|
||||
match store.get_job(job_id).await {
|
||||
Ok(Some(job)) => {
|
||||
if job.user_id != user.user_id {
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
if job.state.is_active() {
|
||||
// Try to stop via scheduler (aborts the worker task + updates
|
||||
// in-memory ContextManager). This is best-effort — the job may
|
||||
// not be in the scheduler map if it already finished.
|
||||
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
|
||||
// consistent even if the scheduler wasn't available or the
|
||||
// job wasn't in its in-memory map.
|
||||
store
|
||||
.update_job_status(
|
||||
job_id,
|
||||
crate::context::JobState::Cancelled,
|
||||
Some("Cancelled by user"),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
// Always persist cancellation to the DB so the state is
|
||||
// consistent even if the scheduler wasn't available or the
|
||||
// job wasn't in its in-memory map.
|
||||
store
|
||||
.update_job_status(
|
||||
job_id,
|
||||
crate::context::JobState::Cancelled,
|
||||
Some("Cancelled by user"),
|
||||
)
|
||||
.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),
|
||||
));
|
||||
}
|
||||
}
|
||||
return Ok(Json(serde_json::json!({
|
||||
"status": "cancelled",
|
||||
"job_id": job_id,
|
||||
})));
|
||||
}
|
||||
|
||||
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(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
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()))?;
|
||||
|
||||
// Try sandbox job restart first.
|
||||
if let Ok(Some(old_job)) = store.get_sandbox_job(old_job_id).await {
|
||||
if old_job.status != "interrupted" && old_job.status != "failed" {
|
||||
match store.get_sandbox_job(old_job_id).await {
|
||||
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((
|
||||
StatusCode::CONFLICT,
|
||||
format!("Cannot restart job in state '{}'", old_job.status),
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
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.
|
||||
if let Ok(Some(old_job)) = store.get_job(old_job_id).await {
|
||||
if old_job.state.is_active() {
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
format!("Cannot restart job in state '{}'", old_job.state),
|
||||
));
|
||||
match store.get_job(old_job_id).await {
|
||||
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.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,
|
||||
})))
|
||||
}
|
||||
|
||||
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()))?;
|
||||
|
||||
return 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())),
|
||||
Err(e) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Database error: {}", e),
|
||||
)),
|
||||
}
|
||||
|
||||
Err((StatusCode::NOT_FOUND, "Job not found".to_string()))
|
||||
}
|
||||
|
||||
/// 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)
|
||||
pub async fn jobs_prompt_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> 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);
|
||||
|
||||
// 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
|
||||
&& 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.
|
||||
let mode = s.get_sandbox_job_mode(job_id).await.ok().flatten();
|
||||
if mode.as_deref() == Some("claude_code") {
|
||||
@@ -522,7 +597,26 @@ 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 {
|
||||
match store.get_job(job_id).await {
|
||||
Ok(Some(agent_job)) => {
|
||||
if agent_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 slot = state.scheduler.as_ref().ok_or((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Agent job prompts require the scheduler to be configured".to_string(),
|
||||
@@ -550,6 +644,7 @@ pub async fn jobs_prompt_handler(
|
||||
/// Load persisted job events for a job (for history replay on page open).
|
||||
pub async fn jobs_events_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
@@ -561,6 +656,24 @@ pub async fn jobs_events_handler(
|
||||
.parse()
|
||||
.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
|
||||
.list_job_events(job_id, None)
|
||||
.await
|
||||
@@ -593,6 +706,7 @@ pub struct FilePathQuery {
|
||||
|
||||
pub async fn job_files_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path(id): Path<String>,
|
||||
Query(query): Query<FilePathQuery>,
|
||||
) -> Result<Json<ProjectFilesResponse>, (StatusCode, String)> {
|
||||
@@ -610,6 +724,10 @@ pub async fn job_files_list_handler(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.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 rel_path = query.path.as_deref().unwrap_or("");
|
||||
let target = base.join(rel_path);
|
||||
@@ -656,6 +774,7 @@ pub async fn job_files_list_handler(
|
||||
|
||||
pub async fn job_files_read_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path(id): Path<String>,
|
||||
Query(query): Query<FilePathQuery>,
|
||||
) -> Result<Json<ProjectFileReadResponse>, (StatusCode, String)> {
|
||||
@@ -673,6 +792,10 @@ pub async fn job_files_read_handler(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.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((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"path parameter required".to_string(),
|
||||
|
||||
@@ -9,8 +9,27 @@ use axum::{
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::channels::web::auth::{AuthenticatedUser, UserIdentity};
|
||||
use crate::channels::web::server::GatewayState;
|
||||
use crate::channels::web::types::*;
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
/// Resolve the workspace for the authenticated user.
|
||||
///
|
||||
/// Prefers `workspace_pool` (multi-user mode) when available, falling back
|
||||
/// to the single-user `state.workspace`.
|
||||
pub(crate) async fn resolve_workspace(
|
||||
state: &GatewayState,
|
||||
user: &UserIdentity,
|
||||
) -> Result<Arc<Workspace>, (StatusCode, String)> {
|
||||
if let Some(ref pool) = state.workspace_pool {
|
||||
return Ok(pool.get_or_create(user).await);
|
||||
}
|
||||
state.workspace.as_ref().cloned().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Workspace not available".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TreeQuery {
|
||||
@@ -20,12 +39,10 @@ pub struct TreeQuery {
|
||||
|
||||
pub async fn memory_tree_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
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(),
|
||||
))?;
|
||||
let workspace = resolve_workspace(&state, &user).await?;
|
||||
|
||||
// Build tree from list_all (flat list of all paths)
|
||||
let all_paths = workspace
|
||||
@@ -68,12 +85,10 @@ pub struct ListQuery {
|
||||
|
||||
pub async fn memory_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
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 workspace = resolve_workspace(&state, &user).await?;
|
||||
|
||||
let path = query.path.as_deref().unwrap_or("");
|
||||
let entries = workspace
|
||||
@@ -104,12 +119,10 @@ pub struct ReadQuery {
|
||||
|
||||
pub async fn memory_read_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
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 workspace = resolve_workspace(&state, &user).await?;
|
||||
|
||||
let doc = workspace
|
||||
.read(&query.path)
|
||||
@@ -123,17 +136,75 @@ pub async fn memory_read_handler(
|
||||
}))
|
||||
}
|
||||
|
||||
// memory_write_handler lives in server.rs (layer-aware version with append,
|
||||
// privacy redirect, and proper error status codes).
|
||||
pub async fn memory_write_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Json(req): Json<MemoryWriteRequest>,
|
||||
) -> Result<Json<MemoryWriteResponse>, (StatusCode, String)> {
|
||||
let workspace = resolve_workspace(&state, &user).await?;
|
||||
|
||||
// Route through layer-aware methods when a layer is specified.
|
||||
//
|
||||
// Note: unlike MemoryWriteTool, this endpoint does NOT block writes to
|
||||
// identity files (IDENTITY.md, SOUL.md, etc.). The HTTP API is an
|
||||
// authenticated admin interface; the supervisor uses it to seed identity
|
||||
// files at startup. Identity-file protection is enforced at the tool
|
||||
// layer (LLM-facing) where the write originates from an untrusted agent.
|
||||
if let Some(ref layer_name) = req.layer {
|
||||
let result = if req.append {
|
||||
workspace
|
||||
.append_to_layer(layer_name, &req.path, &req.content, req.force)
|
||||
.await
|
||||
} else {
|
||||
workspace
|
||||
.write_to_layer(layer_name, &req.path, &req.content, req.force)
|
||||
.await
|
||||
}
|
||||
.map_err(|e| {
|
||||
use crate::error::WorkspaceError;
|
||||
let status = match &e {
|
||||
WorkspaceError::LayerNotFound { .. } => StatusCode::BAD_REQUEST,
|
||||
WorkspaceError::LayerReadOnly { .. } => StatusCode::FORBIDDEN,
|
||||
WorkspaceError::PrivacyRedirectFailed => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
(status, e.to_string())
|
||||
})?;
|
||||
return Ok(Json(MemoryWriteResponse {
|
||||
path: req.path,
|
||||
status: "written",
|
||||
redirected: Some(result.redirected),
|
||||
actual_layer: Some(result.actual_layer),
|
||||
}));
|
||||
}
|
||||
|
||||
// Non-layer path: honor the append field
|
||||
if req.append {
|
||||
workspace
|
||||
.append(&req.path, &req.content)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
} else {
|
||||
workspace
|
||||
.write(&req.path, &req.content)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
}
|
||||
|
||||
Ok(Json(MemoryWriteResponse {
|
||||
path: req.path,
|
||||
status: "written",
|
||||
redirected: None,
|
||||
actual_layer: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn memory_search_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
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 workspace = resolve_workspace(&state, &user).await?;
|
||||
|
||||
let limit = req.limit.unwrap_or(10);
|
||||
let results = workspace
|
||||
@@ -142,10 +213,10 @@ pub async fn memory_search_handler(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let hits: Vec<SearchHit> = results
|
||||
.into_iter()
|
||||
.iter()
|
||||
.map(|r| SearchHit {
|
||||
path: r.document_path,
|
||||
content: r.content,
|
||||
path: r.document_id.to_string(),
|
||||
content: r.content.clone(),
|
||||
score: r.score as f64,
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
//! Handler modules for the web gateway API.
|
||||
//!
|
||||
//! 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 memory;
|
||||
pub mod routines;
|
||||
pub mod skills;
|
||||
|
||||
// Modules not yet wired into server.rs router -- suppress dead_code until
|
||||
@@ -17,12 +14,6 @@ pub mod chat;
|
||||
#[allow(dead_code)]
|
||||
pub mod extensions;
|
||||
#[allow(dead_code)]
|
||||
pub mod jobs;
|
||||
#[allow(dead_code)]
|
||||
pub mod memory;
|
||||
#[allow(dead_code)]
|
||||
pub mod routines;
|
||||
#[allow(dead_code)]
|
||||
pub mod settings;
|
||||
#[allow(dead_code)]
|
||||
pub mod static_files;
|
||||
|
||||
@@ -11,12 +11,14 @@ use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::agent::routine::{Trigger, next_cron_fire};
|
||||
use crate::channels::web::auth::AuthenticatedUser;
|
||||
use crate::channels::web::server::GatewayState;
|
||||
use crate::channels::web::types::*;
|
||||
use crate::error::RoutineError;
|
||||
|
||||
pub async fn routines_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
) -> Result<Json<RoutineListResponse>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
@@ -24,7 +26,7 @@ pub async fn routines_list_handler(
|
||||
))?;
|
||||
|
||||
let routines = store
|
||||
.list_all_routines()
|
||||
.list_routines(&user.user_id)
|
||||
.await
|
||||
.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(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
) -> Result<Json<RoutineSummaryResponse>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
@@ -42,7 +45,7 @@ pub async fn routines_summary_handler(
|
||||
))?;
|
||||
|
||||
let routines = store
|
||||
.list_all_routines()
|
||||
.list_routines(&user.user_id)
|
||||
.await
|
||||
.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(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<RoutineDetailResponse>, (StatusCode, String)> {
|
||||
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()))?
|
||||
.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
|
||||
.list_routine_runs(routine_id, 20)
|
||||
.await
|
||||
@@ -106,7 +114,7 @@ pub async fn routines_detail_handler(
|
||||
trigger_type: run.trigger_type.clone(),
|
||||
started_at: run.started_at.to_rfc3339(),
|
||||
completed_at: run.completed_at.map(|dt| dt.to_rfc3339()),
|
||||
status: format!("{:?}", run.status),
|
||||
status: run.status.to_string(),
|
||||
result_summary: run.result_summary.clone(),
|
||||
tokens_used: run.tokens_used,
|
||||
job_id: run.job_id,
|
||||
@@ -137,6 +145,7 @@ pub async fn routines_detail_handler(
|
||||
|
||||
pub async fn routines_trigger_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
// 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()))?;
|
||||
|
||||
let run_id = engine
|
||||
.fire_manual(routine_id, Some(&state.user_id))
|
||||
.fire_manual(routine_id, Some(&user.user_id))
|
||||
.await
|
||||
.map_err(|e| (routine_error_status(&e), e.to_string()))?;
|
||||
|
||||
@@ -170,6 +179,7 @@ pub struct ToggleRequest {
|
||||
|
||||
pub async fn routines_toggle_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path(id): Path<String>,
|
||||
body: Option<Json<ToggleRequest>>,
|
||||
) -> 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()))?
|
||||
.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;
|
||||
// If a specific value was provided, use it; otherwise toggle.
|
||||
routine.enabled = match body {
|
||||
@@ -230,6 +244,7 @@ pub async fn routines_toggle_handler(
|
||||
|
||||
pub async fn routines_delete_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
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)
|
||||
.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
|
||||
.delete_routine(routine_id)
|
||||
.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(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
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)
|
||||
.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
|
||||
.list_routine_runs(routine_id, 50)
|
||||
.await
|
||||
@@ -285,7 +324,7 @@ pub async fn routines_runs_handler(
|
||||
trigger_type: run.trigger_type.clone(),
|
||||
started_at: run.started_at.to_rfc3339(),
|
||||
completed_at: run.completed_at.map(|dt| dt.to_rfc3339()),
|
||||
status: format!("{:?}", run.status),
|
||||
status: run.status.to_string(),
|
||||
result_summary: run.result_summary.clone(),
|
||||
tokens_used: run.tokens_used,
|
||||
job_id: run.job_id,
|
||||
|
||||
@@ -8,17 +8,19 @@ use axum::{
|
||||
http::StatusCode,
|
||||
};
|
||||
|
||||
use crate::channels::web::auth::AuthenticatedUser;
|
||||
use crate::channels::web::server::GatewayState;
|
||||
use crate::channels::web::types::*;
|
||||
|
||||
pub async fn settings_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
) -> Result<Json<SettingsListResponse>, StatusCode> {
|
||||
let store = state
|
||||
.store
|
||||
.as_ref()
|
||||
.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);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
@@ -37,6 +39,7 @@ pub async fn settings_list_handler(
|
||||
|
||||
pub async fn settings_get_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path(key): Path<String>,
|
||||
) -> Result<Json<SettingResponse>, StatusCode> {
|
||||
let store = state
|
||||
@@ -44,7 +47,7 @@ pub async fn settings_get_handler(
|
||||
.as_ref()
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
let row = store
|
||||
.get_setting_full(&state.user_id, &key)
|
||||
.get_setting_full(&user.user_id, &key)
|
||||
.await
|
||||
.map_err(|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(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path(key): Path<String>,
|
||||
Json(body): Json<SettingWriteRequest>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
@@ -69,7 +73,7 @@ pub async fn settings_set_handler(
|
||||
.as_ref()
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
store
|
||||
.set_setting(&state.user_id, &key, &body.value)
|
||||
.set_setting(&user.user_id, &key, &body.value)
|
||||
.await
|
||||
.map_err(|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(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path(key): Path<String>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let store = state
|
||||
@@ -88,7 +93,7 @@ pub async fn settings_delete_handler(
|
||||
.as_ref()
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
store
|
||||
.delete_setting(&state.user_id, &key)
|
||||
.delete_setting(&user.user_id, &key)
|
||||
.await
|
||||
.map_err(|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(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
) -> Result<Json<SettingsExportResponse>, StatusCode> {
|
||||
let store = state
|
||||
.store
|
||||
.as_ref()
|
||||
.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);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
@@ -115,6 +121,7 @@ pub async fn settings_export_handler(
|
||||
|
||||
pub async fn settings_import_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Json(body): Json<SettingsImportRequest>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let store = state
|
||||
@@ -122,7 +129,7 @@ pub async fn settings_import_handler(
|
||||
.as_ref()
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
store
|
||||
.set_all_settings(&state.user_id, &body.settings)
|
||||
.set_all_settings(&user.user_id, &body.settings)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to import settings: {}", e);
|
||||
|
||||
@@ -8,11 +8,13 @@ use axum::{
|
||||
http::StatusCode,
|
||||
};
|
||||
|
||||
use crate::channels::web::auth::AuthenticatedUser;
|
||||
use crate::channels::web::server::GatewayState;
|
||||
use crate::channels::web::types::*;
|
||||
|
||||
pub async fn skills_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(_user): AuthenticatedUser,
|
||||
) -> Result<Json<SkillListResponse>, (StatusCode, String)> {
|
||||
let registry = state.skill_registry.as_ref().ok_or((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
@@ -45,6 +47,7 @@ pub async fn skills_list_handler(
|
||||
|
||||
pub async fn skills_search_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(_user): AuthenticatedUser,
|
||||
Json(req): Json<SkillSearchRequest>,
|
||||
) -> Result<Json<SkillSearchResponse>, (StatusCode, String)> {
|
||||
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(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(req): Json<SkillInstallRequest>,
|
||||
) -> 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((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Skills system not enabled".to_string(),
|
||||
@@ -219,6 +225,7 @@ pub async fn skills_install_handler(
|
||||
|
||||
pub async fn skills_remove_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
headers: axum::http::HeaderMap,
|
||||
Path(name): Path<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((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Skills system not enabled".to_string(),
|
||||
|
||||
@@ -7,6 +7,7 @@ use axum::{
|
||||
};
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::channels::web::auth::AuthenticatedUser;
|
||||
use crate::channels::web::types::*;
|
||||
|
||||
// --- Static file handlers ---
|
||||
@@ -113,6 +114,7 @@ use crate::channels::web::server::GatewayState;
|
||||
|
||||
pub async fn logs_events_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(_user): AuthenticatedUser,
|
||||
) -> Result<
|
||||
Sse<impl futures::Stream<Item = Result<Event, Infallible>> + Send + 'static>,
|
||||
(StatusCode, String),
|
||||
@@ -152,6 +154,7 @@ pub async fn logs_events_handler(
|
||||
|
||||
pub async fn gateway_status_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(_user): AuthenticatedUser,
|
||||
) -> Json<GatewayStatusResponse> {
|
||||
let sse_connections = state.sse.connection_count();
|
||||
let ws_connections = state
|
||||
|
||||
@@ -54,10 +54,37 @@ fn validate_webhook_secret(
|
||||
///
|
||||
/// This endpoint is **public** (no gateway auth token required) but protected
|
||||
/// by the per-routine webhook secret sent via the `X-Webhook-Secret` header.
|
||||
///
|
||||
/// **Single-user/backward-compatible**: looks up routines by path across all
|
||||
/// users. For multi-tenant isolation, use the user-scoped endpoint at
|
||||
/// `/api/webhooks/u/{user_id}/{path}` instead.
|
||||
pub async fn webhook_trigger_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(path): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
fire_webhook_inner(state, &path, None, &headers).await
|
||||
}
|
||||
|
||||
/// Handle incoming webhook POST to `/api/webhooks/u/{user_id}/{path}`.
|
||||
///
|
||||
/// User-scoped variant for multi-tenant deployments. The `user_id` in the URL
|
||||
/// restricts the routine lookup to that user only, preventing cross-user
|
||||
/// webhook triggering even when paths collide.
|
||||
pub async fn webhook_trigger_user_scoped_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path((user_id, path)): Path<(String, String)>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
fire_webhook_inner(state, &path, Some(&user_id), &headers).await
|
||||
}
|
||||
|
||||
/// Shared webhook logic for both scoped and unscoped endpoints.
|
||||
async fn fire_webhook_inner(
|
||||
state: Arc<GatewayState>,
|
||||
path: &str,
|
||||
user_id: Option<&str>,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
// Rate limit check
|
||||
if !state.webhook_rate_limiter.check() {
|
||||
@@ -72,9 +99,9 @@ pub async fn webhook_trigger_handler(
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
// Targeted query instead of loading all routines
|
||||
// Targeted query — when user_id is provided, restrict to that user's routines
|
||||
let routine = store
|
||||
.get_webhook_routine_by_path(&path)
|
||||
.get_webhook_routine_by_path(path, user_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((
|
||||
@@ -99,7 +126,7 @@ pub async fn webhook_trigger_handler(
|
||||
))?
|
||||
};
|
||||
|
||||
let run_id = engine.fire_webhook(routine.id, &path).await.map_err(|e| {
|
||||
let run_id = engine.fire_webhook(routine.id, path).await.map_err(|e| {
|
||||
let status = match &e {
|
||||
crate::error::RoutineError::NotFound { .. } => StatusCode::NOT_FOUND,
|
||||
crate::error::RoutineError::Disabled { .. }
|
||||
|
||||
+137
-36
@@ -31,6 +31,9 @@ pub mod ws;
|
||||
/// [`TestGatewayBuilder`](test_helpers::TestGatewayBuilder).
|
||||
pub mod test_helpers;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -52,22 +55,24 @@ use crate::workspace::Workspace;
|
||||
|
||||
use self::log_layer::{LogBroadcaster, LogLevelHandle};
|
||||
|
||||
use self::auth::MultiAuthState;
|
||||
use self::server::GatewayState;
|
||||
use self::sse::SseManager;
|
||||
use self::types::SseEvent;
|
||||
use self::types::AppEvent;
|
||||
|
||||
/// Web gateway channel implementing the Channel trait.
|
||||
pub struct GatewayChannel {
|
||||
config: GatewayConfig,
|
||||
state: Arc<GatewayState>,
|
||||
/// The actual auth token in use (generated or from config).
|
||||
auth_token: String,
|
||||
/// Multi-user auth state (replaces bare auth_token).
|
||||
auth: MultiAuthState,
|
||||
}
|
||||
|
||||
impl GatewayChannel {
|
||||
/// Create a new gateway channel.
|
||||
///
|
||||
/// 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 {
|
||||
let auth_token = config.auth_token.clone().unwrap_or_else(|| {
|
||||
use rand::RngCore;
|
||||
@@ -77,10 +82,13 @@ impl GatewayChannel {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
});
|
||||
|
||||
let auth = MultiAuthState::single(auth_token, config.user_id.clone());
|
||||
|
||||
let state = Arc::new(GatewayState {
|
||||
msg_tx: tokio::sync::RwLock::new(None),
|
||||
sse: SseManager::new(),
|
||||
sse: Arc::new(SseManager::new()),
|
||||
workspace: None,
|
||||
workspace_pool: None,
|
||||
session_manager: None,
|
||||
log_broadcaster: None,
|
||||
log_level_handle: None,
|
||||
@@ -90,13 +98,14 @@ impl GatewayChannel {
|
||||
job_manager: None,
|
||||
prompt_queue: None,
|
||||
scheduler: None,
|
||||
user_id: config.user_id.clone(),
|
||||
owner_id: config.user_id.clone(),
|
||||
default_sender_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::RateLimiter::new(30, 60),
|
||||
chat_rate_limiter: server::PerUserRateLimiter::new(30, 60),
|
||||
oauth_rate_limiter: server::RateLimiter::new(10, 60),
|
||||
webhook_rate_limiter: server::RateLimiter::new(10, 60),
|
||||
registry_entries: Vec::new(),
|
||||
@@ -109,7 +118,63 @@ impl GatewayChannel {
|
||||
Self {
|
||||
config,
|
||||
state,
|
||||
auth_token,
|
||||
auth,
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebind the single-user auth identity to the durable owner scope while
|
||||
/// preserving the configured gateway sender/routing identity.
|
||||
pub fn with_owner_scope(mut self, owner_id: impl Into<String>) -> Self {
|
||||
let owner_id = owner_id.into();
|
||||
let single_user_token = if self.config.user_tokens.is_none() {
|
||||
self.auth.first_token().map(ToOwned::to_owned)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(token) = single_user_token {
|
||||
self.auth = MultiAuthState::single(token, owner_id.clone());
|
||||
}
|
||||
self.rebuild_state(|s| s.owner_id = owner_id);
|
||||
self
|
||||
}
|
||||
|
||||
/// 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,
|
||||
owner_id: config.user_id.clone(),
|
||||
default_sender_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 +183,9 @@ impl GatewayChannel {
|
||||
let mut new_state = GatewayState {
|
||||
msg_tx: tokio::sync::RwLock::new(None),
|
||||
// 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_pool: self.state.workspace_pool.clone(),
|
||||
session_manager: self.state.session_manager.clone(),
|
||||
log_broadcaster: self.state.log_broadcaster.clone(),
|
||||
log_level_handle: self.state.log_level_handle.clone(),
|
||||
@@ -129,13 +195,14 @@ impl GatewayChannel {
|
||||
job_manager: self.state.job_manager.clone(),
|
||||
prompt_queue: self.state.prompt_queue.clone(),
|
||||
scheduler: self.state.scheduler.clone(),
|
||||
user_id: self.state.user_id.clone(),
|
||||
owner_id: self.state.owner_id.clone(),
|
||||
default_sender_id: self.state.default_sender_id.clone(),
|
||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||
ws_tracker: self.state.ws_tracker.clone(),
|
||||
llm_provider: self.state.llm_provider.clone(),
|
||||
skill_registry: self.state.skill_registry.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),
|
||||
webhook_rate_limiter: server::RateLimiter::new(10, 60),
|
||||
registry_entries: self.state.registry_entries.clone(),
|
||||
@@ -260,9 +327,15 @@ impl GatewayChannel {
|
||||
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 {
|
||||
&self.auth_token
|
||||
self.auth.first_token().unwrap_or("")
|
||||
}
|
||||
|
||||
/// Get a reference to the shared gateway state (for the agent to push SSE events).
|
||||
@@ -291,7 +364,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)))
|
||||
}
|
||||
@@ -311,10 +384,13 @@ impl Channel for GatewayChannel {
|
||||
}
|
||||
};
|
||||
|
||||
self.state.sse.broadcast(SseEvent::Response {
|
||||
content: response.content,
|
||||
thread_id,
|
||||
});
|
||||
self.state.sse.broadcast_for_user(
|
||||
&msg.user_id,
|
||||
AppEvent::Response {
|
||||
content: response.content,
|
||||
thread_id,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -329,11 +405,11 @@ impl Channel for GatewayChannel {
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
let event = match status {
|
||||
StatusUpdate::Thinking(msg) => SseEvent::Thinking {
|
||||
StatusUpdate::Thinking(msg) => AppEvent::Thinking {
|
||||
message: msg,
|
||||
thread_id: thread_id.clone(),
|
||||
},
|
||||
StatusUpdate::ToolStarted { name } => SseEvent::ToolStarted {
|
||||
StatusUpdate::ToolStarted { name } => AppEvent::ToolStarted {
|
||||
name,
|
||||
thread_id: thread_id.clone(),
|
||||
},
|
||||
@@ -342,23 +418,23 @@ impl Channel for GatewayChannel {
|
||||
success,
|
||||
error,
|
||||
parameters,
|
||||
} => SseEvent::ToolCompleted {
|
||||
} => AppEvent::ToolCompleted {
|
||||
name,
|
||||
success,
|
||||
error,
|
||||
parameters,
|
||||
thread_id: thread_id.clone(),
|
||||
},
|
||||
StatusUpdate::ToolResult { name, preview } => SseEvent::ToolResult {
|
||||
StatusUpdate::ToolResult { name, preview } => AppEvent::ToolResult {
|
||||
name,
|
||||
preview,
|
||||
thread_id: thread_id.clone(),
|
||||
},
|
||||
StatusUpdate::StreamChunk(content) => SseEvent::StreamChunk {
|
||||
StatusUpdate::StreamChunk(content) => AppEvent::StreamChunk {
|
||||
content,
|
||||
thread_id: thread_id.clone(),
|
||||
},
|
||||
StatusUpdate::Status(msg) => SseEvent::Status {
|
||||
StatusUpdate::Status(msg) => AppEvent::Status {
|
||||
message: msg,
|
||||
thread_id: thread_id.clone(),
|
||||
},
|
||||
@@ -366,7 +442,7 @@ impl Channel for GatewayChannel {
|
||||
job_id,
|
||||
title,
|
||||
browse_url,
|
||||
} => SseEvent::JobStarted {
|
||||
} => AppEvent::JobStarted {
|
||||
job_id,
|
||||
title,
|
||||
browse_url,
|
||||
@@ -377,7 +453,7 @@ impl Channel for GatewayChannel {
|
||||
description,
|
||||
parameters,
|
||||
allow_always,
|
||||
} => SseEvent::ApprovalNeeded {
|
||||
} => AppEvent::ApprovalNeeded {
|
||||
request_id,
|
||||
tool_name,
|
||||
description,
|
||||
@@ -391,7 +467,7 @@ impl Channel for GatewayChannel {
|
||||
instructions,
|
||||
auth_url,
|
||||
setup_url,
|
||||
} => SseEvent::AuthRequired {
|
||||
} => AppEvent::AuthRequired {
|
||||
extension_name,
|
||||
instructions,
|
||||
auth_url,
|
||||
@@ -401,25 +477,39 @@ impl Channel for GatewayChannel {
|
||||
extension_name,
|
||||
success,
|
||||
message,
|
||||
} => SseEvent::AuthCompleted {
|
||||
} => AppEvent::AuthCompleted {
|
||||
extension_name,
|
||||
success,
|
||||
message,
|
||||
},
|
||||
StatusUpdate::ImageGenerated { data_url, path } => SseEvent::ImageGenerated {
|
||||
StatusUpdate::ImageGenerated { data_url, path } => AppEvent::ImageGenerated {
|
||||
data_url,
|
||||
path,
|
||||
thread_id: thread_id.clone(),
|
||||
},
|
||||
StatusUpdate::Suggestions { suggestions } => SseEvent::Suggestions {
|
||||
StatusUpdate::Suggestions { suggestions } => AppEvent::Suggestions {
|
||||
suggestions,
|
||||
thread_id: thread_id.clone(),
|
||||
},
|
||||
StatusUpdate::ReasoningUpdate {
|
||||
narrative,
|
||||
decisions,
|
||||
} => AppEvent::ReasoningUpdate {
|
||||
narrative,
|
||||
decisions: decisions
|
||||
.into_iter()
|
||||
.map(|d| crate::channels::web::types::ToolDecisionDto {
|
||||
tool_name: d.tool_name,
|
||||
rationale: d.rationale,
|
||||
})
|
||||
.collect(),
|
||||
thread_id,
|
||||
},
|
||||
StatusUpdate::TurnCost {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cost_usd,
|
||||
} => SseEvent::TurnCost {
|
||||
} => AppEvent::TurnCost {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cost_usd,
|
||||
@@ -427,13 +517,21 @@ impl Channel for GatewayChannel {
|
||||
},
|
||||
};
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
async fn broadcast(
|
||||
&self,
|
||||
_user_id: &str,
|
||||
user_id: &str,
|
||||
response: OutgoingResponse,
|
||||
) -> Result<(), ChannelError> {
|
||||
let thread_id = match response.thread_id {
|
||||
@@ -445,10 +543,13 @@ impl Channel for GatewayChannel {
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
self.state.sse.broadcast(SseEvent::Response {
|
||||
content: response.content,
|
||||
thread_id,
|
||||
});
|
||||
self.state.sse.broadcast_for_user(
|
||||
user_id,
|
||||
AppEvent::Response {
|
||||
content: response.content,
|
||||
thread_id,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -231,6 +231,7 @@ pub fn convert_messages(messages: &[OpenAiMessage]) -> Result<Vec<ChatMessage>,
|
||||
name: tc.function.name.clone(),
|
||||
arguments: serde_json::from_str(&tc.function.arguments)
|
||||
.unwrap_or(serde_json::Value::Object(Default::default())),
|
||||
reasoning: None,
|
||||
})
|
||||
.collect();
|
||||
Ok(ChatMessage::assistant_with_tool_calls(
|
||||
@@ -463,9 +464,10 @@ fn build_tool_request(
|
||||
|
||||
pub async fn chat_completions_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
super::auth::AuthenticatedUser(user): super::auth::AuthenticatedUser,
|
||||
Json(req): Json<OpenAiChatRequest>,
|
||||
) -> 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(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"Rate limit exceeded. Please try again later.",
|
||||
@@ -953,6 +955,7 @@ mod tests {
|
||||
id: "call_abc".to_string(),
|
||||
name: "search".to_string(),
|
||||
arguments: serde_json::json!({"query": "rust"}),
|
||||
reasoning: None,
|
||||
}];
|
||||
|
||||
let converted = convert_tool_calls_to_openai(&calls);
|
||||
|
||||
+1497
-386
File diff suppressed because it is too large
Load Diff
+138
-61
@@ -11,15 +11,31 @@ use tokio::sync::broadcast;
|
||||
use tokio_stream::StreamExt;
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
|
||||
use crate::channels::web::types::SseEvent;
|
||||
use crate::channels::web::types::AppEvent;
|
||||
|
||||
/// Maximum number of concurrent SSE/WebSocket connections.
|
||||
/// Prevents resource exhaustion from connection flooding.
|
||||
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: AppEvent,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
tx: broadcast::Sender<SseEvent>,
|
||||
tx: broadcast::Sender<ScopedEvent>,
|
||||
connection_count: Arc<AtomicU64>,
|
||||
max_connections: u64,
|
||||
}
|
||||
@@ -45,7 +61,7 @@ impl SseManager {
|
||||
/// only be called before the server starts accepting connections (i.e.,
|
||||
/// during startup wiring). Calling it after connections are established
|
||||
/// 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 {
|
||||
tx,
|
||||
connection_count: Arc::new(AtomicU64::new(0)),
|
||||
@@ -53,15 +69,28 @@ impl SseManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Broadcast an event to all connected clients.
|
||||
pub fn broadcast(&self, event: SseEvent) {
|
||||
// Ignore send errors (no receivers is fine)
|
||||
let _ = self.tx.send(event);
|
||||
/// Get a clone of the broadcast sender for use by other components.
|
||||
pub(crate) fn sender(&self) -> broadcast::Sender<ScopedEvent> {
|
||||
self.tx.clone()
|
||||
}
|
||||
|
||||
/// Get a clone of the broadcast sender for use by other components.
|
||||
pub fn sender(&self) -> broadcast::Sender<SseEvent> {
|
||||
self.tx.clone()
|
||||
/// Broadcast an event to all connected clients (global/unscoped).
|
||||
pub fn broadcast(&self, event: AppEvent) {
|
||||
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: AppEvent) {
|
||||
let _ = self.tx.send(ScopedEvent {
|
||||
user_id: Some(user_id.to_string()),
|
||||
event,
|
||||
});
|
||||
}
|
||||
|
||||
/// Get current number of active connections.
|
||||
@@ -71,11 +100,15 @@ impl SseManager {
|
||||
|
||||
/// Create a raw broadcast subscription for non-SSE consumers (e.g. WebSocket).
|
||||
///
|
||||
/// Returns a stream of `SseEvent` values and increments/decrements the
|
||||
/// connection counter on creation/drop, just like `subscribe()` does for SSE.
|
||||
/// When `user_id` is `Some`, only events scoped to that user (or global
|
||||
/// events) are delivered. When `None`, all events are delivered (single-user
|
||||
/// backwards compatibility).
|
||||
///
|
||||
/// 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 = AppEvent> + Send + 'static + use<>> {
|
||||
// Atomically increment only if below the limit. This prevents
|
||||
// concurrent callers from overshooting max_connections.
|
||||
let counter = Arc::clone(&self.connection_count);
|
||||
@@ -91,7 +124,19 @@ impl SseManager {
|
||||
.ok()?;
|
||||
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 {
|
||||
inner: stream,
|
||||
@@ -101,9 +146,13 @@ impl SseManager {
|
||||
|
||||
/// 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.
|
||||
pub fn subscribe(
|
||||
&self,
|
||||
user_id: Option<String>,
|
||||
) -> Option<Sse<impl Stream<Item = Result<Event, Infallible>> + Send + 'static + use<>>> {
|
||||
// Atomically increment only if below the limit.
|
||||
let counter = Arc::clone(&self.connection_count);
|
||||
@@ -120,34 +169,25 @@ impl SseManager {
|
||||
let rx = self.tx.subscribe();
|
||||
|
||||
let stream = BroadcastStream::new(rx)
|
||||
.filter_map(|result| result.ok())
|
||||
.map(|event| {
|
||||
let data = serde_json::to_string(&event).unwrap_or_default();
|
||||
let event_type = match &event {
|
||||
SseEvent::Response { .. } => "response",
|
||||
SseEvent::Thinking { .. } => "thinking",
|
||||
SseEvent::ToolStarted { .. } => "tool_started",
|
||||
SseEvent::ToolCompleted { .. } => "tool_completed",
|
||||
SseEvent::ToolResult { .. } => "tool_result",
|
||||
SseEvent::StreamChunk { .. } => "stream_chunk",
|
||||
SseEvent::Status { .. } => "status",
|
||||
SseEvent::ApprovalNeeded { .. } => "approval_needed",
|
||||
SseEvent::AuthRequired { .. } => "auth_required",
|
||||
SseEvent::AuthCompleted { .. } => "auth_completed",
|
||||
SseEvent::Error { .. } => "error",
|
||||
SseEvent::JobStarted { .. } => "job_started",
|
||||
SseEvent::JobMessage { .. } => "job_message",
|
||||
SseEvent::JobToolUse { .. } => "job_tool_use",
|
||||
SseEvent::JobToolResult { .. } => "job_tool_result",
|
||||
SseEvent::JobStatus { .. } => "job_status",
|
||||
SseEvent::JobResult { .. } => "job_result",
|
||||
SseEvent::Heartbeat => "heartbeat",
|
||||
SseEvent::ImageGenerated { .. } => "image_generated",
|
||||
SseEvent::Suggestions { .. } => "suggestions",
|
||||
SseEvent::TurnCost { .. } => "turn_cost",
|
||||
SseEvent::ExtensionStatus { .. } => "extension_status",
|
||||
.filter_map(move |result| match result {
|
||||
Ok(scoped) => match (&user_id, &scoped.user_id) {
|
||||
(_, 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;
|
||||
}
|
||||
};
|
||||
Ok(Event::default().event(event_type).data(data))
|
||||
let event_type = event.event_type();
|
||||
Some(Ok(Event::default().event(event_type).data(data)))
|
||||
});
|
||||
|
||||
// Wrap in a stream that decrements on drop
|
||||
@@ -209,24 +249,22 @@ mod tests {
|
||||
fn test_broadcast_without_receivers() {
|
||||
let manager = SseManager::new();
|
||||
// Should not panic even with no receivers
|
||||
manager.broadcast(SseEvent::Heartbeat);
|
||||
manager.broadcast(AppEvent::Heartbeat);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_broadcast_to_receiver() {
|
||||
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(AppEvent::Status {
|
||||
message: "test".to_string(),
|
||||
thread_id: None,
|
||||
});
|
||||
|
||||
let event = rx.next().await;
|
||||
assert!(event.is_some());
|
||||
let event = event.unwrap().unwrap();
|
||||
let event = stream.next().await.unwrap();
|
||||
match event {
|
||||
SseEvent::Status { message, .. } => assert_eq!(message, "test"),
|
||||
AppEvent::Status { message, .. } => assert_eq!(message, "test"),
|
||||
_ => panic!("unexpected event type"),
|
||||
}
|
||||
}
|
||||
@@ -234,18 +272,18 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_subscribe_raw_receives_events() {
|
||||
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);
|
||||
|
||||
manager.broadcast(SseEvent::Thinking {
|
||||
manager.broadcast(AppEvent::Thinking {
|
||||
message: "working".to_string(),
|
||||
thread_id: None,
|
||||
});
|
||||
|
||||
let event = stream.next().await.unwrap();
|
||||
match event {
|
||||
SseEvent::Thinking { message, .. } => assert_eq!(message, "working"),
|
||||
AppEvent::Thinking { message, .. } => assert_eq!(message, "working"),
|
||||
_ => panic!("Expected Thinking event"),
|
||||
}
|
||||
}
|
||||
@@ -254,7 +292,7 @@ mod tests {
|
||||
async fn test_subscribe_raw_decrements_on_drop() {
|
||||
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);
|
||||
}
|
||||
// Stream dropped, counter should decrement
|
||||
@@ -264,16 +302,16 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_subscribe_raw_multiple_subscribers() {
|
||||
let manager = SseManager::new();
|
||||
let mut s1 = Box::pin(manager.subscribe_raw().expect("should subscribe"));
|
||||
let mut s2 = 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(None).expect("should subscribe"));
|
||||
assert_eq!(manager.connection_count(), 2);
|
||||
|
||||
manager.broadcast(SseEvent::Heartbeat);
|
||||
manager.broadcast(AppEvent::Heartbeat);
|
||||
|
||||
let e1 = s1.next().await.unwrap();
|
||||
let e2 = s2.next().await.unwrap();
|
||||
assert!(matches!(e1, SseEvent::Heartbeat));
|
||||
assert!(matches!(e2, SseEvent::Heartbeat));
|
||||
assert!(matches!(e1, AppEvent::Heartbeat));
|
||||
assert!(matches!(e2, AppEvent::Heartbeat));
|
||||
|
||||
drop(s1);
|
||||
assert_eq!(manager.connection_count(), 1);
|
||||
@@ -286,12 +324,51 @@ mod tests {
|
||||
let mut manager = SseManager::new();
|
||||
manager.max_connections = 2; // Low limit for testing
|
||||
|
||||
let _s1 = Box::pin(manager.subscribe_raw().expect("first should succeed"));
|
||||
let _s2 = Box::pin(manager.subscribe_raw().expect("second should succeed"));
|
||||
let _s1 = Box::pin(manager.subscribe_raw(None).expect("first should succeed"));
|
||||
let _s2 = Box::pin(manager.subscribe_raw(None).expect("second should succeed"));
|
||||
assert_eq!(manager.connection_count(), 2);
|
||||
|
||||
// Third should be rejected
|
||||
assert!(manager.subscribe_raw().is_none());
|
||||
assert!(manager.subscribe().is_none());
|
||||
assert!(manager.subscribe_raw(None).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",
|
||||
AppEvent::Status {
|
||||
message: "alice only".to_string(),
|
||||
thread_id: None,
|
||||
},
|
||||
);
|
||||
|
||||
// Send global event
|
||||
manager.broadcast(AppEvent::Heartbeat);
|
||||
|
||||
// Alice gets her scoped event
|
||||
let e = alice.next().await.unwrap();
|
||||
assert!(matches!(e, AppEvent::Status { .. }));
|
||||
|
||||
// Alice also gets the global heartbeat
|
||||
let e = alice.next().await.unwrap();
|
||||
assert!(matches!(e, AppEvent::Heartbeat));
|
||||
|
||||
// Bob only gets the global heartbeat (alice's event was filtered)
|
||||
let e = bob.next().await.unwrap(); // safety: test-only
|
||||
assert!(matches!(e, AppEvent::Heartbeat)); // safety: test assertion
|
||||
}
|
||||
}
|
||||
|
||||
+237
-18
@@ -3056,16 +3056,17 @@ function showConfigureModal(name) {
|
||||
.then((setup) => {
|
||||
const secrets = Array.isArray(setup.secrets) ? setup.secrets : [];
|
||||
const setupFields = Array.isArray(setup.fields) ? setup.fields : [];
|
||||
if (secrets.length === 0 && setupFields.length === 0) {
|
||||
showToast('No configuration needed for ' + name, 'info');
|
||||
const interactiveLogin = setup.interactive_login || null;
|
||||
if (secrets.length === 0 && setupFields.length === 0 && !interactiveLogin) {
|
||||
showToast(I18n.t('extensions.noConfigNeeded', { name: name }), 'info');
|
||||
return;
|
||||
}
|
||||
renderConfigureModal(name, secrets, setupFields);
|
||||
renderConfigureModal(name, secrets, setupFields, interactiveLogin);
|
||||
})
|
||||
.catch((err) => showToast('Failed to load setup: ' + err.message, 'error'));
|
||||
.catch((err) => showToast(I18n.t('error.loadFailed', { message: err.message }), 'error'));
|
||||
}
|
||||
|
||||
function renderConfigureModal(name, secrets, setupFields) {
|
||||
function renderConfigureModal(name, secrets, setupFields, interactiveLogin) {
|
||||
closeConfigureModal();
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'configure-overlay';
|
||||
@@ -3091,6 +3092,13 @@ function renderConfigureModal(name, secrets, setupFields) {
|
||||
modal.appendChild(hint);
|
||||
}
|
||||
|
||||
if (interactiveLogin) {
|
||||
const hint = document.createElement('div');
|
||||
hint.className = 'configure-hint';
|
||||
hint.textContent = interactiveLoginHintText(name, interactiveLogin);
|
||||
modal.appendChild(hint);
|
||||
}
|
||||
|
||||
const form = document.createElement('div');
|
||||
form.className = 'configure-form';
|
||||
|
||||
@@ -3180,7 +3188,13 @@ function renderConfigureModal(name, secrets, setupFields) {
|
||||
fields.push({ kind: 'field', name: setupField.name, input: input });
|
||||
}
|
||||
|
||||
modal.appendChild(form);
|
||||
if (fields.length > 0) {
|
||||
modal.appendChild(form);
|
||||
}
|
||||
|
||||
if (interactiveLogin) {
|
||||
modal.appendChild(renderInteractiveLoginPanel(name));
|
||||
}
|
||||
|
||||
const error = document.createElement('div');
|
||||
error.className = 'configure-inline-error';
|
||||
@@ -3195,11 +3209,23 @@ function renderConfigureModal(name, secrets, setupFields) {
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'configure-actions';
|
||||
|
||||
const submitBtn = document.createElement('button');
|
||||
submitBtn.className = 'btn-ext activate';
|
||||
submitBtn.textContent = I18n.t('config.save');
|
||||
submitBtn.addEventListener('click', () => submitConfigureModal(name, fields));
|
||||
actions.appendChild(submitBtn);
|
||||
if (fields.length > 0) {
|
||||
const submitBtn = document.createElement('button');
|
||||
submitBtn.className = 'btn-ext activate';
|
||||
submitBtn.textContent = I18n.t('config.save');
|
||||
submitBtn.addEventListener('click', () => submitConfigureModal(name, fields));
|
||||
actions.appendChild(submitBtn);
|
||||
}
|
||||
|
||||
if (interactiveLogin) {
|
||||
const loginBtn = document.createElement('button');
|
||||
loginBtn.className = 'btn-ext activate';
|
||||
loginBtn.dataset.defaultLabel = interactiveLoginDefaultLabel(name, interactiveLogin);
|
||||
loginBtn.textContent = loginBtn.dataset.defaultLabel;
|
||||
loginBtn.dataset.interactiveLogin = 'true';
|
||||
loginBtn.addEventListener('click', () => startInteractiveLogin(name, overlay));
|
||||
actions.appendChild(loginBtn);
|
||||
}
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'btn-ext remove';
|
||||
@@ -3211,7 +3237,200 @@ function renderConfigureModal(name, secrets, setupFields) {
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
if (fields.length > 0) fields[0].input.focus();
|
||||
if (fields.length > 0) {
|
||||
fields[0].input.focus();
|
||||
} else {
|
||||
const loginBtn = overlay.querySelector('.configure-actions button[data-interactive-login="true"]');
|
||||
if (loginBtn) loginBtn.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function renderInteractiveLoginPanel(name) {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'configure-qr-login';
|
||||
panel.style.display = 'none';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'configure-verification-title';
|
||||
title.textContent =
|
||||
name === 'wechat' ? I18n.t('config.wechatQrTitle') : I18n.t('auth.connect');
|
||||
panel.appendChild(title);
|
||||
|
||||
const status = document.createElement('div');
|
||||
status.className = 'configure-verification-instructions';
|
||||
status.textContent = interactiveLoginStatusText(name, null);
|
||||
status.dataset.qrStatus = 'true';
|
||||
panel.appendChild(status);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.className = 'configure-verification-link';
|
||||
link.textContent =
|
||||
name === 'wechat' ? I18n.t('config.wechatQrOpen') : I18n.t('auth.connect');
|
||||
link.target = '_blank';
|
||||
link.rel = 'noreferrer noopener';
|
||||
link.style.display = 'none';
|
||||
link.dataset.qrLink = 'true';
|
||||
panel.appendChild(link);
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
function interactiveLoginHintText(name, interactiveLogin) {
|
||||
if (name === 'wechat') return I18n.t('config.wechatHint');
|
||||
return (interactiveLogin && interactiveLogin.instructions) || '';
|
||||
}
|
||||
|
||||
function interactiveLoginDefaultLabel(name, interactiveLogin) {
|
||||
if (name === 'wechat') return I18n.t('config.wechatConnect');
|
||||
return (interactiveLogin && interactiveLogin.button_label) || I18n.t('auth.connect');
|
||||
}
|
||||
|
||||
function interactiveLoginWaitingLabel(name) {
|
||||
if (name === 'wechat') return I18n.t('config.wechatWaiting');
|
||||
return I18n.t('status.connecting');
|
||||
}
|
||||
|
||||
function interactiveLoginStatusText(name, res) {
|
||||
if (name !== 'wechat') return (res && res.message) || '';
|
||||
if (!res) return I18n.t('config.wechatQrIntro');
|
||||
|
||||
switch (res.status) {
|
||||
case 'pending':
|
||||
return res.qr_code_url ? I18n.t('config.wechatQrReady') : I18n.t('config.wechatQrWaiting');
|
||||
case 'scanned':
|
||||
return I18n.t('config.wechatQrScanned');
|
||||
case 'refreshed':
|
||||
return I18n.t('config.wechatQrRefreshed');
|
||||
case 'succeeded':
|
||||
return I18n.t('config.wechatConnected');
|
||||
case 'failed':
|
||||
return res.message || I18n.t('config.wechatQrFailed');
|
||||
default:
|
||||
return res.message || I18n.t('config.wechatQrIntro');
|
||||
}
|
||||
}
|
||||
|
||||
function getInteractiveLoginButton(overlay) {
|
||||
return overlay && overlay.querySelector('.configure-actions button[data-interactive-login="true"]');
|
||||
}
|
||||
|
||||
function getInteractiveLoginPanel(overlay) {
|
||||
return overlay && overlay.querySelector('.configure-qr-login');
|
||||
}
|
||||
|
||||
function updateInteractiveLoginPanel(overlay, res) {
|
||||
const panel = getInteractiveLoginPanel(overlay);
|
||||
if (!panel) return;
|
||||
const name = overlay && overlay.dataset ? overlay.dataset.extensionName : '';
|
||||
const status = panel.querySelector('[data-qr-status="true"]');
|
||||
const link = panel.querySelector('[data-qr-link="true"]');
|
||||
|
||||
panel.style.display = '';
|
||||
if (status) {
|
||||
if (name === 'wechat' && res.status === 'refreshed') {
|
||||
status.textContent = I18n.t('config.wechatQrRefreshedHint');
|
||||
} else {
|
||||
status.textContent = interactiveLoginStatusText(name, res);
|
||||
}
|
||||
}
|
||||
|
||||
if (link && res.qr_code_url) {
|
||||
link.href = res.qr_code_url;
|
||||
link.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
function setInteractiveLoginBusy(overlay, busy, label) {
|
||||
const loginBtn = getInteractiveLoginButton(overlay);
|
||||
if (!loginBtn) return;
|
||||
loginBtn.disabled = !!busy;
|
||||
loginBtn.textContent = label || loginBtn.dataset.defaultLabel || I18n.t('auth.connect');
|
||||
}
|
||||
|
||||
function startInteractiveLogin(name, overlay) {
|
||||
if (!overlay || !document.body.contains(overlay)) return;
|
||||
clearConfigureInlineError(overlay);
|
||||
setConfigureInlineStatus(
|
||||
overlay,
|
||||
name === 'wechat' ? I18n.t('config.wechatPreparingQr') : I18n.t('status.connecting'),
|
||||
);
|
||||
setInteractiveLoginBusy(overlay, true, interactiveLoginWaitingLabel(name));
|
||||
|
||||
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/login/start', {
|
||||
method: 'POST',
|
||||
body: { force: true },
|
||||
})
|
||||
.then((res) => {
|
||||
if (!overlay || !document.body.contains(overlay)) return;
|
||||
if (!res.success || !res.session_id) {
|
||||
setInteractiveLoginBusy(overlay, false);
|
||||
setConfigureInlineError(
|
||||
overlay,
|
||||
res.message || I18n.t('config.interactiveLoginStartFailed'),
|
||||
);
|
||||
setConfigureInlineStatus(overlay, '');
|
||||
return;
|
||||
}
|
||||
|
||||
overlay.dataset.interactiveLoginSessionId = res.session_id;
|
||||
updateInteractiveLoginPanel(overlay, res);
|
||||
setConfigureInlineStatus(overlay, interactiveLoginStatusText(name, res));
|
||||
pollInteractiveLogin(name, overlay, res.session_id);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!overlay || !document.body.contains(overlay)) return;
|
||||
setInteractiveLoginBusy(overlay, false);
|
||||
setConfigureInlineError(
|
||||
overlay,
|
||||
err.message || I18n.t('config.interactiveLoginStartFailed'),
|
||||
);
|
||||
setConfigureInlineStatus(overlay, '');
|
||||
});
|
||||
}
|
||||
|
||||
function pollInteractiveLogin(name, overlay, sessionId) {
|
||||
if (!overlay || !document.body.contains(overlay)) return;
|
||||
if (overlay.dataset.interactiveLoginSessionId !== sessionId) return;
|
||||
|
||||
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/login/poll', {
|
||||
method: 'POST',
|
||||
body: { session_id: sessionId },
|
||||
})
|
||||
.then((res) => {
|
||||
if (!overlay || !document.body.contains(overlay)) return;
|
||||
if (overlay.dataset.interactiveLoginSessionId !== sessionId) return;
|
||||
|
||||
updateInteractiveLoginPanel(overlay, res);
|
||||
setConfigureInlineStatus(overlay, interactiveLoginStatusText(name, res));
|
||||
|
||||
if (res.status === 'pending' || res.status === 'scanned' || res.status === 'refreshed') {
|
||||
if (res.status === 'refreshed') {
|
||||
setInteractiveLoginBusy(overlay, true, interactiveLoginWaitingLabel(name));
|
||||
}
|
||||
window.setTimeout(function() {
|
||||
pollInteractiveLogin(name, overlay, sessionId);
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.success && res.activated) {
|
||||
closeConfigureModal(name);
|
||||
showToast(res.message || I18n.t('config.connectedSuccess', { name: name }), 'success');
|
||||
refreshCurrentSettingsTab();
|
||||
return;
|
||||
}
|
||||
|
||||
setInteractiveLoginBusy(overlay, false);
|
||||
setConfigureInlineError(overlay, res.message || I18n.t('config.interactiveLoginFailed'));
|
||||
setConfigureInlineStatus(overlay, '');
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!overlay || !document.body.contains(overlay)) return;
|
||||
if (overlay.dataset.interactiveLoginSessionId !== sessionId) return;
|
||||
setInteractiveLoginBusy(overlay, false);
|
||||
setConfigureInlineError(overlay, err.message || I18n.t('config.interactiveLoginFailed'));
|
||||
setConfigureInlineStatus(overlay, '');
|
||||
});
|
||||
}
|
||||
|
||||
function renderTelegramVerificationChallenge(overlay, verification) {
|
||||
@@ -3517,9 +3736,9 @@ function renderWasmChannelStepper(ext) {
|
||||
var status = ext.activation_status || 'installed';
|
||||
|
||||
var steps = [
|
||||
{ label: 'Installed', key: 'installed' },
|
||||
{ label: 'Configured', key: 'configured' },
|
||||
{ label: status === 'pairing' ? 'Awaiting Pairing' : 'Active', key: 'active' },
|
||||
{ label: I18n.t('status.installed'), key: 'installed' },
|
||||
{ label: I18n.t('status.configured'), key: 'configured' },
|
||||
{ label: status === 'pairing' ? I18n.t('status.pairingShort') : I18n.t('status.active'), key: 'active' },
|
||||
];
|
||||
|
||||
var reachedIdx;
|
||||
@@ -4265,9 +4484,9 @@ function renderRoutineDetail(routine) {
|
||||
+ '<th>Trigger</th><th>Started</th><th>Completed</th><th>Status</th><th>Summary</th><th>Tokens</th>'
|
||||
+ '</tr></thead><tbody>';
|
||||
for (const run of routine.recent_runs) {
|
||||
const runStatusClass = run.status === 'Ok' ? 'completed'
|
||||
: run.status === 'Failed' ? 'failed'
|
||||
: run.status === 'Attention' ? 'stuck'
|
||||
const runStatusClass = run.status === 'ok' ? 'completed'
|
||||
: run.status === 'failed' ? 'failed'
|
||||
: run.status === 'attention' ? 'stuck'
|
||||
: 'in_progress';
|
||||
html += '<tr>'
|
||||
+ '<td>' + escapeHtml(run.trigger_type) + '</td>'
|
||||
|
||||
@@ -54,7 +54,9 @@ I18n.register('en', {
|
||||
'status.restart': 'Restart',
|
||||
'status.active': 'Active',
|
||||
'status.installed': 'Installed',
|
||||
'status.configured': 'Configured',
|
||||
'status.awaitingPairing': 'Awaiting Pairing',
|
||||
'status.pairingShort': 'Pairing',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.connections': 'Connections',
|
||||
@@ -359,6 +361,23 @@ I18n.register('en', {
|
||||
'config.telegramStartOver': 'Start over',
|
||||
'config.telegramStartOverHint': 'Telegram verification did not complete. Click Start over to generate a new code and try again.',
|
||||
'config.telegramOpenBot': 'Open bot in Telegram',
|
||||
'config.wechatHint': 'Open the WeChat QR page in a new tab, then scan and confirm in WeChat.',
|
||||
'config.wechatConnect': 'Open QR Page',
|
||||
'config.wechatWaiting': 'Waiting for scan...',
|
||||
'config.wechatPreparingQr': 'Preparing WeChat QR page...',
|
||||
'config.wechatQrTitle': 'Open WeChat QR Page',
|
||||
'config.wechatQrOpen': 'Open QR Page',
|
||||
'config.wechatQrIntro': 'The QR flow opens in a separate tab.',
|
||||
'config.wechatQrReady': 'QR page is ready. Open it in a new tab, then scan and confirm in WeChat.',
|
||||
'config.wechatQrWaiting': 'Preparing the WeChat QR page...',
|
||||
'config.wechatQrScanned': 'QR scanned. Confirm the login in WeChat.',
|
||||
'config.wechatQrRefreshed': 'QR page refreshed.',
|
||||
'config.wechatQrRefreshedHint': 'The previous QR page expired. Open the new page and scan again.',
|
||||
'config.wechatConnected': 'WeChat connected.',
|
||||
'config.wechatQrFailed': 'WeChat connection failed.',
|
||||
'config.interactiveLoginStartFailed': 'Failed to start interactive login',
|
||||
'config.interactiveLoginFailed': 'Interactive login failed',
|
||||
'config.connectedSuccess': '{name} connected successfully',
|
||||
'config.optional': ' (optional)',
|
||||
'config.alreadySet': '(already set — leave empty to keep)',
|
||||
'config.alreadyConfigured': 'Already configured',
|
||||
|
||||
@@ -54,7 +54,9 @@ I18n.register('zh-CN', {
|
||||
'status.restart': '重启',
|
||||
'status.active': '已激活',
|
||||
'status.installed': '已安装',
|
||||
'status.configured': '已配置',
|
||||
'status.awaitingPairing': '等待配对',
|
||||
'status.pairingShort': '配对中',
|
||||
|
||||
// 仪表盘
|
||||
'dashboard.connections': '连接数',
|
||||
@@ -358,6 +360,24 @@ I18n.register('zh-CN', {
|
||||
'config.telegramCommandLabel': '请在 Telegram 中发送:',
|
||||
'config.telegramStartOver': '重新开始',
|
||||
'config.telegramStartOverHint': 'Telegram 验证未完成。点击“重新开始”以生成新的验证码并重试。',
|
||||
'config.telegramOpenBot': '在 Telegram 中打开机器人',
|
||||
'config.wechatHint': '在新标签页打开微信扫码页,然后在微信里扫码并确认。',
|
||||
'config.wechatConnect': '打开扫码页',
|
||||
'config.wechatWaiting': '等待扫码中...',
|
||||
'config.wechatPreparingQr': '正在准备微信扫码页...',
|
||||
'config.wechatQrTitle': '打开微信扫码页',
|
||||
'config.wechatQrOpen': '打开扫码页',
|
||||
'config.wechatQrIntro': '扫码流程会在新标签页中打开。',
|
||||
'config.wechatQrReady': '扫码页已就绪。请在新标签页打开后,用微信扫码并确认。',
|
||||
'config.wechatQrWaiting': '正在准备微信扫码页...',
|
||||
'config.wechatQrScanned': '已扫码,请在微信中确认登录。',
|
||||
'config.wechatQrRefreshed': '扫码页已刷新。',
|
||||
'config.wechatQrRefreshedHint': '之前的扫码页已过期,请打开新页面重新扫码。',
|
||||
'config.wechatConnected': '微信已连接。',
|
||||
'config.wechatQrFailed': '微信连接失败。',
|
||||
'config.interactiveLoginStartFailed': '启动交互式登录失败',
|
||||
'config.interactiveLoginFailed': '交互式登录失败',
|
||||
'config.connectedSuccess': '{name} 连接成功',
|
||||
'config.optional': '(可选)',
|
||||
'config.alreadySet': '(已设置 — 留空以保持不变)',
|
||||
'config.alreadyConfigured': '已配置',
|
||||
|
||||
@@ -2961,15 +2961,18 @@ body {
|
||||
/* WASM channel setup stepper */
|
||||
.ext-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 0;
|
||||
margin: 8px 0 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stepper-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.stepper-circle {
|
||||
@@ -2986,7 +2989,10 @@ body {
|
||||
|
||||
.stepper-label {
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.25;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stepper-step.completed .stepper-circle {
|
||||
@@ -3043,7 +3049,8 @@ body {
|
||||
height: 2px;
|
||||
background: var(--border);
|
||||
margin: 0 4px;
|
||||
flex-shrink: 0;
|
||||
flex: 0 0 20px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.stepper-connector.completed {
|
||||
@@ -3238,6 +3245,17 @@ body {
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.configure-qr-login {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin: 16px 0 0 0;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.configure-verification-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
@@ -3262,14 +3280,29 @@ body {
|
||||
}
|
||||
|
||||
.configure-verification-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: fit-content;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent, var(--text-link, #4ea3ff));
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: background var(--transition-fast), transform 150ms var(--ease-spring);
|
||||
}
|
||||
|
||||
.configure-verification-link:hover {
|
||||
text-decoration: underline;
|
||||
background: var(--badge-sandbox-bg);
|
||||
transform: translateY(-1px);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.configure-verification-link:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.configure-inline-error {
|
||||
|
||||
@@ -10,7 +10,8 @@ use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
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::ws::WsConnectionTracker;
|
||||
|
||||
@@ -64,8 +65,9 @@ impl TestGatewayBuilder {
|
||||
pub fn build(self) -> Arc<GatewayState> {
|
||||
Arc::new(GatewayState {
|
||||
msg_tx: tokio::sync::RwLock::new(self.msg_tx),
|
||||
sse: SseManager::new(),
|
||||
sse: Arc::new(SseManager::new()),
|
||||
workspace: None,
|
||||
workspace_pool: None,
|
||||
session_manager: None,
|
||||
log_broadcaster: None,
|
||||
log_level_handle: None,
|
||||
@@ -74,14 +76,15 @@ impl TestGatewayBuilder {
|
||||
store: None,
|
||||
job_manager: None,
|
||||
prompt_queue: None,
|
||||
user_id: self.user_id,
|
||||
owner_id: self.user_id.clone(),
|
||||
default_sender_id: self.user_id,
|
||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
||||
llm_provider: self.llm_provider,
|
||||
skill_registry: None,
|
||||
skill_catalog: None,
|
||||
scheduler: None,
|
||||
chat_rate_limiter: RateLimiter::new(30, 60),
|
||||
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(),
|
||||
@@ -98,11 +101,26 @@ impl TestGatewayBuilder {
|
||||
self,
|
||||
auth_token: &str,
|
||||
) -> Result<(SocketAddr, Arc<GatewayState>), crate::error::ChannelError> {
|
||||
let auth = MultiAuthState::single(auth_token.to_string(), "test-user".to_string());
|
||||
let state = self.build();
|
||||
let addr: SocketAddr = "127.0.0.1:0"
|
||||
.parse()
|
||||
.expect("hard-coded address must parse");
|
||||
let bound = start_server(addr, state.clone(), auth_token.to_string()).await?;
|
||||
.expect("hard-coded address must parse"); // safety: constant literal
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Integration tests for the web gateway module.
|
||||
|
||||
mod multi_tenant;
|
||||
@@ -0,0 +1,833 @@
|
||||
//! 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::GatewayChannel;
|
||||
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;
|
||||
use crate::config::GatewayConfig;
|
||||
|
||||
// ── 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,
|
||||
owner_id: "test".to_string(),
|
||||
default_sender_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(),
|
||||
})
|
||||
}
|
||||
|
||||
fn gateway_config() -> GatewayConfig {
|
||||
GatewayConfig {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 3000,
|
||||
auth_token: Some("gateway-auth".to_string()),
|
||||
user_id: "gateway-sender".to_string(),
|
||||
workspace_read_scopes: Vec::new(),
|
||||
memory_layers: Vec::new(),
|
||||
user_tokens: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_owner_scope_updates_gateway_owner_scope_in_multi_user_mode() {
|
||||
let mut gateway = GatewayChannel::new(gateway_config());
|
||||
gateway.auth = two_user_auth();
|
||||
gateway.config.user_tokens = Some(HashMap::new());
|
||||
let gateway = gateway.with_owner_scope("owner-scope");
|
||||
|
||||
assert_eq!(gateway.state.owner_id, "owner-scope");
|
||||
assert_eq!(gateway.state.default_sender_id, "gateway-sender");
|
||||
|
||||
let alice = gateway
|
||||
.auth
|
||||
.authenticate("tok-alice")
|
||||
.expect("alice token should remain valid");
|
||||
let bob = gateway
|
||||
.auth
|
||||
.authenticate("tok-bob")
|
||||
.expect("bob token should remain valid");
|
||||
assert_eq!(alice.user_id, "alice");
|
||||
assert_eq!(bob.user_id, "bob");
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
+63
-207
@@ -63,6 +63,9 @@ pub struct TurnInfo {
|
||||
pub started_at: String,
|
||||
pub completed_at: Option<String>,
|
||||
pub tool_calls: Vec<ToolCallInfo>,
|
||||
/// Agent's reasoning narrative for this turn.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub narrative: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -74,6 +77,9 @@ pub struct ToolCallInfo {
|
||||
pub result_preview: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
/// Agent's reasoning for choosing this tool.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rationale: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -114,165 +120,9 @@ pub struct ApprovalRequest {
|
||||
pub thread_id: Option<String>,
|
||||
}
|
||||
|
||||
// --- SSE Event Types ---
|
||||
// --- App Event (re-exported from ironclaw_common) ---
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum SseEvent {
|
||||
#[serde(rename = "response")]
|
||||
Response { content: String, thread_id: String },
|
||||
#[serde(rename = "thinking")]
|
||||
Thinking {
|
||||
message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
#[serde(rename = "tool_started")]
|
||||
ToolStarted {
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
#[serde(rename = "tool_completed")]
|
||||
ToolCompleted {
|
||||
name: String,
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
parameters: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
#[serde(rename = "tool_result")]
|
||||
ToolResult {
|
||||
name: String,
|
||||
preview: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
#[serde(rename = "stream_chunk")]
|
||||
StreamChunk {
|
||||
content: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
#[serde(rename = "status")]
|
||||
Status {
|
||||
message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
#[serde(rename = "job_started")]
|
||||
JobStarted {
|
||||
job_id: String,
|
||||
title: String,
|
||||
browse_url: String,
|
||||
},
|
||||
#[serde(rename = "approval_needed")]
|
||||
ApprovalNeeded {
|
||||
request_id: String,
|
||||
tool_name: String,
|
||||
description: String,
|
||||
parameters: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
/// Whether the "always" auto-approve option should be shown.
|
||||
allow_always: bool,
|
||||
},
|
||||
#[serde(rename = "auth_required")]
|
||||
AuthRequired {
|
||||
extension_name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
instructions: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
auth_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
setup_url: Option<String>,
|
||||
},
|
||||
#[serde(rename = "auth_completed")]
|
||||
AuthCompleted {
|
||||
extension_name: String,
|
||||
success: bool,
|
||||
message: String,
|
||||
},
|
||||
#[serde(rename = "error")]
|
||||
Error {
|
||||
message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
#[serde(rename = "heartbeat")]
|
||||
Heartbeat,
|
||||
|
||||
// Sandbox job streaming events (worker + Claude Code bridge)
|
||||
#[serde(rename = "job_message")]
|
||||
JobMessage {
|
||||
job_id: String,
|
||||
role: String,
|
||||
content: String,
|
||||
},
|
||||
#[serde(rename = "job_tool_use")]
|
||||
JobToolUse {
|
||||
job_id: String,
|
||||
tool_name: String,
|
||||
input: serde_json::Value,
|
||||
},
|
||||
#[serde(rename = "job_tool_result")]
|
||||
JobToolResult {
|
||||
job_id: String,
|
||||
tool_name: String,
|
||||
output: String,
|
||||
},
|
||||
#[serde(rename = "job_status")]
|
||||
JobStatus { job_id: String, message: String },
|
||||
#[serde(rename = "job_result")]
|
||||
JobResult {
|
||||
job_id: String,
|
||||
status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
session_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
fallback_deliverable: Option<serde_json::Value>,
|
||||
},
|
||||
|
||||
/// An image was generated by a tool.
|
||||
#[serde(rename = "image_generated")]
|
||||
ImageGenerated {
|
||||
data_url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
|
||||
/// Suggested follow-up messages for the user.
|
||||
#[serde(rename = "suggestions")]
|
||||
Suggestions {
|
||||
suggestions: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
|
||||
/// Per-turn token usage and cost summary.
|
||||
#[serde(rename = "turn_cost")]
|
||||
TurnCost {
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
cost_usd: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
|
||||
/// Extension activation status change (WASM channels).
|
||||
#[serde(rename = "extension_status")]
|
||||
ExtensionStatus {
|
||||
extension_name: String,
|
||||
status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
message: Option<String>,
|
||||
},
|
||||
}
|
||||
pub use ironclaw_common::{AppEvent, ToolDecisionDto};
|
||||
|
||||
// --- Memory ---
|
||||
|
||||
@@ -456,6 +306,7 @@ pub fn classify_wasm_channel_activation(
|
||||
ext: &crate::extensions::InstalledExtension,
|
||||
has_paired: bool,
|
||||
has_owner_binding: bool,
|
||||
requires_binding: bool,
|
||||
) -> Option<ExtensionActivationStatus> {
|
||||
if ext.kind != crate::extensions::ExtensionKind::WasmChannel {
|
||||
return None;
|
||||
@@ -466,7 +317,7 @@ pub fn classify_wasm_channel_activation(
|
||||
} else if !ext.authenticated {
|
||||
ExtensionActivationStatus::Installed
|
||||
} else if ext.active {
|
||||
if has_paired || has_owner_binding {
|
||||
if !requires_binding || has_paired || has_owner_binding {
|
||||
ExtensionActivationStatus::Active
|
||||
} else {
|
||||
ExtensionActivationStatus::Pairing
|
||||
@@ -536,6 +387,8 @@ pub struct ExtensionSetupResponse {
|
||||
pub kind: String,
|
||||
pub secrets: Vec<SecretFieldInfo>,
|
||||
pub fields: Vec<SetupFieldInfo>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub interactive_login: Option<crate::extensions::InteractiveLoginInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -568,6 +421,32 @@ pub struct ExtensionSetupRequest {
|
||||
pub fields: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ExtensionInteractiveLoginStartRequest {
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ExtensionInteractiveLoginPollRequest {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ExtensionInteractiveLoginResponse {
|
||||
pub success: bool,
|
||||
pub status: String,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub qr_code_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub instructions: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub activated: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ActionResponse {
|
||||
pub success: bool,
|
||||
@@ -784,32 +663,9 @@ pub enum WsServerMessage {
|
||||
}
|
||||
|
||||
impl WsServerMessage {
|
||||
/// Create a WsServerMessage from an SseEvent.
|
||||
pub fn from_sse_event(event: &SseEvent) -> Self {
|
||||
let event_type = match event {
|
||||
SseEvent::Response { .. } => "response",
|
||||
SseEvent::Thinking { .. } => "thinking",
|
||||
SseEvent::ToolStarted { .. } => "tool_started",
|
||||
SseEvent::ToolCompleted { .. } => "tool_completed",
|
||||
SseEvent::ToolResult { .. } => "tool_result",
|
||||
SseEvent::StreamChunk { .. } => "stream_chunk",
|
||||
SseEvent::Status { .. } => "status",
|
||||
SseEvent::JobStarted { .. } => "job_started",
|
||||
SseEvent::ApprovalNeeded { .. } => "approval_needed",
|
||||
SseEvent::AuthRequired { .. } => "auth_required",
|
||||
SseEvent::AuthCompleted { .. } => "auth_completed",
|
||||
SseEvent::Error { .. } => "error",
|
||||
SseEvent::Heartbeat => "heartbeat",
|
||||
SseEvent::JobMessage { .. } => "job_message",
|
||||
SseEvent::JobToolUse { .. } => "job_tool_use",
|
||||
SseEvent::JobToolResult { .. } => "job_tool_result",
|
||||
SseEvent::JobStatus { .. } => "job_status",
|
||||
SseEvent::JobResult { .. } => "job_result",
|
||||
SseEvent::ImageGenerated { .. } => "image_generated",
|
||||
SseEvent::Suggestions { .. } => "suggestions",
|
||||
SseEvent::TurnCost { .. } => "turn_cost",
|
||||
SseEvent::ExtensionStatus { .. } => "extension_status",
|
||||
};
|
||||
/// Create a WsServerMessage from an AppEvent.
|
||||
pub fn from_app_event(event: &AppEvent) -> Self {
|
||||
let event_type = event.event_type();
|
||||
let data = serde_json::to_value(event).unwrap_or(serde_json::Value::Null);
|
||||
WsServerMessage::Event {
|
||||
event_type: event_type.to_string(),
|
||||
@@ -1101,12 +957,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ws_server_from_sse_response() {
|
||||
let sse = SseEvent::Response {
|
||||
fn test_ws_server_from_app_event_response() {
|
||||
let event = AppEvent::Response {
|
||||
content: "hello".to_string(),
|
||||
thread_id: "t1".to_string(),
|
||||
};
|
||||
let ws = WsServerMessage::from_sse_event(&sse);
|
||||
let ws = WsServerMessage::from_app_event(&event);
|
||||
match ws {
|
||||
WsServerMessage::Event { event_type, data } => {
|
||||
assert_eq!(event_type, "response");
|
||||
@@ -1118,12 +974,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ws_server_from_sse_thinking() {
|
||||
let sse = SseEvent::Thinking {
|
||||
fn test_ws_server_from_app_event_thinking() {
|
||||
let event = AppEvent::Thinking {
|
||||
message: "reasoning...".to_string(),
|
||||
thread_id: None,
|
||||
};
|
||||
let ws = WsServerMessage::from_sse_event(&sse);
|
||||
let ws = WsServerMessage::from_app_event(&event);
|
||||
match ws {
|
||||
WsServerMessage::Event { event_type, data } => {
|
||||
assert_eq!(event_type, "thinking");
|
||||
@@ -1134,8 +990,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ws_server_from_sse_approval_needed() {
|
||||
let sse = SseEvent::ApprovalNeeded {
|
||||
fn test_ws_server_from_app_event_approval_needed() {
|
||||
let event = AppEvent::ApprovalNeeded {
|
||||
request_id: "r1".to_string(),
|
||||
tool_name: "shell".to_string(),
|
||||
description: "Run ls".to_string(),
|
||||
@@ -1143,7 +999,7 @@ mod tests {
|
||||
thread_id: Some("t1".to_string()),
|
||||
allow_always: true,
|
||||
};
|
||||
let ws = WsServerMessage::from_sse_event(&sse);
|
||||
let ws = WsServerMessage::from_app_event(&event);
|
||||
match ws {
|
||||
WsServerMessage::Event { event_type, data } => {
|
||||
assert_eq!(event_type, "approval_needed");
|
||||
@@ -1155,9 +1011,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ws_server_from_sse_heartbeat() {
|
||||
let sse = SseEvent::Heartbeat;
|
||||
let ws = WsServerMessage::from_sse_event(&sse);
|
||||
fn test_ws_server_from_app_event_heartbeat() {
|
||||
let event = AppEvent::Heartbeat;
|
||||
let ws = WsServerMessage::from_app_event(&event);
|
||||
match ws {
|
||||
WsServerMessage::Event { event_type, .. } => {
|
||||
assert_eq!(event_type, "heartbeat");
|
||||
@@ -1197,8 +1053,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sse_auth_required_serialize() {
|
||||
let event = SseEvent::AuthRequired {
|
||||
fn test_app_event_auth_required_serialize() {
|
||||
let event = AppEvent::AuthRequired {
|
||||
extension_name: "notion".to_string(),
|
||||
instructions: Some("Get your token from...".to_string()),
|
||||
auth_url: None,
|
||||
@@ -1214,8 +1070,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sse_auth_completed_serialize() {
|
||||
let event = SseEvent::AuthCompleted {
|
||||
fn test_app_event_auth_completed_serialize() {
|
||||
let event = AppEvent::AuthCompleted {
|
||||
extension_name: "notion".to_string(),
|
||||
success: true,
|
||||
message: "notion authenticated (3 tools loaded)".to_string(),
|
||||
@@ -1228,14 +1084,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ws_server_from_sse_auth_required() {
|
||||
let sse = SseEvent::AuthRequired {
|
||||
fn test_ws_server_from_app_event_auth_required() {
|
||||
let event = AppEvent::AuthRequired {
|
||||
extension_name: "openai".to_string(),
|
||||
instructions: Some("Enter API key".to_string()),
|
||||
auth_url: None,
|
||||
setup_url: None,
|
||||
};
|
||||
let ws = WsServerMessage::from_sse_event(&sse);
|
||||
let ws = WsServerMessage::from_app_event(&event);
|
||||
match ws {
|
||||
WsServerMessage::Event { event_type, data } => {
|
||||
assert_eq!(event_type, "auth_required");
|
||||
@@ -1246,13 +1102,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ws_server_from_sse_auth_completed() {
|
||||
let sse = SseEvent::AuthCompleted {
|
||||
fn test_ws_server_from_app_event_auth_completed() {
|
||||
let event = AppEvent::AuthCompleted {
|
||||
extension_name: "slack".to_string(),
|
||||
success: false,
|
||||
message: "Invalid token".to_string(),
|
||||
};
|
||||
let ws = WsServerMessage::from_sse_event(&sse);
|
||||
let ws = WsServerMessage::from_app_event(&event);
|
||||
match ws {
|
||||
WsServerMessage::Event { event_type, data } => {
|
||||
assert_eq!(event_type, "auth_completed");
|
||||
|
||||
+86
-115
@@ -2,28 +2,21 @@
|
||||
|
||||
use crate::channels::web::types::{ToolCallInfo, TurnInfo};
|
||||
|
||||
/// Truncate a string to at most `max_bytes` bytes at a char boundary, appending "...".
|
||||
///
|
||||
/// If the input is wrapped in `<tool_output …>…</tool_output>` and truncation
|
||||
/// removes the closing tag, the tag is re-appended so downstream XML parsers
|
||||
/// never see an unclosed element.
|
||||
pub fn truncate_preview(s: &str, max_bytes: usize) -> String {
|
||||
if s.len() <= max_bytes {
|
||||
return s.to_string();
|
||||
}
|
||||
// Walk backwards from max_bytes to find a valid char boundary
|
||||
let mut end = max_bytes;
|
||||
while end > 0 && !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
let mut result = format!("{}...", &s[..end]);
|
||||
pub use ironclaw_common::truncate_preview;
|
||||
|
||||
// Re-close <tool_output> if truncation cut through the closing tag.
|
||||
if s.starts_with("<tool_output") && !result.ends_with("</tool_output>") {
|
||||
result.push_str("\n</tool_output>");
|
||||
}
|
||||
|
||||
result
|
||||
/// Parse tool call summary JSON objects into `ToolCallInfo` structs.
|
||||
fn parse_tool_call_infos(calls: &[serde_json::Value]) -> Vec<ToolCallInfo> {
|
||||
calls
|
||||
.iter()
|
||||
.map(|c| ToolCallInfo {
|
||||
name: c["name"].as_str().unwrap_or("unknown").to_string(),
|
||||
has_result: c.get("result_preview").is_some_and(|v| !v.is_null()),
|
||||
has_error: c.get("error").is_some_and(|v| !v.is_null()),
|
||||
result_preview: c["result_preview"].as_str().map(String::from),
|
||||
error: c["error"].as_str().map(String::from),
|
||||
rationale: c["rationale"].as_str().map(String::from),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build TurnInfo pairs from flat DB messages (user/tool_calls/assistant triples).
|
||||
@@ -49,6 +42,7 @@ pub fn build_turns_from_db_messages(
|
||||
started_at: msg.created_at.to_rfc3339(),
|
||||
completed_at: None,
|
||||
tool_calls: Vec::new(),
|
||||
narrative: None,
|
||||
};
|
||||
|
||||
// Check if next message is a tool_calls record
|
||||
@@ -56,18 +50,28 @@ pub fn build_turns_from_db_messages(
|
||||
&& next.role == "tool_calls"
|
||||
{
|
||||
let tc_msg = iter.next().expect("peeked");
|
||||
match serde_json::from_str::<Vec<serde_json::Value>>(&tc_msg.content) {
|
||||
Ok(calls) => {
|
||||
turn.tool_calls = calls
|
||||
.iter()
|
||||
.map(|c| ToolCallInfo {
|
||||
name: c["name"].as_str().unwrap_or("unknown").to_string(),
|
||||
has_result: c.get("result_preview").is_some(),
|
||||
has_error: c.get("error").is_some(),
|
||||
result_preview: c["result_preview"].as_str().map(String::from),
|
||||
error: c["error"].as_str().map(String::from),
|
||||
})
|
||||
.collect();
|
||||
// Parse tool_calls JSON — supports two formats:
|
||||
// safety: no byte-index slicing; comment describes JSON shape
|
||||
match serde_json::from_str::<serde_json::Value>(&tc_msg.content) {
|
||||
Ok(serde_json::Value::Array(calls)) => {
|
||||
// Old format: plain array
|
||||
turn.tool_calls = parse_tool_call_infos(&calls);
|
||||
}
|
||||
Ok(serde_json::Value::Object(obj)) => {
|
||||
// New wrapped format with narrative
|
||||
turn.narrative = obj
|
||||
.get("narrative")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
if let Some(serde_json::Value::Array(calls)) = obj.get("calls") {
|
||||
turn.tool_calls = parse_tool_call_infos(calls);
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
tracing::warn!(
|
||||
message_id = %tc_msg.id,
|
||||
"Unexpected tool_calls JSON shape in DB, skipping"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
@@ -105,6 +109,7 @@ pub fn build_turns_from_db_messages(
|
||||
started_at: msg.created_at.to_rfc3339(),
|
||||
completed_at: Some(msg.created_at.to_rfc3339()),
|
||||
tool_calls: Vec::new(),
|
||||
narrative: None,
|
||||
});
|
||||
turn_number += 1;
|
||||
}
|
||||
@@ -118,88 +123,6 @@ mod tests {
|
||||
use super::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ---- truncate_preview tests ----
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_short_string() {
|
||||
assert_eq!(truncate_preview("hello", 10), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_exact_boundary() {
|
||||
assert_eq!(truncate_preview("hello", 5), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_truncates_ascii() {
|
||||
assert_eq!(truncate_preview("hello world", 5), "hello...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_empty_string() {
|
||||
assert_eq!(truncate_preview("", 10), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_multibyte_char_boundary() {
|
||||
// '€' is 3 bytes (E2 82 AC). "a€b" = [61, E2, 82, AC, 62] = 5 bytes
|
||||
// Truncating at max_bytes=3 should not split the euro sign.
|
||||
let s = "a€b";
|
||||
let result = truncate_preview(s, 3);
|
||||
// max_bytes=3 lands mid-€, so it walks back to byte 1 ("a")
|
||||
assert_eq!(result, "a...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_emoji() {
|
||||
// '🦀' is 4 bytes. "hi🦀" = 6 bytes
|
||||
let s = "hi🦀";
|
||||
let result = truncate_preview(s, 4);
|
||||
// max_bytes=4 lands mid-🦀, walks back to byte 2 ("hi")
|
||||
assert_eq!(result, "hi...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_cjk() {
|
||||
// CJK characters are 3 bytes each. "你好世界" = 12 bytes
|
||||
let s = "你好世界";
|
||||
let result = truncate_preview(s, 7);
|
||||
// max_bytes=7 lands mid-character (byte 7 is inside 世), walks back to 6 ("你好")
|
||||
assert_eq!(result, "你好...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_zero_max_bytes() {
|
||||
assert_eq!(truncate_preview("hello", 0), "...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_closes_tool_output_tag() {
|
||||
let s = "<tool_output name=\"search\">\nSome very long content here\n</tool_output>";
|
||||
// Truncate so it cuts before the closing tag
|
||||
let result = truncate_preview(s, 60);
|
||||
assert!(result.ends_with("</tool_output>"));
|
||||
assert!(result.contains("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_no_extra_close_when_intact() {
|
||||
let s = "<tool_output name=\"echo\">\nshort\n</tool_output>";
|
||||
// The string is short enough not to be truncated
|
||||
let result = truncate_preview(s, 500);
|
||||
assert_eq!(result, s);
|
||||
// Should not have a duplicate closing tag
|
||||
assert_eq!(result.matches("</tool_output>").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_non_xml_unaffected() {
|
||||
let s = "Just a plain long string that gets truncated";
|
||||
let result = truncate_preview(s, 10);
|
||||
assert_eq!(result, "Just a pla...");
|
||||
assert!(!result.contains("</tool_output>"));
|
||||
}
|
||||
|
||||
// ---- build_turns_from_db_messages tests ----
|
||||
|
||||
fn make_msg(role: &str, content: &str, offset_ms: i64) -> crate::history::ConversationMessage {
|
||||
@@ -305,4 +228,52 @@ mod tests {
|
||||
assert!(turns[0].tool_calls.is_empty());
|
||||
assert_eq!(turns[0].state, "Completed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_turns_with_wrapped_tool_calls_format() {
|
||||
let tc_json = serde_json::json!({
|
||||
"narrative": "Searching memory for context before proceeding.",
|
||||
"calls": [
|
||||
{"name": "memory_search", "result_preview": "found 3 items", "rationale": "consult prior context"},
|
||||
{"name": "shell", "error": "permission denied"}
|
||||
]
|
||||
});
|
||||
let messages = vec![
|
||||
make_msg("user", "Find info", 0),
|
||||
make_msg("tool_calls", &tc_json.to_string(), 500),
|
||||
make_msg("assistant", "Here's what I found", 1000),
|
||||
];
|
||||
let turns = build_turns_from_db_messages(&messages);
|
||||
assert_eq!(turns.len(), 1);
|
||||
assert_eq!(
|
||||
turns[0].narrative.as_deref(),
|
||||
Some("Searching memory for context before proceeding.")
|
||||
);
|
||||
assert_eq!(turns[0].tool_calls.len(), 2);
|
||||
assert_eq!(turns[0].tool_calls[0].name, "memory_search");
|
||||
assert_eq!(
|
||||
turns[0].tool_calls[0].rationale.as_deref(),
|
||||
Some("consult prior context")
|
||||
);
|
||||
assert!(turns[0].tool_calls[0].has_result);
|
||||
assert_eq!(turns[0].tool_calls[1].name, "shell");
|
||||
assert!(turns[0].tool_calls[1].has_error);
|
||||
assert_eq!(turns[0].response.as_deref(), Some("Here's what I found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_turns_wrapped_format_without_narrative() {
|
||||
let tc_json = serde_json::json!({
|
||||
"calls": [{"name": "echo", "result_preview": "hello"}]
|
||||
});
|
||||
let messages = vec![
|
||||
make_msg("user", "Say hi", 0),
|
||||
make_msg("tool_calls", &tc_json.to_string(), 500),
|
||||
make_msg("assistant", "Done", 1000),
|
||||
];
|
||||
let turns = build_turns_from_db_messages(&messages);
|
||||
assert_eq!(turns.len(), 1);
|
||||
assert!(turns[0].narrative.is_none());
|
||||
assert_eq!(turns[0].tool_calls.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
+29
-17
@@ -62,7 +62,11 @@ impl Default for WsConnectionTracker {
|
||||
///
|
||||
/// When either task ends (client disconnect or broadcast closed), both are
|
||||
/// 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();
|
||||
|
||||
// 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();
|
||||
|
||||
// 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.
|
||||
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");
|
||||
// Decrement the WS tracker we already incremented above.
|
||||
if let Some(ref tracker) = tracker_for_drop {
|
||||
@@ -93,7 +97,7 @@ pub async fn handle_ws_connection(socket: WebSocket, state: Arc<GatewayState>) {
|
||||
let msg = tokio::select! {
|
||||
event = event_stream.next() => {
|
||||
match event {
|
||||
Some(sse_event) => WsServerMessage::from_sse_event(&sse_event),
|
||||
Some(app_event) => WsServerMessage::from_app_event(&app_event),
|
||||
None => break, // Broadcast channel closed
|
||||
}
|
||||
}
|
||||
@@ -117,7 +121,7 @@ pub async fn handle_ws_connection(socket: WebSocket, state: Arc<GatewayState>) {
|
||||
});
|
||||
|
||||
// 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 {
|
||||
match frame {
|
||||
Message::Text(text) => {
|
||||
@@ -263,11 +267,15 @@ async fn handle_client_message(
|
||||
token,
|
||||
} => {
|
||||
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) => {
|
||||
if result.verification.is_some() {
|
||||
state.sse.broadcast(
|
||||
crate::channels::web::types::SseEvent::AuthRequired {
|
||||
state.sse.broadcast_for_user(
|
||||
user_id,
|
||||
crate::channels::web::types::AppEvent::AuthRequired {
|
||||
extension_name: extension_name.clone(),
|
||||
instructions: Some(result.message),
|
||||
auth_url: None,
|
||||
@@ -275,9 +283,10 @@ async fn handle_client_message(
|
||||
},
|
||||
);
|
||||
} else {
|
||||
crate::channels::web::server::clear_auth_mode(state).await;
|
||||
state.sse.broadcast(
|
||||
crate::channels::web::types::SseEvent::AuthCompleted {
|
||||
crate::channels::web::server::clear_auth_mode(state, user_id).await;
|
||||
state.sse.broadcast_for_user(
|
||||
user_id,
|
||||
crate::channels::web::types::AppEvent::AuthCompleted {
|
||||
extension_name,
|
||||
success: true,
|
||||
message: result.message,
|
||||
@@ -288,8 +297,9 @@ async fn handle_client_message(
|
||||
Err(e) => {
|
||||
let msg = format!("Auth failed: {}", e);
|
||||
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
|
||||
state.sse.broadcast(
|
||||
crate::channels::web::types::SseEvent::AuthRequired {
|
||||
state.sse.broadcast_for_user(
|
||||
user_id,
|
||||
crate::channels::web::types::AppEvent::AuthRequired {
|
||||
extension_name: extension_name.clone(),
|
||||
instructions: Some(msg.clone()),
|
||||
auth_url: None,
|
||||
@@ -311,7 +321,7 @@ async fn handle_client_message(
|
||||
}
|
||||
}
|
||||
WsClientMessage::AuthCancel { .. } => {
|
||||
crate::channels::web::server::clear_auth_mode(state).await;
|
||||
crate::channels::web::server::clear_auth_mode(state, user_id).await;
|
||||
}
|
||||
WsClientMessage::Ping => {
|
||||
let _ = direct_tx.send(WsServerMessage::Pong).await;
|
||||
@@ -498,8 +508,9 @@ mod tests {
|
||||
|
||||
GatewayState {
|
||||
msg_tx: tokio::sync::RwLock::new(msg_tx),
|
||||
sse: SseManager::new(),
|
||||
sse: Arc::new(SseManager::new()),
|
||||
workspace: None,
|
||||
workspace_pool: None,
|
||||
session_manager: None,
|
||||
log_broadcaster: None,
|
||||
log_level_handle: None,
|
||||
@@ -509,13 +520,14 @@ mod tests {
|
||||
job_manager: None,
|
||||
prompt_queue: None,
|
||||
scheduler: None,
|
||||
user_id: "test".to_string(),
|
||||
owner_id: "test".to_string(),
|
||||
default_sender_id: "test".to_string(),
|
||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
||||
llm_provider: None,
|
||||
skill_registry: 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),
|
||||
webhook_rate_limiter: crate::channels::web::server::RateLimiter::new(10, 60),
|
||||
registry_entries: Vec::new(),
|
||||
|
||||
+584
-29
@@ -62,6 +62,30 @@ pub fn builtin_client_id_override_env(secret_name: &str) -> Option<&'static str>
|
||||
}
|
||||
}
|
||||
|
||||
/// Suppress the baked-in desktop OAuth client secret when a hosted proxy is configured.
|
||||
///
|
||||
/// In hosted deployments, IronClaw may resolve the platform Google client ID from
|
||||
/// environment variables while still falling back to the baked-in desktop secret.
|
||||
/// That client_id/client_secret mismatch breaks Google token exchange and refresh.
|
||||
///
|
||||
/// When the proxy is configured, the platform will inject the correct server-side
|
||||
/// secret for matching platform credentials, so the baked-in secret must be omitted.
|
||||
pub fn hosted_proxy_client_secret(
|
||||
client_secret: &Option<String>,
|
||||
builtin: Option<&OAuthCredentials>,
|
||||
exchange_proxy_configured: bool,
|
||||
) -> Option<String> {
|
||||
if !exchange_proxy_configured {
|
||||
return client_secret.clone();
|
||||
}
|
||||
|
||||
let builtin_secret = builtin.map(|credentials| credentials.client_secret);
|
||||
match (client_secret, builtin_secret) {
|
||||
(Some(resolved), Some(baked_in)) if resolved == baked_in => None,
|
||||
_ => client_secret.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared callback server ──────────────────────────────────────────────
|
||||
|
||||
// Core OAuth callback infrastructure is defined in `crate::llm::oauth_helpers`
|
||||
@@ -447,9 +471,10 @@ pub struct PendingOAuthFlow {
|
||||
pub user_id: String,
|
||||
/// Secrets store reference for token persistence.
|
||||
pub secrets: Arc<dyn SecretsStore + Send + Sync>,
|
||||
/// SSE broadcast sender for notifying the web UI.
|
||||
pub sse_sender: Option<tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>>,
|
||||
/// Gateway auth token for authenticating with the platform token exchange proxy.
|
||||
/// SSE broadcast manager for notifying the web UI.
|
||||
pub sse_manager: Option<Arc<crate::channels::web::sse::SseManager>>,
|
||||
/// OAuth proxy auth token for authenticating with the hosted token exchange proxy.
|
||||
/// Kept as `gateway_token` for public API compatibility.
|
||||
pub gateway_token: Option<String>,
|
||||
/// Additional form params for the token exchange request.
|
||||
/// Used for provider-specific requirements such as RFC 8707 `resource`.
|
||||
@@ -472,6 +497,12 @@ impl std::fmt::Debug for PendingOAuthFlow {
|
||||
}
|
||||
}
|
||||
|
||||
impl PendingOAuthFlow {
|
||||
pub fn oauth_proxy_auth_token(&self) -> Option<&str> {
|
||||
self.gateway_token.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe registry of pending OAuth flows, keyed by CSRF `state` parameter.
|
||||
pub type PendingOAuthRegistry = Arc<RwLock<HashMap<String, PendingOAuthFlow>>>;
|
||||
|
||||
@@ -505,6 +536,22 @@ pub fn exchange_proxy_url() -> Option<String> {
|
||||
.filter(|url| !url.is_empty())
|
||||
}
|
||||
|
||||
/// Returns the configured OAuth proxy auth token, if any.
|
||||
///
|
||||
/// New hosted infra can inject a dedicated shared proxy secret via
|
||||
/// `IRONCLAW_OAUTH_PROXY_AUTH_TOKEN`. Existing hosted instances continue to
|
||||
/// work by falling back to `GATEWAY_AUTH_TOKEN`.
|
||||
pub fn oauth_proxy_auth_token() -> Option<String> {
|
||||
fn normalized_env_value(key: &str) -> Option<String> {
|
||||
crate::config::helpers::env_or_override(key)
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
normalized_env_value("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN")
|
||||
.or_else(|| normalized_env_value("GATEWAY_AUTH_TOKEN"))
|
||||
}
|
||||
|
||||
/// Maximum age for pending OAuth flows (5 minutes, matching TCP listener timeout).
|
||||
pub const OAUTH_FLOW_EXPIRY: Duration = Duration::from_secs(300);
|
||||
|
||||
@@ -650,6 +697,8 @@ pub fn strip_instance_prefix(state: &str) -> &str {
|
||||
|
||||
pub struct ProxyTokenExchangeRequest<'a> {
|
||||
pub proxy_url: &'a str,
|
||||
/// OAuth proxy auth token.
|
||||
/// Kept as `gateway_token` for public API compatibility.
|
||||
pub gateway_token: &'a str,
|
||||
pub token_url: &'a str,
|
||||
pub client_id: &'a str,
|
||||
@@ -661,9 +710,53 @@ pub struct ProxyTokenExchangeRequest<'a> {
|
||||
pub extra_token_params: &'a HashMap<String, String>,
|
||||
}
|
||||
|
||||
pub struct ProxyRefreshTokenRequest<'a> {
|
||||
pub proxy_url: &'a str,
|
||||
/// OAuth proxy auth token.
|
||||
/// Kept as `gateway_token` for public API compatibility.
|
||||
pub gateway_token: &'a str,
|
||||
pub token_url: &'a str,
|
||||
pub client_id: &'a str,
|
||||
pub client_secret: Option<&'a str>,
|
||||
pub refresh_token: &'a str,
|
||||
pub provider: Option<&'a str>,
|
||||
}
|
||||
|
||||
fn oauth_token_response_from_json(
|
||||
token_data: serde_json::Value,
|
||||
access_token_field: &str,
|
||||
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
||||
let access_token = token_data
|
||||
.get(access_token_field)
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
let fields: Vec<&str> = token_data
|
||||
.as_object()
|
||||
.map(|o| o.keys().map(|k| k.as_str()).collect())
|
||||
.unwrap_or_default();
|
||||
OAuthCallbackError::Io(format!(
|
||||
"No '{}' field in proxy response (fields present: {:?})",
|
||||
access_token_field, fields
|
||||
))
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
let refresh_token = token_data
|
||||
.get("refresh_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
let expires_in = token_data.get("expires_in").and_then(|v| v.as_u64());
|
||||
|
||||
Ok(OAuthTokenResponse {
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_in,
|
||||
})
|
||||
}
|
||||
|
||||
/// Exchange an OAuth authorization code via the platform's token exchange proxy.
|
||||
///
|
||||
/// Authenticated via the gateway auth token (Bearer header). The caller may
|
||||
/// Authenticated via an OAuth proxy auth token (Bearer header). The caller may
|
||||
/// either rely on proxy-side secret lookup or forward a `client_secret` when
|
||||
/// the provider requires it.
|
||||
///
|
||||
@@ -675,13 +768,14 @@ pub async fn exchange_via_proxy(
|
||||
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
||||
if request.gateway_token.is_empty() {
|
||||
return Err(OAuthCallbackError::Io(
|
||||
"Gateway auth token is required for proxy token exchange".to_string(),
|
||||
"OAuth proxy auth token is required for proxy token exchange".to_string(),
|
||||
));
|
||||
}
|
||||
let exchange_url = format!("{}/oauth/exchange", request.proxy_url.trim_end_matches('/'));
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(60))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|e| OAuthCallbackError::Io(format!("Failed to build HTTP client: {}", e)))?;
|
||||
let mut params = vec![
|
||||
@@ -724,41 +818,454 @@ pub async fn exchange_via_proxy(
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| OAuthCallbackError::Io(format!("Failed to parse proxy response: {}", e)))?;
|
||||
oauth_token_response_from_json(token_data, request.access_token_field)
|
||||
}
|
||||
|
||||
let access_token = token_data
|
||||
.get(request.access_token_field)
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
let fields: Vec<&str> = token_data
|
||||
.as_object()
|
||||
.map(|o| o.keys().map(|k| k.as_str()).collect())
|
||||
.unwrap_or_default();
|
||||
OAuthCallbackError::Io(format!(
|
||||
"No '{}' field in proxy response (fields present: {:?})",
|
||||
request.access_token_field, fields
|
||||
))
|
||||
})?
|
||||
.to_string();
|
||||
/// Refresh an OAuth access token via the platform's token refresh proxy.
|
||||
///
|
||||
/// Authenticated via an OAuth proxy auth token (Bearer header). The caller may
|
||||
/// either rely on proxy-side secret lookup or forward a `client_secret` when
|
||||
/// the provider requires it.
|
||||
pub async fn refresh_token_via_proxy(
|
||||
request: ProxyRefreshTokenRequest<'_>,
|
||||
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
||||
if request.gateway_token.is_empty() {
|
||||
return Err(OAuthCallbackError::Io(
|
||||
"OAuth proxy auth token is required for proxy token refresh".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let refresh_token = token_data
|
||||
.get("refresh_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
let expires_in = token_data.get("expires_in").and_then(|v| v.as_u64());
|
||||
let refresh_url = format!("{}/oauth/refresh", request.proxy_url.trim_end_matches('/'));
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(15))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|e| OAuthCallbackError::Io(format!("Failed to build HTTP client: {}", e)))?;
|
||||
|
||||
Ok(OAuthTokenResponse {
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_in,
|
||||
})
|
||||
let mut params = vec![
|
||||
("refresh_token", request.refresh_token.to_string()),
|
||||
("token_url", request.token_url.to_string()),
|
||||
("client_id", request.client_id.to_string()),
|
||||
];
|
||||
if let Some(secret) = request.client_secret {
|
||||
params.push(("client_secret", secret.to_string()));
|
||||
}
|
||||
if let Some(provider) = request.provider {
|
||||
params.push(("provider", provider.to_string()));
|
||||
}
|
||||
|
||||
let response = client
|
||||
.post(&refresh_url)
|
||||
.bearer_auth(request.gateway_token)
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
OAuthCallbackError::Io(format!("Token refresh proxy request failed: {}", e))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(OAuthCallbackError::Io(format!(
|
||||
"Token refresh proxy failed: {} - {}",
|
||||
status, body
|
||||
)));
|
||||
}
|
||||
|
||||
let token_data: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| OAuthCallbackError::Io(format!("Failed to parse proxy response: {}", e)))?;
|
||||
|
||||
oauth_token_response_from_json(token_data, "access_token")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Form, State};
|
||||
use axum::http::HeaderMap;
|
||||
use axum::response::Redirect;
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
use serde_json::json;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{Mutex, oneshot};
|
||||
|
||||
use crate::cli::oauth_defaults::{
|
||||
builtin_credentials, callback_host, callback_url, is_loopback_host, landing_html,
|
||||
};
|
||||
use crate::config::helpers::lock_env;
|
||||
use crate::testing::credentials::{TEST_OAUTH_CLIENT_ID, TEST_OAUTH_CLIENT_SECRET};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct RecordedProxyRequest {
|
||||
authorization: Option<String>,
|
||||
form: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MockProxyState {
|
||||
requests: Arc<Mutex<Vec<RecordedProxyRequest>>>,
|
||||
exchange_redirect_target: String,
|
||||
refresh_redirect_target: String,
|
||||
}
|
||||
|
||||
struct MockProxyServer {
|
||||
addr: SocketAddr,
|
||||
requests: Arc<Mutex<Vec<RecordedProxyRequest>>>,
|
||||
shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
server_task: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl MockProxyServer {
|
||||
async fn start() -> Self {
|
||||
async fn exchange_handler(
|
||||
State(state): State<MockProxyState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> Json<serde_json::Value> {
|
||||
state.requests.lock().await.push(RecordedProxyRequest {
|
||||
authorization: headers
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string),
|
||||
form,
|
||||
});
|
||||
Json(json!({
|
||||
"access_token": "proxy-access-token",
|
||||
"refresh_token": "proxy-refresh-token",
|
||||
"expires_in": 7200
|
||||
}))
|
||||
}
|
||||
|
||||
async fn refresh_handler(
|
||||
State(state): State<MockProxyState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> Json<serde_json::Value> {
|
||||
state.requests.lock().await.push(RecordedProxyRequest {
|
||||
authorization: headers
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string),
|
||||
form,
|
||||
});
|
||||
Json(json!({
|
||||
"access_token": "proxy-access-token",
|
||||
"refresh_token": "proxy-refresh-token",
|
||||
"expires_in": 7200
|
||||
}))
|
||||
}
|
||||
|
||||
async fn exchange_redirect_handler(State(state): State<MockProxyState>) -> Redirect {
|
||||
Redirect::temporary(&state.exchange_redirect_target)
|
||||
}
|
||||
|
||||
async fn refresh_redirect_handler(State(state): State<MockProxyState>) -> Redirect {
|
||||
Redirect::temporary(&state.refresh_redirect_target)
|
||||
}
|
||||
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind mock proxy");
|
||||
let addr = listener.local_addr().expect("read mock proxy addr");
|
||||
let exchange_redirect_target = format!("http://{addr}/oauth/exchange");
|
||||
let refresh_redirect_target = format!("http://{addr}/oauth/refresh");
|
||||
let app = Router::new()
|
||||
.route("/oauth/exchange", post(exchange_handler))
|
||||
.route("/oauth/refresh", post(refresh_handler))
|
||||
.route("/redirect/oauth/exchange", post(exchange_redirect_handler))
|
||||
.route("/redirect/oauth/refresh", post(refresh_redirect_handler))
|
||||
.with_state(MockProxyState {
|
||||
requests: Arc::clone(&requests),
|
||||
exchange_redirect_target,
|
||||
refresh_redirect_target,
|
||||
});
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
||||
let server_task = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await;
|
||||
});
|
||||
|
||||
Self {
|
||||
addr,
|
||||
requests,
|
||||
shutdown_tx: Some(shutdown_tx),
|
||||
server_task: Some(server_task),
|
||||
}
|
||||
}
|
||||
|
||||
fn base_url(&self) -> String {
|
||||
format!("http://{}", self.addr)
|
||||
}
|
||||
|
||||
fn redirecting_base_url(&self) -> String {
|
||||
format!("{}/redirect", self.base_url())
|
||||
}
|
||||
|
||||
async fn requests(&self) -> Vec<RecordedProxyRequest> {
|
||||
self.requests.lock().await.clone()
|
||||
}
|
||||
|
||||
async fn shutdown(mut self) {
|
||||
if let Some(tx) = self.shutdown_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
if let Some(task) = self.server_task.take() {
|
||||
let _ = task.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MockProxyServer {
|
||||
fn drop(&mut self) {
|
||||
if let Some(tx) = self.shutdown_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
if let Some(task) = self.server_task.take() {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
original: Option<String>,
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
unsafe {
|
||||
if let Some(ref value) = self.original {
|
||||
std::env::set_var(self.key, value);
|
||||
} else {
|
||||
std::env::remove_var(self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_env_var(key: &'static str, value: Option<&str>) -> EnvVarGuard {
|
||||
let original = std::env::var(key).ok();
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
unsafe {
|
||||
if let Some(value) = value {
|
||||
std::env::set_var(key, value);
|
||||
} else {
|
||||
std::env::remove_var(key);
|
||||
}
|
||||
}
|
||||
EnvVarGuard { key, original }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hosted_proxy_client_secret_suppresses_builtin_secret() {
|
||||
let builtin = builtin_credentials("google_oauth_token").expect("google builtin creds");
|
||||
let client_secret = Some(builtin.client_secret.to_string());
|
||||
|
||||
let result = super::hosted_proxy_client_secret(&client_secret, Some(&builtin), true);
|
||||
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hosted_proxy_client_secret_preserves_explicit_secret() {
|
||||
let builtin = builtin_credentials("google_oauth_token").expect("google builtin creds");
|
||||
let client_secret = Some("hosted-server-secret".to_string());
|
||||
|
||||
let result = super::hosted_proxy_client_secret(&client_secret, Some(&builtin), true);
|
||||
|
||||
assert_eq!(result, client_secret);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_exchange_via_proxy_sends_auth_and_form() {
|
||||
let server = MockProxyServer::start().await;
|
||||
let mut extra_token_params = HashMap::new();
|
||||
extra_token_params.insert("resource".to_string(), "https://mcp.notion.com".to_string());
|
||||
|
||||
let response = super::exchange_via_proxy(super::ProxyTokenExchangeRequest {
|
||||
proxy_url: &server.base_url(),
|
||||
gateway_token: "shared-oauth-proxy-secret",
|
||||
code: "auth-code-123",
|
||||
redirect_uri: "https://oauth.example.com/oauth/callback",
|
||||
token_url: "https://oauth2.googleapis.com/token",
|
||||
client_id: TEST_OAUTH_CLIENT_ID,
|
||||
client_secret: Some(TEST_OAUTH_CLIENT_SECRET),
|
||||
access_token_field: "access_token",
|
||||
code_verifier: Some("code-verifier-123"),
|
||||
extra_token_params: &extra_token_params,
|
||||
})
|
||||
.await
|
||||
.expect("proxy exchange succeeds");
|
||||
|
||||
assert_eq!(response.access_token, "proxy-access-token");
|
||||
assert_eq!(
|
||||
response.refresh_token.as_deref(),
|
||||
Some("proxy-refresh-token")
|
||||
);
|
||||
assert_eq!(response.expires_in, Some(7200));
|
||||
|
||||
let requests = server.requests().await;
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(
|
||||
requests[0].authorization.as_deref(),
|
||||
Some("Bearer shared-oauth-proxy-secret")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].form.get("code").map(String::as_str),
|
||||
Some("auth-code-123")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].form.get("redirect_uri").map(String::as_str),
|
||||
Some("https://oauth.example.com/oauth/callback")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].form.get("token_url").map(String::as_str),
|
||||
Some("https://oauth2.googleapis.com/token")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].form.get("client_id").map(String::as_str),
|
||||
Some(TEST_OAUTH_CLIENT_ID)
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].form.get("client_secret").map(String::as_str),
|
||||
Some(TEST_OAUTH_CLIENT_SECRET)
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0]
|
||||
.form
|
||||
.get("access_token_field")
|
||||
.map(String::as_str),
|
||||
Some("access_token")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].form.get("code_verifier").map(String::as_str),
|
||||
Some("code-verifier-123")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].form.get("resource").map(String::as_str),
|
||||
Some("https://mcp.notion.com")
|
||||
);
|
||||
|
||||
server.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_refresh_token_via_proxy_sends_auth_and_form() {
|
||||
let server = MockProxyServer::start().await;
|
||||
|
||||
let response = super::refresh_token_via_proxy(super::ProxyRefreshTokenRequest {
|
||||
proxy_url: &server.base_url(),
|
||||
gateway_token: "gateway-test-token",
|
||||
token_url: "https://oauth2.googleapis.com/token",
|
||||
client_id: TEST_OAUTH_CLIENT_ID,
|
||||
client_secret: Some(TEST_OAUTH_CLIENT_SECRET),
|
||||
refresh_token: "refresh-token-123",
|
||||
provider: Some("google"),
|
||||
})
|
||||
.await
|
||||
.expect("proxy refresh succeeds");
|
||||
|
||||
assert_eq!(response.access_token, "proxy-access-token");
|
||||
assert_eq!(
|
||||
response.refresh_token.as_deref(),
|
||||
Some("proxy-refresh-token")
|
||||
);
|
||||
assert_eq!(response.expires_in, Some(7200));
|
||||
|
||||
let requests = server.requests().await;
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(
|
||||
requests[0].authorization.as_deref(),
|
||||
Some("Bearer gateway-test-token")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].form.get("token_url").map(String::as_str),
|
||||
Some("https://oauth2.googleapis.com/token")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].form.get("client_id").map(String::as_str),
|
||||
Some(TEST_OAUTH_CLIENT_ID)
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].form.get("client_secret").map(String::as_str),
|
||||
Some(TEST_OAUTH_CLIENT_SECRET)
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].form.get("refresh_token").map(String::as_str),
|
||||
Some("refresh-token-123")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].form.get("provider").map(String::as_str),
|
||||
Some("google")
|
||||
);
|
||||
|
||||
server.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_exchange_via_proxy_does_not_follow_redirects() {
|
||||
let server = MockProxyServer::start().await;
|
||||
|
||||
let error = match super::exchange_via_proxy(super::ProxyTokenExchangeRequest {
|
||||
proxy_url: &server.redirecting_base_url(),
|
||||
gateway_token: "gateway-test-token",
|
||||
code: "auth-code-123",
|
||||
redirect_uri: "http://localhost:3000/oauth/callback",
|
||||
token_url: "https://oauth2.googleapis.com/token",
|
||||
client_id: TEST_OAUTH_CLIENT_ID,
|
||||
client_secret: Some(TEST_OAUTH_CLIENT_SECRET),
|
||||
access_token_field: "access_token",
|
||||
code_verifier: Some("code-verifier-123"),
|
||||
extra_token_params: &HashMap::new(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("redirected proxy exchange should fail"),
|
||||
Err(error) => error,
|
||||
};
|
||||
|
||||
assert!(error.to_string().contains("307"));
|
||||
assert!(server.requests().await.is_empty());
|
||||
|
||||
server.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_refresh_token_via_proxy_does_not_follow_redirects() {
|
||||
let server = MockProxyServer::start().await;
|
||||
|
||||
let error = match super::refresh_token_via_proxy(super::ProxyRefreshTokenRequest {
|
||||
proxy_url: &server.redirecting_base_url(),
|
||||
gateway_token: "gateway-test-token",
|
||||
token_url: "https://oauth2.googleapis.com/token",
|
||||
client_id: TEST_OAUTH_CLIENT_ID,
|
||||
client_secret: Some(TEST_OAUTH_CLIENT_SECRET),
|
||||
refresh_token: "refresh-token-123",
|
||||
provider: Some("google"),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("redirected proxy refresh should fail"),
|
||||
Err(error) => error,
|
||||
};
|
||||
|
||||
assert!(error.to_string().contains("307"));
|
||||
assert!(server.requests().await.is_empty());
|
||||
|
||||
server.shutdown().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_loopback_host() {
|
||||
@@ -1159,6 +1666,54 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oauth_proxy_auth_token_prefers_dedicated_env() {
|
||||
let _guard = lock_env();
|
||||
let _proxy_guard = set_env_var(
|
||||
"IRONCLAW_OAUTH_PROXY_AUTH_TOKEN",
|
||||
Some("shared-proxy-secret"),
|
||||
);
|
||||
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-token"));
|
||||
|
||||
assert_eq!(
|
||||
crate::cli::oauth_defaults::oauth_proxy_auth_token().as_deref(),
|
||||
Some("shared-proxy-secret")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oauth_proxy_auth_token_falls_back_to_gateway_token() {
|
||||
let _guard = lock_env();
|
||||
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
|
||||
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-token"));
|
||||
|
||||
assert_eq!(
|
||||
crate::cli::oauth_defaults::oauth_proxy_auth_token().as_deref(),
|
||||
Some("gateway-token")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oauth_proxy_auth_token_whitespace_dedicated_env_falls_back_to_gateway_token() {
|
||||
let _guard = lock_env();
|
||||
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", Some(" "));
|
||||
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-token"));
|
||||
|
||||
assert_eq!(
|
||||
crate::cli::oauth_defaults::oauth_proxy_auth_token().as_deref(),
|
||||
Some("gateway-token")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oauth_proxy_auth_token_returns_none_when_unset() {
|
||||
let _guard = lock_env();
|
||||
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
|
||||
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", None);
|
||||
|
||||
assert_eq!(crate::cli::oauth_defaults::oauth_proxy_auth_token(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_instance_prefix_with_colon() {
|
||||
use crate::cli::oauth_defaults::strip_instance_prefix;
|
||||
|
||||
+66
-10
@@ -10,7 +10,7 @@ use clap::Subcommand;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::agent::routine::{
|
||||
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger, next_cron_fire,
|
||||
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, RunStatus, Trigger, next_cron_fire,
|
||||
};
|
||||
use crate::db::Database;
|
||||
|
||||
@@ -251,15 +251,26 @@ async fn list(
|
||||
);
|
||||
println!("{}", "-".repeat(130));
|
||||
|
||||
// Fetch last-run status for all routines in a single batch query
|
||||
let routine_ids: Vec<Uuid> = filtered.iter().map(|r| r.id).collect();
|
||||
let last_run_results = db
|
||||
.batch_get_last_run_status(&routine_ids)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
for r in &filtered {
|
||||
let status = if r.enabled {
|
||||
if r.consecutive_failures > 0 {
|
||||
format!("err({})", r.consecutive_failures)
|
||||
} else {
|
||||
"active".to_string()
|
||||
}
|
||||
} else {
|
||||
let last_run_status = last_run_results.get(&r.id).copied();
|
||||
|
||||
let status = if !r.enabled {
|
||||
"disabled".to_string()
|
||||
} else if last_run_status == Some(RunStatus::Running) {
|
||||
"running".to_string()
|
||||
} else if r.consecutive_failures > 0 {
|
||||
format!("err({})", r.consecutive_failures)
|
||||
} else if last_run_status == Some(RunStatus::Attention) {
|
||||
"attention".to_string()
|
||||
} else {
|
||||
"active".to_string()
|
||||
};
|
||||
|
||||
let next_fire = r
|
||||
@@ -340,8 +351,8 @@ async fn create(
|
||||
prompt: prompt.to_string(),
|
||||
context_paths: Vec::new(),
|
||||
max_tokens: 4096,
|
||||
use_tools: false,
|
||||
max_tool_rounds: 0,
|
||||
use_tools: true,
|
||||
max_tool_rounds: 3,
|
||||
},
|
||||
guardrails: RoutineGuardrails {
|
||||
cooldown: std::time::Duration::from_secs(cooldown_secs),
|
||||
@@ -685,6 +696,7 @@ fn truncate(s: &str, max_chars: usize) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agent::routine::RoutineAction;
|
||||
|
||||
#[test]
|
||||
fn format_relative_future() {
|
||||
@@ -743,4 +755,48 @@ mod tests {
|
||||
assert!(notify.on_failure); // safety: test-only assertion
|
||||
assert!(!notify.on_success); // safety: test-only assertion
|
||||
}
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
#[tokio::test]
|
||||
async fn cli_create_defaults_lightweight_routines_to_tools_enabled() {
|
||||
let harness = crate::testing::TestHarnessBuilder::new().build().await;
|
||||
let db = harness.db.clone();
|
||||
|
||||
run_routines_command(
|
||||
RoutinesCommand::Create {
|
||||
name: "cli-digest".to_string(),
|
||||
schedule: "0 0 9 * * *".to_string(),
|
||||
prompt: "Prepare the morning digest.".to_string(),
|
||||
description: "CLI created routine".to_string(),
|
||||
timezone: Some("UTC".to_string()),
|
||||
cooldown: 300,
|
||||
notify_channel: None,
|
||||
},
|
||||
db.clone(),
|
||||
"user1",
|
||||
)
|
||||
.await
|
||||
.expect("create routine");
|
||||
|
||||
let routine = db
|
||||
.get_routine_by_name("user1", "cli-digest")
|
||||
.await
|
||||
.expect("get routine by name")
|
||||
.expect("cli-digest should exist");
|
||||
|
||||
match routine.action {
|
||||
RoutineAction::Lightweight {
|
||||
use_tools,
|
||||
max_tool_rounds,
|
||||
..
|
||||
} => {
|
||||
assert!(
|
||||
use_tools,
|
||||
"CLI-created lightweight routines should default to tools"
|
||||
);
|
||||
assert_eq!(max_tool_rounds, 3);
|
||||
}
|
||||
other => panic!("expected lightweight action, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+270
-16
@@ -2,6 +2,7 @@
|
||||
//!
|
||||
//! Commands for installing, listing, removing, and authenticating WASM tools.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
@@ -79,6 +80,10 @@ pub enum ToolCommand {
|
||||
/// Directory to look for tool (default: ~/.ironclaw/tools/)
|
||||
#[arg(short, long)]
|
||||
dir: Option<PathBuf>,
|
||||
|
||||
/// User ID for checking credential status (default: "default")
|
||||
#[arg(short, long, default_value = "default")]
|
||||
user: String,
|
||||
},
|
||||
|
||||
/// Configure authentication for a tool
|
||||
@@ -124,7 +129,11 @@ pub async fn run_tool_command(cmd: ToolCommand) -> anyhow::Result<()> {
|
||||
} => install_tool(path, name, capabilities, target, release, skip_build, force).await,
|
||||
ToolCommand::List { dir, verbose } => list_tools(dir, verbose).await,
|
||||
ToolCommand::Remove { name, dir } => remove_tool(name, dir).await,
|
||||
ToolCommand::Info { name_or_path, dir } => show_tool_info(name_or_path, dir).await,
|
||||
ToolCommand::Info {
|
||||
name_or_path,
|
||||
dir,
|
||||
user,
|
||||
} => show_tool_info(name_or_path, dir, user).await,
|
||||
ToolCommand::Auth { name, dir, user } => auth_tool(name, dir, user).await,
|
||||
ToolCommand::Setup { name, dir, user } => setup_tool(name, dir, user).await,
|
||||
}
|
||||
@@ -388,7 +397,11 @@ async fn remove_tool(name: String, dir: Option<PathBuf>) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
/// Show information about a tool.
|
||||
async fn show_tool_info(name_or_path: String, dir: Option<PathBuf>) -> anyhow::Result<()> {
|
||||
async fn show_tool_info(
|
||||
name_or_path: String,
|
||||
dir: Option<PathBuf>,
|
||||
user_id: String,
|
||||
) -> anyhow::Result<()> {
|
||||
let wasm_path = if name_or_path.ends_with(".wasm") {
|
||||
PathBuf::from(&name_or_path)
|
||||
} else {
|
||||
@@ -423,7 +436,37 @@ async fn show_tool_info(name_or_path: String, dir: Option<PathBuf>) -> anyhow::R
|
||||
println!("\nCapabilities ({}):", caps_path.display());
|
||||
let content = fs::read_to_string(&caps_path).await?;
|
||||
match CapabilitiesFile::from_json(&content) {
|
||||
Ok(caps) => print_capabilities_detail(&caps),
|
||||
Ok(caps) => {
|
||||
// Lazily init secrets store only when auth secrets need checking.
|
||||
let has_auth = caps.auth.is_some()
|
||||
|| caps
|
||||
.setup
|
||||
.as_ref()
|
||||
.is_some_and(|s| !s.required_secrets.is_empty())
|
||||
|| caps
|
||||
.http
|
||||
.as_ref()
|
||||
.is_some_and(|h| !h.credentials.is_empty());
|
||||
let secrets_store = if has_auth {
|
||||
match init_secrets_store().await {
|
||||
Ok(store) => Some(store),
|
||||
Err(e) => {
|
||||
eprintln!(" Warning: could not init secrets store: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
print_capabilities_detail(
|
||||
&caps,
|
||||
secrets_store
|
||||
.as_ref()
|
||||
.map(|s| s.as_ref() as &(dyn SecretsStore + Send + Sync)),
|
||||
&user_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) => println!(" Error parsing: {}", e),
|
||||
}
|
||||
} else {
|
||||
@@ -476,8 +519,89 @@ fn print_capabilities_summary(caps: &CapabilitiesFile) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-secret info collected from all auth-related capability sections.
|
||||
struct AuthSecretInfo {
|
||||
secret_name: String,
|
||||
/// Human-readable label (from auth.display_name or setup prompt).
|
||||
description: Option<String>,
|
||||
/// Injection location (from http.credentials).
|
||||
location: Option<String>,
|
||||
}
|
||||
|
||||
/// Collected auth secrets and the set of secret names they cover.
|
||||
struct CollectedAuthSecrets {
|
||||
secrets: Vec<AuthSecretInfo>,
|
||||
/// Secret names present in `secrets`, for filtering the Secrets capability section.
|
||||
seen_names: HashSet<String>,
|
||||
}
|
||||
|
||||
/// Collect and deduplicate auth secrets from all auth-related capability sections.
|
||||
///
|
||||
/// Priority for the description label: auth.display_name > setup.required_secrets.prompt.
|
||||
/// Injection location is merged from http.credentials.
|
||||
fn collect_auth_secrets(caps: &CapabilitiesFile) -> CollectedAuthSecrets {
|
||||
let mut secrets: Vec<AuthSecretInfo> = Vec::new();
|
||||
let mut seen: HashMap<String, usize> = HashMap::new();
|
||||
|
||||
// auth.display_name is the best label — seed first.
|
||||
if let Some(ref auth) = caps.auth {
|
||||
let index = secrets.len();
|
||||
seen.insert(auth.secret_name.clone(), index);
|
||||
secrets.push(AuthSecretInfo {
|
||||
secret_name: auth.secret_name.clone(),
|
||||
description: auth.display_name.clone(),
|
||||
location: None,
|
||||
});
|
||||
}
|
||||
|
||||
// setup.required_secrets.prompt is second-best label.
|
||||
if let Some(ref setup) = caps.setup {
|
||||
for secret in &setup.required_secrets {
|
||||
if !seen.contains_key(&secret.name) {
|
||||
let index = secrets.len();
|
||||
seen.insert(secret.name.clone(), index);
|
||||
secrets.push(AuthSecretInfo {
|
||||
secret_name: secret.name.clone(),
|
||||
description: Some(secret.prompt.clone()),
|
||||
location: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge injection location from http.credentials.
|
||||
if let Some(ref http) = caps.http {
|
||||
for cred in http.credentials.values() {
|
||||
let loc = format!("{:?}", cred.location);
|
||||
if let Some(&index) = seen.get(&cred.secret_name) {
|
||||
secrets[index].location = Some(loc);
|
||||
} else {
|
||||
let index = secrets.len();
|
||||
seen.insert(cred.secret_name.clone(), index);
|
||||
secrets.push(AuthSecretInfo {
|
||||
secret_name: cred.secret_name.clone(),
|
||||
description: None,
|
||||
location: Some(loc),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let seen_names = seen.into_keys().collect();
|
||||
CollectedAuthSecrets {
|
||||
secrets,
|
||||
seen_names,
|
||||
}
|
||||
}
|
||||
|
||||
/// Print detailed capabilities.
|
||||
fn print_capabilities_detail(caps: &CapabilitiesFile) {
|
||||
async fn print_capabilities_detail(
|
||||
caps: &CapabilitiesFile,
|
||||
secrets_store: Option<&(dyn SecretsStore + Send + Sync)>,
|
||||
user_id: &str,
|
||||
) {
|
||||
let mut collected = collect_auth_secrets(caps);
|
||||
|
||||
if let Some(ref http) = caps.http {
|
||||
println!(" HTTP:");
|
||||
for endpoint in &http.allowlist {
|
||||
@@ -490,13 +614,6 @@ fn print_capabilities_detail(caps: &CapabilitiesFile) {
|
||||
println!(" {} {} {}", methods, endpoint.host, path);
|
||||
}
|
||||
|
||||
if !http.credentials.is_empty() {
|
||||
println!(" Credentials:");
|
||||
for (key, cred) in &http.credentials {
|
||||
println!(" {}: {} -> {:?}", key, cred.secret_name, cred.location);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref rate) = http.rate_limit {
|
||||
println!(
|
||||
" Rate limit: {}/min, {}/hour",
|
||||
@@ -505,12 +622,24 @@ fn print_capabilities_detail(caps: &CapabilitiesFile) {
|
||||
}
|
||||
}
|
||||
|
||||
// Filter secrets already covered by the auth section (always rendered when non-empty).
|
||||
if let Some(ref secrets) = caps.secrets
|
||||
&& !secrets.allowed_names.is_empty()
|
||||
{
|
||||
println!(" Secrets (existence check only):");
|
||||
for name in &secrets.allowed_names {
|
||||
println!(" {}", name);
|
||||
let extra: Vec<_> = if collected.secrets.is_empty() {
|
||||
secrets.allowed_names.iter().collect()
|
||||
} else {
|
||||
secrets
|
||||
.allowed_names
|
||||
.iter()
|
||||
.filter(|name| !collected.seen_names.contains(name.as_str()))
|
||||
.collect()
|
||||
};
|
||||
if !extra.is_empty() {
|
||||
println!(" Secrets (existence check only):");
|
||||
for name in extra {
|
||||
println!(" {}", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,6 +660,38 @@ fn print_capabilities_detail(caps: &CapabilitiesFile) {
|
||||
println!(" {}", prefix);
|
||||
}
|
||||
}
|
||||
|
||||
// Consolidated auth status — sorted by secret name for deterministic output.
|
||||
if !collected.secrets.is_empty() {
|
||||
collected
|
||||
.secrets
|
||||
.sort_by(|a, b| a.secret_name.cmp(&b.secret_name));
|
||||
println!(" Auth:");
|
||||
for info in &collected.secrets {
|
||||
let (icon, label) = match secrets_store {
|
||||
Some(store) => match store.exists(user_id, &info.secret_name).await {
|
||||
Ok(true) => ("\u{2713}", "configured"),
|
||||
Ok(false) => ("\u{2717}", "missing"),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
" Warning: failed to check secret `{}`: {}",
|
||||
info.secret_name, e
|
||||
);
|
||||
("?", "unknown")
|
||||
}
|
||||
},
|
||||
None => ("?", "unknown"),
|
||||
};
|
||||
let mut parts = info.secret_name.clone();
|
||||
if let Some(ref desc) = info.description {
|
||||
parts = format!("{} ({})", parts, desc);
|
||||
}
|
||||
if let Some(ref loc) = info.location {
|
||||
parts = format!("{} -> {}", parts, loc);
|
||||
}
|
||||
println!(" {} {} {}", parts, icon, label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a tool name to prevent path traversal.
|
||||
@@ -677,8 +838,7 @@ async fn combine_provider_scopes(
|
||||
secret_name: &str,
|
||||
base_oauth: &crate::tools::wasm::OAuthConfigSchema,
|
||||
) -> crate::tools::wasm::OAuthConfigSchema {
|
||||
let mut all_scopes: std::collections::HashSet<String> =
|
||||
base_oauth.scopes.iter().cloned().collect();
|
||||
let mut all_scopes: HashSet<String> = base_oauth.scopes.iter().cloned().collect();
|
||||
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(tools_dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
@@ -1127,6 +1287,8 @@ async fn setup_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyh
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::secrets::{CreateSecretParams, SecretsStore};
|
||||
use crate::testing::credentials::test_secrets_store;
|
||||
|
||||
#[test]
|
||||
fn test_format_size() {
|
||||
@@ -1143,4 +1305,96 @@ mod tests {
|
||||
assert!(dir.to_string_lossy().contains(".ironclaw"));
|
||||
assert!(dir.to_string_lossy().contains("tools"));
|
||||
}
|
||||
|
||||
/// Verify that auth secrets are deduplicated across auth, setup, and http.credentials,
|
||||
/// and that credential status is checked against the secrets store.
|
||||
#[tokio::test]
|
||||
async fn test_auth_secret_dedup_and_status() {
|
||||
let caps = CapabilitiesFile::from_json(
|
||||
r#"{
|
||||
"auth": {
|
||||
"secret_name": "gh_token",
|
||||
"display_name": "GitHub"
|
||||
},
|
||||
"setup": {
|
||||
"required_secrets": [
|
||||
{ "name": "gh_token", "prompt": "GitHub PAT" },
|
||||
{ "name": "extra_key", "prompt": "Extra API Key" }
|
||||
]
|
||||
},
|
||||
"http": {
|
||||
"allowlist": [{ "host": "api.github.com" }],
|
||||
"credentials": {
|
||||
"github": {
|
||||
"secret_name": "gh_token",
|
||||
"location": { "type": "bearer" },
|
||||
"host_patterns": ["api.github.com"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"secrets": {
|
||||
"allowed_names": ["gh_token", "gh_*"]
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let collected = collect_auth_secrets(&caps);
|
||||
|
||||
// gh_token should appear once (from auth), with location merged from credentials.
|
||||
// extra_key should appear once (from setup).
|
||||
assert_eq!(collected.secrets.len(), 2);
|
||||
let gh = collected
|
||||
.secrets
|
||||
.iter()
|
||||
.find(|s| s.secret_name == "gh_token")
|
||||
.unwrap();
|
||||
assert_eq!(gh.description.as_deref(), Some("GitHub"));
|
||||
assert!(
|
||||
gh.location.is_some(),
|
||||
"location should be merged from http.credentials"
|
||||
);
|
||||
|
||||
let extra = collected
|
||||
.secrets
|
||||
.iter()
|
||||
.find(|s| s.secret_name == "extra_key")
|
||||
.unwrap();
|
||||
assert_eq!(extra.description.as_deref(), Some("Extra API Key"));
|
||||
assert!(extra.location.is_none());
|
||||
|
||||
// Secrets section should filter gh_token (in seen_names) but keep gh_* (wildcard).
|
||||
let secrets = caps.secrets.as_ref().unwrap();
|
||||
let extra_secrets: Vec<_> = secrets
|
||||
.allowed_names
|
||||
.iter()
|
||||
.filter(|name| !collected.seen_names.contains(name.as_str()))
|
||||
.collect();
|
||||
assert_eq!(extra_secrets, vec!["gh_*"]);
|
||||
|
||||
// Verify store check: missing secret -> exists returns false.
|
||||
let store = test_secrets_store();
|
||||
assert!(!store.exists("default", "gh_token").await.unwrap());
|
||||
|
||||
// Store gh_token and verify it's found.
|
||||
store
|
||||
.create(
|
||||
"default",
|
||||
CreateSecretParams::new("gh_token", "ghp_test123"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(store.exists("default", "gh_token").await.unwrap());
|
||||
// extra_key still missing.
|
||||
assert!(!store.exists("default", "extra_key").await.unwrap());
|
||||
}
|
||||
|
||||
/// No auth sections → collect_auth_secrets returns empty.
|
||||
#[test]
|
||||
fn test_collect_auth_secrets_empty_caps() {
|
||||
let caps = CapabilitiesFile::default();
|
||||
let collected = collect_auth_secrets(&caps);
|
||||
assert!(collected.secrets.is_empty());
|
||||
assert!(collected.seen_names.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
+20
-1
@@ -1,6 +1,6 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::helpers::{parse_bool_env, parse_option_env, parse_optional_env};
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_option_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
use crate::settings::Settings;
|
||||
|
||||
@@ -23,6 +23,8 @@ pub struct AgentConfig {
|
||||
pub max_cost_per_day_cents: Option<u64>,
|
||||
/// Maximum LLM/tool actions per hour. None = unlimited.
|
||||
pub max_actions_per_hour: Option<u64>,
|
||||
/// Maximum daily LLM spend per user in cents. None = unlimited.
|
||||
pub max_cost_per_user_per_day_cents: Option<u64>,
|
||||
/// Maximum tool-call iterations per agentic loop invocation. Default 50.
|
||||
pub max_tool_iterations: usize,
|
||||
/// When true, skip tool approval checks entirely. For benchmarks/CI.
|
||||
@@ -31,6 +33,13 @@ pub struct AgentConfig {
|
||||
pub default_timezone: String,
|
||||
/// Maximum tokens per job (0 = unlimited).
|
||||
pub max_tokens_per_job: u64,
|
||||
/// Whether the deployment is multi-tenant (multiple users sharing one
|
||||
/// instance). Auto-detected from GATEWAY_USER_TOKENS presence.
|
||||
pub multi_tenant: bool,
|
||||
/// Maximum concurrent LLM calls per user. None = use default (4).
|
||||
pub max_llm_concurrent_per_user: Option<usize>,
|
||||
/// Maximum concurrent jobs per user. None = use default (3).
|
||||
pub max_jobs_concurrent_per_user: Option<usize>,
|
||||
}
|
||||
|
||||
impl AgentConfig {
|
||||
@@ -49,10 +58,14 @@ impl AgentConfig {
|
||||
allow_local_tools: true,
|
||||
max_cost_per_day_cents: None,
|
||||
max_actions_per_hour: None,
|
||||
max_cost_per_user_per_day_cents: None,
|
||||
max_tool_iterations: 10,
|
||||
auto_approve_tools: true,
|
||||
default_timezone: "UTC".to_string(),
|
||||
max_tokens_per_job: 0,
|
||||
multi_tenant: false,
|
||||
max_llm_concurrent_per_user: None,
|
||||
max_jobs_concurrent_per_user: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +100,7 @@ impl AgentConfig {
|
||||
allow_local_tools: parse_bool_env("ALLOW_LOCAL_TOOLS", false)?,
|
||||
max_cost_per_day_cents: parse_option_env("MAX_COST_PER_DAY_CENTS")?,
|
||||
max_actions_per_hour: parse_option_env("MAX_ACTIONS_PER_HOUR")?,
|
||||
max_cost_per_user_per_day_cents: parse_option_env("MAX_COST_PER_USER_PER_DAY_CENTS")?,
|
||||
max_tool_iterations: parse_optional_env(
|
||||
"AGENT_MAX_TOOL_ITERATIONS",
|
||||
settings.agent.max_tool_iterations,
|
||||
@@ -112,6 +126,11 @@ impl AgentConfig {
|
||||
"AGENT_MAX_TOKENS_PER_JOB",
|
||||
settings.agent.max_tokens_per_job,
|
||||
)?,
|
||||
// Auto-detected from GATEWAY_USER_TOKENS presence. Not a separate
|
||||
// knob — multi-tenant mode is always implied by configuring user tokens.
|
||||
multi_tenant: optional_env("GATEWAY_USER_TOKENS")?.is_some(),
|
||||
max_llm_concurrent_per_user: parse_option_env("TENANT_MAX_LLM_CONCURRENT")?,
|
||||
max_jobs_concurrent_per_user: parse_option_env("TENANT_MAX_JOBS_CONCURRENT")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use secrecy::SecretString;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
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.
|
||||
pub auth_token: Option<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).
|
||||
@@ -115,6 +136,118 @@ impl ChannelsConfig {
|
||||
.or_else(|| cs.gateway_user_id.clone())
|
||||
.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 {
|
||||
host: optional_env("GATEWAY_HOST")?
|
||||
.or_else(|| cs.gateway_host.clone())
|
||||
@@ -126,6 +259,9 @@ impl ChannelsConfig {
|
||||
auth_token: optional_env("GATEWAY_AUTH_TOKEN")?
|
||||
.or_else(|| cs.gateway_auth_token.clone()),
|
||||
user_id,
|
||||
workspace_read_scopes,
|
||||
memory_layers,
|
||||
user_tokens,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
@@ -281,6 +417,9 @@ mod tests {
|
||||
port: 3000,
|
||||
auth_token: Some("tok-abc".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.port, 3000);
|
||||
@@ -295,6 +434,9 @@ mod tests {
|
||||
port: 3001,
|
||||
auth_token: None,
|
||||
user_id: "anon".to_string(),
|
||||
workspace_read_scopes: vec![],
|
||||
memory_layers: vec![],
|
||||
user_tokens: None,
|
||||
};
|
||||
assert!(cfg.auth_token.is_none());
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ pub struct HeartbeatConfig {
|
||||
pub quiet_hours_end: Option<u32>,
|
||||
/// Timezone for fire_at and quiet hours evaluation (IANA name).
|
||||
pub timezone: Option<String>,
|
||||
/// When true, cycle through all users with routines. Auto-detected from
|
||||
/// GATEWAY_USER_TOKENS or set explicitly via HEARTBEAT_MULTI_TENANT.
|
||||
pub multi_tenant: bool,
|
||||
}
|
||||
|
||||
impl Default for HeartbeatConfig {
|
||||
@@ -34,6 +37,7 @@ impl Default for HeartbeatConfig {
|
||||
quiet_hours_start: None,
|
||||
quiet_hours_end: None,
|
||||
timezone: None,
|
||||
multi_tenant: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,6 +105,12 @@ impl HeartbeatConfig {
|
||||
}
|
||||
tz
|
||||
},
|
||||
// Auto-detect multi-tenant mode from GATEWAY_USER_TOKENS presence,
|
||||
// or allow explicit override via HEARTBEAT_MULTI_TENANT.
|
||||
multi_tenant: parse_bool_env(
|
||||
"HEARTBEAT_MULTI_TENANT",
|
||||
optional_env("GATEWAY_USER_TOKENS")?.is_some(),
|
||||
)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+12
-4
@@ -406,7 +406,7 @@ impl LlmConfig {
|
||||
// Resolve extra headers
|
||||
let extra_headers = if let Some(env_var) = extra_headers_env {
|
||||
optional_env(env_var)?
|
||||
.map(|val| parse_extra_headers(&val))
|
||||
.map(|val| parse_extra_headers_with_key(&val, env_var))
|
||||
.transpose()?
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
@@ -475,7 +475,10 @@ impl LlmConfig {
|
||||
///
|
||||
/// Format: `Key1:Value1,Key2:Value2` (colon-separated, not `=`, because
|
||||
/// header values often contain `=`).
|
||||
fn parse_extra_headers(val: &str) -> Result<Vec<(String, String)>, ConfigError> {
|
||||
fn parse_extra_headers_with_key(
|
||||
val: &str,
|
||||
env_var_name: &str,
|
||||
) -> Result<Vec<(String, String)>, ConfigError> {
|
||||
if val.trim().is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
@@ -488,14 +491,14 @@ fn parse_extra_headers(val: &str) -> Result<Vec<(String, String)>, ConfigError>
|
||||
}
|
||||
let Some((key, value)) = pair.split_once(':') else {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "LLM_EXTRA_HEADERS".to_string(),
|
||||
key: env_var_name.to_string(),
|
||||
message: format!("malformed header entry '{}', expected Key:Value", pair),
|
||||
});
|
||||
};
|
||||
let key = key.trim();
|
||||
if key.is_empty() {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "LLM_EXTRA_HEADERS".to_string(),
|
||||
key: env_var_name.to_string(),
|
||||
message: format!("empty header name in entry '{}'", pair),
|
||||
});
|
||||
}
|
||||
@@ -536,6 +539,11 @@ mod tests {
|
||||
use crate::settings::Settings;
|
||||
use crate::testing::credentials::*;
|
||||
|
||||
/// Convenience wrapper for tests — uses "TEST_HEADERS" as the env var name.
|
||||
fn parse_extra_headers(val: &str) -> Result<Vec<(String, String)>, ConfigError> {
|
||||
parse_extra_headers_with_key(val, "TEST_HEADERS")
|
||||
}
|
||||
|
||||
/// Clear all openai-compatible-related env vars.
|
||||
fn clear_openai_compatible_env() {
|
||||
// SAFETY: Only called under ENV_MUTEX in tests.
|
||||
|
||||
+5
-7
@@ -312,13 +312,11 @@ impl Config {
|
||||
let tunnel = TunnelConfig::resolve(settings)?;
|
||||
let channels = ChannelsConfig::resolve(settings, &owner_id)?;
|
||||
|
||||
// Resolve workspace config using the gateway user_id for default layers.
|
||||
let workspace_user_id = channels
|
||||
.gateway
|
||||
.as_ref()
|
||||
.map(|gw| gw.user_id.as_str())
|
||||
.unwrap_or("default");
|
||||
let workspace = WorkspaceConfig::resolve(workspace_user_id)?;
|
||||
// Resolve the startup workspace against the durable owner scope. The
|
||||
// gateway may expose a distinct sender identity, but the base runtime
|
||||
// workspace stays owner-scoped and per-user gateway workspaces are
|
||||
// handled separately by WorkspacePool.
|
||||
let workspace = WorkspaceConfig::resolve(&owner_id)?;
|
||||
|
||||
Ok(Self {
|
||||
owner_id: owner_id.clone(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user