Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions src/octop/api/common/memory_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,18 +86,41 @@ def get_or_open(
backend_config=backend_config,
)
bridge = Bridge(memory)
# 2026-09-07 修复:替换旧实例(fingerprint 变化)时先释放旧资源
old = self._entries.get(agent_id)
if old is not None and old[2] != fingerprint:
_close_memory_instances(old[0], old[1])
self._entries[agent_id] = (memory, bridge, fingerprint)
while len(self._entries) > self._max_size:
_, evicted = self._entries.popitem(last=False)
logger.debug("memory dashboard cache evicted agent_id=%s", evicted)
evicted_id, evicted_val = self._entries.popitem(last=False)
_close_memory_instances(evicted_val[0], evicted_val[1])
logger.debug("memory dashboard cache evicted agent_id=%s", evicted_id)
return memory, bridge

def invalidate(self, agent_id: str | None = None) -> None:
with self._lock:
if agent_id is None:
for _id, val in self._entries.items():
_close_memory_instances(val[0], val[1])
self._entries.clear()
else:
self._entries.pop(agent_id, None)
val = self._entries.pop(agent_id, None)
if val is not None:
_close_memory_instances(val[0], val[1])


def _close_memory_instances(memory: Any, bridge: Any) -> None:
"""防御性释放:harness-memory 无公开 teardown,sqlite 连接随 GC 关闭;
兜底尝试 close/teardown(若未来版本新增),静默忽略异常。"""
for inst in (bridge, memory):
for name in ("close", "teardown", "aclose"):
fn = getattr(inst, name, None)
if callable(fn):
try:
fn()
except Exception: # noqa: BLE001 - 释放失败不影响主流程
logger.debug("memory instance %s.%s failed", type(inst).__name__, name, exc_info=True)
break


_CACHE = _MemoryCache()
Expand Down
4 changes: 4 additions & 0 deletions src/octop/api/common/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ def workspace_api_path(raw: str) -> str:
text = raw.strip().replace("\\", "/")
if not text or text == "/":
return "."
# 拒绝路径穿越段(2026-09-07 修复:原只 lstrip("/"),`..` 段透传给后端
# 触发 500 噪音;对齐 host_dirs/knowledge relpath 的既有拒绝纪律)。
if any(seg == ".." for seg in text.split("/")):
raise OctopError(ErrorCode.FORBIDDEN, f"path traversal not allowed: {raw!r}")
return text.lstrip("/")


Expand Down
21 changes: 16 additions & 5 deletions src/octop/api/routers/connectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import asyncio
import html
import json
import logging
import secrets
Expand Down Expand Up @@ -414,6 +415,10 @@ def _credentials_preview(kind: str, creds: dict[str, Any]) -> dict[str, Any]:
return preview


# 2026-09-07 修复:后台任务强引用集合(asyncio 要求保存引用防 GC 回收)。
_BACKGROUND_TASKS: set[asyncio.Task] = set()


def _schedule_connector_reload(server: Any, user_id: int, *, all_users: bool = False) -> None:
assert server.app_runtime is not None

Expand All @@ -426,7 +431,9 @@ async def _run() -> None:
except Exception:
logger.exception("background connector reload failed for user %s", user_id)

asyncio.create_task(_run())
task = asyncio.create_task(_run(), name=f"connector-reload-user-{user_id}")
_BACKGROUND_TASKS.add(task)
task.add_done_callback(_BACKGROUND_TASKS.discard)


def _can_manage_connector(inst: Any, user: Any) -> bool:
Expand Down Expand Up @@ -1350,18 +1357,22 @@ async def oauth_callback(
redirect = row.redirect_after or "/connectors"
locale = resolve_request_locale(request)
success_message = tr("connector.oauth.callback_success", locale)
html = f"""<!DOCTYPE html><html><body>
# 2026-09-07 修复:redirect_after 为用户可控(OAuth start body),原样拼入
# JS 字符串可被注入(self-XSS)。转义后再嵌入。
redirect_esc = html.escape(redirect, quote=True)
state_esc = html.escape(row.state_id, quote=True)
html_doc = f"""<!DOCTYPE html><html><body>
<script>
if (window.opener) {{
window.opener.postMessage({{ type: 'octop:connector-oauth', state_id: '{row.state_id}' }}, '*');
window.opener.postMessage({{ type: 'octop:connector-oauth', state_id: '{state_esc}' }}, '*');
window.close();
}} else {{
window.location.href = '{redirect}?oauth_state={row.state_id}';
window.location.href = '{redirect_esc}?oauth_state={state_esc}';
}}
</script>
<p>{success_message}</p>
</body></html>"""
return HTMLResponse(html)
return HTMLResponse(html_doc)


@router.get("/connectors/oauth/pending/{state_id}", summary="Poll OAuth result")
Expand Down
3 changes: 3 additions & 0 deletions src/octop/api/routers/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@

from octop.api.deps import current_user, get_server, require_permission
from octop.infra.agents.providers.model_flags import is_local_runtime_provider

# 2026-09-07 修复:后台任务强引用集合(asyncio 要求保存引用防 GC 回收)。
_BACKGROUND_TASKS: set[asyncio.Task] = set()
from octop.infra.agents.providers.presets import load_provider_presets
from octop.infra.agents.providers.probe import (
fetch_openai_compatible_models,
Expand Down
11 changes: 9 additions & 2 deletions src/octop/infra/agents/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,9 @@ def __init__(
self._history_backfills: dict[str, asyncio.Event] = {}
self._reload_dirty: set[str] = set()
self._reload_worker_running: dict[str, bool] = {}
# 2026-09-07 修复:后台任务强引用集合(asyncio 要求保存引用防 GC 回收——
# 原 create_task 返回值只存 bool 标记,任务对象可能被回收导致 reload 静默失败)。
self._background_tasks: set[asyncio.Task] = set()
self._bootstrap_graph_refresh_pending: set[str] = set()
# Chat user id used to resolve connectors when agent.user_id is NULL (shared agents).
self._connector_user_override: dict[str, int] = {}
Expand Down Expand Up @@ -573,10 +576,12 @@ async def create(
self._repos.agent_repo.set_state(agent_id, "starting")
row = self._repos.agent_repo.get(agent_id)
assert row is not None
asyncio.create_task(
task = asyncio.create_task(
self._complete_create_bootstrap(row),
name=f"bootstrap-agent-{agent_id}",
)
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
else:
agent = await self._start_agent(row, init_workspace=True)
if agent is not None and spec.template_name:
Expand Down Expand Up @@ -2332,7 +2337,9 @@ def _schedule_reload(self, agent_id: str) -> None:
if self._reload_worker_running.get(agent_id):
return
self._reload_worker_running[agent_id] = True
asyncio.create_task(self._reload_worker(agent_id), name=f"reload-agent-{agent_id}")
task = asyncio.create_task(self._reload_worker(agent_id), name=f"reload-agent-{agent_id}")
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)

async def _reload_worker(self, agent_id: str) -> None:
try:
Expand Down
15 changes: 14 additions & 1 deletion src/octop/infra/connectors/mcp_tool_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import asyncio
import hashlib
import json
import threading
from typing import Any

from langchain_core.tools import StructuredTool
Expand Down Expand Up @@ -79,7 +80,19 @@ async def _run() -> Any:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(_run())
return _tool.invoke(kwargs)
# 2026-09-07 修复:原代码在 running loop 存在时直接 `_tool.invoke(kwargs)`
# ——不持共享锁(共享 MCP 会话并发交错)且阻塞事件循环。
# 改为独立线程起新 loop 跑带锁协程:持锁序列化 + 不阻塞当前 loop;
# 不用 run_coroutine_threadsafe(调用方恰在 loop 线程时会死锁)。
box: dict[str, Any] = {}

def _worker() -> None:
box["value"] = asyncio.run(_run())

t = threading.Thread(target=_worker, daemon=True)
t.start()
t.join()
return box["value"]

st_kwargs: dict[str, Any] = {
"name": name,
Expand Down
7 changes: 6 additions & 1 deletion src/octop/infra/gateway/media/backend_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,12 @@ def is_allowed_host_download_abs_path(path: str, *, workspace: Path) -> bool:
pass

if "/.octop/agents/" in norm:
return True
# 2026-09-07 修复:原对任意 /.octop/agents/ 路径放行(不校验归属),
# 多用户/共享 agent 场景下可跨 agent 读他人工作区。收窄为当前 agent
# 自身 workspace 前缀(workspace 内部已由上方 relative_to 覆盖,此分支
# 只是兜底同前缀场景);其他 agent 目录显式拒绝。
ws_norm = str(workspace.resolve()).replace("\\", "/").lower()
return norm == ws_norm or norm.startswith(ws_norm.rstrip("/") + "/")
if is_allowed_host_temp_path(resolved):
return True

Expand Down