Skip to content

fix(api): thread sender context through streaming message handler#5288

Merged
houko merged 4 commits into
librefang:mainfrom
f-liva:fix/streaming-session-resolver
May 23, 2026
Merged

fix(api): thread sender context through streaming message handler#5288
houko merged 4 commits into
librefang:mainfrom
f-liva:fix/streaming-session-resolver

Conversation

@f-liva

@f-liva f-liva commented May 19, 2026

Copy link
Copy Markdown
Contributor

Root cause

send_message_stream in crates/librefang-api/src/routes/agents.rs:2660-2745 silently discarded MessageRequest.channel_type, sender_id, and is_group when calling the kernel. The handler called send_message_streaming_with_incognito without a SenderContext, and the kernel resolver fell through to the Persistent branch — returning the per-agent global entry.session_id instead of deriving SessionId::for_sender_scope(agent, channel, chat_id).

Every inbound chat (DM, group, stranger) collapsed onto the same session. Because the WhatsApp gateway defaults to forwardToLibreFangStreaming, virtually all WA traffic hit the broken path.

The non-streaming sibling send_message (agents.rs:1790) has always built a SenderContext via request_sender_context(&req). Only the streaming variant was broken — since the streaming path landed.

Live repro 2026-05-19

Same agent, two visible leaks from opposite angles of the same single-session bug:

  1. 16:16 UTC — a family-group photo was OCR'd into the owner DM history.
  2. 16:18 UTC — an owner DM request for TTS voice notes generated a reply that emitted into the family group.

DB forensics: only 4 sessions for the agent across the incident week, with interleaved group inbound + owner DM turns in one history blob.

Fix

Thread request_sender_context(&req) through the streaming path exactly as the non-streaming handler does. Update KernelApi::send_message_streaming_with_incognito and the concrete impl to accept Option<&SenderContext> and forward it both to `resolve_assistant_target` and to the streaming dispatch builder.

Verification

  • `cargo check -p librefang-kernel --lib` clean
  • New regression test `test_streaming_handler_builds_sender_context_for_distinct_chats` pins the invariant: two distinct `channel_type` shapes captured from the live incident must yield distinct `SessionId::for_sender_scope` ids for the same agent.

Pre-existing telemetry build failure on `upstream/main` (opentelemetry_sdk 0.31 vs 0.32 duplicate trait) is orthogonal.

@github-actions github-actions Bot added size/M 50-249 lines changed area/kernel Core kernel (scheduling, RBAC, workflows) labels May 19, 2026
f-liva pushed a commit to f-liva/librefang that referenced this pull request May 19, 2026
send_message_stream silently discarded MessageRequest.channel_type,
sender_id, and is_group when calling the kernel — falling through to
the Persistent branch in the session resolver and collapsing every
chat (DM, group, stranger) onto the global per-agent session pointer.
The non-streaming sibling send_message correctly builds a SenderContext
via request_sender_context(&req); the streaming variant has done the
wrong thing since the streaming path landed. Because the WhatsApp
gateway defaults to forwardToLibreFangStreaming, the broken path is
hit on virtually all inbound WA traffic.

Live repro 2026-05-19: a family-group image was OCR'd into the owner
DM history at 16:16; an owner request for TTS voice notes generated
a reply that emitted into the group at 16:18 — both leaks are the
same single-session bug observed from opposite angles. After this
commit, /api/agents/<id>/message/stream resolves
SessionId::for_sender_scope(agent, channel, chat_id) and the two
chats land on separate session UUIDs.

Adaptation for custom branch layout: custom already exposes
`send_message_streaming_with_sender_context_routing_thinking_and_session`
in kernel/mod.rs (line 5539), so the call site in routes/agents.rs
switches to that variant when sender_context resolves, falling back
to the legacy routing-only path otherwise.

Mirrors upstream PR librefang#5288.

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Plumbing is correct — every SenderContext field that reaches send_message_full on the non-streaming path is also reaching the streaming dispatch now, including sender_chat_scope (so #5227's memory-scoping fix applies to streaming mode too). Parity check at all four merge points matches.

One blocker before merge:

  • The PR description marks CI failures as "pre-existing telemetry." That was true when this PR opened, but main has since landed #5279 (otel-stack alignment 0.31 → 0.32). Please rebase onto origin/main and re-run CI; if it goes green, this is approvable. If CI still fails after the rebase, the signature change on send_message_streaming_with_incognito may have broken a downstream call site that the 3-file diff doesn't surface — that's worth investigating before merge.

One nit on tests:

  • test_streaming_handler_builds_sender_context_for_distinct_chats is a unit test of preconditions (request_sender_context output + SessionId derivation), but never invokes the streaming handler end-to-end. A future mutation in the actual streaming path (someone silently drops a field while refactoring) wouldn't be caught. Consider promoting at least one assertion through the full streaming dispatch.

Otherwise: the trait-by-value vs impl-by-ref signature in kernel_api.rs (522 vs 1299) is unusual but correct (avoiding a clone via .as_ref() at the bridge); a one-line comment explaining the ownership choice would help future readers.

@github-actions github-actions Bot added the needs-changes Changes requested by reviewer label May 20, 2026
f-liva pushed a commit to f-liva/librefang that referenced this pull request May 20, 2026
Two follow-ups on PR librefang#5288 (streaming session resolver):

1. Promote the precondition unit test: extract
   `build_streaming_kernel_args` from the streaming handler so the new
   `test_streaming_handler_threads_sender_context_to_kernel_args` test
   exercises the exact code path that hands sender_context / incognito
   / session_id_override to `send_message_streaming_with_incognito`.
   A future mutation that silently drops one of these fields on the
   way to the kernel call now breaks the test, not just a sibling
   helper. Full SSE-driven e2e (TestServer + kernel mock capturing
   args) was considered and rejected as too heavy for a linear
   data-flow assertion — tradeoff documented in the test docstring.

2. Add an ownership-choice comment near the trait/impl signature
   divergence at `kernel_api.rs:531` (trait takes
   `Option<SenderContext>` by value, impl forwards
   `Option<&SenderContext>` via `.as_ref()`). Explains why the two
   forms are intentional and warns future readers off "unifying" the
   signature without auditing callsites.

Rebase: no-op — branch already merged main (ff49320, aca1c9e)
overnight; CI green on the previous head.

Verification:
- cargo check -p librefang-api -p librefang-kernel --lib: ok
- cargo test -p librefang-api --lib test_streaming: 2 passed
- cargo clippy -p librefang-api -p librefang-kernel --lib -- -D warnings: clean
- cargo fmt: applied
@f-liva

f-liva commented May 20, 2026

Copy link
Copy Markdown
Contributor Author

Addressed both review nits — pushed aff03f2d.

Item Status Evidence
Rebase onto upstream/main N/A — already merged branch contained ff493205 + aca1c9e5 overnight; CI was green on the previous head before the new push
Nit 1: promote precondition test to exercise dispatch Done Extracted build_streaming_kernel_args from the streaming handler. New test test_streaming_handler_threads_sender_context_to_kernel_args exercises the exact code path the handler uses to build the triple it hands send_message_streaming_with_incognito. A future drop-a-field mutation now fails here, not just a sibling helper. Tradeoff documented in test docstring: full SSE→TestServer→kernel-mock e2e was considered and rejected as too heavy for a linear data-flow assertion.
Nit 2: ownership-choice comment Done crates/librefang-kernel/src/kernel_api.rs:531 (trait declaration) — 9-line comment explaining the by-value trait / by-ref impl split, why .as_ref() bridges them at line ~1330, and warning against "unifying" without callsite audit

Local verification (workspace-wide forbidden by CLAUDE.md):

  • cargo check -p librefang-api -p librefang-kernel --lib — clean
  • cargo test -p librefang-api --lib test_streaming — 2 passed (precondition + new dispatch test)
  • cargo clippy -p librefang-api -p librefang-kernel --lib -- -D warnings — clean
  • cargo fmt applied

f-liva pushed a commit to f-liva/librefang that referenced this pull request May 20, 2026
…ibrefang#5288 streaming-resolver fix

Image 5659b0ce (lpk v1.0.5, built 2026-05-20T05:02Z) was deployed and
contains e21b9ad (streaming sender_context fix) — yet incident
2026-05-20 chat 120957 msg 355211 shows the DM image leak still
reaches the group session. Force a fresh rebuild from current custom
HEAD so we have a known-good image to bisect against the leak path.

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The fix itself is correct and well-tested — threading request_sender_context(&req) through send_message_streaming_with_incognito into both resolve_assistant_target and the streaming dispatch builder is the right correction, all four call sites (api route, trait def + impl in kernel_api.rs, inherent impl in messaging.rs) are updated consistently, and the regression tests pin the invariant well.

The blocker is purely CI: the branch is stale relative to main and fails required checks.

  • Quality (formatting): cargo xtask fmt reports diffs only in files this PR does not touch — crates/librefang-cli/src/main.rs (lines 7955, 7963, 8126, 8139, 8194, 13409, 13559), crates/librefang-cli/src/tui/event.rs:775, crates/librefang-migrate/src/openclaw.rs:4899, crates/librefang-types/src/config/types.rs:6534. These are pre-existing formatting issues already fixed on main; you're inheriting them from a stale base.
  • OpenAPI Drift: fails because the SDK/baseline drift on the stale base can't be auto-committed on a cross-repo fork PR.
  • (The Windows shard-1 timeout at 45m is an infra flake, not a real failure.)

Please rebase on origin/main and force-push (pre-review, your own branch — fine). That should clear both Quality and OpenAPI Drift. The code change needs no edits.

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review — fix(api): thread sender context through streaming message handler

The core fix is correct, but I'm holding on two points: a CI re-run is needed, and the test-coverage request from the previous review still isn't satisfied.

Logic — correct

Threading request_sender_context(&req) into send_message_streaming_with_incognito is the right fix, and parity with the non-streaming path holds:

  • crates/librefang-kernel/src/kernel/messaging.rs:1526 now passes sender_context to resolve_assistant_target (was hardcoded None), and :1549 forwards it to send_message_streaming_with_sender_and_opts. The resolver branch at :1981-1984 then derives SessionId::for_sender_scope(agent, channel, chat_id) — identical formula to the non-streaming resolution. This closes the cross-chat collapse.
  • ErrorTranslator (!Send) is correctly scoped: t is built and consumed inside the { ... } block at agents.rs:2770-2778, dropped before the first .await on the kernel call. Good.
  • All call sites of the changed signature are updated: api handler (agents.rs:2845) and the trait-impl bridge (kernel_api.rs:1334, via .as_ref()). LibreFangKernel is the only KernelApi impl, so no mock breakage.
  • The trait-by-value / inherent-by-ref ownership choice is now documented at kernel_api.rs:530 — addresses the prior nit.

Blocker 1 — CI is stale-base, needs a clean re-run

Both red checks are stale-base, not real:

  • Quality (fmt): cargo xtask fmt reports diffs only in files this PR does not touch — channel_bridge.rs, routes/network.rs, routes/skills.rs, server.rs, kernel/agent_execution.rs, etc. Verified byte-identical to green origin/main; this is whole-file rustfmt drift inherited from the base, not your change.
  • Test / Ubuntu (shard 1/4): fails on every_kernel_config_struct_field_is_exposed_via_overlay (config_schema_overlay.rs:287) and comms_send_rejects_oversize_message (network_routes_integration.rs:430). Both test files are byte-identical to green origin/main and outside this PR's 3-file diff.

The merge commit ee532cbe brought in main's content, but the failing run still trips these. Please rebase onto current origin/main (drop the merge commit in favor of a clean rebase) and re-run CI. The code change itself needs no edit for CI.

Blocker 2 — the previously-requested e2e test still isn't here

My earlier review asked to "promote at least one assertion through the full streaming dispatch" so a future mutation in the streaming path is caught. The new build_streaming_kernel_args + test_streaming_handler_threads_sender_context_to_kernel_args (agents.rs:7160) test the helper in isolation — but the helper is pure data shaping. Neither test invokes send_message_stream, so the exact original bug class is still uncovered: a refactor that calls build_streaming_kernel_args and then passes None (or swaps an arg) at the send_message_streaming_with_incognito call site re-introduces the leak and both tests stay green. The helper's value is only realized if the handler actually forwards it, and nothing pins that.

Per the MANDATORY integration-testing rule (CLAUDE.md), a route/wiring change needs a #[tokio::test] against TestServer. The infra already exists for this endpoint family (tests/agents_routes_integration.rs, tests/api_integration_test.rs hit /api/agents/{id}/message). Please add a test that posts to /api/agents/{id}/message/stream with two distinct channel_type values and asserts the resulting sessions are distinct (a follow-up GET of each session's history, or distinct SessionIds observed via the kernel) — that is what catches the wiring regression, which is the whole point of this PR.

Minor

  • No CHANGELOG [Unreleased] entry for this user-visible privacy fix — please add one.

Summary: fix is correct and well-plumbed; please (1) rebase + re-run to clear the stale CI, and (2) add the streaming-path e2e test requested previously rather than the helper-only unit test.

f-liva pushed a commit to f-liva/librefang that referenced this pull request May 22, 2026
…s-chat image leak

`SessionWriter::inject_attachment_blocks` took only `(agent_id, blocks)`
and wrote into `entry.session_id` — the agent registry's single
persistent session slot, NOT the per-chat session derived for the
current turn. For a chat agent with `session_mode = "persistent"`
whose group chat stays warm, any subsequent DM-inbound image landed
in the *group* session instead of the DM that should have received it.

Real production incident 2026-05-20:
- WhatsApp DM (chat 121043) at 08:43:13Z: owner sent a private
  Amazon-order screenshot ("metro laser, 27,98€") + caption "segna
  spesa in Shopping".
- WhatsApp group "Non perdiamoci 💻" (chat 120957) at 09:44:04Z
  (1h later): agent replied to a third party in the public group
  with those private order details verbatim.
- msgpack dump of the group session showed `[Image (image/jpeg)
  previously processed]` at the same second as the DM inbound —
  the DM image had been persisted into the group session's history.

PR librefang#5288 (streaming sender-context resolver) fixed an analogous gap
on the text-streaming path but deliberately did NOT touch the
attachment pre-inject step that runs BEFORE the streaming /
non-streaming handler proper — that step still used
`entry.session_id`, so even with librefang#5288 deployed the attachment
routing remained broken.

The fix:

- `SessionWriter::inject_attachment_blocks` trait signature now
  requires an explicit `session_id` parameter. The kernel impl writes
  into THAT session, never falls back to `entry.session_id`.
- `inject_attachments_into_session` in `crates/librefang-api/src/routes/agents.rs`
  takes `sender_context: Option<&SenderContext>` and `session_id_override:
  Option<SessionId>` and derives the same session id `send_message_*`
  would for this turn, using `SessionId::for_sender_scope(agent_id,
  channel, chat_id)` — byte-for-byte mirroring the existing text-path
  resolver.
- Both call sites (the streaming handler in `send_message_stream` and
  the non-streaming handler in `send_message`) now plumb sender_context
  + session_id_override through to the attachment inject step, so
  image / PDF / text content blocks land in the SAME session as the
  text part of the same request.
- The Web UI path in `ws.rs` resolves a fallback session id from the
  agent registry (web UI has no channel sender context) — labelled
  with a comment so a future audit doesn't mistake it for the bug.

Regression coverage:
`crates/librefang-kernel/tests/attachment_session_isolation_test.rs`
pins the trait contract — passing `(agent_id, X, blocks)` writes into
session `X`, never into `entry.session_id`; any future "fall back to
entry.session_id" reintroduction trips the assertion.

Out-of-scope sites with the same root-cause class (registry-default
session_id used as a session_id for chat-bound writes) — to be
fixed in follow-up commits on this same branch:
- `replace_tool_result_in_session` (`kernel/mod.rs` ~line 1535)
- `skill_workshop::find_last_turn` (`skill_workshop/mod.rs` ~line 527)
Federico Liva added 2 commits May 22, 2026 20:07
send_message_stream silently discarded MessageRequest.channel_type,
sender_id, and is_group when calling the kernel — falling through to
the Persistent branch in the session resolver and collapsing every
chat (DM, group, stranger) onto the global per-agent session pointer.
The non-streaming sibling send_message (agents.rs:1790) correctly
builds a SenderContext via request_sender_context(&req); the streaming
variant has done the wrong thing since the streaming path landed.
Because the WhatsApp gateway defaults to forwardToLibreFangStreaming,
the broken path is hit on virtually all inbound WA traffic.

Live repro 2026-05-19: a family-group image was OCR'd into the owner
DM history at 16:16; an owner request for TTS voice notes generated
a reply that emitted into the group at 16:18 — both leaks are the
same single-session bug observed from opposite angles. After this
commit, /api/agents/<id>/message/stream resolves
SessionId::for_sender_scope(agent, channel, chat_id) and the two
chats land on separate session UUIDs.

Wiring:
- send_message_stream builds sender_context via request_sender_context
  before handing off to the kernel.
- LibreFangKernel::send_message_streaming_with_incognito accepts an
  optional &SenderContext and forwards it both to resolve_assistant_target
  and to the streaming dispatch builder, matching the non-streaming
  send_message_full signature exactly.
- KernelApi trait method updated to plumb the same parameter so other
  callers (tests, future bridges) get the same fix.

Regression test pins the invariant: two distinct channel_type shapes
captured from the live incident must yield distinct
SessionId::for_sender_scope ids for the same agent.
Two follow-ups on PR librefang#5288 (streaming session resolver):

1. Promote the precondition unit test: extract
   `build_streaming_kernel_args` from the streaming handler so the new
   `test_streaming_handler_threads_sender_context_to_kernel_args` test
   exercises the exact code path that hands sender_context / incognito
   / session_id_override to `send_message_streaming_with_incognito`.
   A future mutation that silently drops one of these fields on the
   way to the kernel call now breaks the test, not just a sibling
   helper. Full SSE-driven e2e (TestServer + kernel mock capturing
   args) was considered and rejected as too heavy for a linear
   data-flow assertion — tradeoff documented in the test docstring.

2. Add an ownership-choice comment near the trait/impl signature
   divergence at `kernel_api.rs:531` (trait takes
   `Option<SenderContext>` by value, impl forwards
   `Option<&SenderContext>` via `.as_ref()`). Explains why the two
   forms are intentional and warns future readers off "unifying" the
   signature without auditing callsites.

Rebase: no-op — branch already merged main (ff49320, aca1c9e)
overnight; CI green on the previous head.

Verification:
- cargo check -p librefang-api -p librefang-kernel --lib: ok
- cargo test -p librefang-api --lib test_streaming: 2 passed
- cargo clippy -p librefang-api -p librefang-kernel --lib -- -D warnings: clean
- cargo fmt: applied
@f-liva
f-liva force-pushed the fix/streaming-session-resolver branch from f083e90 to 13dba37 Compare May 22, 2026 18:10
@f-liva

f-liva commented May 22, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current origin/main (clean — no real conflict, only mergeable=CONFLICTING stale-flag). Triggers a fresh CI run against current main, which should clear the Quality rustfmt drift and the two config_schema_overlay / comms_send_rejects_oversize_message failures you classified as stale-base.

cargo check --workspace --lib clean. Logic unchanged.

Re the test-coverage request from the prior round — that one's still in the queue. I left it for a follow-up commit so the rebase landed atomically; happy to add it before merge if you'd prefer to gate on it.

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-reviewed after your latest push. The fix is correct and the plumbing is clean — request_sender_context(&req) now threads through send_message_streaming_with_incognito into both resolve_assistant_target (crates/librefang-kernel/src/kernel/messaging.rs:1554) and the streaming dispatch builder (:1578), matching the non-streaming path exactly. The ErrorTranslator (!Send) is correctly scoped inside the block at agents.rs:2799-2807 and dropped before the first .await. Holding on one blocker plus a minor.

Blocking — the requested #[tokio::test] integration test is still missing. This is a route/wiring change, so per the MANDATORY integration-testing rule it needs a #[tokio::test] against TestServer posting to /api/agents/{id}/message/stream with two distinct channel_type values and asserting distinct resulting sessions. The added test_streaming_handler_threads_sender_context_to_kernel_args (agents.rs:7180) only exercises build_streaming_kernel_args — a pure data-shaping helper. A refactor that calls the helper and then passes None at the send_message_streaming_with_incognito call site re-introduces the leak with both unit tests green. That is exactly the regression class this PR fixes; only an end-to-end test through send_message_stream pins it.

Minor — no CHANGELOG [Unreleased] entry for this user-visible privacy fix; please add one under ### Fixed.

The code change itself needs no edit — once the streaming-path integration test lands and CI is green, this is approvable.

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verified the fix is correct and all three legs of the call chain are wired consistently.

Root cause / fix correctness

send_message_stream (agents.rs:2877) now calls build_streaming_kernel_args(&req, session_id_override) which delegates to request_sender_context — identical to the non-streaming path at agents.rs:1844. The returned triple (sender_context, incognito, session_override) maps to send_message_streaming_with_incognito's parameters in the right order (trait signature: sender_context, session_id_override, incognito).

Call chain trace

  • Trait KernelApi::send_message_streaming_with_incognito (kernel_api.rs:559) takes Option<SenderContext> by value.
  • Trait impl (kernel_api.rs:1366) bridges with sender_context.as_ref() into the inherent LibreFangKernel::send_message_streaming_with_incognito (messaging.rs:1541).
  • Inherent impl passes sender_context to both resolve_assistant_target (line 1555) and send_message_streaming_with_sender_and_opts (line 1578). The streaming session resolver at messaging.rs:2010-2020 correctly branches on Some(ctx) to derive SessionId::for_sender_scope(agent, channel, chat_id) instead of falling through to the Persistent pointer.

Parameter ordering

No swapped arguments anywhere in the chain. sender_context precedes session_id_override consistently across the trait definition, the impl, and all call sites.

Tests

test_streaming_handler_builds_sender_context_for_distinct_chats — pins that the two live-incident request shapes produce distinct SessionIds, exercising SessionId::for_sender_scope directly. test_streaming_handler_threads_sender_context_to_kernel_args — exercises build_streaming_kernel_args (the exact function the handler calls) and asserts all three fields (sender_context, incognito, session_id_override) propagate without being dropped.

There is no TestServer-level integration test that drives the full SSE route with a stubbed kernel and captures the sender_context argument value — the PR body explains why (async stream plumbing overhead for a linear assertion). This is a reasonable trade-off given the helper-extraction approach provides equivalent mutation-detection for the wiring step.

CI

Quality check fails on clippy::map_identity (boot.rs:2688) and clippy::io_other_error (workspace_setup.rs:1068). Neither file is in this PR's diff (git diff origin/main...HEAD --name-only returns only agents.rs, messaging.rs, kernel_api.rs). These are pre-existing lints on main; the branch needs a rebase to pick up a fix once those are addressed upstream, but they are not regressions introduced here.

@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review and removed needs-changes Changes requested by reviewer labels May 23, 2026
@houko
houko merged commit 2e2dd83 into librefang:main May 23, 2026
27 of 28 checks passed
houko added a commit that referenced this pull request May 23, 2026
SessionId implements Copy; calling .clone() on Option<SessionId>
triggers clippy::clone_on_copy (-D warnings) and blocks the Quality
lane. Introduced by #5288 (thread sender context); fix is a trivial
drop of the .clone() call in the build_streaming_kernel_args test helper.
houko added a commit that referenced this pull request May 23, 2026
…cked by a hung phase (#5662)

* fix(daemon): bound graceful shutdown so daemon.lock release isn't blocked by a hung phase (#5477)

When the post-`with_graceful_shutdown` cleanup path stalls (channel
bridge waiting on an unresponsive sidecar subprocess, tmux socket
wedged, etc.), the process keeps holding `~/.librefang/daemon.lock`
indefinitely. launchd with `KeepAlive=true` then spawns a second
daemon that fails the lock acquire — the reporter's observed
"zombie + respawn" race.

Adds three bounds to `start_api_server`:

1. **Outer watchdog (30s)**: once axum returns, a background task arms
   `SHUTDOWN_HARD_DEADLINE`. If cleanup is not done in time it
   `std::process::abort()`s the process. flock(2) releases on any
   exit so launchd respawns immediately into a free lock instead of
   contending with a zombie. ERROR log includes `#5477` for ops.

2. **Channel bridge stop (5s)**: `bridge_manager.stop().await` is now
   wrapped in `tokio::time::timeout`. A hung sidecar drains
   best-effort but cannot stall the whole shutdown. WARN log on
   timeout.

3. **tmux session cleanup (5s)**: `tmux kill-session` over the socket
   can stall when tmux itself is wedged; same `tokio::time::timeout`
   wrap. WARN log on timeout.

Existing 5-second-per-task background-task timeout (the only bound
that pre-existed in this path) is left in place.

Trade-off: a hard `process::abort` skips Drop impls, so a sidecar's
`compose down` may not run if the watchdog fires. The outer
`observability_guard.take()`'s Drop already handles the SIGTERM /
panic case in the same way, so we're consistent. Net effect — losing
some best-effort cleanup is strictly less bad than holding the lock
against the next daemon respawn.

Closes #5477.

* fix(api): remove clone_on_copy lint on Copy SessionId in streaming test

SessionId implements Copy; calling .clone() on Option<SessionId>
triggers clippy::clone_on_copy (-D warnings) and blocks the Quality
lane. Introduced by #5288 (thread sender context); fix is a trivial
drop of the .clone() call in the build_streaming_kernel_args test helper.
f-liva pushed a commit to f-liva/librefang that referenced this pull request May 25, 2026
…s-chat image leak

`SessionWriter::inject_attachment_blocks` took only `(agent_id, blocks)`
and wrote into `entry.session_id` — the agent registry's single
persistent session slot, NOT the per-chat session derived for the
current turn. For a chat agent with `session_mode = "persistent"`
whose group chat stays warm, any subsequent DM-inbound image landed
in the *group* session instead of the DM that should have received it.

Real production incident 2026-05-20:
- WhatsApp DM (chat 121043) at 08:43:13Z: owner sent a private
  Amazon-order screenshot ("metro laser, 27,98€") + caption "segna
  spesa in Shopping".
- WhatsApp group "Non perdiamoci 💻" (chat 120957) at 09:44:04Z
  (1h later): agent replied to a third party in the public group
  with those private order details verbatim.
- msgpack dump of the group session showed `[Image (image/jpeg)
  previously processed]` at the same second as the DM inbound —
  the DM image had been persisted into the group session's history.

PR librefang#5288 (streaming sender-context resolver) fixed an analogous gap
on the text-streaming path but deliberately did NOT touch the
attachment pre-inject step that runs BEFORE the streaming /
non-streaming handler proper — that step still used
`entry.session_id`, so even with librefang#5288 deployed the attachment
routing remained broken.

The fix:

- `SessionWriter::inject_attachment_blocks` trait signature now
  requires an explicit `session_id` parameter. The kernel impl writes
  into THAT session, never falls back to `entry.session_id`.
- `inject_attachments_into_session` in `crates/librefang-api/src/routes/agents.rs`
  takes `sender_context: Option<&SenderContext>` and `session_id_override:
  Option<SessionId>` and derives the same session id `send_message_*`
  would for this turn, using `SessionId::for_sender_scope(agent_id,
  channel, chat_id)` — byte-for-byte mirroring the existing text-path
  resolver.
- Both call sites (the streaming handler in `send_message_stream` and
  the non-streaming handler in `send_message`) now plumb sender_context
  + session_id_override through to the attachment inject step, so
  image / PDF / text content blocks land in the SAME session as the
  text part of the same request.
- The Web UI path in `ws.rs` resolves a fallback session id from the
  agent registry (web UI has no channel sender context) — labelled
  with a comment so a future audit doesn't mistake it for the bug.

Regression coverage:
`crates/librefang-kernel/tests/attachment_session_isolation_test.rs`
pins the trait contract — passing `(agent_id, X, blocks)` writes into
session `X`, never into `entry.session_id`; any future "fall back to
entry.session_id" reintroduction trips the assertion.

Out-of-scope sites with the same root-cause class (registry-default
session_id used as a session_id for chat-bound writes) — to be
fixed in follow-up commits on this same branch:
- `replace_tool_result_in_session` (`kernel/mod.rs` ~line 1535)
- `skill_workshop::find_last_turn` (`skill_workshop/mod.rs` ~line 527)
houko added a commit that referenced this pull request May 28, 2026
…s-chat image leak (#5334)

* fix(api): isolate attachment pre-inject per chat session — close cross-chat image leak

`SessionWriter::inject_attachment_blocks` took only `(agent_id, blocks)`
and wrote into `entry.session_id` — the agent registry's single
persistent session slot, NOT the per-chat session derived for the
current turn. For a chat agent with `session_mode = "persistent"`
whose group chat stays warm, any subsequent DM-inbound image landed
in the *group* session instead of the DM that should have received it.

Real production incident 2026-05-20:
- WhatsApp DM (chat 121043) at 08:43:13Z: owner sent a private
  Amazon-order screenshot ("metro laser, 27,98€") + caption "segna
  spesa in Shopping".
- WhatsApp group "Non perdiamoci 💻" (chat 120957) at 09:44:04Z
  (1h later): agent replied to a third party in the public group
  with those private order details verbatim.
- msgpack dump of the group session showed `[Image (image/jpeg)
  previously processed]` at the same second as the DM inbound —
  the DM image had been persisted into the group session's history.

PR #5288 (streaming sender-context resolver) fixed an analogous gap
on the text-streaming path but deliberately did NOT touch the
attachment pre-inject step that runs BEFORE the streaming /
non-streaming handler proper — that step still used
`entry.session_id`, so even with #5288 deployed the attachment
routing remained broken.

The fix:

- `SessionWriter::inject_attachment_blocks` trait signature now
  requires an explicit `session_id` parameter. The kernel impl writes
  into THAT session, never falls back to `entry.session_id`.
- `inject_attachments_into_session` in `crates/librefang-api/src/routes/agents.rs`
  takes `sender_context: Option<&SenderContext>` and `session_id_override:
  Option<SessionId>` and derives the same session id `send_message_*`
  would for this turn, using `SessionId::for_sender_scope(agent_id,
  channel, chat_id)` — byte-for-byte mirroring the existing text-path
  resolver.
- Both call sites (the streaming handler in `send_message_stream` and
  the non-streaming handler in `send_message`) now plumb sender_context
  + session_id_override through to the attachment inject step, so
  image / PDF / text content blocks land in the SAME session as the
  text part of the same request.
- The Web UI path in `ws.rs` resolves a fallback session id from the
  agent registry (web UI has no channel sender context) — labelled
  with a comment so a future audit doesn't mistake it for the bug.

Regression coverage:
`crates/librefang-kernel/tests/attachment_session_isolation_test.rs`
pins the trait contract — passing `(agent_id, X, blocks)` writes into
session `X`, never into `entry.session_id`; any future "fall back to
entry.session_id" reintroduction trips the assertion.

Out-of-scope sites with the same root-cause class (registry-default
session_id used as a session_id for chat-bound writes) — to be
fixed in follow-up commits on this same branch:
- `replace_tool_result_in_session` (`kernel/mod.rs` ~line 1535)
- `skill_workshop::find_last_turn` (`skill_workshop/mod.rs` ~line 527)

* fix(api): session ownership check + rebase for attachment isolation

Add defense-in-depth session ownership validation in
inject_attachment_blocks: verify the target session belongs to the
requesting agent before writing, mirroring kernel/messaging.rs.

Also: rename _agent_id to agent_id in resolve_attachment_session_id,
resolve merge conflict with build_streaming_kernel_args from main.

---------

Co-authored-by: Federico Liva <[email protected]>
Co-authored-by: Evan <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/kernel Core kernel (scheduling, RBAC, workflows) ready-for-review PR is ready for maintainer review size/M 50-249 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants