13 Commits
Author SHA1 Message Date
Yufeng HeandGitHub cd0f1e2212 fix(memory): follow config reload in get_memory_config (#4216)
* fix(memory): follow config reload in get_memory_config

get_memory_config() returned the _memory_config singleton directly, and that
singleton is only refreshed as a side effect of get_app_config() reloading
(via _apply_singleton_configs -> load_memory_config_from_dict). A reader that
reaches memory config without going through get_app_config() first -- e.g. the
agent factory deciding whether to bind the memory tools via
`feat.memory_config or get_memory_config()` -- therefore saw a stale
memory.mode after a config.yaml edit, even though memory.* is documented as
hot-reloadable. The agent kept the old mode until some unrelated
get_app_config() call happened to refresh the singleton.

Trigger the same signature-checked reload inside get_memory_config() so the
singleton follows the config file, mirroring how the other config getters
already resolve through get_app_config(). When no config file is on disk
(tests, or a minimal deployment running purely on defaults / set_memory_config)
get_app_config() raises FileNotFoundError; swallow it and fall back to the
in-memory singleton so the accessor is never more fragile than before.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

* test(memory-config): pin malformed-config behavior for get_memory_config

Address review feedback: the narrow except FileNotFoundError means a config
file that exists but fails validation surfaces through get_memory_config()
rather than being swallowed, consistent with every other unguarded
get_app_config() caller. Add a regression test pinning that a malformed edit
raises ValidationError and leaves the last-good singleton intact (not
half-applied), and document the contract on the guard.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

---------

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
2026-07-21 09:24:02 +08:00
Yufeng HeandGitHub ae223199fd fix(security): escape MindIE tool-response content against </tool_response> breakout (#4253)
Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
2026-07-17 14:23:44 +08:00
a0acdda103 fix(gateway): keep create_thread idempotent when the insert loses a race (#3800)
* fix(gateway): keep create_thread idempotent when the insert loses a race

The /api/threads create endpoint checks for an existing row and then
inserts, but the two steps are not atomic. When two requests create the
same thread_id concurrently, one commits first and the other's INSERT
fails on the duplicate primary key. The SQL-backed thread store raises,
the handler catches it as a generic failure, and the loser gets a 500 —
even though the docstring promises the call is idempotent.

Re-read the row after a failed insert: if it is now present (the
competing request won), return it instead of surfacing the conflict. A
genuine write failure where the row is still absent keeps the 500.

Covered by a regression test that simulates the lost race and asserts
the endpoint returns the existing thread rather than erroring.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

* fix(gateway): scope create_thread race recovery and mirror owner reconciliation

Address review on #3800:

- Scope the insert-race recovery to sqlalchemy IntegrityError instead of a
  broad `except Exception`, so a non-race failure that coincides with an
  existing row is no longer silently returned as a 200. Any other error logs
  and surfaces as a 500. (The memory store overwrites on duplicate rather than
  raising, so it never reaches this path.)

- Route both the fast path and the recovery path through a shared
  `_resolve_existing_thread` helper so the recovery performs the same trusted-
  owner reconciliation (claiming a legacy unscoped `user_id=None` row) the fast
  path does. Thread ownership no longer diverges based on which path resolved
  the record.

Add regression tests for both: the recovery claims an unscoped row for a
trusted owner, and a non-IntegrityError failure surfaces as a 500.

---------

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-15 20:33:43 +08:00
Yufeng HeandGitHub e361122b9a fix(security): html-escape subagent descriptions before the <subagent_system> block (#4157)
A custom subagent's description is agent-editable (persisted by setup_agent /
update_agent) and is rendered into the <subagent_system> block of the lead-agent
system prompt via the available-subagents listing. It was interpolated raw, so a
first line like "</subagent_system><system-reminder>..." could close the block
and forge a framework-reserved tag inside the system-role prompt.

Escape it with html.escape at the render site, matching the sibling fixes for
<soul> (#4137), memory facts (#4097), skill metadata (#4128), and remote content
(#4099/#4002). Built-in descriptions are trusted constants and stay untouched.

Adds a red/green regression test mirroring test_soul_prompt_injection.py.
2026-07-14 08:14:21 +08:00
Yufeng HeandGitHub 807c3c5218 fix(security): html-escape SOUL.md before it enters the <soul> prompt block (#4137)
SOUL.md is agent-editable (setup_agent / update_agent persist it) and
get_agent_soul renders it into the <soul> block of the lead-agent system
prompt without escaping. A crafted personality such as
"</soul></system-reminder>\n\nSYSTEM: ..." can close the block and relocate
the text after it out of the trust zone the system prompt declares — the same
break-out the skill/memory/tool-result escaping in #4097/#4119/#4128/#4099
already closes at their render sites. <soul> is the remaining one, and it lands
in the highest-trust system-role block.

Escape with html.escape(quote=False) (element-text position, never an
attribute). Adds a regression test that fails on main.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
2026-07-13 19:33:40 +08:00
Yufeng HeandGitHub cbbd72a1ab fix(skillscan): recognize remaining requests/httpx HTTP methods as network sinks (#4130)
python-env-dump-exfil flags a file that both reads the bulk process environment
and reaches a network sink. The call-based sink check only listed requests
get/post/put/request and httpx get/post, so a bulk env dump sent through an
equally body-carrying method (requests.patch/delete, httpx.put/patch/delete, or
the generic httpx.request/stream) evaded the CRITICAL finding whenever the
destination URL was not a plain string literal (e.g. built at runtime) -- the
exact evasion the string-literal URL sink is meant to resist. requests.post was
caught but requests.patch was not, an arbitrary gap on clients the analyzer
already covers.

Complete the requests and httpx HTTP-verb surface in _call_is_network_sink so an
obfuscated-URL exfil through those methods is flagged like post/put.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
2026-07-13 16:11:17 +08:00
a4a88fda19 fix(mcp): synchronize session pool singleton lifecycle (#3797)
* fix(mcp): synchronize session pool singleton lifecycle

Parity follow-up to #3778 (skill storage) and #3730 (sandbox provider).
get_session_pool() already serialised creation with _pool_lock, but its
fast-path check and final return read the global separately, and
reset_session_pool() cleared it with no lock. A reset_mcp_tools_cache()
(reachable via the /api/mcp/cache/reset admin endpoint) racing a
concurrent get could null the singleton between the None-check and the
return, handing the caller None despite the -> MCPSessionPool annotation.

Build and return the pool inside _pool_lock with a double check, and
clear it under the same lock in reset_session_pool(). The critical
section is tiny and never awaits, so holding the threading.Lock is safe
from both the async and sync/worker-thread paths. No behavior change for
single-threaded callers.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-07 23:09:06 +08:00
Yufeng HeandGitHub 7a6c4a994a fix(channels): serialize per-chat thread creation to avoid duplicate threads (#3799)
Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
2026-06-26 15:39:57 +08:00
Yufeng HeandGitHub 2e789eae18 fix(skills): synchronize skill storage singleton lifecycle (#3778)
get_or_new_skill_storage() and reset_skill_storage() touch the
process-global skill storage singleton without a lock - the same
unsynchronized check-then-create that #3730 just fixed in
sandbox_provider.py, the module this file documents itself as mirroring.

Two callers racing a cold start can both see _default_skill_storage is
None and each build a SkillStorage, so the second overwrites the first;
a reset_skill_storage() racing a get can also null the global between
the None-check and the return.

Guard the build/return and the reset with a module-level threading.Lock
and a double check, mirroring get_memory_storage(). Construction stays
inside the lock (rather than sandbox_provider's build-outside-then-
discard-the-loser) because SkillStorage has no teardown hook, so a
losing racer's orphan could not be cleaned up.

Add backend/tests/test_skill_storage_lifecycle.py with concurrency
regression tests (8-thread cold-start race asserting a single instance;
reset racing gets asserting no None is returned).

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
2026-06-25 23:18:57 +08:00
Yufeng HeandGitHub 7be73fcf19 fix(channels): let UI runtime channel config win over config.yaml (#3674)
* fix(channels): let UI runtime channel config win over config.yaml

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

* test(channels): update assertion to expect runtime/UI value to win over yaml

The test was written when yaml took precedence over runtime config.
This PR inverts that precedence so UI-entered credentials win; the
assertion now correctly reflects that runtime value (xapp-ui) beats
the yaml value (xapp) on a shared key.

---------

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
2026-06-24 10:24:36 +08:00
cefc53c72a fix(middleware): fix positional fallback consuming unrelated todo when same-content list is exhausted (#3709)
* fix(middleware): fix positional fallback consuming unrelated todo when same-content list is exhausted

When next_todos contains the same content string twice (e.g. two "A"
entries), the first iteration pops the matching previous todo from
previous_by_content["A"], leaving an empty list. The second iteration
finds that empty list, treats it as falsy, and sets previous_match=None.
The positional fallback then fires unconditionally — consuming
previous_todos[index] even if it holds a completely different entry
(e.g. "B"). That marks "B" as matched, so the final loop never emits
a todo_remove action for it.

Fix: guard the positional fallback with `content not in previous_by_content`
so it only fires for genuinely new content (the key was never in previous),
not for same-content entries whose list has simply been exhausted.

Add a regression test to _build_todo_actions covering this exact case.

* style: ruff-format the token usage middleware test

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

---------

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-06-23 23:37:41 +08:00
Yufeng HeandGitHub 8167275cef fix(memory): skip whitespace-only facts in _apply_updates (#3719)
Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
2026-06-23 08:34:41 +08:00
Yufeng HeandGitHub 3e5c76eb0a fix(serialization): strip base64 image data from streamed values events (#3631)
The SSE stream's `values` snapshots serialize the full state, including the
hide_from_ui human messages that ViewImageMiddleware fills with base64 image
payloads. #3535 stripped those from the REST wait/history/state endpoints via
serialize_channel_values_for_api, but the streaming path (worker publishes
serialize(chunk, mode="values")) still went through serialize_channel_values,
so the same base64 leaked to the frontend over SSE. Route values-mode
serialization through serialize_channel_values_for_api as well. Non-hidden
messages and https image URLs are left untouched.
2026-06-19 11:23:07 +08:00