fix: 岗位会商 handoff 将上游产物镜像到当前席位沙箱
席位线程隔离导致下游 read_file 找不到上游路径;镜像到 workspace/upstream 并改写 handoff 引用,附带安全路径校验与 SQL 回退测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
440b490b0d
commit
32e509b2b3
3 files changed
+238
-7
No files matched your search
@@ -6,6 +6,7 @@ import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
@@ -1244,8 +1245,11 @@ def test_activation_requires_a_structured_intent_snapshot() -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handoff_context_inlines_only_upstream_thread_text_artifacts(tmp_path, monkeypatch):
|
||||
artifact = tmp_path / "handoff.md"
|
||||
upstream_dir = tmp_path / "up" / "outputs"
|
||||
upstream_dir.mkdir(parents=True)
|
||||
artifact = upstream_dir / "handoff.md"
|
||||
artifact.write_text("上游产物正文:可供下一岗位直接参考。", encoding="utf-8")
|
||||
mirror_root = tmp_path / "current" / "workspace" / "upstream"
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
class StubStore:
|
||||
@@ -1307,9 +1311,12 @@ async def test_handoff_context_inlines_only_upstream_thread_text_artifacts(tmp_p
|
||||
|
||||
async def resolve(thread_id: str, path: str):
|
||||
calls.append((thread_id, path))
|
||||
assert thread_id == "upstream-thread"
|
||||
assert path == "mnt/user-data/outputs/handoff.md"
|
||||
return artifact
|
||||
if thread_id == "upstream-thread":
|
||||
assert path == "mnt/user-data/outputs/handoff.md"
|
||||
return artifact
|
||||
assert thread_id == "current-thread"
|
||||
assert path == "mnt/user-data/workspace/upstream/handoff.md"
|
||||
return mirror_root / "handoff.md"
|
||||
|
||||
monkeypatch.setattr(position_roundtable_router, "aresolve_thread_virtual_path", resolve)
|
||||
request = SimpleNamespace(
|
||||
@@ -1325,8 +1332,102 @@ async def test_handoff_context_inlines_only_upstream_thread_text_artifacts(tmp_p
|
||||
assert len(context["upstream_nodes"]) == 1
|
||||
assert upstream["latest_answer"] == "上游问答结果"
|
||||
assert upstream["artifacts"][0]["content"] == "上游产物正文:可供下一岗位直接参考。"
|
||||
assert upstream["artifacts"][0]["upstream_path"] == "mnt/user-data/outputs/handoff.md"
|
||||
assert upstream["artifacts"][0]["path"] == "mnt/user-data/workspace/upstream/handoff.md"
|
||||
assert upstream["artifacts"][0]["mirrored_to_sandbox"] is True
|
||||
assert "content" not in upstream["artifacts"][1]
|
||||
assert calls == [("upstream-thread", "mnt/user-data/outputs/handoff.md")]
|
||||
assert "mirrored_to_sandbox" not in upstream["artifacts"][1]
|
||||
assert upstream["artifacts"][1]["path"] == "mnt/user-data/uploads/outside.md"
|
||||
assert (mirror_root / "handoff.md").read_text(encoding="utf-8") == "上游产物正文:可供下一岗位直接参考。"
|
||||
assert "/mnt/user-data/workspace/upstream/handoff.md" in context["instructions"]
|
||||
assert set(calls) == {
|
||||
("upstream-thread", "mnt/user-data/outputs/handoff.md"),
|
||||
("current-thread", "mnt/user-data/workspace/upstream/handoff.md"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handoff_mirror_falls_back_to_persisted_content_and_skips_unsafe_paths(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
mirror_root = tmp_path / "mirror"
|
||||
resolved: list[tuple[str, str]] = []
|
||||
|
||||
class StubStore:
|
||||
async def get_session(self, session_id, user_id):
|
||||
assert (session_id, user_id) == ("session-a", "user-a")
|
||||
return {"task_snapshot": {"title": "测试任务"}, "intent_snapshot": {}}
|
||||
|
||||
async def list_nodes(self, session_id, user_id):
|
||||
return [
|
||||
{
|
||||
"node_key": "stage-1-seat-1-agent-a",
|
||||
"stage_index": 0,
|
||||
"seat_index": 0,
|
||||
"agent_id": "agent-a",
|
||||
"position_id": "planning",
|
||||
"thread_id": "upstream-thread",
|
||||
"status": "done",
|
||||
"revision": 1,
|
||||
"latest_answer": "上游问答结果",
|
||||
"artifact_manifest": [
|
||||
{
|
||||
"name": "gone.md",
|
||||
"path": "mnt/user-data/outputs/gone.md",
|
||||
"mime_type": "text/markdown",
|
||||
"content": "数据库恢复正文",
|
||||
},
|
||||
{
|
||||
"name": "escape.md",
|
||||
"path": "mnt/user-data/outputs/../escape.md",
|
||||
"mime_type": "text/markdown",
|
||||
"content": "不应被镜像",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"node_key": "stage-2-seat-1-agent-b",
|
||||
"stage_index": 1,
|
||||
"seat_index": 0,
|
||||
"agent_id": "agent-b",
|
||||
"position_id": "solution",
|
||||
"thread_id": "current-thread",
|
||||
"status": "ready",
|
||||
"revision": 0,
|
||||
"latest_answer": None,
|
||||
"artifact_manifest": [],
|
||||
},
|
||||
]
|
||||
|
||||
missing_source = tmp_path / "missing-source"
|
||||
|
||||
async def resolve(thread_id: str, path: str):
|
||||
resolved.append((thread_id, path))
|
||||
if thread_id == "upstream-thread":
|
||||
return missing_source / "not-materialized.md"
|
||||
assert thread_id == "current-thread"
|
||||
return mirror_root / Path(path).name
|
||||
|
||||
monkeypatch.setattr(position_roundtable_router, "aresolve_thread_virtual_path", resolve)
|
||||
request = SimpleNamespace(
|
||||
app=SimpleNamespace(state=SimpleNamespace(position_roundtable_store=StubStore())),
|
||||
state=SimpleNamespace(user=SimpleNamespace(id="user-a")),
|
||||
)
|
||||
|
||||
context = await position_roundtable_router._handoff_context(
|
||||
request, "session-a", "stage-2-seat-1-agent-b"
|
||||
)
|
||||
|
||||
artifacts = context["upstream_nodes"][0]["artifacts"]
|
||||
assert artifacts[0]["path"] == "mnt/user-data/workspace/upstream/gone.md"
|
||||
assert artifacts[0]["mirrored_to_sandbox"] is True
|
||||
assert (mirror_root / "gone.md").read_text(encoding="utf-8") == "数据库恢复正文"
|
||||
# 持久化正文同时内嵌,沙箱磁盘丢失也不影响下一岗位读到内容。
|
||||
assert artifacts[0]["content"] == "数据库恢复正文"
|
||||
assert artifacts[1]["path"] == "mnt/user-data/outputs/../escape.md"
|
||||
assert "mirrored_to_sandbox" not in artifacts[1]
|
||||
assert not (mirror_root / "escape.md").exists()
|
||||
assert all(".." not in path for _, path in resolved)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in new issue
Block a user