Release exp-v0.52.197: preserve api_content for agent replay (#6757, @franksong2702)

Ships PR #6757 with a maintainer hardening pass folded in (authorship preserved).

Preserves the exact provider-facing reply text (api_content) through the internal
session store for byte-accurate agent replay, while guaranteeing it never reaches
a browser: every client-facing surface strips it via the public projection.

Maintainer fixes (2 defects my adversarial re-gate reproduced):
- Selective session redaction restored: only transcript fields are scrubbed;
  operational fields (e.g. workspace path) pass through unchanged, so a path
  containing credential-shaped text is no longer masked into an invalid path.
- Runner-backed SSE relay now projects payloads before emit, closing an
  api_content leak through _stream_runner_run_events.
+5 regression tests. Codex adversarial re-gate: SAFE TO SHIP. Full suite clean.

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
This commit is contained in:
nesquena-hermesandfranksong2702 committed 2026-08-11 02:10:21 +00:00
1 parent 728ec10e66
commit cf7eb48e56
9 files changed
+3187 -112

No files matched your search

+126 -38
View File
@@ -2891,6 +2891,8 @@ from api.helpers import (
_security_headers,
_sanitize_error,
redact_session_data,
public_session_projection,
strip_public_internal_fields,
_redact_text,
_CLIENT_DISCONNECT_ERRORS,
)
@@ -9623,6 +9625,7 @@ from api.models import (
get_state_db_session_message_keys_before_timestamp,
get_state_db_session_summary,
merge_session_messages_append_only,
_reconcile_api_content_sidecars,
_enrich_sidebar_lineage_metadata,
_active_stream_ids,
_evict_sessions_over_cap,
@@ -13283,7 +13286,7 @@ def handle_get(handler, parsed) -> bool:
}
attach_todo_state(sess, msgs)
sess = _merge_cli_sidebar_metadata(sess, cli_meta)
return j(handler, {"session": redact_session_data(sess)})
return j(handler, {"session": public_session_projection(sess)})
if parsed.path == "/api/session/lineage/report":
sid = parse_qs(parsed.query).get("session_id", [""])[0]
@@ -14411,7 +14414,9 @@ def handle_post(handler, parsed) -> bool:
"created_at": share_meta["share_created_at"],
"updated_at": share_meta["share_updated_at"],
},
"session": response_session.compact() | {"messages": response_session.messages},
"session": public_session_projection(
response_session.compact() | {"messages": response_session.messages}
),
},
)
@@ -14450,7 +14455,9 @@ def handle_post(handler, parsed) -> bool:
handler,
{
"ok": True,
"session": response_session.compact() | {"messages": response_session.messages},
"session": public_session_projection(
response_session.compact() | {"messages": response_session.messages}
),
},
)
@@ -14580,7 +14587,9 @@ def handle_post(handler, parsed) -> bool:
profile=getattr(s, "profile", None),
session_id=getattr(s, "session_id", None),
)
payload = {"session": s.compact() | {"messages": s.messages}}
payload = {
"session": public_session_projection(s.compact() | {"messages": s.messages})
}
if worktree_skipped:
# Config-default worktree was skipped (non-git workspace); tell the
# client the session is plain so the UI doesn't assume isolation.
@@ -14677,7 +14686,14 @@ def handle_post(handler, parsed) -> bool:
session_id=getattr(copied_session, "session_id", None),
)
return j(handler, {"session": copied_session.compact() | {"messages": copied_session.messages}})
return j(
handler,
{
"session": public_session_projection(
copied_session.compact() | {"messages": copied_session.messages}
)
},
)
except Exception as e:
return bad(handler, str(e))
@@ -15076,7 +15092,10 @@ def handle_post(handler, parsed) -> bool:
except Exception:
logger.debug("Failed to close workspace terminal after workspace update")
set_last_workspace(new_ws)
return j(handler, {"session": s.compact() | {"messages": s.messages}})
return j(
handler,
{"session": public_session_projection(s.compact() | {"messages": s.messages})},
)
if parsed.path == "/api/session/worktree/remove":
sid = body.get("session_id", "")
if not sid or not isinstance(sid, str) or not sid.strip():
@@ -15353,7 +15372,13 @@ def handle_post(handler, parsed) -> bool:
from api.config import _evict_session_agent
_evict_session_agent(body["session_id"])
return j(
handler, {"ok": True, "session": s.compact() | {"messages": s.messages}}
handler,
{
"ok": True,
"session": public_session_projection(
s.compact() | {"messages": s.messages}
),
},
)
if parsed.path == "/api/session/branch":
@@ -15455,11 +15480,21 @@ def handle_post(handler, parsed) -> bool:
from api.session_ops import truncate_context_for_display_keep
fork_keep = keep_count if keep_count is not None else len(source_messages)
forked_context = truncate_context_for_display_keep(
getattr(source, "context_messages", None),
source_messages,
fork_keep,
forked_context = copy.deepcopy(
truncate_context_for_display_keep(
getattr(source, "context_messages", None),
source_messages,
fork_keep,
)
)
# `truncate_context_for_display_keep` aligns rows by visible display
# identity but intentionally does not carry provider replay sidecars.
# Reconcile only the retained prefix before constructing the branch so
# its persisted model context receives the same conservative
# api_content bytes as the forked display messages. The helper mutates
# the freshly aligned context copy; the source session and
# forked_messages ownership remain untouched.
_reconcile_api_content_sidecars(forked_context, forked_messages)
branch = Session(
workspace=source.workspace,
model=source.model,
@@ -17028,7 +17063,10 @@ def _handle_session_export(handler, parsed):
active_profile = get_active_profile_name()
if not _profiles_match(getattr(s, "profile", None), active_profile):
return bad(handler, "Session not found", 404)
safe = redact_session_data(s.__dict__)
# ``public_session_projection`` supersedes the narrower
# ``redact_session_data`` path so export context_messages uses the same
# alias-stripping boundary as the visible transcript.
safe = public_session_projection(s.__dict__)
qs = parse_qs(parsed.query)
fmt = qs.get("format", ["json"])[0].lower()
if fmt == "html":
@@ -17694,6 +17732,29 @@ def _runner_event_payload(entry: dict):
return entry
def _project_runner_event_payload(payload):
"""Strip internal replay fields (api_content, row-id aliases) from a runner
SSE payload before it is relayed to the browser.
Runner-backed SSE relays adapter payloads verbatim; a terminal event can
carry a full ``session`` object (with per-message ``api_content`` sidecars)
or be session/message-shaped itself. Neither must reach a client, so route
the transcript-bearing shapes through the same public projection every other
session emitter uses. Non-session payloads pass through unchanged.
"""
if not isinstance(payload, dict):
return payload
# Terminal events wrap the session under a "session" key.
if isinstance(payload.get("session"), dict):
projected = dict(payload)
projected["session"] = public_session_projection(payload["session"])
return projected
# Payload is itself session/message-shaped (has a messages transcript).
if "messages" in payload or "context_messages" in payload:
return public_session_projection(payload)
return payload
def _runner_event_id(run_id: str, entry: dict) -> str | None:
event_id = entry.get("event_id") or entry.get("id")
if event_id:
@@ -17740,7 +17801,7 @@ def _stream_runner_run_events(handler, run_id: str, cursor: str | None = None) -
if not isinstance(entry, dict):
continue
event = _runner_event_name(entry)
_sse_with_id(handler, event, _runner_event_payload(entry), _runner_event_id(run_id, entry))
_sse_with_id(handler, event, _project_runner_event_payload(_runner_event_payload(entry)), _runner_event_id(run_id, entry))
emitted = True
if event in SSE_RELAY_CLOSE_EVENTS:
terminal = True
@@ -22793,7 +22854,7 @@ def _handle_chat_sync(handler, body):
_merge_display_messages_after_agent_result,
_restore_display_reasoning_metadata,
_restore_reasoning_metadata,
_sanitize_messages_for_api,
_sanitize_messages_for_agent,
_compact_session_image_parts_for_persistence,
_context_messages_for_new_turn,
_workspace_context_prefix,
@@ -22824,7 +22885,7 @@ def _handle_chat_sync(handler, body):
result = agent.run_conversation(
user_message=workspace_ctx + msg,
system_message=workspace_system_msg,
conversation_history=_sanitize_messages_for_api(
conversation_history=_sanitize_messages_for_agent(
_previous_context_messages,
cfg=get_config(),
effective_model=_model,
@@ -22906,7 +22967,7 @@ def _handle_chat_sync(handler, body):
{
"answer": result.get("final_response") or "",
"status": "done" if result.get("completed", True) else "partial",
"session": s.compact() | {"messages": s.messages},
"session": public_session_projection(s.compact() | {"messages": s.messages}),
"result": {k: v for k, v in result.items() if k != "messages"},
},
)
@@ -26080,6 +26141,11 @@ def _normalize_message_for_import_refresh(message: object) -> object:
normalized = dict(message)
normalized.pop("timestamp", None)
normalized.pop("_ts", None)
# These are WebUI/Agent replay bookkeeping aliases at the message's top
# level. Strip only those exact keys; nested business payloads are opaque
# and must remain part of the semantic import comparison.
for key in ("api_content", "_state_db_row_id", "_db_row_id", "state_db_row_id"):
normalized.pop(key, None)
return normalized
@@ -26238,19 +26304,21 @@ def _handle_session_import_cli(handler, body):
return j(
handler,
{
"session": existing.compact()
| {
"messages": existing.messages,
"is_cli_session": (False if _existing_is_sa else True),
# Greptile #4911 follow-up: read read_only from
# the persisted Session, NOT from cli_meta. This
# refresh path is for an already-WebUI-owned
# session; the WebUI's persisted view is the
# source of truth for the response, not the
# foreign store's current value. (Mirrors the
# GET /api/session fix.)
"read_only": bool(getattr(existing, "read_only", False)),
},
"session": public_session_projection(
existing.compact()
| {
"messages": existing.messages,
"is_cli_session": (False if _existing_is_sa else True),
# Greptile #4911 follow-up: read read_only from
# the persisted Session, NOT from cli_meta. This
# refresh path is for an already-WebUI-owned
# session; the WebUI's persisted view is the
# source of truth for the response, not the
# foreign store's current value. (Mirrors the
# GET /api/session fix.)
"read_only": bool(getattr(existing, "read_only", False)),
}
),
"imported": False,
},
)
@@ -26334,7 +26402,13 @@ def _handle_session_import_cli(handler, body):
"messages": msgs,
"tool_calls": [],
}
return j(handler, {"session": session_payload, "imported": False})
return j(
handler,
{
"session": public_session_projection(session_payload),
"imported": False,
},
)
s = import_cli_session(
sid,
@@ -26379,11 +26453,13 @@ def _handle_session_import_cli(handler, body):
return j(
handler,
{
"session": s.compact()
| {
"messages": msgs,
"is_cli_session": True,
},
"session": public_session_projection(
s.compact()
| {
"messages": msgs,
"is_cli_session": True,
}
),
"imported": True,
},
)
@@ -26393,9 +26469,15 @@ def _handle_session_import(handler, body):
"""Import a session from a JSON export. Creates a new session with a new ID."""
if not body or not isinstance(body, dict):
return bad(handler, "Request body must be a JSON object")
messages = body.get("messages")
messages = strip_public_internal_fields(
body.get("messages"),
message_records=True,
)
if not isinstance(messages, list):
return bad(handler, 'JSON must contain a "messages" array')
raw_tool_calls = body.get("tool_calls", [])
if not isinstance(raw_tool_calls, list):
return bad(handler, 'JSON "tool_calls" must be an array')
title = body.get("title", "Imported session")
try:
workspace = str(resolve_trusted_workspace(body.get("workspace", str(DEFAULT_WORKSPACE))))
@@ -26407,7 +26489,7 @@ def _handle_session_import(handler, body):
workspace=workspace,
model=model,
messages=messages,
tool_calls=body.get("tool_calls", []),
tool_calls=strip_public_internal_fields(raw_tool_calls),
profile=get_active_profile_name(),
)
s.pinned = body.get("pinned", False)
@@ -26417,7 +26499,13 @@ def _handle_session_import(handler, body):
_evict_sessions_over_cap() # #4765: safe LRU eviction (never active/unsaved)
s.save()
publish_session_list_changed("session_import")
return j(handler, {"ok": True, "session": s.compact() | {"messages": s.messages}})
return j(
handler,
{
"ok": True,
"session": public_session_projection(s.compact() | {"messages": s.messages}),
},
)
def _mask_secrets(obj):