fred-runtime 2.0.7__tar.gz → 2.0.8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/PKG-INFO +1 -1
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/app/agent_app.py +127 -49
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime.egg-info/PKG-INFO +1 -1
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/pyproject.toml +1 -1
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/tests/test_history.py +131 -2
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/README.md +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/__init__.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/app/__init__.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/app/_catalogs.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/app/config.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/app/config_loader.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/app/container.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/app/context.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/app/dependencies.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/app/mcp_config.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/app/observability_factory.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/app/openai_compat_router.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/cli/__init__.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/cli/completion.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/cli/entrypoint.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/cli/history_display.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/cli/kpi_display.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/cli/pod_client.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/cli/repl.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/cli/repl_helpers.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/cli/url_helpers.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/client.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/common/__init__.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/common/context_aware_tool.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/common/kf_base_client.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/common/kf_fast_text_client.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/common/kf_http_client.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/common/kf_logs_client.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/common/kf_markdown_media_client.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/common/kf_vectorsearch_client.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/common/kf_workspace_client.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/common/mcp_interceptors.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/common/mcp_runtime.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/common/mcp_toolkit.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/common/mcp_utils.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/common/structures.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/common/token_expiry.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/common/tool_node_utils.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/deep/__init__.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/deep/deep_runtime.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/eval/__init__.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/eval/collector.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/graph/__init__.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/graph/graph_runtime.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/integrations/__init__.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/integrations/v2_runtime/__init__.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/integrations/v2_runtime/adapters.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/model_routing/__init__.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/model_routing/catalog.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/model_routing/contracts.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/model_routing/provider.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/model_routing/resolver.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/react/__init__.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/react/react_langchain_adapter.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/react/react_message_codec.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/react/react_model_adapter.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/react/react_prompting.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/react/react_runtime.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/react/react_stream_adapter.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/react/react_tool_binding.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/react/react_tool_loop.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/react/react_tool_rendering.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/react/react_tool_resolution.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/react/react_tool_utils.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/react/react_tracing.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/runtime_context.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/runtime_support/__init__.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/runtime_support/checkpoints.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/runtime_support/model_metadata.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/runtime_support/request_context_helpers.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/runtime_support/sql_checkpointer.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/runtime_support/user_token_refresher.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/support/__init__.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/support/filesystem_context.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/support/tool_approval.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/support/tool_loop.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime.egg-info/SOURCES.txt +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime.egg-info/dependency_links.txt +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime.egg-info/entry_points.txt +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime.egg-info/requires.txt +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime.egg-info/top_level.txt +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/setup.cfg +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/tests/test_agent_app.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/tests/test_client.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/tests/test_config_loader.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/tests/test_context.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/tests/test_conversational_memory.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/tests/test_eval_collector.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/tests/test_eval_trace.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/tests/test_graph_runtime_observability.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/tests/test_kf_workspace_client.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/tests/test_kpi_display.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/tests/test_mcp_config.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/tests/test_model_routing.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/tests/test_openai_compat_router.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/tests/test_pod_client.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/tests/test_repl_helpers.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/tests/test_smoke.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/tests/test_token_expiry.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/tests/test_url_helpers.py +0 -0
- {fred_runtime-2.0.7 → fred_runtime-2.0.8}/tests/test_user_token_refresher.py +0 -0
|
@@ -814,6 +814,7 @@ class _AgentTemplateSummary(BaseModel):
|
|
|
814
814
|
template_agent_id: str
|
|
815
815
|
title: str
|
|
816
816
|
description: str
|
|
817
|
+
description_by_lang: dict[str, str] | None = None
|
|
817
818
|
kind: ExecutionCategory
|
|
818
819
|
default_tuning: AgentTuning
|
|
819
820
|
available_mcp_servers: list[MCPServerConfiguration] = Field(default_factory=list)
|
|
@@ -2060,6 +2061,7 @@ def _build_agent_router(
|
|
|
2060
2061
|
template_agent_id=definition.agent_id,
|
|
2061
2062
|
title=definition.role,
|
|
2062
2063
|
description=definition.description,
|
|
2064
|
+
description_by_lang=getattr(definition, "description_by_lang", None),
|
|
2063
2065
|
kind=definition.execution_category,
|
|
2064
2066
|
default_tuning=_definition_to_agent_tuning(definition),
|
|
2065
2067
|
available_mcp_servers=_available_mcp_servers_for_definition(definition),
|
|
@@ -2101,22 +2103,20 @@ def _build_agent_router(
|
|
|
2101
2103
|
]
|
|
2102
2104
|
)
|
|
2103
2105
|
|
|
2104
|
-
@router.get("/sessions"
|
|
2105
|
-
async def list_sessions(
|
|
2106
|
+
@router.get("/sessions")
|
|
2107
|
+
async def list_sessions(
|
|
2108
|
+
caller: KeycloakUser | None = Depends(_authenticated_user),
|
|
2109
|
+
user_id: str | None = None,
|
|
2110
|
+
) -> list[str]:
|
|
2106
2111
|
"""
|
|
2107
|
-
Return the session IDs for
|
|
2112
|
+
Return the session IDs for the authenticated user, most recent first.
|
|
2108
2113
|
|
|
2109
|
-
GET <configured base_url>/agents/sessions
|
|
2110
|
-
Authorization: Bearer <user JWT>
|
|
2111
|
-
Response: JSON array of session_id strings
|
|
2112
|
-
|
|
2113
|
-
Why this endpoint exists:
|
|
2114
|
-
- the UI needs to list past conversations for a returning user
|
|
2115
|
-
- the checkpointer has no user_id index; only the history store does
|
|
2114
|
+
GET <configured base_url>/agents/sessions
|
|
2115
|
+
Authorization: Bearer <user JWT>
|
|
2116
2116
|
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2117
|
+
Security: user identity is always extracted from the JWT token.
|
|
2118
|
+
The user_id query parameter is accepted only in dev mode (security
|
|
2119
|
+
disabled) for CLI convenience; it is ignored when security is enabled.
|
|
2120
2120
|
"""
|
|
2121
2121
|
history_store = get_runtime_context().config.history_store
|
|
2122
2122
|
if history_store is None:
|
|
@@ -2124,29 +2124,30 @@ def _build_agent_router(
|
|
|
2124
2124
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
2125
2125
|
detail="No history store configured — session listing is unavailable.",
|
|
2126
2126
|
)
|
|
2127
|
-
|
|
2127
|
+
effective_uid = caller.uid if caller is not None else user_id
|
|
2128
|
+
if effective_uid is None:
|
|
2129
|
+
return []
|
|
2130
|
+
return await history_store.list_sessions(user_id=effective_uid)
|
|
2128
2131
|
|
|
2129
2132
|
@router.get(
|
|
2130
2133
|
"/sessions/{session_id}/messages",
|
|
2131
|
-
dependencies=_auth_deps,
|
|
2132
2134
|
response_model=list[ChatMessage],
|
|
2133
2135
|
)
|
|
2134
|
-
async def get_session_messages(
|
|
2136
|
+
async def get_session_messages(
|
|
2137
|
+
session_id: str,
|
|
2138
|
+
caller: KeycloakUser | None = Depends(_authenticated_user),
|
|
2139
|
+
) -> list[ChatMessage]:
|
|
2135
2140
|
"""
|
|
2136
2141
|
Return the conversation history for a session as a flat message list.
|
|
2137
2142
|
|
|
2138
2143
|
GET <configured base_url>/agents/sessions/{session_id}/messages
|
|
2139
|
-
Authorization: Bearer <user JWT>
|
|
2140
|
-
Response: JSON array of ChatMessage objects (role/channel/parts/metadata).
|
|
2144
|
+
Authorization: Bearer <user JWT>
|
|
2141
2145
|
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
- the checkpointer (former source) only works for agents with a ``messages``
|
|
2146
|
-
channel in state and is not queryable per user_id
|
|
2146
|
+
Security: only rows belonging to the authenticated user are returned.
|
|
2147
|
+
Returns [] when the session does not exist or belongs to another user —
|
|
2148
|
+
callers cannot distinguish the two cases by design.
|
|
2147
2149
|
|
|
2148
2150
|
Returns 503 when no history store is configured (stateless pod mode).
|
|
2149
|
-
Returns [] when the session exists but has no rows yet.
|
|
2150
2151
|
"""
|
|
2151
2152
|
history_store = get_runtime_context().config.history_store
|
|
2152
2153
|
if history_store is None:
|
|
@@ -2154,27 +2155,29 @@ def _build_agent_router(
|
|
|
2154
2155
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
2155
2156
|
detail="No history store configured — session history is unavailable.",
|
|
2156
2157
|
)
|
|
2157
|
-
|
|
2158
|
+
caller_uid = caller.uid if caller is not None else None
|
|
2159
|
+
return await history_store.get(session_id=session_id, user_id=caller_uid)
|
|
2158
2160
|
|
|
2159
2161
|
@router.delete(
|
|
2160
2162
|
"/sessions/{session_id}",
|
|
2161
|
-
dependencies=_auth_deps,
|
|
2162
2163
|
status_code=status.HTTP_200_OK,
|
|
2163
2164
|
)
|
|
2164
|
-
async def delete_session_history(
|
|
2165
|
+
async def delete_session_history(
|
|
2166
|
+
session_id: str,
|
|
2167
|
+
caller: KeycloakUser | None = Depends(_authenticated_user),
|
|
2168
|
+
) -> dict[str, int]:
|
|
2165
2169
|
"""
|
|
2166
|
-
Permanently delete
|
|
2170
|
+
Permanently delete history rows for a session.
|
|
2167
2171
|
|
|
2168
2172
|
DELETE <base_url>/agents/sessions/{session_id}
|
|
2169
2173
|
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2174
|
+
Security: only rows belonging to the authenticated user are deleted.
|
|
2175
|
+
Returns {"deleted": 0} when the session does not exist or belongs to
|
|
2176
|
+
another user — callers cannot distinguish the two cases by design.
|
|
2177
|
+
|
|
2178
|
+
Checkpoint state is NOT touched; delete separately via
|
|
2179
|
+
DELETE /agents/checkpoints/{session_id} if required.
|
|
2176
2180
|
|
|
2177
|
-
Returns {"deleted": N} where N is the number of rows removed.
|
|
2178
2181
|
Returns 503 when no history store is configured.
|
|
2179
2182
|
"""
|
|
2180
2183
|
history_store = get_runtime_context().config.history_store
|
|
@@ -2183,7 +2186,10 @@ def _build_agent_router(
|
|
|
2183
2186
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
2184
2187
|
detail="No history store configured — session history is unavailable.",
|
|
2185
2188
|
)
|
|
2186
|
-
|
|
2189
|
+
caller_uid = caller.uid if caller is not None else None
|
|
2190
|
+
count = await history_store.delete_session(
|
|
2191
|
+
session_id=session_id, user_id=caller_uid
|
|
2192
|
+
)
|
|
2187
2193
|
return {"deleted": count}
|
|
2188
2194
|
|
|
2189
2195
|
# ------------------------------------------------------------------
|
|
@@ -2199,8 +2205,36 @@ def _build_agent_router(
|
|
|
2199
2205
|
)
|
|
2200
2206
|
return cp
|
|
2201
2207
|
|
|
2202
|
-
|
|
2208
|
+
def _get_history_store_for_owned_access(
|
|
2209
|
+
caller: KeycloakUser | None,
|
|
2210
|
+
) -> HistoryStorePort | None:
|
|
2211
|
+
"""Return the history store used as ownership oracle, failing closed.
|
|
2212
|
+
|
|
2213
|
+
Why this helper exists:
|
|
2214
|
+
- checkpoint tables do not carry user_id, so ownership checks depend on
|
|
2215
|
+
the history store
|
|
2216
|
+
- when security is enabled, proceeding without that oracle would leak
|
|
2217
|
+
checkpoint visibility across users
|
|
2218
|
+
|
|
2219
|
+
How to use it:
|
|
2220
|
+
- call from checkpoint endpoints before listing or mutating per-session
|
|
2221
|
+
checkpoint data
|
|
2222
|
+
"""
|
|
2223
|
+
if caller is None:
|
|
2224
|
+
return None
|
|
2225
|
+
history_store = get_runtime_context().config.history_store
|
|
2226
|
+
if history_store is None:
|
|
2227
|
+
raise HTTPException(
|
|
2228
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
2229
|
+
detail=(
|
|
2230
|
+
"No history store configured — ownership checks are unavailable."
|
|
2231
|
+
),
|
|
2232
|
+
)
|
|
2233
|
+
return history_store
|
|
2234
|
+
|
|
2235
|
+
@router.get("/checkpoints")
|
|
2203
2236
|
async def list_checkpoint_threads(
|
|
2237
|
+
caller: KeycloakUser | None = Depends(_authenticated_user),
|
|
2204
2238
|
limit: int = 100,
|
|
2205
2239
|
) -> list[_CheckpointThreadSummary]:
|
|
2206
2240
|
"""
|
|
@@ -2271,6 +2305,15 @@ def _build_agent_router(
|
|
|
2271
2305
|
.limit(limit)
|
|
2272
2306
|
)
|
|
2273
2307
|
rows = (await conn.execute(stmt)).fetchall()
|
|
2308
|
+
|
|
2309
|
+
# Scope to the caller's own sessions using the history store as the
|
|
2310
|
+
# ownership oracle (checkpoint tables carry no user_id column).
|
|
2311
|
+
caller_uid = caller.uid if caller is not None else None
|
|
2312
|
+
history_store = _get_history_store_for_owned_access(caller)
|
|
2313
|
+
if caller_uid is not None and history_store is not None:
|
|
2314
|
+
owned = set(await history_store.list_sessions(user_id=caller_uid))
|
|
2315
|
+
rows = [r for r in rows if str(r.thread_id) in owned]
|
|
2316
|
+
|
|
2274
2317
|
return [
|
|
2275
2318
|
_CheckpointThreadSummary(
|
|
2276
2319
|
session_id=str(row.thread_id),
|
|
@@ -2338,8 +2381,11 @@ def _build_agent_router(
|
|
|
2338
2381
|
blob_bytes_approx=int(blob_bytes),
|
|
2339
2382
|
)
|
|
2340
2383
|
|
|
2341
|
-
@router.get("/checkpoints/{session_id}"
|
|
2342
|
-
async def get_checkpoint_thread(
|
|
2384
|
+
@router.get("/checkpoints/{session_id}")
|
|
2385
|
+
async def get_checkpoint_thread(
|
|
2386
|
+
session_id: str,
|
|
2387
|
+
caller: KeycloakUser | None = Depends(_authenticated_user),
|
|
2388
|
+
) -> _CheckpointThreadDetail:
|
|
2343
2389
|
"""
|
|
2344
2390
|
Return all checkpoints for one session, newest first.
|
|
2345
2391
|
|
|
@@ -2360,7 +2406,20 @@ def _build_agent_router(
|
|
|
2360
2406
|
|
|
2361
2407
|
Returns 503 when no checkpointer is configured.
|
|
2362
2408
|
Returns an empty checkpoints list when the session has no rows.
|
|
2409
|
+
Returns 403 when the session does not belong to the authenticated user.
|
|
2363
2410
|
"""
|
|
2411
|
+
caller_uid = caller.uid if caller is not None else None
|
|
2412
|
+
history_store = _get_history_store_for_owned_access(caller)
|
|
2413
|
+
if (
|
|
2414
|
+
caller_uid is not None
|
|
2415
|
+
and history_store is not None
|
|
2416
|
+
and not await history_store.session_belongs_to_user(session_id, caller_uid)
|
|
2417
|
+
):
|
|
2418
|
+
raise HTTPException(
|
|
2419
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
2420
|
+
detail="Access denied.",
|
|
2421
|
+
)
|
|
2422
|
+
|
|
2364
2423
|
from sqlalchemy import desc, func, select
|
|
2365
2424
|
|
|
2366
2425
|
cp = _get_checkpointer()
|
|
@@ -2429,25 +2488,38 @@ def _build_agent_router(
|
|
|
2429
2488
|
|
|
2430
2489
|
@router.delete(
|
|
2431
2490
|
"/checkpoints/{session_id}",
|
|
2432
|
-
dependencies=_auth_deps,
|
|
2433
2491
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
2434
2492
|
response_model=None,
|
|
2435
2493
|
)
|
|
2436
|
-
async def delete_checkpoint_thread(
|
|
2494
|
+
async def delete_checkpoint_thread(
|
|
2495
|
+
session_id: str,
|
|
2496
|
+
caller: KeycloakUser | None = Depends(_authenticated_user),
|
|
2497
|
+
) -> None:
|
|
2437
2498
|
"""
|
|
2438
2499
|
Purge all checkpoint data for one session.
|
|
2439
2500
|
|
|
2440
2501
|
DELETE <base_url>/agents/checkpoints/{session_id}
|
|
2441
2502
|
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
to resume from any prior HITL pause or conversation state for this session.
|
|
2503
|
+
Security: the session must belong to the authenticated user.
|
|
2504
|
+
Returns 403 when ownership cannot be confirmed via the history store.
|
|
2445
2505
|
|
|
2446
|
-
|
|
2447
|
-
|
|
2506
|
+
Deletes all rows in the checkpoints, blobs, and writes tables.
|
|
2507
|
+
History store rows are NOT deleted — use DELETE /sessions/{session_id}
|
|
2508
|
+
to remove those separately.
|
|
2448
2509
|
|
|
2449
|
-
Returns 204 on success, 503 when no checkpointer
|
|
2510
|
+
Returns 204 on success, 403 when not owned, 503 when no checkpointer.
|
|
2450
2511
|
"""
|
|
2512
|
+
caller_uid = caller.uid if caller is not None else None
|
|
2513
|
+
history_store = _get_history_store_for_owned_access(caller)
|
|
2514
|
+
if (
|
|
2515
|
+
caller_uid is not None
|
|
2516
|
+
and history_store is not None
|
|
2517
|
+
and not await history_store.session_belongs_to_user(session_id, caller_uid)
|
|
2518
|
+
):
|
|
2519
|
+
raise HTTPException(
|
|
2520
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
2521
|
+
detail="Access denied.",
|
|
2522
|
+
)
|
|
2451
2523
|
cp = _get_checkpointer()
|
|
2452
2524
|
await cp.adelete_thread(session_id)
|
|
2453
2525
|
|
|
@@ -2850,6 +2922,12 @@ def create_agent_app(
|
|
|
2850
2922
|
await container.initialize_sql()
|
|
2851
2923
|
container.start_metrics_exporter()
|
|
2852
2924
|
await container.start_kpi_tasks()
|
|
2925
|
+
checkpointer = container.get_checkpointer()
|
|
2926
|
+
history_store = container.get_history_store()
|
|
2927
|
+
if (checkpointer is None) != (history_store is None):
|
|
2928
|
+
raise RuntimeError(
|
|
2929
|
+
"Invalid runtime storage state: checkpointer and history store must be configured together."
|
|
2930
|
+
)
|
|
2853
2931
|
set_runtime_context(
|
|
2854
2932
|
FredRuntimeContext(
|
|
2855
2933
|
RuntimeConfig(
|
|
@@ -2857,8 +2935,8 @@ def create_agent_app(
|
|
|
2857
2935
|
service_name=config.app.name,
|
|
2858
2936
|
timeouts=config.ai.timeout,
|
|
2859
2937
|
chat_model_factory=chat_factory,
|
|
2860
|
-
checkpointer=
|
|
2861
|
-
history_store=
|
|
2938
|
+
checkpointer=checkpointer,
|
|
2939
|
+
history_store=history_store,
|
|
2862
2940
|
mcp_configuration=config.get_mcp_configuration(),
|
|
2863
2941
|
control_plane_url=config.platform.control_plane_url,
|
|
2864
2942
|
kpi_writer=container.get_kpi_writer(),
|
|
@@ -331,7 +331,13 @@ def test_postgres_history_store_satisfies_history_store_port() -> None:
|
|
|
331
331
|
"""
|
|
332
332
|
from fred_core.history.postgres_history_store import PostgresHistoryStore
|
|
333
333
|
|
|
334
|
-
required = {
|
|
334
|
+
required = {
|
|
335
|
+
"save",
|
|
336
|
+
"get",
|
|
337
|
+
"list_sessions",
|
|
338
|
+
"delete_session",
|
|
339
|
+
"session_belongs_to_user",
|
|
340
|
+
}
|
|
335
341
|
|
|
336
342
|
# Implementation must have all required methods.
|
|
337
343
|
impl_attrs = set(dir(PostgresHistoryStore))
|
|
@@ -423,4 +429,127 @@ def test_get_session_messages_returns_empty_list_for_unknown_session(
|
|
|
423
429
|
|
|
424
430
|
assert response.status_code == 200
|
|
425
431
|
assert response.json() == []
|
|
426
|
-
|
|
432
|
+
# In dev mode (security disabled) caller_uid is None — user_id=None is passed
|
|
433
|
+
# so the store returns all rows for the session (no ownership filter applied).
|
|
434
|
+
mock_store.get.assert_awaited_once_with(session_id="session-new", user_id=None)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# ---------------------------------------------------------------------------
|
|
438
|
+
# Test 5 — Session endpoint user isolation (security behaviour)
|
|
439
|
+
# ---------------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _make_app_with_user(monkeypatch, tmp_path, user_uid: str):
|
|
443
|
+
"""
|
|
444
|
+
Build a test app where _authenticated_user is overridden to return a fixed
|
|
445
|
+
KeycloakUser. Used to verify ownership enforcement without real Keycloak.
|
|
446
|
+
"""
|
|
447
|
+
from fred_core.security.structure import KeycloakUser
|
|
448
|
+
|
|
449
|
+
fake_user = KeycloakUser(uid=user_uid, username=user_uid, roles=[])
|
|
450
|
+
|
|
451
|
+
# Patch _make_user_dependency before create_routes() runs so the closure
|
|
452
|
+
# produced by create_agent_app picks up our fake-user factory.
|
|
453
|
+
monkeypatch.setattr(
|
|
454
|
+
agent_app_module,
|
|
455
|
+
"_make_user_dependency",
|
|
456
|
+
lambda _fn, _enabled: lambda: fake_user,
|
|
457
|
+
)
|
|
458
|
+
return _make_app(monkeypatch, tmp_path), fake_user
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def test_list_sessions_uses_jwt_uid_not_query_param(monkeypatch, tmp_path) -> None:
|
|
462
|
+
"""
|
|
463
|
+
GET /agents/sessions must use the JWT-extracted uid, ignoring any user_id
|
|
464
|
+
query parameter supplied by the caller.
|
|
465
|
+
|
|
466
|
+
Why this test exists:
|
|
467
|
+
- the old implementation accepted user_id as a caller-supplied query param,
|
|
468
|
+
allowing any authenticated user to list another user's sessions
|
|
469
|
+
- after the fix, the JWT uid is always used when security is enabled
|
|
470
|
+
- this test verifies that a user_id query param is silently ignored when the
|
|
471
|
+
caller is authenticated
|
|
472
|
+
|
|
473
|
+
How to use it:
|
|
474
|
+
- offline: _make_user_dependency is monkeypatched to inject a fixed user
|
|
475
|
+
"""
|
|
476
|
+
app, fake_user = _make_app_with_user(monkeypatch, tmp_path, "alice-uid")
|
|
477
|
+
|
|
478
|
+
mock_store = AsyncMock()
|
|
479
|
+
mock_store.list_sessions = AsyncMock(return_value=["s-alice-1", "s-alice-2"])
|
|
480
|
+
mock_ctx = RuntimeContext(
|
|
481
|
+
RuntimeConfig(
|
|
482
|
+
knowledge_flow_url="http://localhost:8111/knowledge-flow/v1",
|
|
483
|
+
history_store=mock_store,
|
|
484
|
+
)
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
with TestClient(app) as client:
|
|
488
|
+
monkeypatch.setattr(agent_app_module, "get_runtime_context", lambda: mock_ctx)
|
|
489
|
+
# Pass a different user_id in the query string — must be ignored.
|
|
490
|
+
response = client.get("/pod/v1/agents/sessions?user_id=liam-uid")
|
|
491
|
+
|
|
492
|
+
assert response.status_code == 200
|
|
493
|
+
# Store must have been called with alice's uid, not liam's.
|
|
494
|
+
mock_store.list_sessions.assert_awaited_once_with(user_id="alice-uid")
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def test_get_session_messages_passes_caller_uid_to_store(monkeypatch, tmp_path) -> None:
|
|
498
|
+
"""
|
|
499
|
+
GET /sessions/{id}/messages must pass the JWT uid as user_id to the history
|
|
500
|
+
store so only the caller's rows are returned.
|
|
501
|
+
|
|
502
|
+
Why this test exists:
|
|
503
|
+
- verifies that cross-user message history access is blocked at the store call
|
|
504
|
+
level — a user cannot read another user's conversation by knowing a session_id
|
|
505
|
+
"""
|
|
506
|
+
app, fake_user = _make_app_with_user(monkeypatch, tmp_path, "alice-uid")
|
|
507
|
+
|
|
508
|
+
mock_store = AsyncMock()
|
|
509
|
+
mock_store.get = AsyncMock(return_value=[])
|
|
510
|
+
mock_ctx = RuntimeContext(
|
|
511
|
+
RuntimeConfig(
|
|
512
|
+
knowledge_flow_url="http://localhost:8111/knowledge-flow/v1",
|
|
513
|
+
history_store=mock_store,
|
|
514
|
+
)
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
with TestClient(app) as client:
|
|
518
|
+
monkeypatch.setattr(agent_app_module, "get_runtime_context", lambda: mock_ctx)
|
|
519
|
+
response = client.get("/pod/v1/agents/sessions/session-liam/messages")
|
|
520
|
+
|
|
521
|
+
assert response.status_code == 200
|
|
522
|
+
mock_store.get.assert_awaited_once_with(
|
|
523
|
+
session_id="session-liam", user_id="alice-uid"
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def test_delete_session_passes_caller_uid_to_store(monkeypatch, tmp_path) -> None:
|
|
528
|
+
"""
|
|
529
|
+
DELETE /sessions/{id} must pass the JWT uid as user_id so only the caller's
|
|
530
|
+
rows are deleted — prevents one user from deleting another user's history.
|
|
531
|
+
|
|
532
|
+
Why this test exists:
|
|
533
|
+
- delete without user scoping is an irreversible cross-user data destruction
|
|
534
|
+
vector; this verifies the ownership filter is applied at the store layer
|
|
535
|
+
"""
|
|
536
|
+
app, fake_user = _make_app_with_user(monkeypatch, tmp_path, "alice-uid")
|
|
537
|
+
|
|
538
|
+
mock_store = AsyncMock()
|
|
539
|
+
mock_store.delete_session = AsyncMock(return_value=3)
|
|
540
|
+
mock_ctx = RuntimeContext(
|
|
541
|
+
RuntimeConfig(
|
|
542
|
+
knowledge_flow_url="http://localhost:8111/knowledge-flow/v1",
|
|
543
|
+
history_store=mock_store,
|
|
544
|
+
)
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
with TestClient(app) as client:
|
|
548
|
+
monkeypatch.setattr(agent_app_module, "get_runtime_context", lambda: mock_ctx)
|
|
549
|
+
response = client.delete("/pod/v1/agents/sessions/session-liam")
|
|
550
|
+
|
|
551
|
+
assert response.status_code == 200
|
|
552
|
+
assert response.json() == {"deleted": 3}
|
|
553
|
+
mock_store.delete_session.assert_awaited_once_with(
|
|
554
|
+
session_id="session-liam", user_id="alice-uid"
|
|
555
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/runtime_support/request_context_helpers.py
RENAMED
|
File without changes
|
|
File without changes
|
{fred_runtime-2.0.7 → fred_runtime-2.0.8}/fred_runtime/runtime_support/user_token_refresher.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|