Skip to content

fix: resolve four reported bugs (#6423, #6442, #6443, #6444)#6449

Merged
houko merged 3 commits into
mainfrom
fix/open-bug-issues
Jul 13, 2026
Merged

fix: resolve four reported bugs (#6423, #6442, #6443, #6444)#6449
houko merged 3 commits into
mainfrom
fix/open-bug-issues

Conversation

@houko

@houko houko commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes four open bug reports, verified present on main:

Each was cross-checked against the current code before fixing; #6423 in particular is fixed differently from the reporter's suggestion (see below).

#6443 — cross-account (cross-tenant) channel_send guard

account_id selects which registered bot instance a send routes through. It was extracted from the model's tool input and passed straight to the kernel send path with no validation. On a multi-tenant daemon (several bot accounts, each with its own default_agent serving a different customer) an agent induced via hallucination or prompt injection could pass another tenant's account_id and dispatch content into that customer's chat. The #6117 guard does not cover this — it only compares recipient within the same channel and runs before account_id is even parsed.

Changes:

  • Kernel stamps the turn's originating account (SenderContext.account_id, already populated from the inbound adapter config name) into manifest.metadata["sender_account_id"], next to the existing sender_chat_id stamp — both messaging.rs and agent_execution.rs sites.
  • The runtime agent loop reads it back and threads it to channel_send. execute_tool keeps its historical positional signature (external API tool-run routes and ~57 unit tests untouched) and forwards None to a new execute_tool_with_sender_account; only the in-process agent loop threads the stamped account.
  • tool_channel_send rejects an explicit account_id that differs from the turn's account on the same channel, mirroring the bug(security/channels): channel_send can dispatch to a different recipient on the same channel (cross-chat message leak) #6117 recipient guard's explicit-value-only scoping. Auto-filled sends carry no account_id; out-of-band callers (cron / triggers / API) leave the account None and the guard no-ops.
  • The approval-deferred path is covered too: DeferredToolExecution gains an account_id field (serde-default, back-compatible with persisted rows) so an approved, deferred channel_send still enforces the guard.

Question for maintainer — /mcp bridge parity. #6117 fixed the in-process path and then the /mcp bridge (subprocess claude-code driver) in a separate commit. This PR fixes the in-process path for #6443 the same way. The /mcp bridge path (network.rs reads x-librefang-current-* headers forwarded by claude_code.rs::write_mcp_config) would need a current-account-id header threaded through librefang-llm-drivers + librefang-api for full parity — a different crate/domain than the rest of this PR. Should I extend it here, or land it as a follow-up mirroring how #6117 was split? Flagged rather than silently deferred.

#6442 — non-ASCII agent name TOML fallback collision

safe_path_component() strips every non-ASCII character. An agent whose display name is entirely Cyrillic / CJK / accented-Latin sanitizes to the empty string, and the three fallback sites passed the literal "agent", so every such agent collapsed onto workspaces/agents/agent/agent.toml: TOML edits were silently ignored and two such agents overwrote each other. The spawn-time resolver (resolve_workspace_dir) already uses the agent's UUID as the fallback, so the fallback sites disagreed with the directory spawn actually created.

Fix: the three fallback sites (boot.rs re-sync, persist_manifest_to_disk, reload_agent_from_disk) now pass agent_id.to_string() as the fallback, matching resolve_workspace_dir. Distinct agents never collide, and the fallback resolves the same directory spawn created (issue's suggested fix #2).

#6444 — Matrix sidecar DM / mention detection

The adapter hardcoded is_group=True for every room and never set metadata["was_mentioned"] — the only mention signal the group gate reads. Under the default GroupPolicy::MentionOnly, 1:1 DMs and explicit @-mentions were both silently dropped (OB-06 group_gating_skip), so the bot appeared dead.

Fix (both in matrix.py):

  • DM detection — a room is flagged non-group when it is listed in m.direct account data or its /sync summary reports two joined members. The verdict is cached (_dm_rooms) because the summary is sent lazily; a later summary-less sync does not flip a known DM back to group.
  • was_mentioned — set to True when the bot's own MATRIX_USER_ID is in the MSC3952 m.mentions list, parity with the Discord / Feishu adapters.

#6423 — OpenRouter context-window resolution

The symptom (8192 denominator) is real, but the reporter's diagnosis and suggested fix were both off:

  • Root cause is a provider-prefix mismatch, not mere provider-blindness: catalog entries are keyed openrouter/{raw_id}, but the dashboard model-picker (set_agent_model) strips the prefix before persisting, so the manifest holds a bare raw_id.
  • The suggested find_model_for_provider(provider, model) still requires an exact id match, so it would also miss the prefixed catalog id.
  • resolve_model_metadata is not a drop-in — it has zero production callers and its prefix-stripping does not cover the openrouter/ slash form.

Fix: a new ModelCatalog::find_model_for_manifest(provider, model) resolves in order — exact (provider, model), then re-qualified (provider, "{provider}/{model}"), then provider-blind find_model (legacy fallback, so nothing that resolved before regresses). All eight provider-blind find_model(&manifest.model.model) lookup sites in the kernel — context report, live ContextBudget (this is not merely a display bug), compaction, and tool-support detection — now route through it.

Verification

Out-of-scope follow-ups

- #6443 (security): reject cross-account (cross-tenant) channel_send — the account_id tool parameter was accepted from the model unvalidated, so on a multi-tenant daemon an agent (via hallucination or prompt injection) could dispatch content into another tenant's chat; the kernel now stamps the turn's originating account into the manifest and the in-process tool rejects a mismatched explicit account_id on the same channel, mirroring the #6117 recipient guard (in-process path; the /mcp bridge is flagged as a parallel surface in the PR body).
- #6442: derive the fallback agent.toml path from the agent UUID instead of the literal "agent" when the DB has no source_toml_path, so agents whose display name is entirely non-ASCII no longer collapse onto the shared workspaces/agents/agent/agent.toml and auto-discovery finds the real directory.
- #6444: set metadata["was_mentioned"] and detect 1:1 DMs (m.direct / joined-member count) in the Matrix sidecar adapter, so the default mention_only policy no longer silently drops all Matrix inbound.
- #6423: resolve the model context window provider-aware via a new ModelCatalog::find_model_for_manifest that reconciles the openrouter/ prefix mismatch, so OpenRouter agents stop showing the 8192 unknown-model denominator (all eight provider-blind lookup sites routed through it).

Verified: cargo check --lib --tests and clippy -D warnings on librefang-runtime / librefang-kernel / librefang-types, scoped cargo test (runtime channel_send + find_model_for_manifest, kernel non_ascii_fallback), and sdk/python matrix adapter tests (120 passed).
@github-actions github-actions Bot added size/L 250-999 lines changed area/docs Documentation and guides area/runtime Agent loop, LLM drivers, WASM sandbox area/kernel Core kernel (scheduling, RBAC, workflows) area/sdk JavaScript and Python SDKs and removed size/L 250-999 lines changed labels Jul 13, 2026
…y lookup

global_thinking_backfill_allowed() called the provider-blind find_model(), the same lookup this PR's #6423 fix replaced everywhere else in agent_execution.rs and messaging.rs.
A bare OpenRouter manifest model (no `openrouter/` prefix) missed the prefixed catalog entry and fell back to the permissive "unknown model" default, silently backfilling global [thinking] config onto a model that does not support it.
Route this call through find_model_for_manifest(provider, model) like the other eight call sites, and add a regression case alongside the existing #6398 test.
@github-actions github-actions Bot added the size/L 250-999 lines changed label Jul 13, 2026

@houko houko left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the diff against CLAUDE.md (worktree/hook rules, deterministic-prompt-ordering, serde-default gotcha, conventional commits, prose wrapping) plus general correctness. Verified cargo check -p librefang-kernel -p librefang-runtime -p librefang-types --lib --tests and cargo clippy -p librefang-kernel --all-targets -- -D warnings clean in a fresh worktree on this branch.

One thing I fixed directly and pushed (58a4684): manifest_helpers::global_thinking_backfill_allowed still called the provider-blind find_model(model) — the exact lookup this PR's #6423 fix replaced at its other eight call sites in agent_execution.rs / messaging.rs. A bare OpenRouter manifest model would miss the prefixed catalog entry here too and fall back to the permissive "unknown model" default, silently backfilling [thinking] config onto a model that doesn't support it. Routed it through find_model_for_manifest(provider, model) like the rest, and added a regression case next to the existing #6398 test.

Two inline comments below on things I'm not confident are worth fixing unilaterally (both same-bug-class as things this PR does fix, but touch code this PR doesn't, so I've left them for your call rather than expanding the diff).

Also worth a quick look while you're in this area, though I couldn't leave it as a line comment since these files aren't part of the diff: find_model_for_manifest reconciles the OpenRouter prefix mismatch for context-window/tool-support/thinking-backfill lookups, but a few other provider-blind find_model(&model) calls resolving reasoning_echo_policy from a (provider, model) pair look like the same gap — they discard the provider and would miss a bare OpenRouter model too: kernel/session_ops.rs:1204 and kernel/triggers_and_workflow.rs:75 (both discard resolution.resolved's (String, String) provider via .map(|(_, m)| ...)). kernel/handles/catalog_query.rs:25 and :38 (lookup_reasoning_echo_policy / lookup_supports_vision) have the same symptom but are behind the CatalogQuery trait shared with librefang-kernel-handle / drivers, so fixing those would mean a cross-crate signature change rather than a local one. Not blocking — just noting the fix may be more incomplete than the PR description's "all eight" implies, since these are a different symptom (reasoning-echo-policy / vision, not context window) that share the same root cause.


Generated by Claude Code

None
}

/// Resolve a catalog entry for a manifest's `(provider, model)` pair,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, repo-rule nit: CLAUDE.md's prose-wrapping rule says doc comments should break only at sentence boundaries, not at a column width — "one sentence = one line, regardless of length." This doc comment (and quite a few others this PR adds — e.g. the channel_send account-guard comment block in tool_runner/channel.rs:245-261, the #6442 fallback comments in agent_state.rs/boot.rs, several in matrix.py) hard-wraps mid-sentence at ~80 cols instead, e.g.:

/// Resolve a catalog entry for a manifest's `(provider, model)` pair,
/// reconciling the provider-prefix mismatch that OpenRouter (#6423) — and
/// any provider whose live catalog ids are stored as `{provider}/{raw_id}`
/// — exhibits.

That's one sentence split across 4 lines. I recognize this matches the pre-existing style already used nearby (e.g. the #6117 comments this PR extends), so it may just be established practice in this crate rather than an oversight — flagging in case it's actually intended to be enforced going forward, since the rule is explicit about applying to "any paragraph an agent is already touching." Not blocking either way.


Generated by Claude Code

}

#[cfg(test)]
mod non_ascii_fallback_tests {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These regression tests cover the three fallback sites this PR fixes (agent_state.rs, boot.rs), but there's a fourth call site in this same file that looks like the same collision class and wasn't converted: backfill_workspace_dir (workspace_setup.rs:366-367), a few hundred lines up in this file:

let safe_hand = safe_path_component(hid, "hand");
let safe_role = safe_path_component(role, "agent");

If hand_role (read from a tag, potentially free text) sanitizes to empty, every such hand agent collapses onto the shared literal "agent" fallback — the same bug as #6442. hid is probably already a slug/id, so less likely to be non-ASCII, but role looks like it could plausibly be operator-supplied. Flagging in case it was simply missed rather than deliberately out of scope; not necessarily blocking.


Generated by Claude Code

- #6423 completeness: global_thinking_backfill_allowed still called provider-blind find_model, so an OpenRouter agent whose manifest model is stored bare (dashboard set_agent_model) missed the prefixed catalog entry, returned None → unwrap_or(true), and backfilled the global [thinking] config onto a non-reasoning model — the exact #6398 parameter-error regression the gate prevents; the helper now takes provider and routes through find_model_for_manifest, with a regression test.
- #6443 WhatsApp-style adapters: the cross-account guard used an exact sender_channel/channel string match, so it no-opped on adapters that embed the conversation id in the channel (WhatsApp gateway stamps sender_channel="whatsapp:<jid>" per #5227) while channel_send targets the bare "whatsapp"; the guard now compares the base channel type via a channel_base helper, with a regression test.
- #6443 metadata contract: the sender_account_id metadata key is now a shared librefang_types::agent::SENDER_ACCOUNT_ID_METADATA_KEY constant used at both kernel stamp sites and both runtime read sites, so a typo cannot silently disable the security guard.
- #6444 restart persistence: the Matrix DM cache was in-memory only, so a daemon restart (which resumes /sync from the persisted since_token, whose incremental response omits m.direct for unchanged rooms) started empty and dropped every established DM under MentionOnly; the producer now seeds the DM registry via an explicit m.direct account-data GET at startup.
- #6444 m.direct semantics: _update_dm_rooms_from_account_data now FULL-REPLACES the m.direct-derived DM set (it is a complete-state event) instead of add-only, so a room removed from the registry stops being treated as a DM; the member-count-derived DM set is tracked separately and FIFO-bounded like the sibling caches.

Verified: cargo check --lib --tests + clippy -D warnings on librefang-runtime / librefang-kernel / librefang-types (clean), scoped cargo test (kernel global_thinking_backfill + non_ascii_fallback: 4 passed; runtime channel_send + find_model_for_manifest: 24 passed), and sdk/python matrix adapter tests (124 passed).
@github-actions github-actions Bot added size/XL 1000+ lines changed and removed size/L 250-999 lines changed labels Jul 13, 2026
@houko
houko enabled auto-merge (squash) July 13, 2026 08:42
@houko
houko merged commit 492dbf3 into main Jul 13, 2026
32 checks passed
@houko
houko deleted the fix/open-bug-issues branch July 13, 2026 09:32
houko added a commit that referenced this pull request Jul 15, 2026
…ugh the /mcp bridge (#6443) (#6455)

#6449 fixed the cross-account (cross-tenant) channel_send guard on the in-process agent-loop path and flagged the /mcp bridge (subprocess claude-code driver) as an unchanged parallel surface pending a maintainer call.
This lands that parity, mirroring how #6125 threaded the #6117 peer scope through the same chain:

- CompletionRequest gains sender_account_id — the bot account / tenant the inbound turn arrived on. Out-of-band callers (cron, triggers, compaction, routing probes) leave it None via the struct's existing Default.
- Both agent loops (run_agent_loop and the streaming variant) populate it from the manifest metadata stamp the kernel already writes (SENDER_ACCOUNT_ID_METADATA_KEY, #6449).
- The claude-code driver's write_mcp_config forwards it on the bridge connection as X-LibreFang-Current-Account-Id, next to the existing X-LibreFang-Current-Peer-Jid / -Channel / -Chat-Id headers; empty or absent values emit no header.
- mcp_http reads the header back and routes tool execution through execute_tool_with_sender_account, so tool_channel_send's existing #6443 guard rejects an explicit account_id that differs from the turn's account on the same channel. Clients that omit the header run unguarded exactly as before.

Tests:
- claude_code::tests — the peer-scope header tests now cover the account header (emitted when present, omitted when absent).
- api_integration_test::test_mcp_http_channel_send_cross_account_guard_uses_account_header — end-to-end /mcp tools/call: mismatched account blocked, matching account passes, missing header no-ops.

Verified: cargo check --workspace --lib, cargo clippy --workspace --all-targets -D warnings (clean), cargo fmt, scoped cargo test (llm-drivers mcp_config: 5 passed; api channel_send guards: 2 passed; runtime channel_send: 23 passed).

Co-authored-by: Evan <[email protected]>
@houko houko mentioned this pull request Jul 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/docs Documentation and guides area/kernel Core kernel (scheduling, RBAC, workflows) area/runtime Agent loop, LLM drivers, WASM sandbox area/sdk JavaScript and Python SDKs size/XL 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants