1325 Commits

Author SHA1 Message Date
github-actions[bot]
9dcd8969a6 chore: update WASM artifact SHA256 checksums [skip ci] (#2775)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-20 23:34:45 -07:00
Henry Park
15b1f14149 fix(release): include sandbox_daemon in MSI (#2774) ironclaw-v0.26.0 2026-04-20 22:37:09 -07:00
ironclaw-ci[bot]
d546cf6121 chore: release (#2606)
* chore: release

* fix(release): bump ironclaw to v0.26.0

* docs(release): expand v0.26.0 changelog

---------

Co-authored-by: ironclaw-ci[bot] <266877842+ironclaw-ci[bot]@users.noreply.github.com>
Co-authored-by: Henry Park <henrypark133@gmail.com>
ironclaw_common-v0.3.0 ironclaw_skills-v0.2.0
2026-04-20 22:02:12 -07:00
Henry Park
a9045408de Merge pull request #2772 from nearai/staging
Chore: Promote Staging to Main
2026-04-20 20:18:00 -07:00
Henry Park
b6397603ac [codex] Fix windows clippy test gating and bump channel versions (#2773) 2026-04-20 20:08:50 -07:00
Henry Park
8292b225a9 [codex] fix v2 attachment persistence test path (#2770)
* test(e2e): fix v2 attachment persistence assertion

* test(e2e): serialize shared v2 attachment state
2026-04-20 19:50:19 -07:00
Henry Park
6d4935a617 [codex] Stabilize web settings LLM hot reload (#2765)
* Stabilize web settings LLM hot reload

* Keep web settings hot reload DB-scoped

* fix(config): preserve TOML overlay in llm re-resolve

* test(config): allow env lock in async toml re-resolve test

* fix(web): fail closed on hot reload db read errors
2026-04-20 19:08:02 -07:00
Henry Park
714cc41fc9 [codex] fix(gateway): make multi-tenant mode config-driven (#2762)
* fix(gateway): make multi-tenant mode config-driven

* fix(web): address henrypark133 review - add startup multi-tenant coverage (#2762)

* fix(web): address review - restore workspace isolation (#2762)
2026-04-20 19:07:24 -07:00
Henry Park
8b12764c39 [codex] Refresh skill tool ZIP extraction test fixtures (#2764)
* test(skill_tools): refresh ZIP extraction fixtures

* fix(review): tighten skill ZIP test coverage
2026-04-20 19:05:36 -07:00
Henry Park
a4966c6694 [codex] Fix gateway slash autocomplete and attachment rendering (#2763)
* fix gateway slash autocomplete and attachment rendering

* fix(web): restore attachment uploads and binary fallback

* fix(web): preserve in-progress attachment turns on reload
2026-04-20 19:05:28 -07:00
Illia Polosukhin
336bdb1ec8 fix(bridge): sanitize orchestrator failures before showing them to users (#2546) (#2747)
* fix(bridge): sanitize orchestrator failures before showing them to users (#2546)

When the engine returned `ThreadOutcome::Failed { error }` the raw string
reached the user verbatim through `BridgeOutcome::Respond(format!("Error: {error}"))`.
The string includes multiple layers of wrapping (`Orchestrator error: effect
execution error: ...`), the Monty-hosted Python traceback with internal file
paths (`File "orchestrator.py", line 907`), and the upstream HTTP body
(`HTTP 502 Bad Gateway`). This is what the QA bug bash reported: a 502 from
the LLM provider surfaced the whole stack to a user on staging.

Add a shared `bridge::user_facing_errors` module that classifies failure
strings into intent-level categories (LLM unavailable, rate-limited, context
too large, auth failure, iteration limit, unknown) and returns a short,
user-safe message for each. Route `ThreadOutcome::Failed` through a named
helper (`bridge_outcome_for_failed_thread`) that logs the full raw error
server-side via `tracing::warn!` and responds with the sanitized text.

Extensive unit tests cover both the classifier and the router-side helper,
including the exact 502 traceback from the issue as a regression fixture
plus 413 (#2276) and context-length (#2408) variants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bridge): tighten failure classification patterns from PR #2747 review

- Drop overly broad substring matches ("tokens used", "unauthorized",
  "request failed", "provider nearai") that caused misclassification.
- Reword AuthFailure user message to be channel-agnostic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:46:56 +02:00
firat.sertgoz
904e378677 fix(gateway): keep engine threads out of chat sidebar (#2751)
* fix(gateway): keep engine threads out of chat sidebar

* fix: address review findings (iteration 1)

* fix: address review findings (iteration 2)
2026-04-20 20:38:41 +02:00
firat.sertgoz
e35099de23 [codex] Support web document uploads (#2332)
* fix: support web document uploads

* fix(web): address zmanian review — MIME allowlist, rename ImageData, update spec (#2332)

- Add server-side MIME type allowlist in uploads_to_attachments() to reject
  unsafe file types (executables, scripts, HTML). Accepts: image/*, audio/*,
  PDF, plain text, CSV, Markdown, JSON, XML, RTF, and Office documents.
- Rename ImageData → AttachmentData since it now carries any file type.
- Update web channel CLAUDE.md spec: body limit is 16 MB (not 10 MB),
  document supported upload types and MIME rejection behavior.
- Clarify JS file size constants with comment explaining why two exist.
- Add regression tests for MIME allowlist (reject + accept paths).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): server-side magic-byte sniffing + fix JS file staging race (#2332)

Address henrypark133 review: MIME allowlist was validating the client-
supplied media_type field, not actual bytes. Add validate_content_matches_
claimed_type() that checks magic bytes for binary formats (PDF, PNG,
JPEG, GIF, WebP, ZIP/Office, OLE2/Office, RTF, MP3, OGG, WAV) and
UTF-8 validity for text/* claims. Called after base64 decode in
uploads_to_attachments().

Address gemini-code-assist review: handleAttachmentFiles() had a race
condition where stagedBytes/stagedCount were computed from stagedFiles,
but stagedFiles was only updated in the async FileReader.onload callback.
Rapid concurrent calls could bypass MAX_STAGED_FILES and
MAX_TOTAL_FILE_SIZE_BYTES limits. Fix by pushing a placeholder entry
to stagedFiles synchronously (with loading:true), then filling in
data/dataUrl in the callback. Send path blocks while files are loading.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): address zmanian review — harden upload MIME validation (#2332)

* fix(web): correct upload test payloads, tighten ADTS, clear clippy warnings

- Integration tests: use `mime_type`/`data_base64` to match `AttachmentData`
  wire shape so both caller-level tests exercise the validation path they
  claim to (previously failed with 422 before reaching the handler).
- Tighten `audio/aac` magic-byte check with mask 0xF6 so MP3 frames
  mis-declared as AAC are rejected; accept ADIF as fallback.
- Refactor `validate_content_matches_claimed_type` into match-with-guards,
  clearing 13 new `clippy::collapsible_match` warnings.
- Add `debug_assert!` guard where allow-list and extension map must stay
  in sync; fallback stays for defence-in-depth.
- Regression tests: PDF-body mismatch, MP3-spoofed-as-AAC, valid ADTS AAC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
2026-04-21 02:58:29 +09:00
Coffee
ae9b179560 fix(channel): feishu pairing (#2454)
* Fix Feishu webhook auth refresh and extension card overflow

* Keep pending pairing approvals visible in active channel cards

* chore: fmt

* Fix WASM channel secret lookup owner context

* Make channel secret config injection manifest-driven

* Harden WASM secret config mappings and enforce owner scope

* Use owner scope for WASM channel webhook secret resolution

* Use owner scope for WASM channel credential injection

* Remove scope changes from Feishu secret mapping PR

* chore: fmt

* Centralize reserved WASM runtime config keys

* chore: fmt

* Address open review items on feishu-pairing PR

- Extract shared inject_wasm_channel_secret_config_mappings helper so
  startup, hot-activation, and refresh paths share identical behavior
  (env-var fallback + logging). Fixes drift flagged in review.
- Include 'ready' channel state alongside 'active' for the compact
  pending-pairing UI in the gateway.
- Add reserved-key coupling test so a new RUNTIME_CONFIG_KEY_*
  constant cannot be introduced without extending the reserved set.
- Restore OAuth nonce delete rationale after earlier reorder.
- Replace fully-qualified SecretConfigMappingSchema paths with module
  imports.
- Expand comment on validated_secret_config_mappings_with_warnings
  side-effect call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 02:38:32 +09:00
Nige
392a33a478 feat(tui): support multiline message drafting (#2462)
Co-authored-by: Guille <gagdiez.c@gmail.com>
2026-04-21 00:45:44 +09:00
jinxin
4577d0e82f fix(gateway): wire standalone missions tab (load, back, refresh) (#2745)
Three parallel wiring gaps in the engine v2 Missions tab — each one was
code that only handled the Projects drill-in (cr-*) path and silently
no-op'd on the standalone tab, so the surface looked empty or frozen
even when the backend returned data:

- switchTab had no branch for 'missions', so opening the tab rendered
  the panel shell but never fetched data. /api/engine/missions returned
  rows; the table stayed empty.
- close-mission-detail only hid the cr-detail drawer, so the Back
  button on the standalone detail view did nothing.
- refreshMissionView refreshed the detail view or the project drill-in
  but not the Missions list, so fire/pause/resume actions succeeded but
  the list stayed stale.

[skip-regression-check]
2026-04-21 00:13:01 +09:00
Illia Polosukhin
e0c029c796 ci: validate Cargo.toml version before use in Docker workflows (#1901) (#2742)
* ci: validate Cargo.toml version before use in Docker workflows (#1901)

Reject Cargo.toml versions that don't match strict semver before they
reach any shell context, and stop splicing `${{ }}` expressions directly
into `run:` blocks in Summary steps — pass values via `env:` and
reference as shell variables instead. Also validate `inputs.tag`
against Docker tag grammar in docker.yml.

Closes #1901

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: reject SemVer build metadata in Docker workflow version validator

Docker tags forbid '+', so accepting SemVer build metadata in the
validator would pass values like '1.2.3+build.7' through only to fail
at docker push. Tighten the regex to MAJOR.MINOR.PATCH[-prerelease] and
spell out the constraint in the error message.

Addresses PR #2742 review feedback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 00:03:43 +09:00
Illia Polosukhin
c725366e70 docs(rules): add review-driven guidance for Claude Code (#2714)
* docs(rules): add review-driven guidance for Claude Code

Synthesizes recurring patterns from ~30 merged PRs, 147 bot review
comments (Copilot/Gemini), human reviews, and ~50 issues filed in the
past 2 weeks. Each rule cites the motivating PR/issue numbers.

New files:
- error-handling.md — silent-failure taxonomy (unwrap_or_default, .ok()?,
  poisoned caches), persist-then-reload atomicity, channel-edge error
  mapping. (#2526, #2633, #2653, #2673, #2546, #2407, #2408)
- agent-evidence.md — side-effect claims must cite tool evidence,
  empty-fast outputs are errors, external-effect tools must read back,
  setup UI round-trip. (#2544, #2580, #2582, #2541, #2545, #2411, #2543,
  #2586)
- lifecycle.md — discovery vs. activation, terminal auth rejection,
  list_installed vs. list_active, deactivation unwinds, snapshot
  rehydrate must re-validate. (#2556, #2557, #2558, #2564, #2419,
  PR #2617, PR #2631)

Extended:
- types.md — from_trusted boundary rule, validated-newtype template with
  shared validate(&str), serde(try_from) required for validated types,
  wire-stable enums (no Debug; serde alias for migrations), canonical
  wire-contract field naming. (PR #2685, #2681, #2687, #2678, #2669,
  #2665, #2683, #2702)
- safety-and-sandbox.md — every new ingress scans pre-transform/pre-
  injection, bounded resources (interners/streams/fan-out caps), cache
  keys must be complete. (#2491, #2676, #2470, #2633, #2673, #2710,
  PR #2702)
- review-discipline.md — PR scope discipline, guardrail scripts are
  code (regression tests, grouped-import parsing, CI has_code inclusion),
  absolute-path ban in committed docs, stale comments after refactors.
  (PR #2668, #2628, #2680, #2687, #2647, #2689, #2701)

All new files carry paths: frontmatter so they auto-load only on
matching files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(rules): split agent-evidence into prompt + code rule

agent-evidence.md mixed two concerns: runtime agent instruction (what
the LLM should do when concluding a turn) and code-enforcement rules
(what the dispatcher, engine, and tools must implement). Rules under
.claude/rules/ only guide Claude Code when editing the repo — the
runtime agent never reads them.

Splits the two:

- crates/ironclaw_engine/prompts/codeact_postamble.md — new section
  "Evidence before claiming side effects". Sits next to the existing
  "FINAL() answer quality" guidance; loaded via include_str! in
  executor/prompt.rs (no Rust change needed).
- .claude/rules/tool-evidence.md — renamed from agent-evidence.md,
  keeps only the code invariants (engine v2 side-effect gate,
  empty-fast ToolError::EmptyResult, external-effect tools must read
  back, setup UI round-trip).

Prompt tests pass unchanged; the postamble addition is pure text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* prompt: tighten evidence rule to FINAL() claims only, not tool use

Live-test validation of the "Evidence before claiming side effects"
section (added in the prior commit) showed it inhibited legitimate
tool use. With the original wording, `zizmor_scan_v2` live-recording
timed out at 302s with zero responses; reverting the postamble
restored healthy behavior (88s run, 8 shell calls including
`cargo install zizmor` and full workflow analysis).

The original phrasing conflated two things: what the agent should
claim and what tools it should call. The rule is only about the
claim. Re-tunes the section to:

- Open with an explicit "this does not restrict tool calls" scope.
- Drop the "<1ms = failure" heuristic (too broad — normal tools like
  `tool_info(schema)` are legitimately fast).
- Drop the full enumeration of forbidden side-effect verbs; keep the
  rule narrower and clearer.
- Shorten the code example (remove redundant early-return).

Re-tuned run: agent is active (shell calls, real reasoning), live
recording completes in ~9s. The remaining test failure is a
pre-existing assertion bug (exact `t == "shell"` match against tool
strings that now carry arguments like `"shell(cmd)"`) — reproduces
with the old postamble too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(live): fix tool-name assertions + re-record zizmor traces

The two `zizmor_scan*` live tests had four broken tool-name assertions
that silently failed to match: `tools.iter().any(|t| t == "shell")`
against a tool list that now contains `"shell(cmd)"` strings (tool
events carry args via `format_action_display_name` in
`src/bridge/router.rs`). Two of the four were negative assertions
checking for the absence of `tool_install` recovery loops — those
silently passed even when a recovery loop actually ran. `sandbox_live_e2e.rs:203`
already used the correct `t == "shell" || t.starts_with("shell(")`
pattern; applied it consistently to all four sites.

Verified live:

- `IRONCLAW_LIVE_TEST=1 cargo test --test e2e_live -- zizmor_scan --ignored --test-threads=1`
  → 2 passed, 0 failed, 51.78s. Agent installs and runs zizmor
  end-to-end, producing real findings (exit code 14, dangerous
  triggers, excessive permissions, etc.).

Traces re-recorded with the tuned postamble (commit 50d85175) and
scrubbed: replaced `/home/illia/.cargo/bin/zizmor` with
`/home/user/.cargo/bin/zizmor` per the developer-local-path ban in
`.claude/rules/review-discipline.md`. No credentials, PII, or
high-entropy secrets in either trace (only git SHAs from zizmor's
workflow analysis output).

Replay still passes: `cargo test --test e2e_live -- zizmor_scan --ignored`
→ 2/2 ok.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(replay): update zizmor_scan_v2 insta snapshot

The engine v2 replay-snapshot gate (`engine_v2_tests::snapshot_zizmor_scan_v2`)
failed against the re-recorded trace from 1691efec because the old
snapshot encoded a broken run:

- final_state: Failed
- Missing Assistant message role
- 3 issues: thread_failure (error), no_response (warning), llm_error (error)
- 6 tool calls that never produced a final answer

The new trace completes cleanly:

- final_state: Done
- System / User / Assistant roles present
- 1 issue: mixed_mode (info)
- 3 shell tool calls + successful `FINAL()` with real findings

The snapshot was pinning a regression. Regenerated with
`INSTA_UPDATE=always cargo test --test e2e_engine_v2 -- snapshot_zizmor_scan_v2`;
passes on replay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(rules): address PR #2714 review feedback

- review-discipline: reword "Doc Absolute Paths" as a review convention
  (pre-commit only scans .rs; the rule misleadingly claimed enforcement).
- safety-and-sandbox: broaden `paths:` frontmatter to include the actual
  ingress owners (`bridge`, `channels`, `workspace`, `agent`, engine
  crate) so the rule auto-loads where it applies.
- tool-evidence: mark the side-effect gate, empty-fast rule, and
  `unverified` flag as target/aspirational invariants — neither
  `ToolError::EmptyResult`, an `unverified` field on `ToolOutput`, nor a
  byte-count field on `ActionRecord` exist today. Point at concrete
  interim conventions (`ToolError::ExecutionFailed`, `unverified: true`
  in the JSON result body).
- types: scope "Validated newtypes must gate Deserialize" to *new*
  types, document the `CredentialName`/`ExtensionName` exception (they
  intentionally use `#[serde(transparent)]` + derived `Deserialize`
  under the `serde_does_not_revalidate` test). Clarify the
  `from_trusted` trust boundary (trusted = typed upstream, untrusted =
  raw JSON field even if the field *name* is "registry entry").
  Switch `new` template to `impl Into<String>` to avoid an unnecessary
  clone when an owned `String` is passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(rules): simplify types.md + split doc-hygiene; address review round 2

- types: collapse two templates into one canonical validated-newtype
  shape. New types use `#[serde(try_from = "String")]` with a shared
  `validate(&str)` helper — no more dual "transparent for some /
  try_from for others" guidance. `CredentialName`/`ExtensionName` are
  documented as the sole legacy exception (locked in by the
  `serde_does_not_revalidate` test); new code must not copy their
  `transparent` + `from_trusted` pattern. Removes the long "Using
  `from_trusted` safely" section and the separate "Validated newtypes
  must gate Deserialize" subsection that contradicted the Don'ts list.
- doc-hygiene: new tiny rule file scoped to `**/*.md`, `**/*.py`,
  `docs/**` that carries the "no developer-local absolute paths in
  committed docs" convention. Removed from review-discipline.md where
  its `src/**/*.rs` scope meant the rule never loaded on the files it
  governed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(live): match hyphenated tool-install in attempted_relevant_tool

The engine records `action_name` as the raw string the LLM emitted
(`crates/ironclaw_engine/src/executor/structured.rs:381`), and the
registry's lookup canonicalization only affects dispatch — not the
name that reaches `StatusUpdate::ToolStarted`. The two other predicates
in this file (`bad_recovery` at :420, `phase_b_recovery` at :531)
already defend against both forms; this one should too, for
consistency. Addresses PR #2714 review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 00:03:29 +09:00
Zaki Manian
5d99d55015 fix(gate): handle orphaned approval gates when thread deleted (#2347)
* fix(gate): handle orphaned approval gates when thread is deleted (#2323)

When a thread is deleted while an approval gate is pending, the gate
becomes orphaned -- unresolvable on backend and undismissable on
frontend. This fixes three root causes:

1. execute_pending_gate_action now detects missing threads and emits a
   gate_resolved event with resolution "expired" instead of erroring,
   so the frontend can dismiss the stale card.

2. PendingGateStore gains discard_for_thread() for bulk cleanup of all
   gates tied to a specific thread, regardless of user.

3. Frontend handleGateResolved now handles "expired" resolution to
   remove stale approval cards and re-enable chat input.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(gate): split Ok(None)/Err(_) arms and wire discard_for_thread into production

Address review feedback on #2347:

1. Split the conflated `Ok(None) | Err(_)` match arm in
   `execute_pending_gate_action`: `Ok(None)` means the thread was
   genuinely deleted (emit "expired" gate resolution), while `Err(_)`
   indicates a transient DB failure (propagate the error so the caller
   can retry instead of permanently discarding the gate).

2. Wire `discard_for_thread()` into the conversation clear path
   (`clear_engine_conversation`), replacing the per-user `discard()`
   call. This ensures all pending gates for a thread are cleaned up
   regardless of which user created them, preventing orphaned gates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(gate): pre-flight thread check before persisting AlwaysAllow

Address review feedback on #2347:

1. **codex P2 — AlwaysAllow silent commit on thread-delete race.**
   When `resolve_gate` hit `Approved { always: true }`, it persisted
   `AlwaysAllow` to DB *before* calling `execute_pending_gate_action`.
   The rollback branch only fires on `result.is_err()`, but the
   thread-missing path now returns `Ok(BridgeOutcome::Respond(...))` for
   graceful `expired` UX — so the preference would stick even though the
   tool never ran. Added a pre-flight `load_thread` check in the
   `Approved` arm that short-circuits with `emit_gate_expired_dismissal`
   before any auto-approve / DB write occurs.

2. **Rebase onto staging (`scope_thread_id: Option<ExternalThreadId>`).**
   The PR predated #2561/#2473's identity-type tightening. The inline
   `pending.scope_thread_id.clone().or_else(|| Some(pending.thread_id.to_string()))`
   no longer type-checks against `Option<String>` on the wire field.
   Replaced with `Some(pending.effective_wire_thread_id())` — matches
   the five other `GateResolved` emit sites and satisfies the
   "don't re-derive identity values" invariant in
   `src/bridge/CLAUDE.md`.

3. **Extracted `emit_gate_expired_dismissal`** so the expired-SSE path
   is shared between the pre-flight check and
   `execute_pending_gate_action`'s `Ok(None)` arm. Documented the
   pre-flight contract on the helper itself.

4. **Regression test**
   `resolve_gate_approved_with_missing_thread_emits_expired_and_skips_persist`
   drives `resolve_gate` at the caller level with `Approved { always: true }`
   and no thread in the store. Asserts the first SSE event is `expired`
   (not `approved_always`), satisfying `.claude/rules/testing.md` →
   "Test Through the Caller, Not Just the Helper".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
2026-04-20 23:55:31 +09:00
firat.sertgoz
ab38a0b234 feat(bridge): workspace-backed project registration + adapter improvements (#2533)
* feat(projects): workspace-backed project registration; migrate commitments into projects/commitments/

[cherry-pick-target: feat/projects-workspace-backed]

Replace the parallel `.system/engine/projects/*.json` schema with
workspace-backed project registration. Writing any file under
`projects/<slug>/` is now the declaration that the project exists —
the engine auto-registers it on `memory_write`, and `mission_create`
can reference it by slug. The model reasons about projects through
normal workspace APIs instead of a hidden sidecar schema.

Engine + bridge

- `ProjectId::from_slug(user_id, slug)` derives a stable v5 UUID;
  `Project::new` routes through it so constructing the same project
  twice returns the same ID (no duplicates).
- `slugify_simple` in `ironclaw_engine::types` — pure slug, no UUID
  suffix, reverses cleanly from a `projects/<slug>/` directory name.
- Project metadata moves from `.system/engine/projects/{slug}--{id8}/
  project.json` to user-facing `projects/<slug>/.project.json`.
  One-shot startup migration copies legacy files over, idempotent.
- `HybridStore::load_projects_from_workspace` scans `projects/*/` and
  synthesizes a stub `Project` for bare directories, so a write under
  `projects/foo/` surfaces immediately on restart.
- `EffectBridgeAdapter::ensure_project_for_memory_write` hook runs
  after a successful `memory_write`: if the target is under
  `projects/<slug>/...`, finds-or-creates the project and splices
  `project_id` into the tool output (enables
  `{{call-N.project_id}}` template refs).
- Extract `resolve_project_ref` helper from the inline block in
  `handle_mission_call` — now used by both `mission_create`'s
  `project_id` param and future project-aware tools.

Skills (13 files)

- Mechanical `commitments/` → `projects/commitments/` across the nine
  commitment-domain skills (commitment-setup, -triage, -digest,
  decision-capture, delegation-tracker, idea-parking,
  tech-debt-tracker, product-prioritization, security-review).
- Four persona setup skills (ceo-setup, developer-setup,
  trader-setup, content-creator-setup) gain an explicit "declare the
  project" step (write `projects/commitments/AGENTS.md` with
  persona-specific operating principles) and pass
  `project_id: "commitments"` on every `mission_create`. Setup
  markers move to `projects/commitments/.<persona>-setup-complete`.
- `ceo-setup` gets a v0.4.0 rewrite that also installs two dashboard
  widgets under `projects/commitments/.system/widgets/`:
  `commitments-this-week` (overdue / due / completed counts) and
  `delegations-waiting` (delegation list with stale-at-2-days flag).
  Both poll `projects/commitments/widgets/state.json`, refreshed by
  the triage mission each run.

Tests

- Three new unit tests in `bridge::effect_adapter::tests`:
  `extract_project_slug_recognizes_project_paths`,
  `extract_project_slug_rejects_degenerate_targets`,
  `project_new_is_deterministic_from_user_and_slug`.
- Update `tests/e2e_live_personas.rs` path assertions
  (`workspace_paths`, `read_under`, `verify_setup_landed`,
  `DEV_SETUP_CHECKS` needles, two workflow turn messages) to the new
  `projects/commitments/` prefix.
- Add a diagnostic dump in `run_turn` when a persona workflow turn
  times out with no response, so live-test hangs surface the
  captured status events instead of an opaque panic.

No backcompat for the old flat `commitments/` layout — pre-production
deployment, nothing in the wild depends on it.

* fix: adapt cherry-picked project registration to staging API surface

Add missing struct fields (engine_store, skill_registry) and setter
methods to EffectBridgeAdapter, expose MissionManager::store() accessor,
add sync_v1_skill_to_store to skill_migration, and remove references
to fields/methods not yet on staging (Project::goals/metrics,
LiveTestHarnessBuilder::with_skills_dir, V2SkillMetadata::bundle_path).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(bridge): address review — drop slug-prefix fallback, harden tests (#2533)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(bridge): address PR #2533 review — slug-consistency, migration hardening, caller-level tests

- synth_bare_project now normalizes the raw dir name via slugify_simple
  before ProjectId::from_slug, matching Project::new. Returns Option so
  unsluggable dirs (`---`, `!!!`) don't produce phantom projects.
- migrate_legacy_project_jsons upgraded to warn! and moves unparseable
  legacy project.json aside as project.broken.json so the user can
  recover instead of the engine masking the loss on every boot.
- Document project_slug's engine-internal (mission-path, UUID-suffixed)
  scope vs project_dir's user-facing (no-UUID) scope so the two slug
  schemes aren't conflated in future edits.
- Drop unused ProjectId param from project_dir / project_path.
- Trim Project::new docstring per CLAUDE.md style.

Tests added (19):
- types::project: slug variant collapse, unicode, empty-slug stability,
  run/edge normalization
- store_adapter unit: project_slug_for_name contract, project_dir/path,
  synth_bare_project↔Project::new ID equivalence across 12 weird names,
  unsluggable-dir rejection, cross-user isolation
- store_adapter migration_tests (libsql): bare-dir load, metadata over
  synth, non-canonical skip, weird-slug collapse, user-edit preservation,
  broken-JSON move-aside
- effect_adapter caller-level: drives execute_action("memory_write")
  for canonical / idempotent / non-projects / nested / weird-slug /
  cross-user / pathological targets per .claude/rules/testing.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 23:23:15 +09:00
Illia Polosukhin
038853f8ee refactor(types): adopt MissionId in router + introduce McpServerName (#2681)
* refactor(types): adopt MissionId in router + introduce McpServerName

- src/bridge/router.rs response DTO now uses engine's MissionId(Uuid)
  instead of String
- McpServerName newtype in ironclaw_common encapsulates the allowlist
  validation added in #2400

Closes the type gap for two identifiers flagged in the recent
audit of string-typed values in the system.

* refactor(mcp): address review feedback — validate server names at construction

- McpClient::new and new_with_name: validate through McpServerName::new
  with a canonical "unknown" fallback + debug log, instead of
  from_trusted. Closes two HIGH-severity allowlist bypasses (e.g. IPv6
  bracketed hosts and caller-supplied bad names).
- McpClient:🆕 apply the same hyphen→underscore fold as the other
  constructors (only when a hyphen is present) for consistency.
- identity.rs: extract validate_mcp_server_name() helper so
  TryFrom<String> consumes the owned buffer without re-allocating;
  MAX_MCP_SERVER_NAME_LEN aliased to MAX_NAME_LEN to prevent drift.
- factory.rs: capture validated McpServerName and thread .as_str()
  through hottest downstream uses; TODO left for full threading.
- Adds regression tests covering the IPv6-host and invalid-caller-name
  paths against the caller (McpClient::new / new_with_name).

* fix(mcp): annotate panic-safe McpServerName::new("unknown") .expect() calls

The no-panics CI check flagged two `.expect()` calls on `McpServerName::new("unknown")`
fallbacks inside `McpClient::new` and `McpClient::new_with_name`. The literal
`"unknown"` always satisfies the alnum-only validation rule, so the call is
infallible. Document that with an inline `// safety: ...` comment per the
project's panic-check suppression convention.

* refactor(mcp): validate HttpMcpTransport::new server_name with safe fallback

* refactor(mcp): validate McpClient constructor server names with safe fallback

Address PR nearai/ironclaw#2681 Copilot review comments 3108427003,
3108427048, and 3108427073. The three remaining constructors
(`new_with_transport`, `new_with_config`, `new_authenticated`) were
wrapping caller-provided names with `McpServerName::from_trusted`,
allowing invalid values to enter the typed field.

Beyond bypassing the allowlist, the `_with_config` and `_authenticated`
paths also diverged from `HttpMcpTransport::new`, which independently
validates and falls back to `"unknown"` — so an invalid name could
leave the client keyed one way and the transport another, silently
breaking `Mcp-Session-Id` tracking.

Each constructor now runs the same canonicalize-with-fallback pattern
used by `McpClient::new`, `new_with_name`, and `HttpMcpTransport::new`,
and threads the single validated name into both the transport and the
client's typed field so the two cannot diverge.

Regression tests added (per .claude/rules/testing.md, "Test Through
the Caller"): invalid config/transport inputs fall back to "unknown";
valid inputs survive; client and transport names agree for both cases.

* fix(mcp): migrate legacy overlong server names at load instead of dropping

Address PR nearai/ironclaw#2681 review comment 3110617080. Before the
`McpServerName` newtype landed, `McpServerConfig::validate()` only
enforced non-empty + `[A-Za-z0-9_-]`. Delegating validation to
`McpServerName::new` added a 64-byte length cap — and `load_mcp_servers_from*`
silently dropped invalid configs via `retain(...)`, so a legacy persisted
server name >64 bytes would vanish from the loaded config on upgrade.

The load paths now in-place truncate overlong names at a char boundary
(safe even if the pre-validation string contains multi-byte UTF-8),
emit a `warn!` documenting the migration, and let the (now
cap-satisfying) entry pass validation. Invalid-char cases still drop
via `retain`, matching the pre-PR behavior for that class.

Regression tests cover both the happy path (ASCII overlong name kept,
truncated to exactly the cap) and char-boundary safety (multi-byte
sequence straddling byte 64 must not panic).
2026-04-20 23:05:38 +09:00
firat.sertgoz
c8f87537fc fix(gateway): remove v2 active-work pills from web ui (#2671) 2026-04-20 11:14:41 +02:00
firat.sertgoz
532e07fd07 fix: prevent immediate requests creating missions (#2328)
* fix: prevent immediate requests creating missions

* fix: address review findings (iteration 1)

* fix: use prefix stem matching for scheduling intent words

Addresses review feedback: "monitoring" now matches the "monitor" stem,
"routinely" matches "routin", etc. Replaces exact word matching with
starts_with prefix matching so morphological variants are caught without
maintaining an exhaustive word list. Adds regression test for
"set up monitoring now" being correctly allowed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: fix cargo fmt alignment in SCHEDULE_STEMS

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(bridge): add caller-level tests for immediate mission rejection (#2328)

Address henrypark133 review: the `should_reject_immediate_mission_create`
predicate was only covered by helper-level unit tests. Per the "Test
Through the Caller" rule, add three caller-level tests that drive
`EffectBridgeAdapter::execute_action` end-to-end:

- Reject path: foreground + immediate goal → EngineError::Effect
- Allow path: foreground + scheduling intent → mission created
- Alias path: routine_create → mission_create alias also rejected

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(skills): remove useless .into_iter() flagged by clippy 1.95

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ci): resolve clippy 1.95 collapsible-match and useless-conversion lints

Collapse nested `if` into match arm guards per clippy::collapsible_match
(new in Rust 1.95). Replace `.sort_by(|a, b| b.1.cmp(&a.1))` with
`.sort_by_key(|x| Reverse(x.1))` per clippy::unnecessary_sort_by.

Affected crates: ironclaw (main), ironclaw_engine, ironclaw_tui,
ironclaw_skills.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(test): add thread_goal to ThreadExecutionContext in gate integration test

The merge from staging introduced a new test that constructs
ThreadExecutionContext without the thread_goal field added by this PR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ci): resolve clippy lint and 3 test failures

- Add #[allow(clippy::too_many_arguments)] on register_startup_channels
- Extract extension name from tool_install params in pending_gate_extension_name fallback
- Isolate re_resolve_llm tests from user config.toml via temp file
- Mark propagate_approval test #[ignore] (requires prebuilt telegram WASM)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
2026-04-20 15:58:12 +09:00
firat.sertgoz
862ac13a87 feat(memory): configurable insights interval, session summary hook, reasoning-augmented recall (#2336)
* feat(memory): configurable insights interval, session summary hook, reasoning-augmented recall

Three memory enrichment features:

1. Configurable conversation insights interval via MISSION_INSIGHTS_INTERVAL
   env var (default: 5, min: 1) with MissionsConfig + MissionSettings wiring
2. SessionSummaryHook that writes LLM-generated conversation summaries to
   workspace daily logs on session end (fail-open, 30s timeout)
3. Optional reasoning parameter on memory_search that synthesizes raw chunks
   via cheap LLM before returning, controlled by SEARCH_REASONING_ENABLED

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(memory): address PR #2336 review feedback and CI failures

Critical fixes:
- Use DB-first config system for MissionsConfig instead of raw
  std::env::var in router.rs (issue #1)
- SessionSummaryHook now uses thread_ids from HookEvent::SessionEnd
  to summarize the correct conversation instead of guessing via
  recency; falls back to most-recent for backward compatibility (#2)
- Add per-user rate limiter (10/min, 60/hr) and 15s timeout on
  reasoning LLM calls in MemorySearchTool to prevent unbounded
  usage (#3)

Test coverage:
- Caller-level tests for reasoning-augmented recall (LLM wiring,
  disabled config, and failure fallback paths) (#4)
- SessionSummaryHook LLM failure path test confirming fail-open
  behavior (#5)
- reasoning_enabled config field tests (default, env, DB override) (#6)
- MissionSettings and SearchSettings round-trip assertions in
  comprehensive_db_map_round_trip (#11)

Convention fixes:
- Remove double env-var parsing in MissionsConfig::resolve (#7)
- Use ChatMessage::system()/user() constructors in
  SessionSummaryHook (#8)
- Add TODO comments for inline prompt strings (#9)
- Add timeout on reasoning LLM call (#10)

CI fixes:
- Remove 4 stale wasmtime advisory entries from deny.toml
- Add RUSTSEC-2026-0097 (rand 0.8.5) to advisory ignore list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(memory): address henrypark133 + ilblackdragon review — safety, concurrency, prompts (#2336)

- Move inline prompt templates to prompts/*.md per project convention
  (session_summary.md, memory_reasoning_synthesis.md) — resolves TODOs
- Add Arc<Semaphore> to SessionSummaryHook to cap concurrent LLM calls
  on mass session expiry (follows OutboundWebhookHook pattern)
- Sanitize LLM-generated summaries via ironclaw_safety::Sanitizer before
  writing to workspace (mitigates stored prompt injection vector)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(memory): CI compile fix + reasoning sanitizer parity + harden test

- Add live_state / live_state_started_at fields to ConversationSummary
  literals in three session-summary test sites; staging added these
  fields after the branch was created and clippy/test builds were
  failing on missing-field errors.
- Replace silent unwrap_or_default on MissionsConfig::resolve in
  bridge::router::init_engine with an explicit warn-and-default match,
  so a misconfigured MISSION_INSIGHTS_INTERVAL surfaces in logs instead
  of being absorbed into the default.
- Run the reasoning-synthesis output through ironclaw_safety::Sanitizer
  before persisting it to the tool result, matching the parity already
  applied in SessionSummaryHook. Memory chunks fed into synthesis can
  carry attacker-controlled text and the synthesis flows back into
  future LLM contexts via memory_search results.
- Strengthen reasoning_enabled_fires_llm_and_returns_synthesis: add a
  preflight assertion that FTS returns the seeded doc, then
  unconditionally assert the LLM was called once and that synthesis
  matches the mocked response. Removes the prior `if llm.calls() > 0`
  guard that made the synthesis assertions vacuous when search returned
  empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
2026-04-20 15:52:15 +09:00
Illia Polosukhin
a69aa54177 refactor(gateway): hygiene batch — delete dead handler, tighten boundaries, add caller-level chat tests (#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 #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>

* fix(gateway): address PR #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>
2026-04-20 15:36:38 +09:00
Illia Polosukhin
d8802e6cc2 fix(wasm): gate websocket runtime on auth and stop reconnect loop on fatal closes (#2557) (#2707)
* fix(wasm): gate websocket runtime on auth and stop reconnect loop on fatal closes (#2557)

WASM channel runtimes spawned the websocket runtime on `connect_on_start` without
verifying required credentials, and treated auth-rejected close frames as
transient disconnects. When a Discord bot token was missing the runtime would
connect, be rejected with 4003, and the outer reconnect loop retried forever
(capped at 64s backoff) producing continuous network traffic and log spam.

Three guards, all in `src/channels/wasm/wrapper.rs`:

- `websocket_auth_preflight` + `websocket_start_decision` compose capability
  parsing and secret-presence into a typed decision; `Channel::start` matches
  on it and skips the spawn with a bounded `warn!` when the declared
  `identify_secret_name` is not present in the secrets store. Writing the
  secret and restarting the channel connects normally.
- Runtime-entry guard: if `resolve_websocket_identify_message` returns `None`
  after preflight passed (credential revoked mid-flight), the task exits
  instead of connecting.
- `classify_websocket_close_code` maps Discord-documented fatal auth codes
  (4003, 4004, 4010-4014) to `Terminal`; the read loop breaks `'reconnect`
  on terminal closes instead of falling through to backoff.

Regression tests cover preflight, start-decision, and close-code classification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* review: address bot feedback on #2557 websocket preflight

Four distinct issues flagged by gemini-code-assist + copilot-pr-reviewer:

1. Close-code classification was global — 4000-series codes are
   application-defined (RFC 6455), so another provider using e.g. 4003 with
   different semantics would be incorrectly treated as terminal. Scope the
   Discord code table to Discord gateway hosts via `is_discord_gateway_host`;
   non-Discord URLs always return `Reconnect`.

2. Preflight silently allowed a capability that declares
   `identify_secret_name` but omits the `identify` template (no identify
   payload could ever be built even with a valid secret). Catch it in
   `websocket_start_decision` with a new `MalformedConfig { reason }`
   variant so the operator log points at the real cause.

3. `store.exists()` errors were mapped to `MissingSecret`, conflating a
   transient DB blip with a genuinely absent secret. Split the `Err`
   branch: log the store error and fail open (`Ready`); the runtime-entry
   guard still stops any spawn whose identify payload cannot be built.

4. Runtime-entry guard warning said "required auth secret unavailable"
   even when the true cause could be a missing `identify` template or a
   decrypt error. Rephrased to enumerate possible causes and include
   `has_identify_template` as a field.

Tests:
- `test_websocket_start_decision_malformed_config_without_identify_template`
- `test_classify_websocket_close_code_non_discord_host_never_terminal`
  (covers plain non-Discord hosts and a host-suffix spoof)
- existing close-code tests now pass the URL so the Discord-gated signature
  is exercised.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(wasm): use CredentialName newtype in websocket auth preflight

Per .claude/rules/types.md, names that flow between modules and gate side
effects should be typed identifiers, not `String`. The new enums from the
#2557 fix were carrying `secret_name: String` because they inherited from
`WebsocketRuntimeConfig::identify_secret_name: Option<String>` (the raw
string parsed from capability JSON). That field is a valid boundary value,
but everything below it in the auth-flow path should be typed.

- `WebsocketAuthPreflight::MissingSecret { secret_name: String }` →
  `MissingCredential { credential_name: CredentialName }`
- `WebsocketStartDecision::MissingAuth { secret_name: String }` →
  `MissingAuth { credential_name: CredentialName }`
- `websocket_auth_preflight` now takes `Option<&CredentialName>`; name
  validation is lifted to the orchestrator where it belongs.
- `websocket_start_decision` validates `config.identify_secret_name` via
  `CredentialName::new(...)` once, at the boundary. A syntactically
  invalid name surfaces as `MalformedConfig { reason }` with the real
  `IdentityError` embedded — not silently routed through "missing
  credential", which was the exact failure mode bug #2574 fixed in a
  different subsystem.

Internal variables and log field names move from `secret_name` →
`credential_name` to match the codebase convention around `CredentialName`
and the existing `credential_name` fields in `bridge::auth_manager`,
`bridge::router`, and `gate::mod`.

Out of scope (larger follow-ups):
- Capability-wire key `identify_secret_name` stays; it lives in every
  installed channel's `capabilities.json` (`channels-src/discord/...`) and
  renaming it is a breaking change to deployed WASM channels.
- `SecretsStore::{exists, get_decrypted}(&str, &str)` stays; migrating the
  trait signature is a wider refactor that should not ride on this fix.
- `WebsocketRuntimeConfig::identify_secret_name: Option<String>` stays as
  the boundary type that owns the raw-string-from-JSON contract.

Added regression test
`test_websocket_start_decision_malformed_config_with_invalid_credential_name`
to pin the new "invalid name → MalformedConfig" behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* review: canonicalize identify_secret_name + flag asymmetric identify config

Two correctness issues flagged by copilot-pr-reviewer on c4b9d1d8.

1. **Canonicalization mismatch between preflight and runtime.**
   `CredentialName::new()` canonicalizes — it trims whitespace and folds
   `-` → `_`. Preflight checks existence via `credential_name.as_str()`
   (canonical), but `resolve_websocket_identify_message` later reads the
   raw `config.identify_secret_name` and passes that to the store. A
   capability declaring `"github-token"` against a store holding
   `"github_token"` would therefore pass preflight and fail in the spawn.
   Write the canonicalized form back to `config.identify_secret_name`
   before returning `Spawn(config)` so every downstream lookup uses the
   same string. Regression test
   `test_websocket_start_decision_spawn_canonicalizes_secret_name`.

2. **Asymmetric identify config passed through as Spawn.**
   The existing check caught `identify_secret_name` without `identify`,
   but the reverse (`identify` template present, `identify_secret_name`
   missing) slipped through. In that shape `resolve_websocket_identify_message`
   returns `None` because it needs a secret name, no Identify is ever
   sent, and the peer closes the connection — the exact #2557 spin this
   PR exists to prevent. Replaced the one-sided check with a
   `match (identify, credential_name)` so both asymmetric shapes surface
   as `MalformedConfig` with distinct reasons. Regression test
   `test_websocket_start_decision_malformed_config_without_secret_name`.

No changes to wire contract, capability JSON key, or secrets-store trait.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:32:46 +09:00
Illia Polosukhin
833cb4844f refactor(channels): introduce ExternalThreadId newtype at channel boundary (#2685)
* refactor(channels): introduce ExternalThreadId newtype at channel boundary

External channel thread ids (Telegram chat id, web UUID, Slack thread_ts)
flow as raw Option<String> through IncomingMessage, StatusUpdate, and
pending-gate store. Wraps them in a validated ExternalThreadId so the
compiler distinguishes boundary-layer ids from the internal ThreadId(Uuid).

Maps to bug pattern from #2349, #2444, #2517 where thread-id confusion
crossed a layer silently.

* fix(bridge): adapt test thread_id to ExternalThreadId newtype

Post-merge fix: a test added in staging (insert_and_notify_pending_gate_uses_extension_manager_for_auth_display_name) assigned a raw String to message.thread_id, but the field type became ExternalThreadId on this branch. Wrap with ExternalThreadId::from_trusted to match the other tests in the same module.

* refactor(types): address review feedback — byte units, shared validate, try_-variants, dedup pending-gate

* refactor(types): validate scope_thread_id + relay respond prefers typed msg.thread_id

- router.rs: scope_thread_id written to PendingGate was wrapped via
  ExternalThreadId::from_trusted from message.conversation_scope(), which
  can carry untrusted WASM/metadata-sourced strings. Now validates via
  ExternalThreadId::new; invalid values log at debug and store None.
  Applied at both call sites (authentication-fallback path and generic
  gate-insertion path).
- relay/channel.rs: respond() derived thread_id only from response or
  metadata — now also consults the validated msg.thread_id as the second
  fallback (before raw metadata) and filters empty strings so we never
  emit thread_ts: "" to Slack.
2026-04-20 15:29:04 +09:00
Illia Polosukhin
0476a3d8e9 fix(gateway): Settings extension button label reflects auth state (#2235) (#2709)
* fix(gateway): label Settings extension button Setup vs Reconfigure by auth state

Closes nearai/ironclaw#2235. Extracted from #2375.

The Settings → Extensions card fallback branch unconditionally labeled
the action button "Reconfigure", so users opening the settings for a
chat-installed channel saw "Reconfigure" even though credentials had
never been entered — and clicking it opened the credential popup,
matching the QA repro on the 2026-04-09 bug bash.

Pick the label from `ext.authenticated`: "Setup" when no credentials
are on file, "Reconfigure" once they are. `setup_required` /
`installed` keep the legacy label because the inline setup form below
already provides the same action — preserves the no-duplicate-setup
invariant guarded by `test_wasm_channel_setup_states`.

Tests:
- `test_extensions_list_reports_authenticated_after_setup_submit`
  drives POST setup-submit → GET list and asserts `authenticated`
  flips on the wire (the field the JS branch reads).
- `test_settings_extensions_labels.py` (Playwright) covers both
  label states, the no-duplicate-setup invariant, and that clicking
  Reconfigure on an authenticated channel does not fire /activate.

Does not touch `classify_wasm_channel_activation` (keeps the
`has_paired` axis the #1921 truth-table tests guard) or introduce an
`owner_bound` wire field.

Co-Authored-By: Nige <G7CNF@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(gateway): use ext.reconfigure i18n key consistently (PR #2709 review)

Address gemini-code-assist review on #2709: the `inlineSetupCoversIt`
branch was the sole remaining caller of `extensions.reconfigure`. All
other Reconfigure buttons in this file already use `ext.reconfigure`
(lines 177, 218, 354). Both keys resolve to the same string in en/ko/
zh-CN locales, so this is a no-op for users — it removes the odd key
out and aligns with the project's `extensions.*` → `ext.*` migration.

Declined the paired suggestion to swap `var` → `const`: the surrounding
wasm-channel branch consistently uses `var` (lines 309/311/317/325),
and partial modernization inside the same conditional is worse than
matching the existing style.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(gateway): drop 'installed' from inlineSetupCoversIt + fix Playwright mock (PR #2709 review)

Copilot review on #2709 flagged two real bugs:

1. `inlineSetupCoversIt` treated `fallbackStatus === 'installed'` as if
   an inline setup form was present, but the inline form only renders
   when effective status is `setup_required` (see `loadInlineChannelSetup`
   branch at line 380). A production `installed` wire shape
   (`activation_status='installed'`, `onboarding_state=null` —
   `derive_onboarding` only emits non-null for `Pairing`) therefore kept
   the `Reconfigure` label with no inline form, which is exactly the
   #2235 QA repro. Drop `installed` from the conditional.

2. `test_reconfigure_click_does_not_send_auth_event` mocked the
   setup-fetch with empty `secrets`/`fields`, which makes
   `showConfigureModal` short-circuit with a `noConfigNeeded` toast
   and never render `.configure-modal`. The wait-for would have timed
   out on first CI run. Return a non-empty `secrets` array so
   `renderConfigureModal` actually fires.

Also adds `test_fallback_button_says_setup_on_production_installed_wire_shape`
— pins the exact #2235 wire shape (activation_status='installed',
onboarding_state=null) so this class of bug has a named regression
test going forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: reuse `status` and drop redundant presence assert (PR #2709 review)

Copilot second-round review on #2709:

- extensions.js: `fallbackStatus` recomputed the expression already
  stored in `status` at line 309. Reuse `status` directly; drop the
  one-use `inlineSetupCoversIt` alias while we are here — the
  `status === 'setup_required'` branch is short enough to read inline.

- features/extensions/mod.rs: the `telegram.get("authenticated").is_some()`
  assertion is redundant with the preceding `assert_eq!(..., true)` —
  a missing field indexes to `Value::Null` and trips the equality check.
  Folded the "must stay on the wire" rationale into the equality
  assertion's message so the diagnostic still documents why the field
  matters.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Nige <G7CNF@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:27:30 +09:00
Illia Polosukhin
f2c4c258dd docs(skills): clarify /search/issues returns issues and PRs (#2713)
* docs(skills): clarify /search/issues returns issues and PRs

Add a bullet noting the unified /search/issues endpoint returns both
issues and pull requests and there is no /search/pulls endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(skills): simplify /search/issues bullet per review

Addresses gemini-code-assist review on PR #2713: trim redundancy with
the section header and focus the note on the absence of a
/search/pulls endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:22:33 +09:00
Illia Polosukhin
8bf25bc99a fix(setup): run migrations during onboard when DATABASE_URL preset (#846) (#2309)
* fix(setup): run migrations in auto_setup_database for existing PostgreSQL config (#846)

When DATABASE_URL was already set before running `ironclaw onboard`,
auto_setup_database() skipped both connection testing and migrations,
leaving self.db_pool as None. This caused save_and_summarize() to fail
with "Failed to save settings to database" because persist_settings()
had no DB handle to write to. Now both postgres early-return paths call
test_database_connection_postgres() and run_migrations_postgres() before
returning, matching the existing libsql behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(setup): caller-level regression test for Postgres auto_setup_database path

Addresses PR #2309 review feedback: prior test only covered the libSQL
branch while the original bug was in the PostgreSQL early-return path.

Drives auto_setup_database() with a preset DATABASE_URL against a real
pgvector-enabled Postgres container (testcontainers), asserts the
wizard ends up with a live db_pool, proves migrations ran by invoking
persist_settings(), and does a round-trip read-back via Store::get_setting
to confirm actual persistence — not just an in-memory Ok. Skips
gracefully when Docker is unavailable, matching the workspace_integration
pattern. Gated behind the integration feature.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(setup): case-insensitive backend comparison + migration coupling assert

Addresses PR #2309 review: DATABASE_BACKEND comparison is now
case-insensitive, debug_assert guards the db_pool coupling between
connection test and migration, and a new test covers the
DATABASE_BACKEND=postgres early-return path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(setup): typed DatabaseBackend parsing, extract finish_postgres_auto_setup

Address review feedback on #846:

- Parse DATABASE_BACKEND via DatabaseBackend::FromStr instead of stringly
  comparing "postgres"/"postgresql". Aligns with .claude/rules/types.md
  and gains the "pg" alias for free.
- Extract the duplicated body of both postgres early-return branches
  (connection test + debug_assert + migration + settings record) into a
  shared finish_postgres_auto_setup helper so the two call sites cannot
  drift.
- Move the "Using existing PostgreSQL configuration" banner to after the
  connection test succeeds, so a failing connection no longer prints a
  misleading success-toned message first.
- Factor postgres-container setup and round-trip assertions out of the
  two integration tests into start_pg_container and
  assert_auto_setup_postgres_persisted helpers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(setup): make run_migrations_postgres error on missing pool

Address PR #2309 review comments from serrrfirat and Copilot:

- run_migrations_postgres() previously returned Ok(()) silently when
  db_pool was None. That made the correctness of the onboarding path
  depend on a side-effect-only ordering contract with
  test_database_connection_postgres(), flagged as fragile in the review.
  Convert the silent no-op into an explicit SetupError so a future
  regression cannot re-introduce the original #846 failure mode in
  release builds (debug_assert only fires in debug).
- Drop the now-redundant debug_assert in finish_postgres_auto_setup —
  the runtime check in the callee supersedes it.
- Replace the misleading tests/workspace_integration.rs reference in
  start_pg_container's docstring (that file uses env-PG skip, not
  testcontainers) with a self-contained description.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 14:50:52 +09:00
Illia Polosukhin
77e746f683 feat(portfolio): complete tool, tests, widget, and share-gains flow (#2368)
* feat(portfolio): complete tool, tests, widget, and share-gains flow

Portfolio WASM tool with full pipeline:
- Indexer (fixture, dune, dune-replay backends)
- Analyzer (6 protocol classifiers, health extraction, stablecoin detection)
- Strategy filter (yield-floor, health-guard, LP impermanent-loss-watch)
- Intent builder (fixture + solver backends, bounded checks, leg bundling)
- Format (suggestion markdown, progress metric, widget state)

172 unit tests covering all modules including edge cases:
- filter.rs: 33 tests (yield floor, health guard, LP watch, helpers)
- bounded.rs: 16 tests (slippage, cost, chain allowlist, multi-leg)
- parser.rs: 18 tests (delimiters, YAML, kind inference, real strategies)
- fixture.rs: 14 tests (slippage calc, ID formats, payload structure)
- analyzer: 18 tests (stablecoin detection, health extraction, debt/yield)
- format.rs: 16 tests (totals, empty states, progress windowing)
- widget.rs: 10 tests (rendering, intents, non-ready filtering)
- types: 16 tests (parse_decimal, ChainSelector serde)
- 14 YAML replay scenarios + 4 live Dune API tests (ignored by default)

Share-gains feature:
- Gateway-level IronClaw.api.share() modal with X, LinkedIn, Facebook,
  copy-to-clipboard, and download buttons
- Portfolio widget generates SVG card showing gains (APY, annual savings,
  moves found) — no addresses or balances exposed
- "Share gains" button appears only when portfolio has positive delta

E2E Playwright tests (11 scenarios):
- Skill discovery via API and settings UI
- Chat integration (keyword + wallet address triggering)
- Widget rendering with pre-seeded state (positions, totals, suggestions)
- Share button visibility (present with gains, absent without)
- Share modal lifecycle (opens with card image, social buttons, closes)

Supporting changes:
- E2E conftest: SKILLS_DIR points to workspace skills/
- Mock LLM: canned responses for portfolio/defi and wallet address patterns
- Skill YAML, registry entry, capabilities JSON, 3 strategy docs, 4 scripts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(portfolio): address PR review — XSS, OnceLock, bounded checks, docs

Addresses review comments from #2368:

- XSS: widget renders all interpolated fields through escapeHtml();
  share modal creates <img> via DOM API with data:image/ prefix check
- OnceLock: protocol registry parsed once via std::sync::OnceLock
- to_ascii_lowercase() for wallet address lookups (fixture + dune_replay)
- bounded.rs: reject empty value_usd in single-leg slippage check
- fixture.rs: compute min_out amount and value_usd separately
- fixture.rs: clarify expires_at=0 comment (fixture = no expiry)
- schema.json: add "dune-replay" to source enum
- parser.rs: fix doc comment re kind inference (defaults, not inferred)
- live_tests.rs: fix log placeholder (raw_count vs classified.len())
- intent.rs: expand kind comment to match SCHEMA.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(portfolio): escape remaining innerHTML fields, add tests, WASM build

- Escape delta_vs_last_run_usd and next_mission_run in widget innerHTML
- Add fixture test with amount != value_usd (stETH: 3.5 tokens / $12250)
  to verify the review fix separating amount from value_usd
- Add empty-legs test for bundling.rs order_legs
- Add comment explaining multi-leg empty value_usd tolerance in bounded.rs
- WASM component builds successfully (754K release binary)
  via: cargo component build --release --target wasm32-wasip2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(portfolio): address second-round PR review comments

- Tighten share image validation to data:image/png only (was data:image/*)
- Add ClipboardItem existence check to prevent runtime errors in some browsers
- Fix SCHEMA.md to correctly attribute invariant enforcement (bounded.rs vs bundling.rs)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(portfolio): NEAR support end-to-end with engine v2 quality fixes

Add full NEAR Protocol support to the portfolio tool: scan via FastNEAR +
Intear, classify positions through new protocols (Linear, Meta Pool, Rhea
lending, Rhea LP), match against new NEAR-specific yield strategies, and
build intent bundles. Plus assorted infrastructure fixes uncovered while
exercising the v2 / CodeAct path.

Indexer
- New `near` source: FastNEAR `/v1/account/{id}/full` + Intear
  `/list-token-price` (235 KB, vs `/tokens` at 3.2 MB which exceeded fuel).
- New `near-replay` source for offline fixture replay.
- `auto` source dispatches per address: `0x...` → Dune, `*.near`/`*.tg` →
  NEAR backend. Mixed lists are split and merged.
- `classify_near_token()` tags known NEAR DeFi contracts (Linear, Meta
  Pool, Rhea/Burrow, Rhea/Ref) with proper `protocol_id`. Default for
  unknown FT contracts is `wallet`.
- Dust filter raised from \$0.01 → \$1 to keep wallets like `root.near`
  from passing 100+ micro-cap positions through the analyzer.
- Dune `value_usd` now accepts both string and number (Dune started
  returning floats).

Analyzer
- New protocols: `wallet`, `near-staking`, `linear`, `meta-pool`,
  `rhea-lending`, `rhea-lp`. Wallet positions are no longer silently
  dropped (the prior bug that made root.near show "meteor-private" only).

Strategies
- New `near-staking-yield`, `near-lending-yield`, `near-lp-yield` —
  match wallet/staking/LP positions on `chain == "near"`.
- `StrategyAppliesTo` gains `chains` and `tokens` filters.

Tool API
- `propose.strategies` is now optional → falls back to bundled defaults
  (3 EVM + 3 NEAR strategies).
- `propose.config` is now optional → falls back to `ProjectConfig::default()`.
- `build_intent.config` optional with default.
- `propose` recovers from stringified positions (common LLM mistake of
  calling `json.dumps()` first) and returns a clearer error message.
- Capability `dune_api_key` marked `optional: true` — NEAR-only and
  fixture flows no longer block on a missing Dune key.
- Default source is now `auto`.

WASM runtime
- Default fuel limit raised 10M → 500M across config, settings, channel
  runtime, and ResourceLimits. Production was using 10M (config path)
  while tests used `ResourceLimits::DEFAULT_FUEL_LIMIT` (was 100M) — the
  divergence masked the real fuel exhaustion. The 235 KB Intear parse
  uses ~27M fuel, so 500M provides ample headroom.
- Wrapper now logs fuel consumption at debug level for diagnostics.

Engine v2 / CodeAct UX
- Preamble: 3 new rules
  - Never reconstruct tool results manually — reference variables.
  - Never paste Python code outside `\`\`\`repl` or `FINAL(answer)`.
  - Chain tool calls in a single block.
  - Pass native Python objects to tools, never `json.dumps()` first.
- Postamble: explicit good/bad chaining example + `FINAL()` answer
  quality guidance (no terse counts).
- Orchestrator: when an action result exceeds 500 chars, the truncated
  preview now tells the LLM the full result is in `state['<tool>']`
  to discourage manual reconstruction.

Skill (`skills/portfolio/SKILL.md`)
- Step 4 (Propose): explicit anti-patterns for fabricated positions,
  strategy-name-only strings, and `floor_apy` percentage integers.
- Step 5 (Rank): allows informational LLM-only suggestions when
  `propose` returns no `ready` proposals.
- Step 6 (Build intents): explicit skip when no `ready` proposals;
  documents required `plan` shape (`legs`, `expected_out`,
  `expected_cost_usd`, `proposal_id`).
- Step 8 (Summarize): require detailed Markdown output, not counts.

Tests
- `tests/e2e_wasm_portfolio.rs` (5 tests): scan, propose, full pipeline
  via `TestRigBuilder` with canned HTTP — exercises real wasmtime sandbox
  with fuel metering.
- `tests/e2e_live_portfolio.rs` (2 tests, live-only via `IRONCLAW_LIVE_TEST=1`):
  end-to-end via `LiveTestHarness` against real LLM + real FastNEAR/Intear,
  with `engine_v2(true)`. Requires `--test-threads=1` due to a v2
  thread-registry race.
- Portfolio unit tests: 183 pass (added NEAR indexer parsers, dispatch
  auto-detection, new strategy filter cases).
- Live portfolio tests: 10 pass against real APIs.
- Updated `hostile/fake-token-dust` scenario for the new "wallet"
  protocol behaviour.

Bug fixes uncovered along the way
- `intents/bounded.rs`: epsilon raised to 0.005 to tolerate the 2-decimal
  truncation in `intents/fixture.rs` (intent bundles previously failed
  the slippage check on synthetic targets).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(portfolio): address review findings from #2368

Correctness:
- bounded.rs: multi-leg slippage now checks the terminal leg (matching
  plan.expected_out.chain), not just single-leg bundles. Regression
  tests added for the bypass and for a multi-leg bundle with min_out=0
  on the terminal leg.
- bounded.rs: reject zero/negative/NaN/infinite expected_out (would
  make min_required = 0 and every leg pass vacuously).
- indexer/mod.rs: is_near_address now validates NEAR account rules
  (2..64 chars, lowercase, separators). Previously any non-0x string
  (empty, whitespace, emoji, SQL injection) passed.
- indexer/mod.rs: scan_auto rejects addresses that are neither valid
  EVM nor valid NEAR, instead of silently routing them to Dune.

Code quality:
- bundling.rs: replace .expect("indegree") and .expect("leg by id")
  with explicit error returns.
- fixture.rs: replace .unwrap() on plan.legs.last() with an Err path.
- types/mod.rs: pub use → pub(crate) use (crate-internal only).
- dune.rs / near.rs: warn (via host::log at Warn level) when a
  non-zero amount has a missing/zero value_usd, so silent undercounts
  surface in diagnostics rather than being invisible.

Security:
- gateway config.js: hoist the data:image/png prefix check to the top
  of IronClaw.api.share() so both img.src and a.href are gated.
- gateway config.js: add noopener,noreferrer to window.open features
  on share popups to close reverse-tabnabbing surface.
- widget/index.js: extend escapeXml to also escape apostrophes.

Infrastructure:
- limits.rs: TODO comment noting that 500M fuel default is driven by
  one tool (portfolio/near) and follow-up should add a per-tool
  override so the global default can stay tighter.
- test_portfolio.py: silent-return on missing widget tab converted to
  pytest.skip via shared _open_portfolio_tab_or_skip helper, so a
  regression that removes widget registration fails loudly instead of
  passing silently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(portfolio): address follow-up review comments

- lib.rs: BuildIntent.solver now defaults to "fixture" (a valid
  value), not "auto" (unrecognized by intents::build — was shipping
  the default straight into an "Unknown intent solver: 'auto'" error
  whenever the caller omitted the field).
- capabilities.json: update discovery_summary to reflect that
  strategies/config on propose and config/solver on build_intent are
  optional. Stale text had propose requiring both positions and
  strategies.
- limits.rs + config/wasm.rs: fix the fuel-limit doc comments. The
  prior value in limits.rs was 100M (not 10M — that was the config
  path). Clarify both paths converged at 500M in #2368.
- config.js (share modal): add aria-label, aria-modal, role=dialog,
  aria-labelledby for the modal and explicit aria-label on every
  icon-only share button. Mark decorative SVGs aria-hidden. Toast
  becomes role=status with aria-live=polite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 14:47:51 +09:00
Evrard-Nil
0a8428fda1 fix(telegram): handle 'message is too long' with retry splitting (#1943)
* fix(telegram): handle "message is too long" with retry splitting

Reduce TELEGRAM_MAX_MESSAGE_LEN from 4096 to 4000 for safety margin
against Markdown entity/emoji counting edge cases. Add SendError::TooLong
variant and send_chunk() helper that recursively halves chunks on
"message is too long" rejections (up to 3 levels deep), splitting at
natural boundaries.

[skip-regression-check]

* fix: address review feedback — return last chunk id, handle TooLong on plain-text retry

- Extract split_and_send() helper returning last message_id for correct
  reply threading when chunks are split
- Handle TooLong on ParseEntities plain-text fallback path
- Update doc comments on send_message and split_message

[skip-regression-check]

* fix: address review feedback — UTF-16 split, depth cap, markdown flag, tests

- Use UTF-16 code units (via prefix_within_utf16_limit) for midpoint
  calculation in split_and_send, matching Telegram's actual limit
- Extract find_split_midpoint() as a pure testable function
- Reduce MAX_SPLIT_DEPTH from 3 to 2 (max 4 sub-messages per chunk)
- Pass use_markdown flag through recursion so ParseEntities fallback
  disables Markdown for all subsequent splits of that chunk
- Add debug logging on successful sends
- Improve error message when depth limit exhausted
- Add empty-text guard in split_and_send
- Add 5 unit tests for find_split_midpoint (paragraph, newline, space,
  no-boundary, emoji-heavy)
- Fix stale doc comments

* fix: guard against empty first half after trim in split_and_send

Whitespace-heavy text could produce an empty first half after
trim_end(). Skip directly to the second half in that case.

* refactor(telegram): unify message splitting into single parameterized splitter

Collapse the duplicate boundary-search logic in find_split_midpoint /
split_and_send into split_message by parameterizing its UTF-16 limit.
The TooLong retry path now calls split_message(text, limit/2) and sends
each sub-chunk, so the retry benefits from the same paragraph → newline
→ sentence → word hierarchy the initial split already used.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:28:36 +09:00
Illia Polosukhin
3c7925c100 refactor(gateway): delete server.rs shim + relocate tests to slices — ironclaw#2599 stage 6 (#2706)
Finishes the feature-slice migration started in stage 4a. After this:

- `src/channels/web/server.rs` no longer exists.
- Every caller of `crate::channels::web::server::*` now points at
  `platform::router::start_server` or `platform::state::*` directly.
- All ~60 caller-level tests that used to live in `server.rs::tests`
  now live inside the feature slice they actually exercise, next to
  the handler they test.

## What moved where

Classification driven by the handler each test drives:

| Slice | Tests |
|---|---|
| `features/chat/mod.rs::tests` | 3 × history, 4 × auth-token/cancel + gate-resolve, 1 × approval, 3 × pending-gate-extension-name, 1 × test_auth_manager helper |
| `features/pairing/mod.rs::tests` | 1 × list, 5 × approve (claim / no-followup / with-thread / external-callback / blank-code), `make_pairing_test_state` helper |
| `features/extensions/mod.rs::tests` | 2 × activation classifier, 2 × path-traversal guards, 1 × setup-submit-not-activated, 2 × list-inactive-wasm-channel, 1 × phase-precedence, 1 × readiness handler, 2 × apply_extension_readiness |
| `features/oauth/mod.rs::tests` | 13 × oauth callback (missing params / unknown state / expired × 2 / no-ext-mgr / strip-prefix / versioned × 2 / happy × 3 / exchange-fail), 5 × relay oauth callback, + `TestOauthProxy`, `EnvVarGuard`, `set_env_var`, `fresh_pending_oauth_flow`, `expired_flow_created_at`, `test_oauth_router`, `test_relay_oauth_router` helpers |
| `platform/static_files.rs::tests` | 3 × CSP header / base / nonce, 2 × css etag, 1 × css handler, 2 × css multi-tenant, 4 × stamp nonce + build frontend HTML, 1 × test_build_frontend_html_returns_none_in_multi_tenant_mode |
| `platform/state.rs::tests` | 1 × workspace_pool_resolve_seeds_new_user_workspace |
| `handlers/llm.rs::tests` | 3 × llm admin-role guards |
| `handlers/users.rs::tests` | 1 × delete_user_evicts_auth_and_pairing_caches |

## Cross-slice test fixtures

Four helpers that multiple slices share (`insert_test_user`,
`test_secrets_store`, `test_ext_mgr`, `test_ext_mgr_with_db`) moved
into `src/channels/web/test_helpers.rs` as `#[cfg(test)] pub(crate)`
free functions, following the pattern from stage 6a (#2704) for
`test_gateway_state*`. All four keep the exact signatures they had in
`server.rs::tests`, so the move was mechanical. Rust expect suppressions
on the five `.expect(...)` lines inside these fixtures carry
`// safety: cfg(test) fixture` comments — the pre-commit safety check
is diff-line based and doesn't look up whether the containing function
is already `cfg(test)`-gated.

## Mechanical renames (25 files)

`channels::web::server::<item>` call sites now import from:
- `platform::router::start_server`
- `platform::state::{GatewayState, RateLimiter, PerUserRateLimiter,
  WorkspacePool, FrontendCacheKey, FrontendHtmlCache,
  ActiveConfigSnapshot, PromptQueue, RoutineEngineSlot,
  rate_limit_key_from_headers}`

Covers `src/main.rs`, `src/app.rs`, `src/tools/builtin/{job,memory}.rs`,
all 13 handlers in `handlers/*.rs`, the four integration tests
(`ws_gateway_integration`, `openai_compat_integration`,
`multi_tenant_integration`, `oauth_greeting_integration`), plus
`tests/support/gateway_workflow_harness.rs` and
`src/channels/web/tests/multi_tenant.rs`. No behavior change.

## Boundary checker retained

`scripts/check_gateway_boundaries.py` still rejects any
`crate::channels::web::server::` path as a defense-in-depth guard
against accidental re-introduction (literal new `server.rs`, stray
imports, etc.). The explanatory comment and the regression test's
docstring now reflect "shim is gone; this guard prevents re-creation"
instead of "shim exists; don't route through it."

## Documentation updates

- `src/channels/web/CLAUDE.md`: deleted the `server.rs` File Map row,
  updated the `test_helpers.rs` row to list all seven `pub(crate)`
  fixtures (stages 6a + 6 together), fixed all prose references that
  pointed at `server.rs`, and updated the "Adding a New API Endpoint"
  recipe to point at `features/<slice>/` and `platform/router.rs`.
- `src/channels/web/platform/state.rs`: module docstring now says
  "shim was removed" instead of "shim exists pending migration."
- `src/bridge/CLAUDE.md`: `pending_gate_extension_name` reference now
  points at `features/chat/mod.rs`.

## Quality gate

- [x] `cargo fmt --all`
- [x] `cargo clippy --all --benches --tests --examples --all-features` — zero warnings
- [x] `cargo check -p ironclaw --no-default-features --features libsql --tests` — clean
- [x] `cargo test -p ironclaw --lib channels::web` — 434 passed (up from 431 — three tests that were incorrectly filtered under `channels::web::server::tests` now surface under their proper slice's module path)
- [x] `cargo test -p ironclaw --test multi_tenant_integration` — 40 passed
- [x] `cargo test -p ironclaw --test openai_compat_integration` — 16 passed
- [x] `cargo test -p ironclaw --test ws_gateway_integration` — 11 passed
- [x] `python3 scripts/check_gateway_boundaries.py` — clean
- [x] `python3 scripts/check_gateway_boundaries.py test` — 16/16
- [x] `bash scripts/pre-commit-safety.sh` — clean

## Regression coverage

Pure relocation + mechanical rename; no behavior change. The existing
~60 tests from `server.rs::tests` continue to pass unmodified, which is
the regression evidence. A "test that would have caught this" would
necessarily duplicate the existing tests — no new test adds coverage.
[skip-regression-check]

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:20:40 +09:00
Illia Polosukhin
b5dde50486 fix(gateway): address PR #2622 review feedback (follow-up) (#2701)
* fix(gateway): address PR #2622 review feedback

Five small fixes flagged by reviewers on the restage commit:

1. Trim trailing punctuation from `result.message` before
   `format!("{}. Resuming...", ...)` so messages like
   "Configuration saved for 'telegram'." don't render as
   "...telegram'.. Resuming..." (Copilot review).

2. Rename the error-context strings in `fail_waiting_thread` from
   "reconcile waiting thread" / "save reconciled thread" to "fail
   waiting thread" / "save failed thread" — the helper is now used
   for the no-auth-backend failure path too, not just orphan
   reconciliation (Copilot review).

3. Add a `debug!` log when an auth credential is intentionally
   dropped on the SkippedNoBackend + resume_output bare-test path,
   so the silent drop is observable to operators tracing this case
   (serrrfirat). Uses `debug!` (not `info!`) per the REPL/TUI
   logging rule in CLAUDE.md.

4. Document `submit_target` vs `credential_name` asymmetry in
   `submit_pending_auth_credential`'s doc comment — steps 1-2 take
   the extension identity, step 3 takes the credential identity
   because the secrets store has no extension concept (serrrfirat).

5. Document the credential-name validation chain on the step-3
   `secrets_store` fallback — upstream typing (`CredentialName`
   newtype validated at construction + pending-gate insertion by
   the engine) is the trust source, not a per-call check
   (serrrfirat).

Quality gate:
- cargo fmt --all
- cargo clippy --all --benches --tests --examples --all-features (zero warnings)
- 5 router tests pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(gateway): regression test for auth-completed message trim

Extracts the trailing-period trim from `resolve_gate`'s auth-completed
arm into `format_auth_completed_resuming(raw: &str) -> String` and adds
a unit test pinning the expected behavior:

- Strips trailing period(s) from upstream backend messages so
  "Configuration saved for 'telegram'." renders as
  "Configuration saved for 'telegram'. Resuming..." instead of the
  prior "...telegram'.. Resuming..." double period.
- Multiple trailing periods + whitespace collapse to a single period.
- Messages with no trailing punctuation get exactly one period.
- Non-period punctuation (`!`, `?`) is intentionally left intact —
  the spec is "trim periods only", matching the motivating bug.

Also clarifies the inline doc comment to say "trailing period(s) +
whitespace" instead of "trailing punctuation + whitespace", per
@Copilot review feedback on PR #2701 — the predicate only matches `.`
plus whitespace, so the comment now matches the code.

Closes the regression-test enforcement gap that failed CI on the
original follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:51:07 +09:00
jinxin
d30c76de69 feat: add debug inspector panel for web gateway (#1873)
* feat(web): add debug inspector panel for web gateway chat UI (#1493)

Add a debug inspector sidebar with three tabs (Prompt, Activity, Stats)
activated via ?debug=true URL parameter. Consolidate theme-init.js into
a new init.js for early initialization. The panel shows real-time SSE
event timeline, system prompt component breakdown with token estimates,
and session-wide statistics including per-model usage.

- New files: init.js, debug-panel.js, debug-panel.css
- New endpoint: /api/debug/debug/prompt for system prompt inspection
- i18n support (en + zh-CN) for all debug panel strings
- Responsive layout: sidebar on desktop, overlay on tablet, hidden on mobile

* chore: minor

* feat(web): add debug inspection endpoints and verbose SSE mode (#1492)

Add per-subscriber verbose filtering to SSE, new AppEvent variants
(ToolResultFull, TurnMetrics), and enhanced debug prompt endpoint.
Debug subscribers (?debug=true) receive full tool output, per-LLM-call
metrics with model/duration/cache tokens, and tool parameters on
success. Non-debug subscribers see no change (backward compatible).

- New AppEvent variants: tool_result_full, turn_metrics with is_verbose_only()
- SseManager.subscribe()/subscribe_raw() accept verbose flag
- Emit TurnMetrics from dispatcher after each LLM call
- Emit ToolResultFull with 50KB cap after tool execution
- tool_completed() always includes redacted parameters
- /api/debug/prompt returns system_prompt, model, context_limit
- Frontend: turn-based activity tracking, turn navigation, message click
- Frontend: prompt tab with model name, progress bar, full prompt view
- Unit test for verbose SSE filtering

* fix(debug-panel): start turn counter at 0 so first message shows turn 1

* chore: minor

* chore: minor

* chore: fix lint

* fix(i18n): add Korean debug panel translations and fix hardcoded string

* fix(i18n): add Korean debug panel translations and fix hardcoded string

* fix: fix lint

* fix(web): propagate call_id to SSE events, gate debug mode on admin role, and skip verbose broadcasts without subscribers

- Add call_id field to AppEvent::ToolStarted/ToolCompleted/ToolResult and
  propagate from StatusUpdate conversion instead of silently dropping it,
  fixing mismatched tool start/complete pairs during concurrent same-name
  tool calls in the debug panel
- Update debug-panel.js to key pending tools by call_id (flat map) instead
  of FIFO name-based queues
- Skip ToolResultFull/TurnMetrics allocation and broadcast when no
  SSE/WebSocket subscribers are connected (SseManager::has_receivers)
- Require admin role for verbose/debug SSE and WebSocket event streams,
  matching the existing AdminUser gate on /api/debug/prompt
- Add audit log (tracing::debug) on debug prompt endpoint access

* fix(gateway): add admin gate to WS debug mode, add call_id to ToolResultFull, fix debug panel i18n

- Require admin role for WebSocket debug mode (server.rs), matching the
  existing SSE handler check — prevents non-admin users from receiving
  verbose tool output via ?debug=true
- Add call_id: Option<String> to StatusUpdate::ToolResultFull and
  AppEvent::ToolResultFull for correct concurrent same-name tool
  matching; update dispatcher, web gateway conversion, and debug-panel.js
- Remove dead chat_ws_handler from handlers/chat.rs (superseded by
  server.rs local version)
- Fix debug panel overlay blocking page on viewport resize by switching
  from inline style to CSS class toggle with transparent background
- Internationalize hardcoded English strings in debug panel (In/Out/
  Cost/Model/Cache labels) with en/ko/zh-CN translations
- Fix activity entries not updating on language switch: store labelKey,
  resolve during render without mutating entry, rebuild activity DOM
  in refreshDynamicI18n
- Fix pre-existing subscribe_raw() test compilation errors (missing
  verbose parameter)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
2026-04-20 13:02:37 +09:00
firat.sertgoz
141435eb0b feat(gateway): expose engine v2 threads in chat history and sidebar (#2532)
* feat(gateway): expose engine v2 threads in chat history and sidebar

Engine v2 threads weren't appearing in the gateway sidebar and
deep-linking to one by id (`#/chat/<engine-thread-id>`) returned an
empty history because the v1 `assistant` flow dual-writes into the
single assistant conversation id, not the engine thread id.

Three coordinated fixes:

- `chat_history_handler`: extend the ownership check with an engine v2
  lookup so an engine thread id is recognized, then fall back to
  loading messages via `bridge::get_engine_thread` when the v1
  conversation table has nothing.
- `chat_threads_handler`: merge engine threads from
  `bridge::list_engine_threads` into the sidebar, label them with
  their goal, and re-sort by `updated_at`. Bump the v1 conversation
  cap from 50 to 500 so older threads stop silently aging off the
  sidebar.
- Gateway frontend (`app.js`): when restoring from `#/chat/<id>` on
  load, switch even if the id is not in the loaded sidebar list — the
  history endpoint resolves it via the DB. Log a warning instead of
  silently dropping the URL.

Cherry-picked from 8df22ab2 (feat/skills-engine-fixes), adapted to
staging where chat_history_handler and chat_threads_handler still live
in both server.rs and handlers/chat.rs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(gateway): address v2 history review comments

* test(gateway): caller-level coverage for v2 thread ownership + history

Adds tests exercising chat_history_handler through the three ownership
branches the PR introduces, and two HTTP-driven e2e scenarios against
an ENGINE_V2=true server. Fills the caller-level coverage gap flagged
in review.

- Rust caller-level tests on chat_history_handler:
  - v2-owned: engine thread owned by user returns synthesized history
  - cross-user: alice can't read bob's engine thread (404)
  - session-only: in-memory session-owned thread returns 200 without DB
- Playwright e2e under ENGINE_V2=true:
  - engine-only thread appears in sidebar with channel=engine
  - deep-link by engine thread id returns synthesized turns

Exposes a minimal bridge::test_support module (ThreadTestStore,
install_engine_state_with_threads, clear_engine_state, shared test
lock) so cross-module tests can seed ENGINE_STATE without the weight
of the bridge's own full-featured TestStore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): align with staging EngineState fields and clippy rules

- `EngineState` on staging now has `extension_manager` and `project_root`
  fields (added in #2549 and the attachments flow). Update the test-only
  `install_engine_state_with_threads` helper to populate them.
- Rewrite the `let Some(...) else { return None }` in
  `engine_history_entry_to_message` as a `?` — clippy::question_mark is
  denied on staging's all-features CI.
- Add missing `AuthenticatedUser` + `Query` imports to the server.rs
  test module for the `history_request` helper.
- `cargo fmt` on the 500-error test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
2026-04-20 12:57:27 +09:00
Illia Polosukhin
010bb70a99 refactor(gateway): promote cross-slice GatewayState builders to test_helpers — ironclaw#2599 stage-6 prereq (#2704)
Promotes three `GatewayState` builders out of `server.rs::tests` (where
they were private to that test module) into
`src/channels/web/test_helpers.rs` as `pub(crate)` functions:

- `test_gateway_state(ext_mgr)`
- `test_gateway_state_with_dependencies(ext_mgr, store, db_auth, pairing_store)`
- `test_gateway_state_with_store_and_session_manager(store, session_manager)`

Why this is a prerequisite for stage 6 (deleting `server.rs`): the
caller-level tests in `server.rs::tests` that exercise `features/chat`,
`features/extensions`, `features/oauth`, and `features/pairing` all
consume at least one of these three builders. Until the builders had a
reachable home, the tests couldn't migrate into their respective slice
`mod tests` blocks — and without that, `server.rs::tests` can't be
deleted, so the back-compat shim can't go either.

The promoted functions keep the exact positional signatures they had
when they lived in `server.rs::tests`, so the future test migration in
stage 6 becomes a pure `git mv` + import-path update with zero API
surface changes.

Visibility / compilation scope:
- `TestGatewayBuilder` stays `pub` and always-compiled (integration
  tests in `tests/` import the crate without `cfg(test)` set).
- The three cross-slice builders are individually `#[cfg(test)]`-gated
  because their only callers are in-crate unit tests; keeping them
  un-gated would produce dead-code warnings in release builds.
- The `DbAuthenticator` and `ActiveConfigSnapshot` imports that only
  the cross-slice builders need are also `#[cfg(test)]`-gated.

Also updates:
- `features/chat/mod.rs` pending-follow-up comment: the reference
  helpers are now in `test_helpers`, so the note points to stage 6 as
  the migration step rather than a "promote helpers first" prereq.
- `channels/web/CLAUDE.md` file map: adds a `test_helpers.rs` row
  describing both the public builder and the three `pub(crate)` fns.

Mechanical verification:
- `cargo check -p ironclaw --tests --all-features` — clean
- `cargo check -p ironclaw --all-features` — clean (no dead-code warnings)
- `cargo check -p ironclaw --no-default-features --features libsql --tests` — clean
- `cargo clippy -p ironclaw --tests --all-features` — zero warnings
- `cargo test -p ironclaw --lib channels::web` — 431 passed
- `cargo test -p ironclaw --test multi_tenant_integration` — 40 passed
- `cargo test -p ironclaw --test openai_compat_integration` — 16 passed

Regression coverage: this is a pure relocation with no behavior change.
The existing 64 tests in `server.rs::tests` that consume these three
helpers continue to pass unmodified, which is the regression evidence.
A "test that would have caught this" would necessarily be identical to
the existing tests — no new test adds coverage.
[skip-regression-check]

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 12:46:13 +09:00
Illia Polosukhin
e88236ab08 refactor(events): replace JobResult.status String with JobResultStatus enum (#2678)
* refactor(events): replace JobResult.status String with JobResultStatus enum

Producers at 7 sites and consumers at 3 sites previously agreed by
convention only. Promotes the status field to a typed enum with
snake_case serde — wire format preserved.

Maps to bug pattern from #2570, #2531, #2517 where status transitions
drifted between producer and consumer.

Tests cover snake_case serialization, wire-format round-trip, and the
is_success() predicate used at consumer sites.

* refactor(events): add Stuck variant, accept "error" alias, case-insensitive parse

JobResultStatus now covers the full set of wire values producers emit:
- New `Stuck` variant for worker/job.rs `mark_stuck` path (was coerced
  to Failed + warn log, losing the distinction that job monitor and
  recovery logic care about).
- `FromStr` accepts `"error"` as a legacy alias for `Failed` so
  claude_bridge and acp_bridge wire payloads deserialize cleanly
  instead of hitting the default-on-unknown branch.
- `FromStr` trims whitespace and uses `eq_ignore_ascii_case`, so
  `"  COMPLETED  "` and `"Failed"` parse rather than falling back.
- Empty / whitespace-only input now returns `Err` (distinct from
  "unknown value") so callers can log it separately.

Producer migration: worker/job.rs emits `JobResultStatus::Stuck`
directly via `serde_json::json!` so the wire string stays pinned to
`as_str()`. claude_bridge and acp_bridge keep emitting `"error"` on
the wire; the FromStr alias covers them without churn on those call
sites.

Added unit tests for each variant, the `"error"` alias, case
insensitivity, whitespace trimming, empty-string error, and
preservation of the original input in `JobResultStatusParseError`.

---------

Co-authored-by: Henry Park <henrypark133@gmail.com>
2026-04-20 12:39:42 +09:00
Illia Polosukhin
fdaba100a6 feat(gateway): add attachment flows, v2 skill install coverage, and e2e stabilization (#2385)
* feat(gateway): add attachment flows and slash-skill coverage

* feat(v2): persist project attachments across channels

* feat(skills): install GitHub skill bundles

* feat(v2): cover live skill install and setup flow

* test(e2e): stabilize gateway and auth coverage

* test(e2e): stabilize post-merge warnings and browser flows

* fix(review): address follow-up PR feedback

* fix(review): address remaining attachment and skill install comments

* Address remaining attachment review comments

* fix(ci): allowlist ws.rs → server::inline_attachments_to_incoming

ws.rs was already allowlisted for the attachment shim symbols
(`images_to_attachments`, the rate limiter types, etc.) so the new
unified entrypoint added by this branch (combining images and
generic attachments before validation) follows the same pattern.
The entry will be removed together with the rest of the ws.rs
server:: block once the attachment helpers migrate into platform/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(e2e): attachment persistence path and Slack activate signature

Two e2e-surfacing regressions after merging staging:

1. `persist_project_attachments` was writing to
   `<base_dir>/projects/.ironclaw/attachments/...` because PR #2385's
   reviewer-requested switch from `std::env::current_dir()` to an
   explicit `project_root` kept the `.ironclaw/` prefix baked into
   `PROJECT_ATTACHMENT_DIR` while rooting at `ironclaw_base_dir()/projects`.
   Point `resolve_project_root()` at the parent of the base dir so
   `<parent>/.ironclaw/attachments/<owner>/<project>/...` matches the
   prompt's `project_path` and the user's expectation when base dir is
   `~/.ironclaw`. Updates the corresponding assertion in
   test_v2_engine_auth_flow.py to resolve paths against the fixture's
   home tempdir instead of the repo root.

2. `activate_slack()` grew a required `http_url` arg during the
   skill-install branch work but the `active_slack` fixture in
   test_slack_e2e.py still passed the old three-arg shape. That tripped
   every Slack scenario at setup (TypeError). Thread `http_url` from
   `slack_e2e_server` through the fixture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(engine-v2): auth-prompt surfacing, bundle_path injection, attachment-only inputs

- Orchestrator formatter now writes `Installed bundle path on disk:` into
  each skill block so the skill body sees the bundle location it needs to
  reference (e.g. running `pip install -r <bundle>/requirements.txt`).
  Previously the bundle_path metadata field was populated but never
  surfaced into the prompt, so skills that rely on filesystem paths
  silently no-op'd.
- The router no longer rejects messages whose text body is empty when
  the payload carries attachments. Safety validation's empty-input
  guard is a v1 input-sanity check; a pure-attachment follow-up (image
  upload with no caption) is a legitimate submission in the v2 gateway
  contract and previously tripped "Input cannot be empty".
- The engine auth-flow e2e tests now detect gate-paused state via
  `HistoryResponse.pending_gate` (and `resume_kind.Authentication`)
  rather than scanning the turn response text for "paste your token".
  Auth instructions live in the `onboarding_state` SSE event, not in
  the chat response (see `test_auth_no_duplicate_response.py`); the old
  string-matching assertion was checking the wrong surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(e2e): switch approval/auth-prompt probes to pending_gate

Approval and auth prompts are surfaced through HistoryResponse.pending_gate
and the onboarding_state/gate_required SSE events, not as text in
turns[-1].response — the duplicate-response regression guard in
test_auth_no_duplicate_response.py explicitly forbids them from appearing
in the chat transcript.

Update the helpers in test_v2_engine_approval_flow.py,
test_v2_engine_auth_cancel.py, and test_v2_kernel_auth_preflight.py to
poll pending_gate instead of scanning turn text for "requires approval"
or "paste your token". Unblocks 5 approval, 1 auth-cancel, and 3
preflight tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(e2e): google-oauth _wait_for_auth_prompt / _wait_for_response use pending_gate

Bring the Google Drive / skill-OAuth regression file in line with the rest
of the v2 e2e helpers: poll `HistoryResponse.pending_gate` for auth/approval
prompts, and accept a pending_gate as a valid terminal state for
`_wait_for_response` (an auth-retry chain that hits another gate is still
progress, not a hang).

Unblocks the oauth-cancel, invalid-token-paste, and api-key-then-api-call
scenarios; the lingering token-refresh scenario still exposes a real v2
auto-refresh regression (the engine prompts the user instead of issuing a
refresh against the stored refresh_token).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(e2e): relax a few stale v2-surface assertions

- `test_skill_oauth_flow::test_auth_required_sse_event` was pinned to the
  old `onboarding_state/auth_required` SSE payload. The v2 gate pipeline
  delivers credential gates as `gate_required` (resume_kind
  `Authentication`) or, when preflight falls through to approval first,
  `approval_needed`. Accept any of those three, and treat a `thinking`
  "Running <tool>" status as evidence the tool call fired when no
  standalone `tool_started` event is emitted.
- `test_message_persistence` helpers asserted HTTP 200 on `/api/chat/send`,
  but the gateway now returns 202 ACCEPTED (fire-and-forget). Accept both.
- `test_project_detail` flipped the wrong global (`engineV2`) instead of
  `engineV2Enabled`, leaving the `data-v2-only` Projects tab hidden so the
  click timed out. Set the real flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): address attachment index note correctness

Two Copilot review findings on the attachment persistence path:

- `attachment_index_note` in `src/bridge/router.rs` used the raw
  user-supplied filename in the markdown `# Uploaded attachment:` header
  and in the memory-doc `title` field. A filename with newlines /
  backticks / control characters would corrupt the agent-visible
  transcript and break searchable titles. Route the filename through a
  new `sanitize_filename_for_display` that strips control chars,
  collapses newlines/tabs to spaces, swaps backticks for apostrophes,
  truncates at 256 chars, and falls back to `"attachment"` when the
  sanitized result is empty.
- `persist_project_attachments` cleared `attachment.data` before
  calling `attachment_index_note`, so the `size_bytes.unwrap_or(
  data.len() as u64)` fallback reported `0` bytes whenever the channel
  hadn't pre-populated `size_bytes`. Swap the order — build the index
  note while the buffer is still populated, then drop the bytes.

Also adjust `src/agent/attachments.rs::format_attachment` for the
Image arm: when `data` has been cleared but `local_path` is set (the
engine-v2 persist-then-clear flow), the "visual content not available
in this conversation" message is misleading — the image is available,
just on disk. Surface a dedicated prompt that tells the agent to
reference the project file path instead of trying to load bytes from
memory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(e2e): cancel_during_auth asserts pending_gate clears, not chat text

The test polled \`turns[-1].response\` for "cancel" but the cancel flow
never writes an assistant row to the chat-history DB: resolve_gate
returns \`BridgeOutcome::Respond("Cancelled.")\` which broadcasts via
SSE and calls \`stop_thread\` on the engine thread, neither of which
goes through the DB persistence path that populates turn responses.

Switch the test to verify the user-visible signal the gateway actually
emits — \`history.pending_gate\` disappears after "cancel" resolves the
gate. Matches the approach used in \`test_v2_engine_approval_flow.py\`'s
deny-flow tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(e2e): pairing approve test tolerates ExtensionName boundary reject

Staging's new `features/pairing/` slice (ironclaw#2599 stage 4b) validates
the `{channel}` URL segment through `ExtensionName::new` at the handler
boundary: a path-traversal / control-character / whitespace-containing
segment (like `evil.Ignore all`) now returns 400 instead of silently
routing to a pairing-store miss.

The regression test used to assert the older 200+JSON shape. Relax it
to accept either 200 (generic `Invalid or expired pairing code.`) or 400
(boundary validation); the real invariant the test exists to protect —
the raw injection-shaped channel string must not echo back into the
response — is still asserted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): preserve image bytes through LLM call + document drive mock pin

Two review findings:

- `src/bridge/router.rs::persist_project_attachments` was clearing
  `attachment.data` after writing the file to disk. The very next step
  in `handle_with_engine_inner` is `augment_with_attachments`, which
  only emits a multimodal `image_parts` entry when `att.data` is
  non-empty — so every engine-v2 image upload was silently dropped
  from the LLM request even though the file landed on disk. The
  `persisted_attachments` Vec is local to the dispatch and is dropped
  as soon as the engine call returns, so the "storage hygiene" comment
  the clear used to justify was a no-op. Stop clearing; let RAII free
  the bytes. Updates `src/agent/attachments.rs`'s Image-arm prompt to
  reflect the refined invariant (`data.is_empty()` now implies a
  downstream caller or channel stripped the buffer, not the normal
  persist path).

- `tests/e2e/scenarios/test_v2_engine_oauth_google.py::_pin_mock_drive_api_url`
  posts to `/__mock/set_github_api_url`. The wire name is historical
  — the Drive suite reused the knob — but the fixture name made the
  intent hard to follow. Adds a docstring that calls out the shared
  `_github_api_url` in `mock_llm.py` and explains why the endpoint
  rename would cascade into every other test that uses it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): address remaining Copilot feedback on PR 2385

- audio attachments: include `mime` (and size) attribute in `<attachment>`
  XML for parity with image/document so the frontend can render MIME and
  size in attachment cards
- /api/skills list/search: parallelize per-skill filesystem I/O
  (`read_install_metadata`, `try_exists`, `metadata`) via
  `futures::future::join_all` instead of awaiting serially — keeps the
  handler O(n) in wall time for large skill sets
- history parseUserMessageContent: only strip the trailing
  `<attachments>…</attachments>` block when at least one `<attachment>`
  tag is parsed from inside it, otherwise leave the raw text intact so
  user messages that legitimately end with that markup are preserved
- sync_v1_skill_to_store: look up existing shared skill doc via
  `list_skills_global()` instead of `list_shared_memory_docs(project_id)`
  so shared skills installed under one project are updated in place when
  re-synced from another project (prevents duplicate shared docs across
  per-user projects) and preserve the original `project_id` on in-place
  update

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 12:38:30 +09:00
Illia Polosukhin
fb4fc829e1 refactor(ownership): collapse OwnerId+Identity into UserId with role variants (#2677)
* refactor(ownership): collapse OwnerId+Identity into UserId with role variants

- Expand UserRole to {Owner, Admin, Regular}
- UserId carries role; methods is_owner()/is_admin()/is_regular()
- Remove From<String>/From<&str> impls (enforces types.md rule)
- Validated construction via new(); from_trusted() for DB-sourced values

Addresses bug pattern from #2561, #2620, #2349 where owner_id silently
round-tripped as String.

* refactor(ownership): address review feedback — id-only equality, persist owner role, doc fixes

- UserId PartialEq/Eq/Hash now compare only `id`, not `role`. Role is
  metadata that travels with the identity; two UserIds with the same id
  but different roles must be interchangeable as HashMap/HashSet keys
  and cache lookup targets. Added a regression test that builds a
  HashSet keyed on UserId and asserts cross-role `.contains()`
  membership, plus a hash-equality check.
- CLI pairing path now persists the "owner" role string (via
  UserRole::Owner.as_db_role()) instead of the hardcoded "admin", so
  a reload through UserRole::from_db_role stays Owner rather than
  being silently downgraded to Admin.
- Update the feature/pairing approve handler to mirror the refactor:
  build UserId via from_trusted + UserRole::from_db_role(&user.role)
  instead of the removed OwnerId::from.
- AdminScope doc comment now reflects that Owner also passes
  is_admin().
- AdminUser extractor error message now reads "Admin privileges
  required (admin or owner)" so the forbidden response matches the
  actual gate.

---------

Co-authored-by: Henry Park <henrypark133@gmail.com>
2026-04-20 12:31:37 +09:00
Illia Polosukhin
544a893aab refactor(gateway): extract extensions + jobs + settings + routines — ironclaw#2599 stages 4d + 5 (#2687)
* refactor(gateway): extract extensions + jobs + settings + routines slices — ironclaw#2599 stages 4d + 5

Bundles the last feature-slice migrations in one PR. After this lands,
server.rs has zero feature handlers — it's a pure backward-compat
re-export shim that stage 6 will delete.

New feature slices:

- features/extensions/ (stage 4d) — nine routes: list / readiness /
  tools / install / activate / remove / registry / setup / setup-submit.
  Owns derive_activation_status, derive_onboarding, extension_phase_for_web,
  and apply_extension_readiness_to_response. Every handler that takes
  `{name}` from the URL validates via `ExtensionName::new` at the
  boundary (400 on path-traversal / invalid chars / oversized). The
  setup-submit path routes through `AuthManager::resolve_auth_flow_extension_name`
  (canonical resolver) and `platform::engine_dispatch`, preserving the
  identity invariants called out in CLAUDE.md.
- features/jobs/ (stage 5) — nine routes covering sandbox-job lifecycle
  (list / summary / detail / cancel / restart / prompt / events /
  files-list / files-read). Straight file move from handlers/jobs.rs.
- features/settings/ (stage 5) — eight routes (list / export / import /
  get / set / delete / tools-list / tools-set). `resolve_settings_store`
  promoted to `pub(crate)` for `handlers/tool_policy.rs`. Straight file
  move from handlers/settings.rs.
- features/routines/ (stage 5) — seven routes merged from two sources:
  `handlers/routines.rs` (list / summary / detail / trigger / toggle /
  delete) plus the previously-canonical `routines_runs_handler` from
  `server.rs` (the `handlers/routines.rs` copy of the same function was
  marked `#[allow(dead_code)]` and kept in sync manually — that
  redundancy is gone now). Uses the cleaner `routine.is_owned_by(...)`
  ownership predicate throughout instead of the direct `user_id` match
  the `server.rs` copy used.

server.rs changes:

- Deleted 549 lines of extension handlers + 47 lines of
  routines_runs_handler + the now-redundant axum/Json/types/Uuid imports
  the moved handlers pulled in.
- Reduced to 14 lines of `pub use` re-exports for `start_server` and
  `platform::state::*` so external callers (src/main.rs, src/app.rs,
  tests) keep resolving. Stage 6 follow-up deletes even those once the
  callers flip to `platform::*` directly.
- The `#[cfg(test)] mod tests` block stays in place (it's the only
  module still using `test_gateway_state*` helpers that construct state
  for chat + extensions + oauth + pairing together). Test imports
  updated to pull handlers/helpers from their new feature-slice homes.
  Promoting `test_gateway_state*` to `test_helpers.rs` is the last
  blocker before stage 6 and is tracked in the ironclaw#2599 follow-ups
  comment.

Cross-cutting updates:

- platform/router.rs: consolidated feature-slice imports into one
  section with an updated docstring noting the new completion state.
- handlers/mod.rs: dropped `pub mod extensions / jobs / routines /
  settings` declarations (the files physically moved via `git mv`).
  handlers/ now lists only the still-transitional modules.
- handlers/tool_policy.rs: switched `use super::settings::resolve_settings_store`
  to `use crate::channels::web::features::settings::resolve_settings_store`.
- src/extensions/manager.rs: two `handlers::extensions::derive_onboarding`
  call sites redirected to the new slice path.
- src/channels/web/tests/multi_tenant.rs: jobs + routines handler
  imports updated.
- features/{jobs,settings,routines,extensions}/mod.rs: all
  `crate::channels::web::server::{GatewayState, PerUserRateLimiter,
  RateLimiter, ActiveConfigSnapshot}` imports redirected to
  `platform::state::*` directly, so the new slices don't depend on the
  dying shim.

Quality gate:
- cargo fmt clean
- cargo clippy --all --tests --examples --all-features clean
- cargo test --lib channels::web — 424 passed
- scripts/check_gateway_boundaries.py — clean, empty allowlist preserved
- scripts/check_gateway_boundaries.py test — 16/16

Net shape: four slices added under features/, four handlers files
deleted (via `git mv`), server.rs −651 lines (to 14), plus 467 lines
added to features/extensions/ (the nine canonical extension handlers +
helpers). Migration is functionally complete — stage 6 is the final
cleanup.

Explicit non-scope:
- Stage 6 (server.rs shim deletion) is NOT in this PR. Needs the
  `test_gateway_state*` test-helper promotion to `test_helpers.rs` as
  the prerequisite, which is a coordinated cleanup touching every
  caller that still imports `crate::channels::web::server::*`.
- `handlers/` still holds 12 transitional modules (auth, engine,
  frontend, llm, memory, secrets, skills, system_prompt, tokens,
  tool_policy, users, webhooks). Per ironclaw#2599 stage 7, those
  migrate only if churn justifies — most are low-churn and pattern-
  free.
- No `Deps`-view narrowing in any slice; every handler still takes the
  full `GatewayState`. Separate hardening PR per the tracking issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(gateway): fix CLAUDE.md stale references — PR #2687 review

Two Copilot doc fixes:

- `platform/engine_dispatch.rs` description said "server.rs (chat +
  extensions_setup_submit)" but chat migrated in 4c (#2680) and
  extensions in 4d (this PR). Updated to list the three current feature
  slices that compose the dispatch helpers.
- `features/chat/` description referenced
  `AuthManager::resolve_auth_flow_extension_name`, but the public
  `AuthManager` method is `resolve_extension_name_for_auth_flow`
  (src/bridge/auth_manager.rs:406). `resolve_auth_flow_extension_name`
  is the underlying `pub(crate)` free function the method delegates to.
  Corrected to the method name so the docs match the public API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(gateway): wire channel_relay kind_hint + refresh router docstring — PR #2687 review

Two PR #2687 review fixes:

- `extensions_install_handler` now maps `kind: "channel_relay"` to
  `ExtensionKind::ChannelRelay`. Frontend registry entries send
  `channel_relay` as the kind, and `ExtensionManager::install` uses
  `kind_hint` to disambiguate registry name collisions and URL-install
  inference. The dropped arm meant Slack-relay-style installs could
  land on the wrong disambiguation path. Pre-existing in the canonical
  `server.rs` parser; folding the fix here because two reviewers
  (Gemini + Copilot) flagged it and the fix is one line.

- `platform/router.rs` top-of-file docstring rewritten to match the
  post-stage-4d reality: feature handlers live in `features/<slice>/`
  or the transitional `handlers/*.rs` flat folder — none live in
  `server.rs`, which is now a pure re-export shim awaiting stage 6
  deletion. The old wording contradicted the updated inline comment
  at line 64.

Regression coverage: single-arm addition to a match on a small closed
enum; caller-level behavior is covered by existing
`ExtensionManager::install` paths that consume `kind_hint`. No new
test added — the change is a one-arm parity fix, and a test asserting
the match arm would duplicate the compiler's exhaustiveness check.
[skip-regression-check]

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 12:23:07 +09:00
firat.sertgoz
e2544ed470 feat(engine): add mission_get action for retrieving mission results (#2549)
* feat(engine): add mission_get action for retrieving mission results

The LLM had no tool to retrieve mission outcomes — when users asked
"what is the result of the research", the agent fell back to calling
list_jobs because no mission results tool existed. Missions spawn
threads (not jobs), so list_jobs always returned empty results.

Add mission_get action that loads mission details + recent thread
outputs so the LLM can answer mission result queries directly.
Also map routine_history (v1 alias) to mission_get for compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(bridge): address review — ownership check + approach_history cap (#2549)

Add user_id ownership check to mission_get handler to prevent
cross-user IDOR (mirrors fire/pause/resume guards). Cap
approach_history to last 10 entries to prevent context window overflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove redundant .into_iter() to satisfy clippy useless_conversion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 21:19:37 +02:00
firat.sertgoz
fddf56be7a docs(engine): clarify ENGINE_V2 opt-in startup (#2694) 2026-04-19 21:18:13 +02:00
firat.sertgoz
737029d4a3 fix(ci): target promotion PR for Claude review comments (#2576)
* fix(ci): target promotion PR for Claude review comments

The Claude Code Review workflow runs on staging promotion PRs but the
agent was tracing changes back to original source PRs and posting
comments there (e.g. on already-merged #2539 instead of promotion
#2575). Fix by explicitly passing the promotion PR number in the prompt.

Also tell the agent which tools are available to avoid wasting turns
on permission denials (~24% of turns were denied tool calls).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve CI failures — restore clippy allow, fix install-param extraction

- Restore `#[allow(clippy::too_many_arguments)]` on `register_startup_channels`
  that was accidentally removed
- Add `tool_install`/`tool_activate` parameter name extraction to
  `pending_gate_extension_name` fallback path (mirrors AuthManager logic)
  so extension name resolves correctly without auth_manager

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 19:42:31 +02:00
Nige
3c1f37b50a fix(slack): remember thread participation across replies (#1540)
* fix(slack): remember thread participation across replies

* perf(slack): use hashset for active thread tracking

* fix(slack): scope active thread memory

* fix: address review findings (iteration 1)

* fix(slack): address ilblackdragon review — harden thread state (#1540)

---------

Co-authored-by: Firat Sertgoz <f@nuff.tech>
2026-04-19 19:31:50 +02:00
Henry Park
4ab8e434c4 fix(gateway): unify v2 extension auth resume flow (#2622)
Rebased on latest staging (which independently extracted
resolve_auth_flow_extension_name during ironclaw#2617). The previous
PR commits introduced a parallel `resolve_extension_name_for_auth_flow_with_fallback`
helper that duplicated staging's resolver; this commit drops the
redundancy and keeps only the parts that add new behavior:

- extension_manager plumbed through EngineState, resolve_extension_for_action,
  resolve_auth_gate_extension_name, notify_pending_gate, and the
  CredentialProvided arm in resolve_gate. No-AuthManager callers now
  delegate to the canonical resolve_auth_flow_extension_name directly
  (per src/bridge/CLAUDE.md) so the ExtensionManager branch of the
  precedence is actually run on hosted instances without
  SECRETS_MASTER_KEY.

- submit_pending_auth_credential fallback helper: auth_manager →
  extension_manager.configure_token → secrets_store.create →
  SkippedNoBackend. Extracted fail_waiting_thread from
  fail_orphaned_waiting_thread_if_needed for reuse.

- resolve_gate CredentialProvided arm uses the helper; the
  SkippedNoBackend case fails the waiting thread with an explicit
  error message unless the gate carries a staged resume_output (bare
  test-harness path).

Tests added (all caller-level per .claude/rules/testing.md):
- insert_and_notify_pending_gate_uses_extension_manager_for_auth_display_name
- resolve_gate_uses_extension_manager_without_auth_manager_for_auth_resume
- resolve_gate_fails_waiting_thread_when_no_auth_backend_and_no_resume_output
- submit_pending_auth_credential_uses_extension_manager_without_auth_manager
- submit_pending_auth_credential_propagates_validation_failed (new —
  covers the review gap flagged on the original PR)

cargo fmt --all
cargo clippy --all --benches --tests --examples --all-features (zero warnings)
cargo test --lib (5 new router tests pass)

Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 01:47:40 +09:00
Henry Park
64193474dd Preserve paused leases across engine auth resume (#2631)
* Preserve paused leases across engine auth resume

* fix(review): validate paused_lease snapshot at gate resume

Addresses PR #2631 review comments from Copilot:

1. **Snapshot used without validation** (src/bridge/router.rs:684 orig):
   `pending.paused_lease.clone()` was used directly to resume a gated
   action. A gate can sit in the pending-gate store for hours or across
   process restarts; during that window the original lease may have
   been revoked, expired, or the pending record could have drifted off
   its original thread.

   Extract `snapshot_lease_still_valid` + `resume_lease_for_pending_gate`
   helpers. The snapshot must pass four checks before use:
     - `lease.thread_id == pending.thread_id`
     - `granted_actions.covers(&pending.action_name)`
     - `!revoked`
     - `expires_at` is unset or in the future

   If any check fails, fall through to `LeaseManager::find_lease_for_action`
   (the normal path). Matches the reviewer's suggestion to avoid silently
   resuming a stale snapshot; still prefers the snapshot when valid so
   the original bug (no active lease at resume after restart) stays
   fixed.

2. **No router-level regression test** for the snapshot-vs-fallback
   decision. Six new libsql-free tests in `bridge::router::tests`:
     - `resume_lease_prefers_snapshot_even_when_lease_manager_empty` —
       reproduces the original bug; snapshot must carry the resume.
     - `resume_lease_rejects_revoked_snapshot_and_falls_back`
     - `resume_lease_rejects_expired_snapshot_and_falls_back`
     - `resume_lease_rejects_snapshot_with_wrong_thread_id`
     - `resume_lease_rejects_snapshot_missing_action_coverage`
     - `resume_lease_returns_none_when_no_snapshot_and_no_active_lease`

Verified: `cargo fmt`, `cargo clippy --all --benches --tests --examples
--all-features` (0 warnings), `cargo test -p ironclaw_engine` (435
passed), `cargo test -p ironclaw --lib` (5182 passed, +6 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style: collapse assert!(matches!()) per rustc 1.95 rustfmt

CI rustfmt (nightly/stable 1.95.0) wants the `assert!(matches!())` in
`orchestrator.rs::parse_outcome_gate_paused` collapsed to fewer lines.
Local rustfmt 1.94 was happy with the expanded form; matching CI to
unblock the fmt check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 01:45:49 +09:00
Illia Polosukhin
cad5e50f10 feat(llm): hot-reload provider chain from settings (supersedes #2059) (#2673)
* feat(llm): hot-reload provider chain from settings (#1350)

Adds SwappableLlmProvider and LlmReloadHandle so changes to the active
LLM backend/model via the settings API take effect without restarting
the daemon. The settings handlers trigger a chain rebuild from the
latest Config::from_db_with_toml whenever an LLM-relevant key is
written, and atomically swap the inner provider under the running
wrappers.

Addresses review feedback on the original PR #2059 (superseded):
- single RwLock<ProviderSnapshot> for atomic metadata updates (no
  torn reads across model_name / cost / cache multipliers)
- interned &'static str for model_name() to cap Box::leak at the set
  of distinct names a process ever sees, not one leak per swap
- single critical section around swap+snapshot refresh to kill the
  race between concurrent reloads
- tokio::sync::Mutex on LlmReloadHandle to serialize reloads and
  avoid overlapping OAuth refreshes / HTTP probes
- warn!, not silent Ok, when reload wiring is missing from the
  gateway state
- integration coverage per .claude/rules/testing.md: a test that
  drives settings_set_handler end-to-end and asserts the same
  Arc<dyn LlmProvider> reports the new active_model_name after swap

Co-authored-by: Nigel Coleman <coleman.nige@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(llm): gate hot-reload on scope + admin; re-hydrate secrets

Addresses review findings on #2673:

- Scope gate: reload only fires when the written scope actually feeds
  the global provider chain (admin scope or gateway owner scope). A
  member writing their own `selected_model` lands in their user row
  but no longer triggers a chain rebuild that would read back from a
  different scope — fixing both the "write ignored by reload" bug and
  the DoS vector where any authed user could force expensive rebuilds.

- Admin-only provider selection: `llm_backend` and `bedrock_{region,
  cross_region, profile}` join the existing admin-only LLM key list,
  matching the product directive "admins choose the provider, members
  pick the model within it". `selected_model` stays non-admin so every
  user can change their own model.

- Secret re-hydration on reload: `reload_llm_after_settings_change`
  now calls `re_resolve_llm_with_secrets` after the bare `from_db_with_toml`
  read, so a new OPENAI_API_KEY / NEARAI_SESSION_TOKEN added alongside
  a backend switch is visible to the rebuilt chain.

- Style cleanup: drop dead `llm_model` allowlist entry; drop unused
  `Clone` on `ProviderSnapshot`; document `reload_lock`'s purpose;
  explicit comment that `active_config.enabled_channels` is not
  refreshed (channel enablement is orthogonal to LLM config).

New regression tests (5149 → 5154 passing):

- `llm_reload_handle_preserves_old_chain_on_build_failure` — a failed
  reload leaves the primary wrapper pointing at the old chain.
- `settings_set_handler_rejects_member_writing_llm_backend` — member
  writing `llm_backend` gets 403 (admin-only).
- `settings_set_handler_member_selected_model_skips_reload` — member
  can set their own model, and it does NOT trigger a global reload.
- `settings_set_handler_owner_scope_triggers_reload` — owner writing
  their own scope (no `scope=admin`) still reloads the chain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(llm): decouple reload from HTTP status; atomic set_model; cap interner

Addresses PR #2673 review comments from Copilot and gemini-code-assist:

- **Reload failure no longer 500s the setting write** (Copilot):
  `reload_llm_after_settings_change` is now infallible — it logs at
  `error!` when the chain rebuild fails but the handler still returns
  204. Returning 500 after a successful `set_setting` misrepresented
  the outcome (DB committed, chain stale) and drove client retries
  that re-ran the same failing reload.

- **set_model race with swap closed** (gemini): the write lock is now
  held across the inner `set_model` call and the snapshot refresh, so a
  concurrent `swap()` can't clobber the just-updated inner with a
  snapshot of the older one.

- **Interner leak capped** (gemini): `intern_model_name` now refuses
  names longer than 256 bytes and caps distinct entries at 1024, past
  either limit returning a static `<model-name-overflow>` sentinel and
  logging at `warn!`. Protects against adversarial `set_model` input.

New regression tests (5154 passing):

- `settings_set_handler_returns_success_when_reload_fails` — admin
  switches backend to a value with no credentials; handler returns
  204, DB has the new value, old chain still serving.
- `set_model_and_swap_are_mutually_atomic` — concurrent set_model +
  swap stress; final wrapper is readable and consistent.
- `intern_into_rejects_oversized_input` — oversized name never leaks,
  returns sentinel without touching the map.
- `intern_into_caps_distinct_entries` — past the cap, sentinel;
  already-interned names still resolve.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(llm): load reload config from owner scope; rollback on build failure

Addresses PR #2673 review comments from @serrrfirat and Copilot.

- **Reload scope fix** (serrrfirat, Copilot): `reload_llm_after_settings_change`
  now reads with `state.owner_id` instead of the just-written `effective_user_id`.
  `Config::from_db_with_toml` skips the admin-merge step when `user_id == __admin__`,
  so reloading at admin scope was dropping owner-scope overlays that startup
  normally applies. Using `state.owner_id` matches `AppBuilder::init_config`
  and keeps the layering consistent.

- **Rollback on reload failure** (serrrfirat): handlers now snapshot the
  affected keys before the DB write and restore them if the chain rebuild
  returns `ConfigLoadFailed` or `BuildFailed`. The handler then returns
  422 with the rolled-back state. This closes the split-brain window where
  a bad `llm_backend=openai` write could leave the DB saying "openai"
  while the runtime kept serving "nearai". `set_setting`, `delete_setting`,
  and `set_all_settings` all participate.

- **`ReloadOutcome` enum** replaces the previous infallible return, so
  callers can distinguish transient "nothing wired" (skip) from actual
  "chain rebuild failed" (roll back) outcomes.

Regression tests (5184 passing):

- `reload_rebuilds_from_owner_scope_not_effective_scope` — pre-seeds an
  owner-scope `selected_model` overlay and has admin write a benign
  key under `scope=admin`. Assertion: after reload, the wrapper reports
  the owner's overlay, not admin's default. This fails pre-fix because
  reading at `__admin__` scope silently skipped the admin merge.
- `settings_set_handler_rolls_back_on_reload_failure` — pokes a poisoned
  `bedrock_cross_region` sibling into admin scope, triggers a handler
  write, asserts 422 and that the DB is back to its pre-request state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(llm): fail-loud on snapshot read errors; surface 422 reason in body

Addresses Copilot review comments on PR #2673.

- **Snapshot read errors** (c5, c6): `settings_{set,delete,import}_handler`
  used to `.unwrap_or(None)` when reading the previous value for rollback,
  which would silently treat a DB read failure as "no prior value" and
  turn a later rollback into `delete_setting` on a key whose prior value
  we couldn't actually read — a silent data-loss path. The handlers now
  map snapshot read errors to 500 and abort before persisting. The import
  handler does the same inside its per-key snapshot loop.

- **422 body carries the reason** (c7): the `ReloadOutcome::BuildFailed`
  and `ConfigLoadFailed` reason strings are now included in the 422
  response body. Handler error type changed from `Result<StatusCode,
  StatusCode>` to `Result<StatusCode, (StatusCode, String)>` (axum's
  `IntoResponse` for tuples). The web UI's `apiFetch` can surface the
  reason to the operator instead of a bare "Unprocessable Entity".
  Auth/validation paths keep empty-body semantics via the `no_body`
  helper.

Test updates:
- Existing handler tests: `.0` on the error tuple where they previously
  compared bare `StatusCode`.
- Extended `settings_set_handler_rolls_back_on_reload_failure` to assert
  the 422 body includes the failure reason.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Nigel Coleman <coleman.nige@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 00:18:38 +09:00
Illia Polosukhin
a35f9d9ab5 refactor(gateway): split monolithic style.css and app.js into per-surface modules (#2683)
The gateway's static frontend had grown into two merge-conflict hotspots: a
6 887-line `style.css` and an 11 189-line `app.js`, each a catch-all for every
surface of the SPA. Any two PRs touching different tabs were likely to collide.

This change splits both files by surface/concern while preserving bytes and
behavior. `STYLE_CSS` and `APP_JS` in `crates/ironclaw_gateway/src/assets.rs`
now `concat!(include_str!(...))` the split pieces at compile time, so the
served `/style.css` and `/app.js` URLs are unchanged and the existing
workspace-overlay (`custom.css`) path still works. Cuts land on function /
block boundaries; `node --check` validates the concat. Admin assets move
under `static/admin/` for symmetry. Per-commit safety + CI workflow validate
the split files per-file instead of the old monolith.

- Styles split into 20 files under `static/styles/{base,layout}.css +
  styles/{components,primitives,surfaces}/*.css`
- JS split into 24 files under `static/js/{core,surfaces}/*.js`
- No URL, CSP, or behavioural change — pure file-layout refactor

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 00:12:29 +09:00
firat.sertgoz
e8ae9487bb fix(telegram): unblock e2e activation flow (#2652)
* fix(telegram): unblock e2e activation flow

* fix: address review findings (iteration 1)

* test: isolate telegram e2e activation state
2026-04-20 00:03:24 +09:00