Skip to content

Commit 2aeb1a8

Browse files
committed
fix: 修复沙盒共享权限配置错误的问题 #594
1 parent e05eacf commit 2aeb1a8

18 files changed

Lines changed: 244 additions & 151 deletions

File tree

backend/package/yuxi/agents/backends/composite.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,23 @@ def _extract_thread_id(runtime) -> str:
9494
raise ValueError("thread_id is required in runtime configurable context")
9595

9696

97+
def _extract_user_id(runtime) -> str:
98+
config = getattr(runtime, "config", None)
99+
if isinstance(config, dict):
100+
configurable = config.get("configurable", {})
101+
if isinstance(configurable, dict):
102+
user_id = configurable.get("user_id")
103+
if isinstance(user_id, str) and user_id.strip():
104+
return user_id.strip()
105+
106+
context = getattr(runtime, "context", None)
107+
user_id = getattr(context, "user_id", None)
108+
if isinstance(user_id, str) and user_id.strip():
109+
return user_id.strip()
110+
111+
raise ValueError("user_id is required in runtime configurable context")
112+
113+
97114
def _get_visible_knowledge_bases_from_runtime(runtime) -> list[dict]:
98115
context = getattr(runtime, "context", None)
99116
selected = getattr(context, "_visible_knowledge_bases", None)
@@ -105,9 +122,10 @@ def _get_visible_knowledge_bases_from_runtime(runtime) -> list[dict]:
105122
def create_agent_composite_backend(runtime) -> CompositeBackend:
106123
visible_skills = _get_visible_skills_from_runtime(runtime)
107124
thread_id = _extract_thread_id(runtime)
125+
user_id = _extract_user_id(runtime)
108126
visible_kbs = _get_visible_knowledge_bases_from_runtime(runtime)
109127
return CustomCompositeBackend(
110-
default=ProvisionerSandboxBackend(thread_id=thread_id, visible_skills=visible_skills),
128+
default=ProvisionerSandboxBackend(thread_id=thread_id, user_id=user_id, visible_skills=visible_skills),
111129
routes={
112130
"/skills/": SelectedSkillsReadonlyBackend(selected_slugs=visible_skills),
113131
"/home/gem/kbs/": KnowledgeBaseReadonlyBackend(visible_kbs=visible_kbs),

backend/package/yuxi/agents/backends/sandbox/backend.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,13 @@ def _looks_like_binary(content: bytes) -> bool:
6262

6363

6464
class ProvisionerSandboxBackend(BaseSandbox):
65-
def __init__(self, thread_id: str, *, visible_skills: list[str] | None = None):
65+
def __init__(self, thread_id: str, *, user_id: str, visible_skills: list[str] | None = None):
6666
self._thread_id = str(thread_id or "").strip()
6767
if not self._thread_id:
6868
raise ValueError("thread_id is required for ProvisionerSandboxBackend")
69+
self._user_id = str(user_id or "").strip()
70+
if not self._user_id:
71+
raise ValueError("user_id is required for ProvisionerSandboxBackend")
6972

7073
self._visible_skills = list(visible_skills or [])
7174
self._provider = get_sandbox_provider()
@@ -91,7 +94,7 @@ def _build_client(self, sandbox_url: str):
9194

9295
def _get_client(self) -> Any:
9396
sync_thread_visible_skills(self._thread_id, self._visible_skills)
94-
connection = self._provider.get(self._thread_id, create_if_missing=True)
97+
connection = self._provider.get(self._thread_id, user_id=self._user_id, create_if_missing=True)
9598
if connection is None:
9699
raise RuntimeError(f"sandbox is unavailable for thread {self._thread_id}")
97100

backend/package/yuxi/agents/backends/sandbox/paths.py

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from yuxi import config as conf
77
from yuxi.utils.paths import OUTPUTS_DIR_NAME, UPLOADS_DIR_NAME, VIRTUAL_PATH_PREFIX, WORKSPACE_DIR_NAME
88

9-
_SAFE_THREAD_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
9+
_SAFE_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
1010

1111

1212
def get_virtual_path_prefix() -> str:
@@ -17,7 +17,7 @@ def _validate_thread_id(thread_id: str) -> str:
1717
value = str(thread_id or "").strip()
1818
if not value:
1919
raise ValueError("thread_id is required")
20-
if not _SAFE_THREAD_ID_RE.match(value):
20+
if not _SAFE_ID_RE.match(value):
2121
raise ValueError("thread_id contains invalid characters")
2222
return value
2323

@@ -27,18 +27,28 @@ def _thread_root_dir(thread_id: str) -> Path:
2727
return Path(conf.save_dir) / "threads" / safe_thread_id / "user-data"
2828

2929

30-
def _global_user_data_dir() -> Path:
31-
"""Return the shared host-side directory used for thread workspace files."""
32-
return Path(conf.save_dir) / "threads" / "shared"
30+
def _validate_user_id(user_id: str) -> str:
31+
value = str(user_id or "").strip()
32+
if not value:
33+
raise ValueError("user_id is required")
34+
if not _SAFE_ID_RE.match(value):
35+
raise ValueError("user_id contains invalid characters")
36+
return value
37+
38+
39+
def _global_user_data_dir(user_id: str) -> Path:
40+
"""Return the shared host-side directory used for one user's workspace files."""
41+
safe_user_id = _validate_user_id(user_id)
42+
return Path(conf.save_dir) / "threads" / "shared" / safe_user_id
3343

3444

3545
def sandbox_user_data_dir(thread_id: str) -> Path:
3646
return _thread_root_dir(thread_id)
3747

3848

39-
def sandbox_workspace_dir(thread_id: str) -> Path:
49+
def sandbox_workspace_dir(thread_id: str, user_id: str) -> Path:
4050
_validate_thread_id(thread_id)
41-
return _global_user_data_dir() / WORKSPACE_DIR_NAME
51+
return _global_user_data_dir(user_id) / WORKSPACE_DIR_NAME
4252

4353

4454
def sandbox_uploads_dir(thread_id: str) -> Path:
@@ -49,14 +59,14 @@ def sandbox_outputs_dir(thread_id: str) -> Path:
4959
return _thread_root_dir(thread_id) / OUTPUTS_DIR_NAME
5060

5161

52-
def ensure_thread_dirs(thread_id: str) -> None:
53-
_global_user_data_dir().mkdir(parents=True, exist_ok=True)
54-
sandbox_workspace_dir(thread_id).mkdir(parents=True, exist_ok=True)
62+
def ensure_thread_dirs(thread_id: str, user_id: str) -> None:
63+
_global_user_data_dir(user_id).mkdir(parents=True, exist_ok=True)
64+
sandbox_workspace_dir(thread_id, user_id).mkdir(parents=True, exist_ok=True)
5565
sandbox_uploads_dir(thread_id).mkdir(parents=True, exist_ok=True)
5666
sandbox_outputs_dir(thread_id).mkdir(parents=True, exist_ok=True)
5767

5868

59-
def _resolve_user_data_base_dir(thread_id: str, relative_path: str) -> tuple[Path, Path]:
69+
def _resolve_user_data_base_dir(thread_id: str, user_id: str, relative_path: str) -> tuple[Path, Path]:
6070
"""Map a virtual user-data path to the correct host-side base directory."""
6171
parts = Path(relative_path).parts
6272
if not parts:
@@ -65,8 +75,8 @@ def _resolve_user_data_base_dir(thread_id: str, relative_path: str) -> tuple[Pat
6575

6676
namespace = parts[0]
6777
if namespace == WORKSPACE_DIR_NAME:
68-
# Workspace is shared across threads, so it lives outside the per-thread root.
69-
base_dir = sandbox_workspace_dir(thread_id)
78+
# Workspace is shared across one user's threads, so it lives outside the per-thread root.
79+
base_dir = sandbox_workspace_dir(thread_id, user_id)
7080
target_path = base_dir.joinpath(*parts[1:]) if len(parts) > 1 else base_dir
7181
return base_dir.resolve(), target_path.resolve()
7282
if namespace == UPLOADS_DIR_NAME:
@@ -82,15 +92,15 @@ def _resolve_user_data_base_dir(thread_id: str, relative_path: str) -> tuple[Pat
8292
return base_dir.resolve(), (base_dir / relative_path).resolve()
8393

8494

85-
def resolve_virtual_path(thread_id: str, virtual_path: str) -> Path:
95+
def resolve_virtual_path(thread_id: str, virtual_path: str, *, user_id: str) -> Path:
8696
clean_virtual_path = "/" + str(virtual_path or "").strip().lstrip("/")
8797
virtual_prefix = get_virtual_path_prefix()
8898

8999
if clean_virtual_path != virtual_prefix and not clean_virtual_path.startswith(f"{virtual_prefix}/"):
90100
raise ValueError(f"path must start with {virtual_prefix}")
91101

92102
relative_path = clean_virtual_path[len(virtual_prefix) :].lstrip("/")
93-
base_dir, target_path = _resolve_user_data_base_dir(thread_id, relative_path)
103+
base_dir, target_path = _resolve_user_data_base_dir(thread_id, user_id, relative_path)
94104

95105
try:
96106
target_path.relative_to(base_dir)
@@ -100,10 +110,10 @@ def resolve_virtual_path(thread_id: str, virtual_path: str) -> Path:
100110
return target_path
101111

102112

103-
def virtual_path_for_thread_file(thread_id: str, path: str | Path) -> str:
113+
def virtual_path_for_thread_file(thread_id: str, path: str | Path, *, user_id: str) -> str:
104114
target_path = Path(path).resolve()
105115
thread_root = sandbox_user_data_dir(thread_id).resolve()
106-
global_workspace_root = sandbox_workspace_dir(thread_id).resolve()
116+
global_workspace_root = sandbox_workspace_dir(thread_id, user_id).resolve()
107117

108118
try:
109119
relative_path = target_path.relative_to(global_workspace_root)

backend/package/yuxi/agents/backends/sandbox/provider.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ def sandbox_id_for_thread(thread_id: str) -> str:
1919
@dataclass(slots=True)
2020
class SandboxConnection:
2121
thread_id: str
22+
user_id: str
2223
sandbox_id: str
2324
sandbox_url: str
2425

@@ -48,9 +49,10 @@ def _thread_lock(self, thread_id: str) -> threading.Lock:
4849
self._thread_locks[thread_id] = lock
4950
return lock
5051

51-
def _record_to_connection(self, thread_id: str, record: SandboxRecord) -> SandboxConnection:
52+
def _record_to_connection(self, thread_id: str, user_id: str, record: SandboxRecord) -> SandboxConnection:
5253
connection = SandboxConnection(
5354
thread_id=thread_id,
55+
user_id=user_id,
5456
sandbox_id=record.sandbox_id,
5557
sandbox_url=record.sandbox_url,
5658
)
@@ -73,7 +75,7 @@ def _touch_if_needed(self, connection: SandboxConnection) -> bool:
7375
self._last_touch_at[connection.thread_id] = time.time()
7476
return is_alive
7577

76-
def acquire(self, thread_id: str) -> str:
78+
def acquire(self, thread_id: str, *, user_id: str) -> str:
7779
lock = self._thread_lock(thread_id)
7880
with lock:
7981
current = self._connections.get(thread_id)
@@ -91,14 +93,14 @@ def acquire(self, thread_id: str) -> str:
9193
record = self._client.discover(sandbox_id)
9294
if record is None:
9395
logger.info(f"Creating sandbox {sandbox_id} for thread {thread_id}")
94-
record = self._client.create(sandbox_id, thread_id)
96+
record = self._client.create(sandbox_id, thread_id, user_id)
9597
else:
9698
logger.info(f"Reusing sandbox {sandbox_id} for thread {thread_id}")
9799

98-
connection = self._record_to_connection(thread_id, record)
100+
connection = self._record_to_connection(thread_id, user_id, record)
99101
return connection.sandbox_id
100102

101-
def get(self, thread_id: str, *, create_if_missing: bool = False) -> SandboxConnection | None:
103+
def get(self, thread_id: str, *, user_id: str, create_if_missing: bool = False) -> SandboxConnection | None:
102104
lock = self._thread_lock(thread_id)
103105
with lock:
104106
current = self._connections.get(thread_id)
@@ -111,19 +113,19 @@ def get(self, thread_id: str, *, create_if_missing: bool = False) -> SandboxConn
111113
except Exception as exc: # noqa: BLE001
112114
logger.warning(f"Failed to touch sandbox {current.sandbox_id} for thread {thread_id}: {exc}")
113115
return current
114-
115-
current = self._connections.get(thread_id)
116-
if current:
117-
return current
116+
if current.user_id == user_id:
117+
return current
118+
self._connections.pop(thread_id, None)
119+
self._last_touch_at.pop(thread_id, None)
118120

119121
sandbox_id = sandbox_id_for_thread(thread_id)
120122
record = self._client.discover(sandbox_id)
121123
if record is None:
122124
if not create_if_missing:
123125
return None
124-
record = self._client.create(sandbox_id, thread_id)
126+
record = self._client.create(sandbox_id, thread_id, user_id)
125127

126-
return self._record_to_connection(thread_id, record)
128+
return self._record_to_connection(thread_id, user_id, record)
127129

128130
def shutdown(self) -> None:
129131
with self._lock:

backend/package/yuxi/agents/backends/sandbox/provisioner_client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,11 @@ def health(self) -> bool:
2929
response = self._request("GET", "/health")
3030
return response.status_code == 200
3131

32-
def create(self, sandbox_id: str, thread_id: str) -> SandboxRecord:
32+
def create(self, sandbox_id: str, thread_id: str, user_id: str) -> SandboxRecord:
3333
response = self._request(
3434
"POST",
3535
"/api/sandboxes",
36-
json={"sandbox_id": sandbox_id, "thread_id": thread_id},
36+
json={"sandbox_id": sandbox_id, "thread_id": thread_id, "user_id": user_id},
3737
)
3838
if response.status_code >= 400:
3939
raise RuntimeError(f"failed to create sandbox {sandbox_id}: {response.status_code} {response.text}")

backend/package/yuxi/agents/toolkits/buildin/tools.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,11 @@ def _normalize_presented_artifact_path(filepath: str, runtime: ToolRuntime) -> s
8383
thread_id = getattr(runtime_context, "thread_id", None)
8484
if not thread_id:
8585
raise ValueError("当前运行时缺少 thread_id")
86+
user_id = getattr(runtime_context, "user_id", None)
87+
if not user_id:
88+
raise ValueError("当前运行时缺少 user_id")
8689

87-
ensure_thread_dirs(thread_id)
90+
ensure_thread_dirs(thread_id, str(user_id))
8891
outputs_dir = sandbox_outputs_dir(thread_id).resolve()
8992
normalized_input = str(filepath or "").strip()
9093
if not normalized_input:
@@ -93,7 +96,7 @@ def _normalize_presented_artifact_path(filepath: str, runtime: ToolRuntime) -> s
9396
stripped = normalized_input.lstrip("/")
9497
virtual_prefix = VIRTUAL_PATH_PREFIX.lstrip("/")
9598
if stripped == virtual_prefix or stripped.startswith(f"{virtual_prefix}/"):
96-
actual_path = resolve_virtual_path(thread_id, normalized_input)
99+
actual_path = resolve_virtual_path(thread_id, normalized_input, user_id=str(user_id))
97100
else:
98101
actual_path = Path(normalized_input).expanduser().resolve()
99102

backend/package/yuxi/services/conversation_service.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,12 +209,13 @@ def serialize_attachment(record: dict) -> dict:
209209
async def _materialize_attachment_files(
210210
*,
211211
thread_id: str,
212+
user_id: str,
212213
upload: UploadFile,
213214
file_name: str,
214215
file_content: bytes,
215216
) -> dict:
216217
"""将原始附件与可选 markdown 副本落盘到线程 user-data。"""
217-
ensure_thread_dirs(thread_id)
218+
ensure_thread_dirs(thread_id, user_id)
218219

219220
upload_virtual_path = _make_upload_virtual_path(file_name)
220221
uploads_dir = sandbox_uploads_dir(thread_id)
@@ -384,6 +385,7 @@ async def upload_thread_attachment_view(
384385
raise HTTPException(status_code=400, detail=f"附件过大,当前仅支持 {max_size_mb} MB 以内的文件")
385386
materialized = await _materialize_attachment_files(
386387
thread_id=thread_id,
388+
user_id=str(conversation.user_id),
387389
upload=file,
388390
file_name=file_name,
389391
file_content=file_content,

backend/package/yuxi/services/filesystem_service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ async def _resolve_filesystem_state(
6767
runtime_context.user_id = str(user.id)
6868
await resolve_visible_knowledge_bases_for_context(runtime_context)
6969

70-
sandbox_backend = ProvisionerSandboxBackend(thread_id=thread_id)
70+
sandbox_backend = ProvisionerSandboxBackend(thread_id=thread_id, user_id=str(user.id))
7171
return conversation, runtime_context, sandbox_backend
7272

7373

0 commit comments

Comments
 (0)