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:
1 parent
728ec10e66
commit
cf7eb48e56
9 files changed
+3187
-112
No files matched your search
+126
-38
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user