* 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>
* 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>
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.
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>
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>
* 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>
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>
* 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>
* 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>
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.