From 7dc3c6d0670e316145f38ea5d79eefd17e4c978c Mon Sep 17 00:00:00 2001 From: "ironclaw-ci[bot]" <266877842+ironclaw-ci[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:20:16 -0700 Subject: [PATCH 01/15] chore: release v0.20.0 (#1310) Co-authored-by: ironclaw-ci[bot] <266877842+ironclaw-ci[bot]@users.noreply.github.com> --- CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aad4993..7bf7c55d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 diff --git a/Cargo.lock b/Cargo.lock index 2c5547e0..ec10ff96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3436,7 +3436,7 @@ dependencies = [ [[package]] name = "ironclaw" -version = "0.19.0" +version = "0.20.0" dependencies = [ "aes-gcm", "aho-corasick", diff --git a/Cargo.toml b/Cargo.toml index 5b452651..1447c66e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ exclude = [ [package] name = "ironclaw" -version = "0.19.0" +version = "0.20.0" edition = "2024" rust-version = "1.92" description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly" From e4d3200d808737293337220403e21cf4a364e85b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:04:07 -0700 Subject: [PATCH 02/15] chore: update WASM artifact SHA256 checksums [skip ci] (#1424) Co-authored-by: github-actions[bot] --- registry/channels/telegram.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/registry/channels/telegram.json b/registry/channels/telegram.json index 85d793ed..52f66ce3 100644 --- a/registry/channels/telegram.json +++ b/registry/channels/telegram.json @@ -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": { From e077e1277d730601785738d9b8d4ba9aadaa8f29 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Fri, 20 Mar 2026 10:33:57 -0700 Subject: [PATCH 03/15] fix: bump Feishu channel version for promotion --- registry/channels/feishu.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registry/channels/feishu.json b/registry/channels/feishu.json index 66cecf1d..9cac2562 100644 --- a/registry/channels/feishu.json +++ b/registry/channels/feishu.json @@ -2,7 +2,7 @@ "name": "feishu", "display_name": "Feishu / Lark Channel", "kind": "channel", - "version": "0.1.1", + "version": "0.1.2", "wit_version": "0.3.0", "description": "Talk to your agent through a Feishu or Lark bot", "keywords": [ From 91a241a3c7fccb4d17d61c5d011d103968163b68 Mon Sep 17 00:00:00 2001 From: "ironclaw-ci[bot]" <266877842+ironclaw-ci[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:23:39 -0700 Subject: [PATCH 04/15] chore: release v0.21.0 (#1472) Co-authored-by: ironclaw-ci[bot] <266877842+ironclaw-ci[bot]@users.noreply.github.com> --- CHANGELOG.md | 18 ++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bf7c55d..5ef9f5f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 diff --git a/Cargo.lock b/Cargo.lock index ec10ff96..df7c54ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3436,7 +3436,7 @@ dependencies = [ [[package]] name = "ironclaw" -version = "0.20.0" +version = "0.21.0" dependencies = [ "aes-gcm", "aho-corasick", diff --git a/Cargo.toml b/Cargo.toml index 1447c66e..ab003e64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ exclude = [ [package] name = "ironclaw" -version = "0.20.0" +version = "0.21.0" edition = "2024" rust-version = "1.92" description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly" From d47b4b0346537497b47bad0488f3611f82c514ac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:24:47 -0700 Subject: [PATCH 05/15] chore: update WASM artifact SHA256 checksums [skip ci] (#1481) Co-authored-by: github-actions[bot] --- registry/channels/feishu.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/registry/channels/feishu.json b/registry/channels/feishu.json index 9cac2562..dc02b2c7 100644 --- a/registry/channels/feishu.json +++ b/registry/channels/feishu.json @@ -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": "52def36121a93cf0b06dd6d594dc9528745fae56ded7d632998700cc595be762", + "url": "https://github.com/nearai/ironclaw/releases/download/v0.21.0/channel-feishu-0.1.2-wasm32-wasip2.tar.gz" } }, "auth_summary": { From 67a025e2faf73c9f970129523c7bc18b5d3c3c9e Mon Sep 17 00:00:00 2001 From: serrrfirat Date: Wed, 25 Mar 2026 13:59:50 +0300 Subject: [PATCH 06/15] fix(deps): unblock promotion PR #1451 cargo-deny --- Cargo.lock | 18 +++++++++--------- deny.toml | 2 ++ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2c5547e0..0a6b5797 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2339,7 +2339,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5575,7 +5575,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5624,7 +5624,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.9", + "rustls-webpki 0.103.10", "subtle", "zeroize", ] @@ -5696,9 +5696,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "aws-lc-rs", "ring", @@ -6457,9 +6457,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tar" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" dependencies = [ "filetime", "libc", @@ -6479,10 +6479,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/deny.toml b/deny.toml index 80aa2215..fddb3d43 100644 --- a/deny.toml +++ b/deny.toml @@ -15,6 +15,8 @@ ignore = [ "RUSTSEC-2026-0020", # wasmtime wasi:http/types.fields panic — mitigated by fuel limits "RUSTSEC-2026-0021", + # rustls-webpki CRL distributionPoint matching — 0.102.8 pinned by libsql transitive dep + "RUSTSEC-2026-0049", ] [licenses] From ef37d705a16a3ef91bac753ea0c344c998d55a19 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Wed, 25 Mar 2026 15:56:49 -0700 Subject: [PATCH 07/15] Merge pull request #1655 from nearai/codex/fix-staging-promotion-1451-version-bumps fix: bump registry versions for staging promotion 1451 --- registry/channels/feishu.json | 2 +- registry/tools/github.json | 2 +- registry/tools/gmail.json | 2 +- registry/tools/google-calendar.json | 2 +- registry/tools/google-docs.json | 2 +- registry/tools/google-drive.json | 2 +- registry/tools/google-sheets.json | 2 +- registry/tools/google-slides.json | 2 +- registry/tools/llm-context.json | 2 +- registry/tools/slack.json | 2 +- registry/tools/telegram.json | 2 +- registry/tools/web-search.json | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/registry/channels/feishu.json b/registry/channels/feishu.json index 66cecf1d..e666fcd9 100644 --- a/registry/channels/feishu.json +++ b/registry/channels/feishu.json @@ -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": [ diff --git a/registry/tools/github.json b/registry/tools/github.json index e760c4df..aa89794e 100644 --- a/registry/tools/github.json +++ b/registry/tools/github.json @@ -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": [ diff --git a/registry/tools/gmail.json b/registry/tools/gmail.json index 08913ce6..90573115 100644 --- a/registry/tools/gmail.json +++ b/registry/tools/gmail.json @@ -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": [ diff --git a/registry/tools/google-calendar.json b/registry/tools/google-calendar.json index c43112d3..a9cdc539 100644 --- a/registry/tools/google-calendar.json +++ b/registry/tools/google-calendar.json @@ -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": [ diff --git a/registry/tools/google-docs.json b/registry/tools/google-docs.json index 9f1ab133..e3206b5e 100644 --- a/registry/tools/google-docs.json +++ b/registry/tools/google-docs.json @@ -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": [ diff --git a/registry/tools/google-drive.json b/registry/tools/google-drive.json index 9766e555..3de90ed1 100644 --- a/registry/tools/google-drive.json +++ b/registry/tools/google-drive.json @@ -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": [ diff --git a/registry/tools/google-sheets.json b/registry/tools/google-sheets.json index b63265e1..4e5722e9 100644 --- a/registry/tools/google-sheets.json +++ b/registry/tools/google-sheets.json @@ -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": [ diff --git a/registry/tools/google-slides.json b/registry/tools/google-slides.json index 54187531..bbd4f8c0 100644 --- a/registry/tools/google-slides.json +++ b/registry/tools/google-slides.json @@ -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": [ diff --git a/registry/tools/llm-context.json b/registry/tools/llm-context.json index e4e9808c..acea3301 100644 --- a/registry/tools/llm-context.json +++ b/registry/tools/llm-context.json @@ -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": [ diff --git a/registry/tools/slack.json b/registry/tools/slack.json index 8e1df989..27bce7c4 100644 --- a/registry/tools/slack.json +++ b/registry/tools/slack.json @@ -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": [ diff --git a/registry/tools/telegram.json b/registry/tools/telegram.json index 12e58c68..d3370940 100644 --- a/registry/tools/telegram.json +++ b/registry/tools/telegram.json @@ -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": [ diff --git a/registry/tools/web-search.json b/registry/tools/web-search.json index 5c1dedef..47f4d699 100644 --- a/registry/tools/web-search.json +++ b/registry/tools/web-search.json @@ -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": [ From 0b4e7c761b3e12853d743ab5d454255334e2c1c6 Mon Sep 17 00:00:00 2001 From: "ironclaw-ci[bot]" <266877842+ironclaw-ci[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:44:53 -0700 Subject: [PATCH 08/15] chore: release v0.22.0 (#1601) Co-authored-by: ironclaw-ci[bot] <266877842+ironclaw-ci[bot]@users.noreply.github.com> --- CHANGELOG.md | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ef9f5f9..9acc56ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,86 @@ 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 diff --git a/Cargo.lock b/Cargo.lock index 678054e4..581c75bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3390,7 +3390,7 @@ dependencies = [ [[package]] name = "ironclaw" -version = "0.21.0" +version = "0.22.0" dependencies = [ "aes-gcm", "aho-corasick", diff --git a/Cargo.toml b/Cargo.toml index e4f14108..8a77f5b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ exclude = [ [package] name = "ironclaw" -version = "0.21.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" From f02345fd1f9140573d341cf9b4028d55eb021a1d Mon Sep 17 00:00:00 2001 From: Henry Park Date: Wed, 25 Mar 2026 17:58:51 -0700 Subject: [PATCH 09/15] fix: allow publishing ironclaw_common (#1657) --- crates/ironclaw_common/Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ironclaw_common/Cargo.toml b/crates/ironclaw_common/Cargo.toml index 353ab747..6e7db5a4 100644 --- a/crates/ironclaw_common/Cargo.toml +++ b/crates/ironclaw_common/Cargo.toml @@ -8,7 +8,6 @@ authors = ["NEAR AI "] 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 From ab67f028860094dc8086f4e9866ed0e8ff44b3cd Mon Sep 17 00:00:00 2001 From: Henry Park Date: Wed, 25 Mar 2026 18:21:17 -0700 Subject: [PATCH 10/15] fix: publish ironclaw_safety 0.2.0 (#1659) --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ironclaw_safety/Cargo.toml | 3 +-- release-plz.toml | 5 ----- 4 files changed, 3 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 581c75bb..c3747590 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3496,7 +3496,7 @@ dependencies = [ [[package]] name = "ironclaw_safety" -version = "0.1.0" +version = "0.2.0" dependencies = [ "aho-corasick", "regex", diff --git a/Cargo.toml b/Cargo.toml index 8a77f5b4..41895b16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,7 +104,7 @@ cron = "0.13" 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" diff --git a/crates/ironclaw_safety/Cargo.toml b/crates/ironclaw_safety/Cargo.toml index d12aa909..38b8718a 100644 --- a/crates/ironclaw_safety/Cargo.toml +++ b/crates/ironclaw_safety/Cargo.toml @@ -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 "] 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 diff --git a/release-plz.toml b/release-plz.toml index b003952d..e8e0670f 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -1,7 +1,2 @@ [workspace] git_release_enable = false - -[[package]] -name = "ironclaw_safety" -publish = false -release = false From 6b8a38e147885aae6f8cfeb0627a79bec6eebb7c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:40:48 -0700 Subject: [PATCH 11/15] chore: update WASM artifact SHA256 checksums [skip ci] (#1663) Co-authored-by: github-actions[bot] --- registry/channels/feishu.json | 4 ++-- registry/tools/github.json | 4 ++-- registry/tools/gmail.json | 4 ++-- registry/tools/google-calendar.json | 4 ++-- registry/tools/google-docs.json | 4 ++-- registry/tools/google-drive.json | 4 ++-- registry/tools/google-sheets.json | 4 ++-- registry/tools/google-slides.json | 4 ++-- registry/tools/llm-context.json | 4 ++-- registry/tools/slack.json | 4 ++-- registry/tools/telegram.json | 4 ++-- registry/tools/web-search.json | 4 ++-- 12 files changed, 24 insertions(+), 24 deletions(-) diff --git a/registry/channels/feishu.json b/registry/channels/feishu.json index 90619b9c..a7530943 100644 --- a/registry/channels/feishu.json +++ b/registry/channels/feishu.json @@ -19,8 +19,8 @@ }, "artifacts": { "wasm32-wasip2": { - "sha256": "52def36121a93cf0b06dd6d594dc9528745fae56ded7d632998700cc595be762", - "url": "https://github.com/nearai/ironclaw/releases/download/v0.21.0/channel-feishu-0.1.2-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": { diff --git a/registry/tools/github.json b/registry/tools/github.json index aa89794e..bb351259 100644 --- a/registry/tools/github.json +++ b/registry/tools/github.json @@ -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": { diff --git a/registry/tools/gmail.json b/registry/tools/gmail.json index 90573115..c4772129 100644 --- a/registry/tools/gmail.json +++ b/registry/tools/gmail.json @@ -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": { diff --git a/registry/tools/google-calendar.json b/registry/tools/google-calendar.json index a9cdc539..73065a67 100644 --- a/registry/tools/google-calendar.json +++ b/registry/tools/google-calendar.json @@ -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": { diff --git a/registry/tools/google-docs.json b/registry/tools/google-docs.json index e3206b5e..02cc94fe 100644 --- a/registry/tools/google-docs.json +++ b/registry/tools/google-docs.json @@ -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": { diff --git a/registry/tools/google-drive.json b/registry/tools/google-drive.json index 3de90ed1..719690f7 100644 --- a/registry/tools/google-drive.json +++ b/registry/tools/google-drive.json @@ -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": { diff --git a/registry/tools/google-sheets.json b/registry/tools/google-sheets.json index 4e5722e9..09aae574 100644 --- a/registry/tools/google-sheets.json +++ b/registry/tools/google-sheets.json @@ -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": { diff --git a/registry/tools/google-slides.json b/registry/tools/google-slides.json index bbd4f8c0..64bc0e45 100644 --- a/registry/tools/google-slides.json +++ b/registry/tools/google-slides.json @@ -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": { diff --git a/registry/tools/llm-context.json b/registry/tools/llm-context.json index acea3301..422f2e18 100644 --- a/registry/tools/llm-context.json +++ b/registry/tools/llm-context.json @@ -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": { diff --git a/registry/tools/slack.json b/registry/tools/slack.json index 27bce7c4..236062a4 100644 --- a/registry/tools/slack.json +++ b/registry/tools/slack.json @@ -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": { diff --git a/registry/tools/telegram.json b/registry/tools/telegram.json index d3370940..e684ca94 100644 --- a/registry/tools/telegram.json +++ b/registry/tools/telegram.json @@ -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": { diff --git a/registry/tools/web-search.json b/registry/tools/web-search.json index 47f4d699..014466af 100644 --- a/registry/tools/web-search.json +++ b/registry/tools/web-search.json @@ -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": { From adf4e25c8fdebeacb4bb99752861b61f556dc8db Mon Sep 17 00:00:00 2001 From: Pierre LE GUEN <26087574+PierreLeGuen@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:49:05 -0500 Subject: [PATCH 12/15] fix(extensions): channel-relay auth dead-end, observability, and URL override (#1681) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(extensions): channel-relay auth dead-end, add observability and relay URL override Fix a bug where clicking Activate on the Slack relay extension produces a dead-end "Authentication required" error with no OAuth URL. The root cause: `auth_channel_relay()` used `is_relay_channel()` to check auth status, but that function returns true as soon as the extension is *installed* (in-memory set), before OAuth completes. This short-circuits the OAuth flow so the authorization URL is never offered. Changes: 1. **Bug fix** — `auth_channel_relay()` now uses `has_stored_team_id()` which only checks the persistent settings store for an actual team_id. The extension list `authenticated` field uses the same check so the UI accurately reflects OAuth completion status. 2. **Observability** — Added debug/warn/info tracing to all channel-relay code paths that were previously silent on failure: - `activate_channel_relay`: team_id retrieval, relay config, signing secret fetch, hot_add, cache operations - `auth_channel_relay`: auth check, OAuth initiation, nonce storage - `extensions_activate_handler`: request entry, auth fallback flow - `slack_relay_oauth_callback_handler`: team_id persistence (was silently ignored with `let _`) - `RelayClient`: initiate_oauth, get_signing_secret, proxy_provider all log URL, status, and errors - `has_stored_team_id`: store read success/failure 3. **Per-extension relay URL override** — Users can now override the CHANNEL_RELAY_URL via Settings > Extensions > Reconfigure. Stored under `extensions.{name}.relay_url` in settings. Both auth and activate read this override before falling back to the env default. Co-Authored-By: Claude Opus 4.6 (1M context) * style: cargo fmt Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address review feedback — clear relay_url override and improve log message 1. Allow clearing the relay_url override: when an optional setup field with a setting_path is submitted empty, delete the stored setting so the system reverts to the env/default value. Previously empty values were silently skipped, making it impossible to undo an override from the UI. 2. Improve the OAuth callback team_id persistence error log to be self-contained without referencing implementation details. Co-Authored-By: Claude Opus 4.6 (1M context) * style: collapse nested if per clippy::collapsible_if Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address review feedback — security, scope consistency, and error handling 1. OAuth callback team_id persistence is now fatal: if set_setting fails, the callback returns an error instead of proceeding to activate (which would re-read from the store and fail anyway). 2. effective_relay_url uses owner scope (self.user_id) for reads, matching configure() which writes under the same scope. Prevents multi-user mismatch where an override saved via Reconfigure was invisible during auth/activation. 3. has_stored_team_id uses owner scope for the same reason — the OAuth callback stores team_id under state.owner_id (= self.user_id). 4. Security: effective_relay_url validates the override URL — only http/https without embedded credentials (userinfo) is accepted. This prevents API-key exfiltration if a user points relay_url at an attacker-controlled host. Logs only host portion, not full URL. 5. Fixed effective_relay_url docstring to match behavior (returns Option, callers handle the fallback). 6. get_setup_schema for ChannelRelay now logs a warning on settings store errors instead of silently returning None. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- src/channels/relay/client.rs | 67 +++++- src/channels/web/server.rs | 53 ++++- src/extensions/manager.rs | 419 ++++++++++++++++++++++++++++++++--- 3 files changed, 500 insertions(+), 39 deletions(-) diff --git a/src/channels/relay/client.rs b/src/channels/relay/client.rs index 81fbb56c..b67f2c5e 100644 --- a/src/channels/relay/client.rs +++ b/src/channels/relay/client.rs @@ -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 { + 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 { + 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, 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() diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 4bf4de37..26c005d4 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -1177,11 +1177,31 @@ async fn slack_relay_oauth_callback_handler( // Store team_id in settings let team_id_key = format!("relay:{}:team_id", DEFAULT_RELAY_NAME); - let _ = store + tracing::info!( + relay = DEFAULT_RELAY_NAME, + owner_id = %state.owner_id, + team_id_key = %team_id_key, + "relay OAuth callback: storing team_id in settings" + ); + store .set_setting(&state.owner_id, &team_id_key, &serde_json::json!(team_id)) - .await; + .await + .map_err(|e| { + tracing::error!( + relay = DEFAULT_RELAY_NAME, + owner_id = %state.owner_id, + error = %e, + "relay OAuth callback: failed to persist team_id to settings store" + ); + format!("Failed to persist relay team_id: {e}") + })?; // Activate the relay channel + tracing::info!( + relay = DEFAULT_RELAY_NAME, + owner_id = %state.owner_id, + "relay OAuth callback: activating relay channel" + ); ext_mgr .activate_stored_relay(DEFAULT_RELAY_NAME, &state.owner_id) .await @@ -2181,6 +2201,11 @@ async fn extensions_activate_handler( AuthenticatedUser(user): AuthenticatedUser, Path(name): Path, ) -> Result, (StatusCode, String)> { + tracing::debug!( + extension = %name, + user_id = %user.user_id, + "extensions_activate_handler: received activate request" + ); let ext_mgr = state.extension_manager.as_ref().ok_or(( StatusCode::NOT_IMPLEMENTED, "Extension manager not available (secrets store required)".to_string(), @@ -2188,6 +2213,10 @@ async fn extensions_activate_handler( match ext_mgr.activate(&name, &user.user_id).await { Ok(result) => { + tracing::info!( + extension = %name, + "extensions_activate_handler: activation succeeded" + ); // Activation loaded the WASM module. Check if the tool needs // OAuth scope expansion (e.g., adding google-docs when gmail // already has a token but missing the documents scope). @@ -2206,6 +2235,13 @@ async fn extensions_activate_handler( crate::extensions::ExtensionError::AuthRequired ); + tracing::debug!( + extension = %name, + error = %activate_err, + needs_auth = needs_auth, + "extensions_activate_handler: activation failed, attempting auth fallback" + ); + if !needs_auth { return Ok(Json(ActionResponse::fail(activate_err.to_string()))); } @@ -2213,10 +2249,21 @@ async fn extensions_activate_handler( // Activation failed due to auth; try authenticating first. match ext_mgr.auth(&name, &user.user_id).await { Ok(auth_result) if auth_result.is_authenticated() => { + tracing::debug!( + extension = %name, + "extensions_activate_handler: auth reports authenticated, retrying activate" + ); // Auth succeeded, retry activation. match ext_mgr.activate(&name, &user.user_id).await { Ok(result) => Ok(Json(ActionResponse::ok(result.message))), - Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))), + Err(e) => { + tracing::warn!( + extension = %name, + error = %e, + "extensions_activate_handler: retry after auth still failed" + ); + Ok(Json(ActionResponse::fail(e.to_string()))) + } } } Ok(auth_result) => { diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index 90920767..47b45a0f 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -659,6 +659,66 @@ impl ExtensionManager { }) } + /// Resolve the relay URL override for an extension from settings. + /// + /// Returns `Some(url)` if a non-empty per-extension `relay_url` override is + /// set for the given extension; otherwise returns `None` and callers should + /// fall back to the env-level `RelayConfig`. + /// + /// Uses `self.user_id` (owner scope) for consistency with `configure()`, + /// which also writes setting_path fields under the owner scope. + /// + /// The override is validated: only `http` / `https` schemes are accepted + /// and the URL must not contain userinfo (embedded credentials). This + /// prevents a malicious override from exfiltrating the instance-wide relay + /// API key to an attacker-controlled host. + async fn effective_relay_url(&self, name: &str) -> Option { + if let Some(ref store) = self.store { + let key = format!("extensions.{name}.relay_url"); + if let Ok(Some(v)) = store.get_setting(&self.user_id, &key).await { + let url = v + .as_str() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + if let Some(ref u) = url { + // Validate the override to prevent API-key exfiltration: + // only allow http(s) with no embedded credentials. + match url::Url::parse(u) { + Ok(parsed) + if (parsed.scheme() == "http" || parsed.scheme() == "https") + && parsed.username().is_empty() + && parsed.password().is_none() => + { + tracing::debug!( + extension = %name, + relay_url_host = %parsed.host_str().unwrap_or("unknown"), + "effective_relay_url: using per-extension override from settings" + ); + return url; + } + Ok(parsed) => { + tracing::warn!( + extension = %name, + scheme = %parsed.scheme(), + has_userinfo = !parsed.username().is_empty() || parsed.password().is_some(), + "effective_relay_url: rejecting override — \ + only http/https without embedded credentials is allowed" + ); + } + Err(e) => { + tracing::warn!( + extension = %name, + error = %e, + "effective_relay_url: rejecting override — invalid URL" + ); + } + } + } + } + } + None + } + /// Get the shared relay event sender for the webhook endpoint. pub fn relay_event_tx( &self, @@ -892,6 +952,46 @@ impl ExtensionManager { false } + /// Check whether a stored `team_id` setting exists for the given relay extension. + /// + /// Unlike [`is_relay_channel`], this does **not** consult the in-memory + /// `installed_relay_extensions` set — it only looks at the persistent settings + /// store. This distinction matters for `auth_channel_relay`: an extension can + /// be *installed* (present in the in-memory set) but not yet *authenticated* + /// (no OAuth completed, no team_id stored). + async fn has_stored_team_id(&self, name: &str, _user_id: &str) -> bool { + if let Some(ref store) = self.store { + let key = format!("relay:{}:team_id", name); + // Use owner scope (self.user_id) for consistency: the OAuth callback + // stores team_id under state.owner_id which maps to self.user_id. + match store.get_setting(&self.user_id, &key).await { + Ok(Some(v)) => { + let has_id = v.as_str().is_some_and(|s| !s.is_empty()); + tracing::debug!( + extension = %name, + has_team_id = has_id, + "has_stored_team_id: checked store" + ); + return has_id; + } + Ok(None) => { + tracing::debug!( + extension = %name, + "has_stored_team_id: no team_id setting found" + ); + } + Err(e) => { + tracing::warn!( + extension = %name, + error = %e, + "has_stored_team_id: failed to read from settings store" + ); + } + } + } + false + } + /// Restore persisted relay channels after startup. /// /// Loads the persisted active channel list, filters to relay types (those with @@ -1418,7 +1518,7 @@ impl ExtensionManager { let errors = self.activation_errors.read().await; for name in installed.iter() { let active = active_names.contains(name); - let authenticated = self.is_relay_channel(name, user_id).await; + let authenticated = self.has_stored_team_id(name, user_id).await; let activation_error = errors.get(name).cloned(); let registry_entry = self .registry @@ -4191,20 +4291,69 @@ impl ExtensionManager { name: &str, user_id: &str, ) -> Result { - // Check if already authenticated (team_id setting exists) - if self.is_relay_channel(name, user_id).await { + tracing::debug!( + extension = %name, + user_id = %user_id, + "auth_channel_relay: starting" + ); + + // Check if already authenticated by looking for a stored team_id. + // We intentionally skip the `installed_relay_extensions` in-memory set + // here because that set only tracks *installed* extensions — an extension + // can be installed (via registry) but not yet authenticated (no OAuth + // completed). Checking just `is_relay_channel()` would short-circuit + // to "authenticated" even when no team_id exists, preventing the OAuth + // flow from being offered to the user. + if self.has_stored_team_id(name, user_id).await { + tracing::debug!( + extension = %name, + "auth_channel_relay: already authenticated (team_id in store)" + ); return Ok(AuthResult::authenticated(name, ExtensionKind::ChannelRelay)); } + tracing::debug!( + extension = %name, + "auth_channel_relay: no stored team_id, initiating OAuth" + ); + // Use relay config captured at startup - let relay_config = self.relay_config()?; + let relay_config = self.relay_config().map_err(|e| { + tracing::warn!( + extension = %name, + error = %e, + "auth_channel_relay: relay config not available — \ + CHANNEL_RELAY_URL and CHANNEL_RELAY_API_KEY must be set" + ); + e + })?; + + // Allow per-extension URL override from settings + let effective_url = self + .effective_relay_url(name) + .await + .unwrap_or_else(|| relay_config.url.clone()); + + tracing::debug!( + extension = %name, + relay_url = %effective_url, + "auth_channel_relay: creating relay client for OAuth" + ); let client = crate::channels::relay::RelayClient::new( - relay_config.url.clone(), + effective_url.clone(), relay_config.api_key.clone(), relay_config.request_timeout_secs, ) - .map_err(|e| ExtensionError::Config(e.to_string()))?; + .map_err(|e| { + tracing::warn!( + extension = %name, + relay_url = %effective_url, + error = %e, + "auth_channel_relay: failed to create relay HTTP client" + ); + ExtensionError::Config(e.to_string()) + })?; // Generate CSRF nonce — IronClaw validates this on the callback to ensure // the OAuth completion is legitimate. Channel-relay embeds it in the signed @@ -4216,18 +4365,44 @@ impl ExtensionManager { self.secrets .create(user_id, CreateSecretParams::new(&state_key, &state_nonce)) .await - .map_err(|e| ExtensionError::AuthFailed(format!("Failed to store OAuth state: {e}")))?; + .map_err(|e| { + tracing::warn!( + extension = %name, + error = %e, + "auth_channel_relay: failed to store OAuth state nonce" + ); + ExtensionError::AuthFailed(format!("Failed to store OAuth state: {e}")) + })?; // Channel-relay derives all URLs from trusted instance_url in chat-api. // We only pass the nonce for CSRF validation on the callback. + tracing::debug!( + extension = %name, + relay_url = %effective_url, + "auth_channel_relay: calling initiate_oauth on channel-relay" + ); match client.initiate_oauth(Some(&state_nonce)).await { - Ok(auth_url) => Ok(AuthResult::awaiting_authorization( - name, - ExtensionKind::ChannelRelay, - auth_url, - "redirect".to_string(), - )), - Err(e) => Err(ExtensionError::AuthFailed(e.to_string())), + Ok(auth_url) => { + tracing::info!( + extension = %name, + "auth_channel_relay: OAuth URL obtained, awaiting user authorization" + ); + Ok(AuthResult::awaiting_authorization( + name, + ExtensionKind::ChannelRelay, + auth_url, + "redirect".to_string(), + )) + } + Err(e) => { + tracing::warn!( + extension = %name, + relay_url = %effective_url, + error = %e, + "auth_channel_relay: initiate_oauth call to channel-relay failed" + ); + Err(ExtensionError::AuthFailed(e.to_string())) + } } } @@ -4237,40 +4412,112 @@ impl ExtensionManager { name: &str, user_id: &str, ) -> Result { + tracing::debug!( + extension = %name, + user_id = %user_id, + "activate_channel_relay: starting" + ); + let team_id_key = format!("relay:{}:team_id", name); // Get team_id from settings (stored by the OAuth callback) let team_id = if let Some(ref store) = self.store { - store - .get_setting(user_id, &team_id_key) - .await - .ok() - .flatten() - .and_then(|v| v.as_str().map(|s| s.to_string())) - .unwrap_or_default() + match store.get_setting(user_id, &team_id_key).await { + Ok(Some(v)) => { + let id = v.as_str().map(|s| s.to_string()).unwrap_or_default(); + tracing::debug!( + extension = %name, + team_id_empty = id.is_empty(), + "activate_channel_relay: loaded team_id from store" + ); + id + } + Ok(None) => { + tracing::debug!( + extension = %name, + setting_key = %team_id_key, + "activate_channel_relay: no team_id in settings store" + ); + String::new() + } + Err(e) => { + tracing::warn!( + extension = %name, + error = %e, + "activate_channel_relay: failed to read team_id from settings store" + ); + String::new() + } + } } else { + tracing::debug!( + extension = %name, + "activate_channel_relay: no settings store available" + ); String::new() }; if team_id.is_empty() { + tracing::debug!( + extension = %name, + "activate_channel_relay: team_id is empty, returning AuthRequired" + ); return Err(ExtensionError::AuthRequired); } // Use relay config captured at startup - let relay_config = self.relay_config()?; + let relay_config = self.relay_config().map_err(|e| { + tracing::warn!( + extension = %name, + error = %e, + "activate_channel_relay: relay config not available" + ); + e + })?; + + // Allow per-extension URL override from settings + let effective_url = self + .effective_relay_url(name) + .await + .unwrap_or_else(|| relay_config.url.clone()); + + tracing::debug!( + extension = %name, + relay_url = %effective_url, + "activate_channel_relay: relay config loaded" + ); let instance_id = self.relay_instance_id(relay_config, user_id); let client = crate::channels::relay::RelayClient::new( - relay_config.url.clone(), + effective_url.clone(), relay_config.api_key.clone(), relay_config.request_timeout_secs, ) - .map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?; + .map_err(|e| { + tracing::warn!( + extension = %name, + relay_url = %effective_url, + error = %e, + "activate_channel_relay: failed to create relay HTTP client" + ); + ExtensionError::ActivationFailed(e.to_string()) + })?; // Fetch the per-instance signing secret from channel-relay. // This must succeed — there is no fallback. + tracing::debug!( + extension = %name, + relay_url = %effective_url, + "activate_channel_relay: fetching signing secret from channel-relay" + ); let signing_secret = client.get_signing_secret(&team_id).await.map_err(|e| { + tracing::warn!( + extension = %name, + relay_url = %effective_url, + error = %e, + "activate_channel_relay: failed to fetch signing secret from channel-relay" + ); ExtensionError::Config(format!("Failed to fetch relay signing secret: {e}")) })?; @@ -4289,16 +4536,29 @@ impl ExtensionManager { // Hot-add to channel manager let cm_guard = self.relay_channel_manager.read().await; let channel_mgr = cm_guard.as_ref().ok_or_else(|| { + tracing::warn!( + extension = %name, + "activate_channel_relay: channel manager not initialized" + ); ExtensionError::ActivationFailed("Channel manager not initialized".to_string()) })?; - channel_mgr - .hot_add(Box::new(channel)) - .await - .map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?; + channel_mgr.hot_add(Box::new(channel)).await.map_err(|e| { + tracing::warn!( + extension = %name, + error = %e, + "activate_channel_relay: hot_add to channel manager failed" + ); + ExtensionError::ActivationFailed(e.to_string()) + })?; if let Ok(mut cache) = self.relay_signing_secret_cache.lock() { *cache = Some(signing_secret); + } else { + tracing::warn!( + extension = %name, + "activate_channel_relay: failed to cache signing secret (mutex poisoned)" + ); } // Store the event sender so the web gateway's relay webhook endpoint can push events @@ -4316,6 +4576,12 @@ impl ExtensionManager { self.broadcast_extension_status(name, "active", Some(&status_msg)) .await; + tracing::info!( + extension = %name, + instance_id = %instance_id, + "activate_channel_relay: relay channel activated successfully" + ); + Ok(ActivateResult { name: name.to_string(), kind: ExtensionKind::ChannelRelay, @@ -4595,6 +4861,41 @@ impl ExtensionManager { } Ok(ExtensionSetupSchema { secrets, fields }) } + ExtensionKind::ChannelRelay => { + let relay_url_key = format!("extensions.{name}.relay_url"); + let current_url = if let Some(ref store) = self.store { + match store.get_setting(&self.user_id, &relay_url_key).await { + Ok(value_opt) => value_opt + .and_then(|v| v.as_str().map(|s| s.to_string())) + .filter(|s| !s.is_empty()), + Err(e) => { + tracing::warn!( + extension = %name, + setting_key = %relay_url_key, + error = %e, + "get_setup_schema: failed to read relay_url from settings" + ); + None + } + } + } else { + None + }; + let env_url = self.relay_config.as_ref().map(|c| c.url.as_str()); + Ok(ExtensionSetupSchema { + secrets: Vec::new(), + fields: vec![crate::channels::web::types::SetupFieldInfo { + name: "relay_url".to_string(), + prompt: format!( + "Channel-relay service URL (leave empty to use env default{})", + env_url.map(|u| format!(": {u}")).unwrap_or_default() + ), + optional: true, + provided: current_url.is_some(), + input_type: crate::tools::wasm::ToolSetupFieldInputType::Text, + }], + }) + } _ => Ok(ExtensionSetupSchema { secrets: Vec::new(), fields: Vec::new(), @@ -4997,7 +5298,17 @@ impl ExtensionManager { names.insert(server.token_secret_name()); (names, Vec::new()) } - ExtensionKind::ChannelRelay => (std::collections::HashSet::new(), Vec::new()), + ExtensionKind::ChannelRelay => { + let relay_fields = vec![crate::tools::wasm::ToolFieldSetupSchema { + name: "relay_url".to_string(), + prompt: "Channel-relay service URL override".to_string(), + optional: true, + setting_path: Some(format!("extensions.{name}.relay_url")), + input_type: crate::tools::wasm::ToolSetupFieldInputType::Text, + restart_required: false, + }]; + (std::collections::HashSet::new(), relay_fields) + } }; let allowed_fields: std::collections::HashSet = @@ -5088,13 +5399,28 @@ impl ExtensionManager { ))); } let trimmed = field_value.trim(); + let field_def = setup_field_defs.get(field_name); + + // Empty value on an optional field with a setting_path: clear the + // stored override so the system reverts to the env/default value. if trimmed.is_empty() { + if let Some(def) = field_def + && def.optional + { + stored_fields.remove(field_name); + if let Some(setting_path) = &def.setting_path { + Self::validate_setup_setting_path(name, setting_path)?; + if let Some(store) = self.store.as_ref() { + let _ = store.delete_setting(&self.user_id, setting_path).await; + } + } + } continue; } stored_fields.insert(field_name.clone(), trimmed.to_string()); - if let Some(field_def) = setup_field_defs.get(field_name) { + if let Some(field_def) = field_def { if field_def.restart_required { restart_required = true; } @@ -7058,6 +7384,39 @@ mod tests { ); } + /// Regression: installed-but-not-authenticated relay must NOT short-circuit + /// `auth_channel_relay()` to "authenticated". Previously, `auth_channel_relay` + /// called `is_relay_channel()` which checked the in-memory + /// `installed_relay_extensions` set; that returned `true` even when no team_id + /// existed in the store, so the OAuth URL was never offered. + #[tokio::test] + async fn test_auth_channel_relay_installed_without_team_id_is_not_authenticated() { + let dir = tempfile::tempdir().expect("temp dir"); + let mgr = make_test_manager(None, dir.path().to_path_buf()); + + // Mark as installed (simulates clicking Install in the UI) + mgr.installed_relay_extensions + .write() + .await + .insert("slack-relay".to_string()); + + // Without a stored team_id, auth should NOT return authenticated. + // It should fail because relay config is missing (no CHANNEL_RELAY_URL), + // but the key assertion is that it does NOT return Ok(authenticated). + let result = mgr.auth_channel_relay("slack-relay", "test").await; + match result { + Ok(ref auth_result) if auth_result.is_authenticated() => { + panic!( + "auth_channel_relay returned authenticated for installed-but-no-team-id relay; \ + expected either an OAuth URL or a config error" + ); + } + _ => { + // Config error (no relay URL) or awaiting_authorization — both are correct + } + } + } + #[tokio::test] async fn test_remove_relay_shuts_down_via_relay_channel_manager() { // Regression: remove() only checked channel_runtime for shutdown, missing From 1d5777824c617450ac2ce685d15b52c99ef69db3 Mon Sep 17 00:00:00 2001 From: jr42 Date: Thu, 26 Mar 2026 22:47:31 +0100 Subject: [PATCH 13/15] fix(mcp): handle 202 Accepted and wire session manager for Streamable HTTP (#1437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mcp): handle 202 Accepted for Streamable HTTP notifications The MCP Streamable HTTP spec requires servers to respond with 202 Accepted (empty body) for JSON-RPC notifications like `notifications/initialized`. The HTTP transport tried to parse this empty body as JSON, which failed and broke the session handshake — subsequent requests like `tools/list` were rejected because the server considered the session uninitialized. Add an early return for 202 responses that produces an empty McpResponse without attempting body parsing. Fixes #1436 Co-Authored-By: Claude Opus 4.6 (1M context) * fix(mcp): wire session manager into transport for non-OAuth HTTP clients The factory used McpClient::new_with_config().with_session_manager() which only set the session manager on the client, not on the HttpMcpTransport. The transport never captured Mcp-Session-Id from responses, so subsequent requests lacked the header and the server rejected them as uninitialized. Fix by constructing the HttpMcpTransport with the session manager before wrapping it in Arc, matching the pattern already used by new_authenticated(). Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(mcp): deduplicate factory HTTP path, gate dead-code methods as test-only - Collapse the two identical non-OAuth HTTP branches in `create_client_from_config()` into one (early-return for the authenticated path, fall through for the common case). - Gate `McpClient::new_with_config()` and `McpClient::with_session_manager()` as `#[cfg(test)]` — the factory was their only production caller and no longer uses them. Both methods silently skip wiring the session manager into the transport, which was the root cause of #1436. - Add doc warnings on both methods explaining the footgun. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: ilblackdragon@gmail.com --- src/tools/mcp/client.rs | 20 +++++- src/tools/mcp/factory.rs | 117 +++++++++++++++++++++++++++----- src/tools/mcp/http_transport.rs | 28 ++++++++ 3 files changed, 148 insertions(+), 17 deletions(-) diff --git a/src/tools/mcp/client.rs b/src/tools/mcp/client.rs index 148f5a86..32c5767d 100644 --- a/src/tools/mcp/client.rs +++ b/src/tools/mcp/client.rs @@ -117,6 +117,11 @@ impl McpClient { /// The config must use HTTP transport (the default); for stdio/UDS use `new_with_transport`. /// /// Returns an error if the config uses a non-HTTP transport. + /// + /// **Note:** The session manager is NOT wired into the transport. For + /// production use, prefer `create_client_from_config()` which constructs + /// the transport with session tracking. + #[cfg(test)] pub fn new_with_config(config: McpServerConfig) -> Result { if !matches!( config.effective_transport(), @@ -214,7 +219,14 @@ impl McpClient { } } - /// Attach a session manager for Streamable HTTP session tracking. + /// Attach a session manager to the **client** only. + /// + /// **Warning:** This does NOT wire the session manager into the underlying + /// `HttpMcpTransport`, so the transport will not capture `Mcp-Session-Id` + /// from responses. For production use, construct the transport with + /// `HttpMcpTransport::with_session_manager()` and pass it to + /// `new_with_transport()` instead. See `create_client_from_config()`. + #[cfg(test)] pub fn with_session_manager(mut self, session_manager: Arc) -> Self { self.session_manager = Some(session_manager); self @@ -235,6 +247,12 @@ impl McpClient { self.session_manager.is_some() } + /// Get the underlying transport (test-only). + #[cfg(test)] + pub(crate) fn transport(&self) -> &Arc { + &self.transport + } + /// Get the next request ID. fn next_request_id(&self) -> u64 { self.next_id.fetch_add(1, Ordering::SeqCst) diff --git a/src/tools/mcp/factory.rs b/src/tools/mcp/factory.rs index c31c5051..bbb09256 100644 --- a/src/tools/mcp/factory.rs +++ b/src/tools/mcp/factory.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use crate::secrets::SecretsStore; use crate::tools::mcp::config::{EffectiveTransport, McpServerConfig}; +use crate::tools::mcp::http_transport::HttpMcpTransport; use crate::tools::mcp::{McpClient, McpProcessManager, McpSessionManager, McpTransport}; /// Error returned when MCP client creation fails. @@ -78,33 +79,37 @@ pub async fn create_client_from_config( Err(McpFactoryError::UnixNotSupported { name: server_name }) } EffectiveTransport::Http => { + // Authenticated (OAuth) path: tokens exist or server requires auth. if let Some(ref secrets) = secrets { let has_tokens = crate::tools::mcp::is_authenticated(&server, secrets, user_id).await; if has_tokens || server.requires_auth() { - Ok(McpClient::new_authenticated( + return Ok(McpClient::new_authenticated( server, Arc::clone(session_manager), Arc::clone(secrets), user_id, - )) - } else { - Ok(McpClient::new_with_config(server) - .map_err(|e| McpFactoryError::InvalidConfig { - name: server_name.clone(), - reason: e.to_string(), - })? - .with_session_manager(Arc::clone(session_manager))) + )); } - } else { - Ok(McpClient::new_with_config(server) - .map_err(|e| McpFactoryError::InvalidConfig { - name: server_name, - reason: e.to_string(), - })? - .with_session_manager(Arc::clone(session_manager))) } + + // Non-OAuth HTTP: wire the session manager into the *transport* so + // it captures `Mcp-Session-Id` from responses. Passing it only to + // the client (via `with_session_manager`) is not enough — the + // transport must know about it to read/write the header. + let transport = Arc::new( + HttpMcpTransport::new(server.url.clone(), server.name.clone()) + .with_session_manager(Arc::clone(session_manager)), + ); + Ok(McpClient::new_with_transport( + server.name.clone(), + transport, + Some(Arc::clone(session_manager)), + secrets, + user_id, + Some(server), + )) } } } @@ -134,4 +139,84 @@ mod tests { "non-OAuth HTTP clients must carry a session manager" ); } + + /// Regression test: the factory must wire the session manager into the + /// *transport*, not just the client. Otherwise the transport never + /// captures `Mcp-Session-Id` from responses and subsequent requests + /// lack the header, causing the server to reject them. + #[tokio::test] + async fn test_factory_non_oauth_http_transport_captures_session_id() { + use axum::http::header::HeaderName; + use axum::{Router, http::StatusCode, response::IntoResponse, routing::post}; + use tokio::net::TcpListener; + + const SESSION_ID: &str = "test-session-abc123"; + + async fn session_echo() -> impl IntoResponse { + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "result": {} + }) + .to_string(); + ( + StatusCode::OK, + [( + HeaderName::from_static("mcp-session-id"), + SESSION_ID.to_string(), + )], + body, + ) + } + + let app = Router::new().route("/", post(session_echo)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let url = format!("http://127.0.0.1:{}", addr.port()); + + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let server = McpServerConfig::new("session-test", &url); + let session_manager = Arc::new(McpSessionManager::new()); + let process_manager = Arc::new(McpProcessManager::new()); + + let client = create_client_from_config( + server, + &session_manager, + &process_manager, + None, + "test-user", + ) + .await + .expect("factory should succeed for HTTP config"); + + // Pre-create a session entry so that update_session_id has something to update. + // In production, the MCP initialize handshake calls get_or_create before responses arrive. + session_manager.get_or_create("session-test", &url).await; + + // Send a request through the client's transport to trigger session capture. + use crate::tools::mcp::protocol::McpRequest; + let request = McpRequest { + jsonrpc: "2.0".to_string(), + id: Some(1), + method: "test".to_string(), + params: Some(serde_json::json!({})), + }; + let headers = std::collections::HashMap::new(); + client + .transport() + .send(&request, &headers) + .await + .expect("request should succeed"); + + // Verify the session manager captured the session ID from the response. + let captured = session_manager.get_session_id("session-test").await; + assert_eq!( + captured.as_deref(), + Some(SESSION_ID), + "transport must capture Mcp-Session-Id into session manager" + ); + } } diff --git a/src/tools/mcp/http_transport.rs b/src/tools/mcp/http_transport.rs index 59873ce4..ea3e1c03 100644 --- a/src/tools/mcp/http_transport.rs +++ b/src/tools/mcp/http_transport.rs @@ -494,6 +494,34 @@ mod tests { assert_eq!(echoed["authorization"], "Bearer oauth-token"); } + /// Regression test for #1436: 202 Accepted responses for notifications + /// were parsed as JSON, causing "Failed to parse MCP response" errors + /// that broke the MCP session handshake. + #[tokio::test] + async fn test_wire_202_accepted_for_notification() { + use axum::{Router, http::StatusCode, routing::post}; + use tokio::net::TcpListener; + + async fn accept_notification() -> StatusCode { + StatusCode::ACCEPTED + } + + let app = Router::new().route("/", post(accept_notification)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let url = format!("http://127.0.0.1:{}", addr.port()); + + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let transport = HttpMcpTransport::new(&url, "test-202"); + let request = McpRequest::initialized_notification(); + let response = transport.send(&request, &HashMap::new()).await.unwrap(); + assert!(response.result.is_none()); + assert!(response.error.is_none()); + } + #[tokio::test] async fn test_wire_custom_auth_preserved_when_no_per_request_auth() { let (url, _handle) = spawn_echo_server().await; From dd0a0e10abebcd7c161c6e86fb89b8bd06e38592 Mon Sep 17 00:00:00 2001 From: Nige Date: Thu, 26 Mar 2026 23:20:01 +0000 Subject: [PATCH 14/15] fix(routines): recover delete name after failed update fallback (#1108) Co-authored-by: ilblackdragon@gmail.com Co-authored-by: Claude Opus 4.6 (1M context) --- src/context/state.rs | 3 + src/tools/builtin/routine.rs | 38 +++++++++- tests/e2e_builtin_tool_coverage.rs | 43 +++++++++++- .../routine_update_fail_delete_fallback.json | 70 +++++++++++++++++++ 4 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/llm_traces/tools/routine_update_fail_delete_fallback.json diff --git a/src/context/state.rs b/src/context/state.rs index f5307947..0bb1f29a 100644 --- a/src/context/state.rs +++ b/src/context/state.rs @@ -192,6 +192,9 @@ pub struct JobContext { /// but subsequent tools (e.g., `json`) may need the full output. This /// stash stores the complete, unsanitized output so tools can reference /// previous results by ID via `$tool_call_id` parameter syntax. + /// + /// Also used for cross-tool implicit state (keys prefixed with `__`) such + /// as `__routine_last_name` for fallback recovery in routine tool chains. #[serde(skip)] pub tool_output_stash: Arc>>, /// User's preferred timezone (IANA name, e.g. "America/New_York"). Defaults to "UTC". diff --git a/src/tools/builtin/routine.rs b/src/tools/builtin/routine.rs index bbc24139..76f6e38b 100644 --- a/src/tools/builtin/routine.rs +++ b/src/tools/builtin/routine.rs @@ -650,6 +650,23 @@ pub(crate) fn routine_update_parameters_schema() -> Value { }) } +const ROUTINE_LAST_NAME_STASH_KEY: &str = "__routine_last_name"; + +async fn stash_last_routine_name(ctx: &JobContext, name: &str) { + ctx.tool_output_stash + .write() + .await + .insert(ROUTINE_LAST_NAME_STASH_KEY.to_string(), name.to_string()); +} + +async fn restore_last_routine_name(ctx: &JobContext) -> Option { + ctx.tool_output_stash + .read() + .await + .get(ROUTINE_LAST_NAME_STASH_KEY) + .cloned() +} + fn nested_object<'a>(params: &'a Value, field: &str) -> Option<&'a Map> { params.get(field).and_then(Value::as_object) } @@ -1093,6 +1110,7 @@ impl Tool for RoutineCreateTool { ) -> Result { let start = std::time::Instant::now(); let normalized = parse_routine_create_request(¶ms)?; + stash_last_routine_name(ctx, &normalized.name).await; let trigger = build_routine_trigger(&normalized.trigger); let action = build_routine_action(&normalized.name, &normalized.prompt, &normalized.execution); @@ -1274,6 +1292,7 @@ impl Tool for RoutineUpdateTool { let start = std::time::Instant::now(); let name = require_str(¶ms, "name")?; + stash_last_routine_name(ctx, name).await; let mut routine = self .store @@ -1411,11 +1430,24 @@ impl Tool for RoutineDeleteTool { ) -> Result { let start = std::time::Instant::now(); - let name = require_str(¶ms, "name")?; + let name = if let Some(name) = params.get("name").and_then(|v| v.as_str()) { + if name.trim().is_empty() { + return Err(ToolError::InvalidParameters( + "'name' parameter cannot be empty".to_string(), + )); + } + name.to_string() + } else { + restore_last_routine_name(ctx).await.ok_or_else(|| { + ToolError::InvalidParameters( + "missing 'name' parameter and no previous routine target to infer".to_string(), + ) + })? + }; let routine = self .store - .get_routine_by_name(&ctx.user_id, name) + .get_routine_by_name(&ctx.user_id, &name) .await .map_err(|e| ToolError::ExecutionFailed(format!("DB error: {e}")))? .ok_or_else(|| ToolError::ExecutionFailed(format!("routine '{}' not found", name)))?; @@ -1430,7 +1462,7 @@ impl Tool for RoutineDeleteTool { self.engine.refresh_event_cache().await; let result = serde_json::json!({ - "name": name, + "name": &name, "deleted": deleted, }); diff --git a/tests/e2e_builtin_tool_coverage.rs b/tests/e2e_builtin_tool_coverage.rs index 1c3cc6a2..7c0c7bc7 100644 --- a/tests/e2e_builtin_tool_coverage.rs +++ b/tests/e2e_builtin_tool_coverage.rs @@ -205,7 +205,44 @@ mod tests { } // ----------------------------------------------------------------------- - // Test 5: routine_manual_create_defaults_to_tools_enabled + // Test 5: routine_update_fail_delete_fallback + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn routine_update_fail_delete_fallback() { + let trace = LlmTrace::from_file(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/llm_traces/tools/routine_update_fail_delete_fallback.json" + )) + .expect("failed to load routine_update_fail_delete_fallback.json"); + + let rig = TestRigBuilder::new() + .with_trace(trace.clone()) + .with_auto_approve_tools(true) + .build() + .await; + + rig.send_message("Try converting a routine trigger, then recover by deleting it") + .await; + let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await; + + rig.verify_trace_expects(&trace, &responses); + + let completed = rig.tool_calls_completed(); + assert!( + completed.iter().any(|(n, ok)| n == "routine_update" && !ok), + "routine_update should fail in this regression path: {completed:?}" + ); + assert!( + completed.iter().any(|(n, ok)| n == "routine_delete" && *ok), + "routine_delete should recover successfully via preserved routine identity: {completed:?}" + ); + + rig.shutdown(); + } + + // ----------------------------------------------------------------------- + // Test 6: routine_manual_create_defaults_to_tools_enabled // ----------------------------------------------------------------------- #[tokio::test] @@ -246,7 +283,7 @@ mod tests { } // ----------------------------------------------------------------------- - // Test 6: routine_manual_create_explicit_no_tools + // Test 7: routine_manual_create_explicit_no_tools // ----------------------------------------------------------------------- #[tokio::test] @@ -287,7 +324,7 @@ mod tests { } // ----------------------------------------------------------------------- - // Test 7: routine_history + // Test 8: routine_history // ----------------------------------------------------------------------- #[tokio::test] diff --git a/tests/fixtures/llm_traces/tools/routine_update_fail_delete_fallback.json b/tests/fixtures/llm_traces/tools/routine_update_fail_delete_fallback.json new file mode 100644 index 00000000..5c76dbb5 --- /dev/null +++ b/tests/fixtures/llm_traces/tools/routine_update_fail_delete_fallback.json @@ -0,0 +1,70 @@ +{ + "model_name": "test-routine-update-fail-delete-fallback", + "expects": { + "tools_used": ["routine_create", "routine_update", "routine_delete"], + "tool_results_contain": { + "routine_update": "Cannot update schedule or timezone on a non-cron routine.", + "routine_delete": "temp-routine" + }, + "min_responses": 1 + }, + "steps": [ + { + "response": { + "type": "tool_calls", + "tool_calls": [ + { + "id": "call_rc_fallback", + "name": "routine_create", + "arguments": { + "name": "temp-routine", + "trigger_type": "manual", + "prompt": "Temporary routine for fallback test." + } + } + ], + "input_tokens": 120, + "output_tokens": 40 + } + }, + { + "response": { + "type": "tool_calls", + "tool_calls": [ + { + "id": "call_ru_fallback", + "name": "routine_update", + "arguments": { + "name": "temp-routine", + "schedule": "0 */10 * * * *" + } + } + ], + "input_tokens": 200, + "output_tokens": 30 + } + }, + { + "response": { + "type": "tool_calls", + "tool_calls": [ + { + "id": "call_rd_fallback", + "name": "routine_delete", + "arguments": {} + } + ], + "input_tokens": 300, + "output_tokens": 20 + } + }, + { + "response": { + "type": "text", + "content": "I recovered from the failed update and cleaned up the original routine.", + "input_tokens": 380, + "output_tokens": 25 + } + } + ] +} From 5b95d222186f9ee8f89edf69480000ca42f0d7d0 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Thu, 26 Mar 2026 16:45:31 -0700 Subject: [PATCH 15/15] Support direct hosted OAuth callbacks with proxy auth token (#1684) * Support direct hosted OAuth callbacks with proxy auth token * Make OAuth env tests panic-safe * Preserve public OAuth field compatibility * Fix OAuth proxy token whitespace fallback --- src/channels/web/server.rs | 436 ++++++++++++++++++++++++++++++++++++- src/cli/oauth_defaults.rs | 189 +++++++++++++++- src/extensions/manager.rs | 13 +- src/tools/wasm/loader.rs | 55 ++++- src/tools/wasm/wrapper.rs | 18 +- 5 files changed, 689 insertions(+), 22 deletions(-) diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 26c005d4..06870ace 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -836,10 +836,10 @@ async fn oauth_callback_handler( let result: Result<(), String> = async { let token_response = if let Some(proxy_url) = &exchange_proxy_url { - let gateway_token = flow.gateway_token.as_deref().unwrap_or_default(); + let oauth_proxy_auth_token = flow.oauth_proxy_auth_token().unwrap_or_default(); oauth_defaults::exchange_via_proxy(oauth_defaults::ProxyTokenExchangeRequest { proxy_url, - gateway_token, + gateway_token: oauth_proxy_auth_token, token_url: &flow.token_url, client_id: &flow.client_id, client_secret: flow.client_secret.as_deref(), @@ -3057,6 +3057,160 @@ mod tests { .with_state(state) } + #[derive(Clone, Debug)] + struct RecordedOauthProxyRequest { + authorization: Option, + form: std::collections::HashMap, + } + + #[derive(Clone)] + struct MockOauthProxyState { + requests: Arc>>, + } + + struct MockOauthProxyServer { + addr: std::net::SocketAddr, + requests: Arc>>, + shutdown_tx: Option>, + server_task: Option>, + } + + impl MockOauthProxyServer { + async fn start() -> Self { + async fn exchange_handler( + State(state): State, + headers: axum::http::HeaderMap, + axum::Form(form): axum::Form>, + ) -> Json { + state.requests.lock().await.push(RecordedOauthProxyRequest { + authorization: headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .map(str::to_string), + form, + }); + Json(serde_json::json!({ + "access_token": "proxy-access-token", + "refresh_token": "proxy-refresh-token", + "expires_in": 7200 + })) + } + + let requests = Arc::new(tokio::sync::Mutex::new(Vec::new())); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind mock oauth proxy"); + let addr = listener.local_addr().expect("mock oauth proxy addr"); + let app = Router::new() + .route("/oauth/exchange", post(exchange_handler)) + .with_state(MockOauthProxyState { + requests: Arc::clone(&requests), + }); + let (shutdown_tx, shutdown_rx) = tokio::sync::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) + } + + async fn requests(&self) -> Vec { + 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 MockOauthProxyServer { + 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, + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + // SAFETY: Tests use lock_env() to serialize environment 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: Tests use lock_env() to serialize environment access. + unsafe { + if let Some(value) = value { + std::env::set_var(key, value); + } else { + std::env::remove_var(key); + } + } + EnvVarGuard { key, original } + } + + fn fresh_pending_oauth_flow( + secrets: Arc, + sse_manager: Option>, + oauth_proxy_auth_token: Option, + ) -> crate::cli::oauth_defaults::PendingOAuthFlow { + crate::cli::oauth_defaults::PendingOAuthFlow { + extension_name: "test_tool".to_string(), + display_name: "Test Tool".to_string(), + token_url: "https://example.com/token".to_string(), + client_id: "client123".to_string(), + client_secret: None, + redirect_uri: "https://example.com/oauth/callback".to_string(), + code_verifier: Some("test-code-verifier".to_string()), + access_token_field: "access_token".to_string(), + secret_name: "test_token".to_string(), + provider: Some("google".to_string()), + validation_endpoint: None, + scopes: vec!["email".to_string()], + user_id: "test".to_string(), + secrets, + sse_manager, + gateway_token: oauth_proxy_auth_token, + token_exchange_extra_params: std::collections::HashMap::new(), + client_id_secret_name: None, + created_at: std::time::Instant::now(), + } + } + #[tokio::test] async fn test_extensions_setup_submit_returns_failure_when_not_activated() { use axum::body::Body; @@ -3714,6 +3868,284 @@ mod tests { ); } + #[tokio::test] + async fn test_oauth_callback_accepts_versioned_hosted_state_without_instance_name() { + use axum::body::Body; + use tower::ServiceExt; + + let secrets: Arc = + Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new( + crate::secrets::SecretsCrypto::new(secrecy::SecretString::from( + TEST_GATEWAY_CRYPTO_KEY.to_string(), + )) + .expect("crypto"), + ))); + let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets.clone()); + + let Some(created_at) = expired_flow_created_at() else { + eprintln!( + "Skipping versioned OAuth state without instance test: monotonic uptime below expiry window" + ); + return; + }; + let flow = crate::cli::oauth_defaults::PendingOAuthFlow { + extension_name: "test_tool".to_string(), + display_name: "Test Tool".to_string(), + token_url: "https://example.com/token".to_string(), + client_id: "client123".to_string(), + client_secret: None, + redirect_uri: "https://example.com/oauth/callback".to_string(), + code_verifier: None, + access_token_field: "access_token".to_string(), + secret_name: "test_token".to_string(), + provider: None, + validation_endpoint: None, + scopes: vec![], + user_id: "test".to_string(), + secrets, + sse_manager: None, + gateway_token: None, + token_exchange_extra_params: std::collections::HashMap::new(), + client_id_secret_name: None, + created_at, + }; + + ext_mgr + .pending_oauth_flows() + .write() + .await + .insert("test_nonce".to_string(), flow); + + let state = test_gateway_state(Some(ext_mgr.clone())); + let app = test_oauth_router(state); + let versioned_state = + crate::cli::oauth_defaults::encode_hosted_oauth_state("test_nonce", None); + + let req = axum::http::Request::builder() + .uri(format!( + "/oauth/callback?code=fake_code&state={}", + urlencoding::encode(&versioned_state) + )) + .body(Body::empty()) + .expect("request"); + + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::OK); + + let body = axum::body::to_bytes(resp.into_body(), 1024 * 64) + .await + .expect("body"); + let html = String::from_utf8_lossy(&body); + assert!(html.contains("Authorization Failed")); + assert!( + ext_mgr + .pending_oauth_flows() + .read() + .await + .get("test_nonce") + .is_none() + ); + } + + #[allow(clippy::await_holding_lock)] + #[tokio::test] + async fn test_oauth_callback_happy_path_with_gateway_token_fallback() { + use axum::body::Body; + use tower::ServiceExt; + + let proxy = MockOauthProxyServer::start().await; + // Keep the process-wide env locked for the full callback so the handler + // sees a stable proxy URL/token configuration throughout the test. + let _env_guard = crate::config::helpers::lock_env(); + let _exchange_url_guard = + set_env_var("IRONCLAW_OAUTH_EXCHANGE_URL", Some(&proxy.base_url())); + let _proxy_auth_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None); + let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token")); + + let secrets = test_secrets_store(); + let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(Arc::clone(&secrets)); + let sse_mgr = Arc::new(SseManager::new()); + let mut receiver = sse_mgr.sender().subscribe(); + let flow = fresh_pending_oauth_flow( + Arc::clone(&secrets), + Some(Arc::clone(&sse_mgr)), + crate::cli::oauth_defaults::oauth_proxy_auth_token(), + ); + + ext_mgr + .pending_oauth_flows() + .write() + .await + .insert("test_nonce".to_string(), flow); + + let state = test_gateway_state(Some(ext_mgr.clone())); + let app = test_oauth_router(state); + let versioned_state = + crate::cli::oauth_defaults::encode_hosted_oauth_state("test_nonce", Some("myinstance")); + + let req = axum::http::Request::builder() + .uri(format!( + "/oauth/callback?code=fake_code&state={}", + urlencoding::encode(&versioned_state) + )) + .body(Body::empty()) + .expect("request"); + + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::OK); + + let body = axum::body::to_bytes(resp.into_body(), 1024 * 64) + .await + .expect("body"); + let html = String::from_utf8_lossy(&body); + assert!(html.contains("Test Tool Connected")); + + let requests = proxy.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("code").map(String::as_str), + Some("fake_code") + ); + assert_eq!( + requests[0].form.get("code_verifier").map(String::as_str), + Some("test-code-verifier") + ); + + let access_token = secrets + .get_decrypted("test", "test_token") + .await + .expect("access token stored"); + assert_eq!(access_token.expose(), "proxy-access-token"); + + let refresh_token = secrets + .get_decrypted("test", "test_token_refresh_token") + .await + .expect("refresh token stored"); + assert_eq!(refresh_token.expose(), "proxy-refresh-token"); + + match receiver.recv().await.expect("auth_completed event").event { + crate::channels::web::types::AppEvent::AuthCompleted { + extension_name, + success, + .. + } => { + assert_eq!(extension_name, "test_tool"); + assert!(success, "OAuth callback should broadcast success"); + } + event => panic!("expected AuthCompleted event, got {event:?}"), + } + + proxy.shutdown().await; + } + + #[allow(clippy::await_holding_lock)] + #[tokio::test] + async fn test_oauth_callback_happy_path_with_dedicated_proxy_auth_token() { + use axum::body::Body; + use tower::ServiceExt; + + let proxy = MockOauthProxyServer::start().await; + // Keep the process-wide env locked for the full callback so the handler + // sees a stable proxy URL/token configuration throughout the test. + let _env_guard = crate::config::helpers::lock_env(); + let _exchange_url_guard = + set_env_var("IRONCLAW_OAUTH_EXCHANGE_URL", Some(&proxy.base_url())); + let _proxy_auth_guard = set_env_var( + "IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", + Some("shared-oauth-proxy-secret"), + ); + let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", None); + + let secrets = test_secrets_store(); + let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(Arc::clone(&secrets)); + let sse_mgr = Arc::new(SseManager::new()); + let mut receiver = sse_mgr.sender().subscribe(); + let flow = fresh_pending_oauth_flow( + Arc::clone(&secrets), + Some(Arc::clone(&sse_mgr)), + crate::cli::oauth_defaults::oauth_proxy_auth_token(), + ); + + ext_mgr + .pending_oauth_flows() + .write() + .await + .insert("test_nonce".to_string(), flow); + + let state = test_gateway_state(Some(ext_mgr.clone())); + let app = test_oauth_router(state); + let versioned_state = + crate::cli::oauth_defaults::encode_hosted_oauth_state("test_nonce", None); + + let req = axum::http::Request::builder() + .uri(format!( + "/oauth/callback?code=fake_code&state={}", + urlencoding::encode(&versioned_state) + )) + .body(Body::empty()) + .expect("request"); + + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::OK); + + let body = axum::body::to_bytes(resp.into_body(), 1024 * 64) + .await + .expect("body"); + let html = String::from_utf8_lossy(&body); + assert!(html.contains("Test Tool Connected")); + + let requests = proxy.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("fake_code") + ); + assert_eq!( + requests[0].form.get("code_verifier").map(String::as_str), + Some("test-code-verifier") + ); + + let access_token = secrets + .get_decrypted("test", "test_token") + .await + .expect("access token stored"); + assert_eq!(access_token.expose(), "proxy-access-token"); + + let refresh_token = secrets + .get_decrypted("test", "test_token_refresh_token") + .await + .expect("refresh token stored"); + assert_eq!(refresh_token.expose(), "proxy-refresh-token"); + + match receiver.recv().await.expect("auth_completed event").event { + crate::channels::web::types::AppEvent::AuthCompleted { + extension_name, + success, + .. + } => { + assert_eq!(extension_name, "test_tool"); + assert!(success, "OAuth callback should broadcast success"); + } + event => panic!("expected AuthCompleted event, got {event:?}"), + } + + proxy.shutdown().await; + } + // --- Slack relay OAuth CSRF tests --- fn test_relay_oauth_router(state: Arc) -> Router { diff --git a/src/cli/oauth_defaults.rs b/src/cli/oauth_defaults.rs index e9001909..384d5833 100644 --- a/src/cli/oauth_defaults.rs +++ b/src/cli/oauth_defaults.rs @@ -473,7 +473,8 @@ pub struct PendingOAuthFlow { pub secrets: Arc, /// SSE broadcast manager for notifying the web UI. pub sse_manager: Option>, - /// Gateway auth token for authenticating with the platform token exchange proxy. + /// OAuth proxy auth token for authenticating with the hosted token exchange proxy. + /// Kept as `gateway_token` for public API compatibility. pub gateway_token: Option, /// Additional form params for the token exchange request. /// Used for provider-specific requirements such as RFC 8707 `resource`. @@ -496,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>>; @@ -529,6 +536,22 @@ pub fn exchange_proxy_url() -> Option { .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 { + fn normalized_env_value(key: &str) -> Option { + 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); @@ -674,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, @@ -687,6 +712,8 @@ pub struct ProxyTokenExchangeRequest<'a> { 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, @@ -729,7 +756,7 @@ fn oauth_token_response_from_json( /// 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. /// @@ -741,7 +768,7 @@ pub async fn exchange_via_proxy( ) -> Result { 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('/')); @@ -796,7 +823,7 @@ pub async fn exchange_via_proxy( /// Refresh an OAuth access token via the platform's token refresh 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. pub async fn refresh_token_via_proxy( @@ -804,7 +831,7 @@ pub async fn refresh_token_via_proxy( ) -> Result { if request.gateway_token.is_empty() { return Err(OAuthCallbackError::Io( - "Gateway auth token is required for proxy token refresh".to_string(), + "OAuth proxy auth token is required for proxy token refresh".to_string(), )); } @@ -1010,6 +1037,37 @@ mod tests { } } + struct EnvVarGuard { + key: &'static str, + original: Option, + } + + 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"); @@ -1030,6 +1088,79 @@ mod tests { 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; @@ -1535,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; diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index 47b45a0f..55b1e96d 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -403,9 +403,10 @@ pub struct ExtensionManager { /// when running in gateway mode, consumed by the web gateway's /// `/oauth/callback` handler. pending_oauth_flows: crate::cli::oauth_defaults::PendingOAuthRegistry, - /// Gateway auth token for authenticating with the platform token exchange proxy. - /// Read once at construction from `GATEWAY_AUTH_TOKEN` env var. - gateway_token: Option, + /// OAuth proxy auth token for authenticating with the hosted token exchange proxy. + /// Resolved once at construction from `IRONCLAW_OAUTH_PROXY_AUTH_TOKEN`, + /// then `GATEWAY_AUTH_TOKEN` as a backward-compatible fallback. + oauth_proxy_auth_token: Option, /// Relay config captured at startup. Used by `auth_channel_relay` and /// `activate_channel_relay` instead of re-reading env vars. relay_config: Option, @@ -535,7 +536,7 @@ impl ExtensionManager { activation_errors: RwLock::new(HashMap::new()), sse_manager: RwLock::new(None), pending_oauth_flows: crate::cli::oauth_defaults::new_pending_oauth_registry(), - gateway_token: std::env::var("GATEWAY_AUTH_TOKEN").ok(), + oauth_proxy_auth_token: crate::cli::oauth_defaults::oauth_proxy_auth_token(), relay_config: crate::config::RelayConfig::from_env(), relay_event_tx: Arc::new(tokio::sync::Mutex::new(None)), relay_signing_secret_cache: Arc::new(std::sync::Mutex::new(None)), @@ -2788,7 +2789,7 @@ impl ExtensionManager { user_id: user_id.to_string(), secrets: Arc::clone(&self.secrets), sse_manager: self.sse_manager.read().await.clone(), - gateway_token: self.gateway_token.clone(), + gateway_token: self.oauth_proxy_auth_token.clone(), token_exchange_extra_params, client_id_secret_name: if server.oauth.is_none() { Some(server.client_id_secret_name()) @@ -3305,7 +3306,7 @@ impl ExtensionManager { user_id: user_id.to_string(), secrets: Arc::clone(&self.secrets), sse_manager: self.sse_manager.read().await.clone(), - gateway_token: self.gateway_token.clone(), + gateway_token: self.oauth_proxy_auth_token.clone(), token_exchange_extra_params: std::collections::HashMap::new(), client_id_secret_name: None, created_at: std::time::Instant::now(), diff --git a/src/tools/wasm/loader.rs b/src/tools/wasm/loader.rs index 2a7ed040..4876dc1b 100644 --- a/src/tools/wasm/loader.rs +++ b/src/tools/wasm/loader.rs @@ -446,16 +446,14 @@ fn resolve_oauth_refresh_config(cap_file: &CapabilitiesFile) -> Option, /// Hosted OAuth proxy base URL (e.g., "http://host.docker.internal:8080"). pub exchange_proxy_url: Option, - /// Gateway auth token for authenticating with the hosted OAuth proxy. + /// OAuth proxy auth token for authenticating with the hosted OAuth proxy. + /// Kept as `gateway_token` for public API compatibility. pub gateway_token: Option, /// Secret name of the access token (e.g., "google_oauth_token"). /// The refresh token lives at `{secret_name}_refresh_token`. @@ -71,6 +72,12 @@ pub struct OAuthRefreshConfig { pub provider: Option, } +impl OAuthRefreshConfig { + fn oauth_proxy_auth_token(&self) -> Option<&str> { + self.gateway_token.as_deref() + } +} + /// Pre-resolved credential for host-based injection. /// /// Built before each WASM execution by decrypting secrets from the store. @@ -1218,9 +1225,9 @@ async fn refresh_oauth_token( let refresh_name = format!("{}_refresh_token", config.secret_name); if let Some(proxy_url) = config.exchange_proxy_url.as_deref() { - let Some(gateway_token) = config.gateway_token.as_deref() else { + let Some(oauth_proxy_auth_token) = config.oauth_proxy_auth_token() else { tracing::warn!( - "OAuth refresh proxy is configured, but no gateway auth token is available" + "OAuth refresh proxy is configured, but no OAuth proxy auth token is available" ); return false; }; @@ -1235,7 +1242,7 @@ async fn refresh_oauth_token( let token_response = match oauth_defaults::refresh_token_via_proxy( oauth_defaults::ProxyRefreshTokenRequest { proxy_url, - gateway_token, + gateway_token: oauth_proxy_auth_token, token_url: &config.token_url, client_id: &config.client_id, client_secret: config.client_secret.as_deref(), @@ -2704,7 +2711,8 @@ mod tests { } #[tokio::test] - async fn test_resolve_host_credentials_skips_refresh_token_lookup_without_gateway_token() { + async fn test_resolve_host_credentials_skips_refresh_token_lookup_without_oauth_proxy_auth_token() + { use crate::secrets::{ CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore, };