refactor(gateway): hygiene batch — delete dead handler, tighten boundaries, add caller-level chat tests - #2712
Conversation
…aries, add caller-level chat tests
Five follow-ups from the ironclaw#2599 review thread, bundled because
each is a small focused change in the same file family and the test
coverage one depends on the boundary-check one's effect (origin gate
now precedes the WS upgrade extractor, which is what lets `oneshot`
unit-test the origin rejection branch).
## 1. Delete `handlers/static_files.rs` (163 LOC dead code)
Every `pub` fn in this file had a canonical live version the router
actually wires — `platform::static_files::{health,project_*}` and
`features::{logs::logs_events_handler, status::gateway_status_handler}`
— but the stale file was left behind after the stage 4b migration. The
`#[allow(dead_code)]` annotation on `pub mod static_files;` in
`handlers/mod.rs` was a breadcrumb flagging the file for eventual
removal. Nothing imports from `handlers::static_files`, confirmed via
`rg -l handlers::static_files src/`. Safe `git rm`.
## 2. `is_local_origin` case-insensitive host match
`features/chat/mod.rs::is_local_origin` used exact-case matching on
`localhost / 127.0.0.1 / [::1]`. Browsers normalize the Origin header
to lowercase in practice, but RFC 7230 §5.4 allows uppercase
hostnames, and HTTP is case-insensitive in the scheme as well. The
helper now lowercases the whole origin string before parsing so
`http://LOCALHOST`, `HTTP://localhost`, and mixed-case variants all
resolve through the same match path. Regression test
`test_is_local_origin_accepts_uppercase` pins the uppercase cases and
spot-checks that the existing lowercase cases still pass.
## 3. `extensions_install_handler` validates `req.name` via `ExtensionName::new`
Sibling URL-path handlers (`activate`, `remove`, `setup`,
`setup_submit`) already validate at the boundary; `install` was the
last untyped entry point. The JSON-body `name` now parses through
`ExtensionName::new` before it reaches registry lookup, filesystem
path construction under `~/.ironclaw/extensions/`, or any
extension-manager call. Behavior change: paths that used to silently
reach the install pipeline and fail deep now return 400 at the
boundary with `Invalid extension name: ...`. New test
`test_extensions_install_handler_rejects_malformed_name` covers path
traversal, separators, mixed case, whitespace, and the bare `..`
case.
## 4. Refactor `chat_ws_handler` so the Origin gate runs before the WS upgrade extractor
Swap `ws: WebSocketUpgrade` for `ws: Result<WebSocketUpgrade, _>` so
axum hands the handler the raw extraction result instead of rejecting
before the body runs. This reorders the response precedence from
`extract → origin check` to `origin check → extract`, with two real
effects:
- A caller with a bad Origin now always gets `403 Forbidden`
regardless of whether they sent upgrade headers. Previously,
malformed probes without upgrade headers got `426 Upgrade Required`
— less accurate as a security signal, because it told the caller
"you're allowed here, just add these headers."
- `tower::ServiceExt::oneshot` can synthesize the new failure path
(missing `OnUpgrade` hyper extension surfaces as
`Err(WebSocketUpgradeRejection)`), which is what lets the new unit
tests exercise the three origin-gating branches without a real TCP
listener.
## 5. Add four caller-level chat handler tests
Per `.claude/rules/testing.md` ("Test Through the Caller, Not Just
the Helper") and the #2599 follow-ups explicit list. Each test drives
the handler function itself through a populated `GatewayState`
(now possible because stage-6a / #2704 promoted the state builders
into `test_helpers`):
- `test_chat_send_handler_forwards_message_to_msg_tx` — drives
`chat_send_handler`, asserts 202 ACCEPTED *and* the message lands
in the receiver end of `msg_tx`. Helper-level tests on
`web_incoming_message` alone couldn't catch a wrapper that
silently dropped the send.
- `test_chat_send_handler_returns_503_without_channel` — pins the
"channel not started" shape so a refactor that reroutes `msg_tx`
can't regress the 503.
- `test_chat_send_handler_rate_limits_after_threshold` — 30 OK then
1 × 429, covering the `PerUserRateLimiter::new(30, 60)` contract.
- `test_chat_ws_handler_{rejects_missing_origin, rejects_remote_origin,
accepts_localhost_origin}` — three origin-gating cases. The
accept path asserts 426 (Origin passed, upgrade can't complete in
oneshot) rather than 101 — the positive signal is *which* rejection
fires, not that the upgrade completes. Real 101 is still covered by
`tests/ws_gateway_integration.rs`.
- `test_chat_threads_handler_returns_in_memory_threads_without_db`
pins the DB-absent fallback branch.
- `test_chat_new_thread_handler_persists_to_db_and_session` asserts
both side effects fire (session entry + conversations row).
## Quality gate
- `cargo fmt --all`
- `cargo clippy --all --benches --tests --examples --all-features` — zero warnings
- `cargo check -p ironclaw --no-default-features --features libsql --tests` — clean
- `cargo test -p ironclaw --lib channels::web` — 456 passed (up 10 from staging baseline)
- `cargo test -p ironclaw --test multi_tenant_integration` — 40 passed
- `cargo test -p ironclaw --test ws_gateway_integration` — 11 passed
- `python3 scripts/check_gateway_boundaries.py` + test — clean, 16/16
- `bash scripts/pre-commit-safety.sh` — clean
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Refactors parts of the web gateway to remove dead code, tighten input/origin validation boundaries, and add caller-level tests for chat and extensions handlers (aligned with the gateway boundary/testing guidance from ironclaw#2599).
Changes:
- Removed unused legacy static-file handler module and its module export.
- Hardened boundary validation:
is_local_originbecomes case-insensitive;extensions_install_handlervalidatesreq.nameviaExtensionName::new. - Refactored
chat_ws_handlerto run the Origin gate before the WS-upgrade extractor and added caller-level tests for chat handlers.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
src/channels/web/handlers/static_files.rs |
Deleted dead/unused handlers module. |
src/channels/web/handlers/mod.rs |
Removed static_files module export. |
src/channels/web/features/extensions/mod.rs |
Validate install request name via ExtensionName::new; added regression test for malformed names. |
src/channels/web/features/chat/mod.rs |
Origin gate precedes WS upgrade extraction; is_local_origin lowercases; added caller-level tests for chat handlers. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let ws = ws.map_err(|rej| { | ||
| use axum::response::IntoResponse; | ||
| let resp = rej.into_response(); | ||
| let status = resp.status(); | ||
| (status, "WebSocket upgrade failed".to_string()) | ||
| })?; |
There was a problem hiding this comment.
chat_ws_handler converts WebSocketUpgradeRejection into just (StatusCode, String), which drops the rejection response headers/body (e.g., 426 Upgrade Required typically includes WS-specific headers). Consider returning Ok(rej.into_response()) on rejection (or changing the error type) so axum’s full rejection response is preserved.
There was a problem hiding this comment.
Fixed in 03d2d66. Changed the handler's return type to plain axum::response::Response and return rej.into_response() verbatim on upgrade failure — axum's 426 response (with its RFC 7231 §6.5.15 Upgrade header) and 400 variants now pass through untouched. The two origin-rejection early returns also go through .into_response() so the response shape stays consistent. The three caller-level origin tests keep asserting on the status codes (403 / 403 / 426), and the tests/ws_gateway_integration.rs real-TCP suite still passes.
| let received = rx | ||
| .recv() | ||
| .await |
There was a problem hiding this comment.
This test awaits rx.recv().await without a timeout; if the handler stops sending (regression), the test can hang the entire suite instead of failing. Use a short tokio::time::timeout around recv() (similar to other tests in this module).
| let received = rx | |
| .recv() | |
| .await | |
| let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()) | |
| .await | |
| .expect("timed out waiting for msg_tx to receive the accepted message") |
There was a problem hiding this comment.
Fixed in 03d2d66. Wrapped the rx.recv() in a 500ms tokio::time::timeout with an explicit .expect("accepted send must enqueue a message promptly") so a regression that returns 202 without sending fails the test instead of hanging the suite.
| .expect("within-budget call must succeed"); | ||
| assert_eq!(status, StatusCode::ACCEPTED); | ||
| // Drain so the channel doesn't block sender. | ||
| let _ = rx.recv().await; |
There was a problem hiding this comment.
The loop drains the channel via rx.recv().await with no timeout. If a future change causes chat_send_handler to return 202 without actually sending, this will hang rather than fail. Wrap the receive in a small tokio::time::timeout and assert it completed.
| let _ = rx.recv().await; | |
| tokio::time::timeout(tokio::time::Duration::from_millis(100), rx.recv()) | |
| .await | |
| .expect("accepted send must enqueue a message promptly") | |
| .expect("channel closed before queued message was received"); |
There was a problem hiding this comment.
Fixed in 03d2d66. Each of the 30 drain iterations now goes through tokio::time::timeout(Duration::from_millis(100), rx.recv()) with an explicit expect, so a no-send regression fails fast instead of hanging the loop.
There was a problem hiding this comment.
Code Review
This pull request refactors the WebSocket handler to improve origin validation and error reporting, updates origin checks to be case-insensitive, and introduces input validation for extension installation names to prevent path traversal. It also removes the static_files module and adds comprehensive unit tests for chat handlers and extension installation. Feedback includes preserving protocol-required headers in WebSocket upgrade rejections by returning the response directly and improving the robustness of origin string normalization by trimming whitespace before case conversion.
| let ws = ws.map_err(|rej| { | ||
| use axum::response::IntoResponse; | ||
| let resp = rej.into_response(); | ||
| let status = resp.status(); | ||
| (status, "WebSocket upgrade failed".to_string()) | ||
| })?; |
There was a problem hiding this comment.
By extracting only the status code from the WebSocketUpgradeRejection and returning a new (StatusCode, String) error, the handler discards any headers present in the original rejection response. For 426 Upgrade Required responses, RFC 7231 requires an Upgrade header field to indicate the supported protocols, which Axum's rejection response typically includes. Consider changing the handler's return type to axum::response::Response (and converting other error paths via .into_response()) so that the rejection response can be returned directly, preserving its headers.
There was a problem hiding this comment.
Fixed in 03d2d66 — handler return type is now plain axum::response::Response, the WebSocketUpgradeRejection passes through via rej.into_response(), and the early origin-rejection arms route through .into_response() as well. RFC 7231 §6.5.15 Upgrade header on 426 (and diagnostics on the 400 variants) are preserved verbatim. Copilot independently flagged the same finding on a sibling thread; both resolved by the same change.
| // normalize the Origin header to lowercase in practice, but RFC 7230 | ||
| // §5.4 allows uppercase hostnames. Normalize before parsing so the | ||
| // match never depends on the caller's casing. | ||
| let origin_lc = origin.to_ascii_lowercase(); |
There was a problem hiding this comment.
The current implementation is susceptible to false negatives if the Origin header contains leading or trailing whitespace (e.g., " http://localhost"), as the strip_prefix and subsequent matches! check are whitespace-sensitive. To adhere to the project convention of avoiding unnecessary allocations for invalid or empty inputs, the string should be trimmed and checked for emptiness before calling to_ascii_lowercase(), which is the preferred method for case-insensitive comparisons in this repository.
let origin = origin.trim();
if origin.is_empty() {
return false;
}
let origin_lc = origin.to_ascii_lowercase();References
- When canonicalizing a string, perform cheaper validation checks (like length, emptiness, and character set) on a trimmed slice before performing more expensive operations like replace that allocate a new string.
- For case-insensitive comparisons, use to_ascii_lowercase() as it is the project convention, especially when the text is known to be ASCII.
There was a problem hiding this comment.
Respectfully declining this one.
Trimming origin before parsing would flip the behavior for whitespace-padded inputs from reject to accept. Current flow for e.g. " http://localhost":
to_ascii_lowercase()→" http://localhost"(whitespace preserved).strip_prefix("http://")fails on the leading space.host = ""falls throughunwrap_or("").matches!(host, "localhost" | ...)→false, returns 403.
With the suggested trim():
trim()→"http://localhost".strip_prefix+ match →host = "localhost"→ returnstrue, upgrade accepted.
Browsers don't send padded Origin headers in practice (RFC 6454 specifies the serialized form without padding), so callers who send them are either broken clients or probes. Keeping the "reject on odd shape" default is the safer posture — especially on a CSRF gate where the cost of a false reject is a weird-but-fixable client bug and the cost of a false accept is cross-site WS hijacking.
The allocation optimization angle of the suggestion also doesn't buy much — "".to_ascii_lowercase() is essentially free, so the is-empty early-return saves nothing measurable in the hot path. Leaving this as-is.
Three review comments from Copilot and Gemini, grouped by issue:
## 1. Preserve `WebSocketUpgradeRejection` response verbatim (Copilot + Gemini)
`chat_ws_handler` was converting the `WebSocketUpgradeRejection` into
`(StatusCode, String)`, which discarded the rejection response's
headers and body. For `426 Upgrade Required` specifically, RFC 7231
§6.5.15 requires an `Upgrade` header field naming the protocols the
server supports — axum's rejection response includes that header, but
our hand-rolled `(status, "WebSocket upgrade failed")` error dropped
it. Same story for the `400 Bad Request` rejection variants that
include human-readable diagnostics.
Fix: change the handler's return type from
`Result<axum::response::Response, (StatusCode, String)>` to plain
`axum::response::Response`, return `rej.into_response()` verbatim
when the upgrade extractor fails, and route the two origin-rejection
early returns through `.into_response()` as well. The integration
tests in `tests/ws_gateway_integration.rs` still pass unchanged — the
successful-upgrade path routes through `ws.on_upgrade(...)` which
already returns a `Response` — and the three caller-level origin
tests keep their exact status-code assertions (403 / 403 / 426).
## 2. Timeout around `rx.recv()` in `test_chat_send_handler_forwards_message_to_msg_tx` (Copilot)
A future regression that has `chat_send_handler` return 202 without
actually sending on `msg_tx` would hang this test forever instead of
failing fast. Wrap `rx.recv()` in a 500ms `tokio::time::timeout` with
an explicit `.expect("accepted send must enqueue a message promptly")`.
## 3. Timeout around `rx.recv()` in `test_chat_send_handler_rate_limits_after_threshold` (Copilot)
Same shape as #2: the 30-iteration drain loop awaited `rx.recv()`
unbounded. A regression that returns 202-without-send would make the
loop hang. Wrap each drain in a 100ms timeout.
## Declined: `is_local_origin` whitespace trim (Gemini)
Gemini suggested trimming the origin string before `to_ascii_lowercase()`.
Not applying:
- The current path already treats whitespace-padded origins as invalid
(`strip_prefix` fails on the leading space, falls through to
`host = ""`, `matches!` returns `false`, 403). Trimming would flip
that from *reject* to *accept* for inputs like `" http://localhost"`,
which loosens the check.
- Browsers normalize the Origin header; no compliant client sends
padded origins, so the behavior change has no legitimate caller.
- The allocation concern is marginal (`"".to_ascii_lowercase()` is
essentially free, so the is-empty early-return saves nothing
measurable).
Reply on the thread will explain the reasoning.
## Quality gate
- `cargo fmt --all`
- `cargo clippy --all --benches --tests --examples --all-features` — zero warnings
- `cargo test -p ironclaw --lib channels::web` — 456 passed
- `cargo test -p ironclaw --test ws_gateway_integration` — 11 passed
- `bash scripts/pre-commit-safety.sh` — clean
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…aries, add caller-level chat tests (nearai#2712) * refactor(gateway): hygiene batch — delete dead handler, tighten boundaries, add caller-level chat tests Five follow-ups from the ironclaw#2599 review thread, bundled because each is a small focused change in the same file family and the test coverage one depends on the boundary-check one's effect (origin gate now precedes the WS upgrade extractor, which is what lets `oneshot` unit-test the origin rejection branch). ## 1. Delete `handlers/static_files.rs` (163 LOC dead code) Every `pub` fn in this file had a canonical live version the router actually wires — `platform::static_files::{health,project_*}` and `features::{logs::logs_events_handler, status::gateway_status_handler}` — but the stale file was left behind after the stage 4b migration. The `#[allow(dead_code)]` annotation on `pub mod static_files;` in `handlers/mod.rs` was a breadcrumb flagging the file for eventual removal. Nothing imports from `handlers::static_files`, confirmed via `rg -l handlers::static_files src/`. Safe `git rm`. ## 2. `is_local_origin` case-insensitive host match `features/chat/mod.rs::is_local_origin` used exact-case matching on `localhost / 127.0.0.1 / [::1]`. Browsers normalize the Origin header to lowercase in practice, but RFC 7230 §5.4 allows uppercase hostnames, and HTTP is case-insensitive in the scheme as well. The helper now lowercases the whole origin string before parsing so `http://LOCALHOST`, `HTTP://localhost`, and mixed-case variants all resolve through the same match path. Regression test `test_is_local_origin_accepts_uppercase` pins the uppercase cases and spot-checks that the existing lowercase cases still pass. ## 3. `extensions_install_handler` validates `req.name` via `ExtensionName::new` Sibling URL-path handlers (`activate`, `remove`, `setup`, `setup_submit`) already validate at the boundary; `install` was the last untyped entry point. The JSON-body `name` now parses through `ExtensionName::new` before it reaches registry lookup, filesystem path construction under `~/.ironclaw/extensions/`, or any extension-manager call. Behavior change: paths that used to silently reach the install pipeline and fail deep now return 400 at the boundary with `Invalid extension name: ...`. New test `test_extensions_install_handler_rejects_malformed_name` covers path traversal, separators, mixed case, whitespace, and the bare `..` case. ## 4. Refactor `chat_ws_handler` so the Origin gate runs before the WS upgrade extractor Swap `ws: WebSocketUpgrade` for `ws: Result<WebSocketUpgrade, _>` so axum hands the handler the raw extraction result instead of rejecting before the body runs. This reorders the response precedence from `extract → origin check` to `origin check → extract`, with two real effects: - A caller with a bad Origin now always gets `403 Forbidden` regardless of whether they sent upgrade headers. Previously, malformed probes without upgrade headers got `426 Upgrade Required` — less accurate as a security signal, because it told the caller "you're allowed here, just add these headers." - `tower::ServiceExt::oneshot` can synthesize the new failure path (missing `OnUpgrade` hyper extension surfaces as `Err(WebSocketUpgradeRejection)`), which is what lets the new unit tests exercise the three origin-gating branches without a real TCP listener. ## 5. Add four caller-level chat handler tests Per `.claude/rules/testing.md` ("Test Through the Caller, Not Just the Helper") and the nearai#2599 follow-ups explicit list. Each test drives the handler function itself through a populated `GatewayState` (now possible because stage-6a / nearai#2704 promoted the state builders into `test_helpers`): - `test_chat_send_handler_forwards_message_to_msg_tx` — drives `chat_send_handler`, asserts 202 ACCEPTED *and* the message lands in the receiver end of `msg_tx`. Helper-level tests on `web_incoming_message` alone couldn't catch a wrapper that silently dropped the send. - `test_chat_send_handler_returns_503_without_channel` — pins the "channel not started" shape so a refactor that reroutes `msg_tx` can't regress the 503. - `test_chat_send_handler_rate_limits_after_threshold` — 30 OK then 1 × 429, covering the `PerUserRateLimiter::new(30, 60)` contract. - `test_chat_ws_handler_{rejects_missing_origin, rejects_remote_origin, accepts_localhost_origin}` — three origin-gating cases. The accept path asserts 426 (Origin passed, upgrade can't complete in oneshot) rather than 101 — the positive signal is *which* rejection fires, not that the upgrade completes. Real 101 is still covered by `tests/ws_gateway_integration.rs`. - `test_chat_threads_handler_returns_in_memory_threads_without_db` pins the DB-absent fallback branch. - `test_chat_new_thread_handler_persists_to_db_and_session` asserts both side effects fire (session entry + conversations row). ## Quality gate - `cargo fmt --all` - `cargo clippy --all --benches --tests --examples --all-features` — zero warnings - `cargo check -p ironclaw --no-default-features --features libsql --tests` — clean - `cargo test -p ironclaw --lib channels::web` — 456 passed (up 10 from staging baseline) - `cargo test -p ironclaw --test multi_tenant_integration` — 40 passed - `cargo test -p ironclaw --test ws_gateway_integration` — 11 passed - `python3 scripts/check_gateway_boundaries.py` + test — clean, 16/16 - `bash scripts/pre-commit-safety.sh` — clean Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(gateway): address PR nearai#2712 review feedback Three review comments from Copilot and Gemini, grouped by issue: ## 1. Preserve `WebSocketUpgradeRejection` response verbatim (Copilot + Gemini) `chat_ws_handler` was converting the `WebSocketUpgradeRejection` into `(StatusCode, String)`, which discarded the rejection response's headers and body. For `426 Upgrade Required` specifically, RFC 7231 §6.5.15 requires an `Upgrade` header field naming the protocols the server supports — axum's rejection response includes that header, but our hand-rolled `(status, "WebSocket upgrade failed")` error dropped it. Same story for the `400 Bad Request` rejection variants that include human-readable diagnostics. Fix: change the handler's return type from `Result<axum::response::Response, (StatusCode, String)>` to plain `axum::response::Response`, return `rej.into_response()` verbatim when the upgrade extractor fails, and route the two origin-rejection early returns through `.into_response()` as well. The integration tests in `tests/ws_gateway_integration.rs` still pass unchanged — the successful-upgrade path routes through `ws.on_upgrade(...)` which already returns a `Response` — and the three caller-level origin tests keep their exact status-code assertions (403 / 403 / 426). ## 2. Timeout around `rx.recv()` in `test_chat_send_handler_forwards_message_to_msg_tx` (Copilot) A future regression that has `chat_send_handler` return 202 without actually sending on `msg_tx` would hang this test forever instead of failing fast. Wrap `rx.recv()` in a 500ms `tokio::time::timeout` with an explicit `.expect("accepted send must enqueue a message promptly")`. ## 3. Timeout around `rx.recv()` in `test_chat_send_handler_rate_limits_after_threshold` (Copilot) Same shape as #2: the 30-iteration drain loop awaited `rx.recv()` unbounded. A regression that returns 202-without-send would make the loop hang. Wrap each drain in a 100ms timeout. ## Declined: `is_local_origin` whitespace trim (Gemini) Gemini suggested trimming the origin string before `to_ascii_lowercase()`. Not applying: - The current path already treats whitespace-padded origins as invalid (`strip_prefix` fails on the leading space, falls through to `host = ""`, `matches!` returns `false`, 403). Trimming would flip that from *reject* to *accept* for inputs like `" http://localhost"`, which loosens the check. - Browsers normalize the Origin header; no compliant client sends padded origins, so the behavior change has no legitimate caller. - The allocation concern is marginal (`"".to_ascii_lowercase()` is essentially free, so the is-empty early-return saves nothing measurable). Reply on the thread will explain the reasoning. ## Quality gate - `cargo fmt --all` - `cargo clippy --all --benches --tests --examples --all-features` — zero warnings - `cargo test -p ironclaw --lib channels::web` — 456 passed - `cargo test -p ironclaw --test ws_gateway_integration` — 11 passed - `bash scripts/pre-commit-safety.sh` — clean Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Five follow-ups from the ironclaw#2599 tracking comment, bundled because each is small, touches the same file family, and #5 depends on #4's effect (origin gate now precedes the WS upgrade extractor — which is what lets
oneshotunit-test the origin rejection).1. Delete
src/channels/web/handlers/static_files.rs(163 LOC dead code)Every
pubfn in this file had a canonical live version the router actually wires:health_handlerplatform/static_files::health_handlerproject_{redirect,index,file}_handlerplatform/static_files::*logs_events_handlerfeatures/logs::logs_events_handlergateway_status_handlerfeatures/status::gateway_status_handlerLeft behind from stage 4b.
#[allow(dead_code)]on thepub mod static_files;declaration was a breadcrumb. Nothing imports fromhandlers::static_files, safegit rm.2.
is_local_origincase-insensitivefeatures/chat/mod.rs::is_local_originused exact-case matching. Browsers lowercase Origin in practice, but RFC 7230 §5.4 permits uppercase hostnames and HTTP is case-insensitive in the scheme. The helper now lowercases before parsing. Regression testtest_is_local_origin_accepts_uppercasepinshttp://LOCALHOST,HTTP://localhost,HTTP://127.0.0.1.3.
extensions_install_handlervalidatesreq.nameviaExtensionName::newThe sibling URL-path handlers (
activate,remove,setup,setup_submit) all validate at the boundary; install was the last untyped entry point. The JSON-bodynamenow parses throughExtensionName::newbefore reaching registry lookup, filesystem paths under~/.ironclaw/extensions/, or any extension-manager call.Behavior change: names that previously reached the install pipeline and failed deep now return
400 Invalid extension name: ...at the boundary.test_extensions_install_handler_rejects_malformed_namepins..,../traversal,slash/name,BadCase,has space.4. Refactor
chat_ws_handlerso the Origin gate runs before the WS upgrade extractorSwap
ws: WebSocketUpgrade→ws: Result<WebSocketUpgrade, WebSocketUpgradeRejection>. Two real effects:403 Forbiddenregardless of whether they included upgrade headers. Previously a probe without upgrade headers got426 Upgrade Required— which told the caller "you're allowed here, just add these headers" instead of "you're not allowed."tower::ServiceExt::oneshotcan't synthesize hyper'sOnUpgradeextension, so the previous signature made origin-rejection branches untestable via unit tests. The new signature hands the handler the raw extraction result so origin gating fires first;oneshotnow reaches all three origin paths.5. Four caller-level chat handler tests
Per
.claude/rules/testing.md("Test Through the Caller, Not Just the Helper"):chat_send_handler— 3 tests: forwards tomsg_tx(side effect verified via receiver), returns 503 without channel, rate-limits after 30/60s threshold.chat_ws_handler— 3 tests: rejects missing Origin → 403, rejects remote Origin → 403, accepts localhost Origin (asserts 426 as positive signal the gate passed — oneshot can't complete the upgrade, real 101 covered bytests/ws_gateway_integration.rs).chat_threads_handler— 1 test: returns in-memory threads when DB absent (fallback branch).chat_new_thread_handler— 1 test: persists to both session AND conversation store (both side effects verified).Quality gate
cargo fmt --allcargo clippy --all --benches --tests --examples --all-features— zero warningscargo check -p ironclaw --no-default-features --features libsql --tests— cleancargo test -p ironclaw --lib channels::web— 456 passed (up 10 from staging baseline)cargo test -p ironclaw --test multi_tenant_integration— 40 passedcargo test -p ironclaw --test ws_gateway_integration— 11 passed (all real-TCP WS paths still green with the refactored handler)python3 scripts/check_gateway_boundaries.py+ test — clean, 16/16bash scripts/pre-commit-safety.sh— cleanFollow-ups status after this merges
From the #2599 tracking comment:
is_local_origincase sensitivity (this PR)extensions_install_handlerExtensionName::newvalidation (this PR)GatewayStatusResponseduplicateengine_v2fields (coordinated backend+frontend)chat_history_handlerholds session lock across DB I/OExtension.nametyping (String→ExtensionName— needs hyphen-fold harmonization with pairing storage)🤖 Generated with Claude Code