44
55import logging
66import re
7+ from pathlib import Path
78from typing import Any
89
910logger = logging .getLogger (__name__ )
1011
1112WORKSPACE_PREFIX = "/workspace"
13+ SKILLS_PREFIX = "/skills"
14+ MEMORIES_PREFIX = "/memories"
15+ MEMORY_FILE = f"{ MEMORIES_PREFIX } /AGENTS.md"
16+
17+ _SYSTEM_PREFIXES = (SKILLS_PREFIX , MEMORIES_PREFIX )
1218_DRIVE_IN_WORKSPACE = re .compile (r"^/workspace([A-Za-z]:)" )
1319_WINDOWS_ABS = re .compile (r"^[A-Za-z]:[\\/]" )
1420
@@ -23,21 +29,12 @@ def normalize_workspace_path(path: str | None) -> str:
2329
2430 Rejects Windows absolute paths, ``/workspaceD:...`` concatenations, and ``..``.
2531 """
26- if path is None or not str (path ).strip ():
27- raise ValueError ("Path is required; use /workspace/<relative-path>" )
28-
29- raw = str (path ).strip ().replace ("\\ " , "/" )
30- if _WINDOWS_ABS .match (raw ) or raw .startswith ("//" ):
31- raise ValueError (
32- "Host absolute paths are not allowed. Use /workspace/<relative-path>."
33- )
32+ raw = _normalize_slashes (path )
3433 if _DRIVE_IN_WORKSPACE .match (raw ):
3534 raise ValueError (
3635 "Invalid path: do not concatenate /workspace with a Windows drive path "
3736 "(e.g. /workspaceD:\\ ...). Use /workspace/<relative-path>."
3837 )
39- if not raw .startswith ("/" ):
40- raw = f"/{ raw } "
4138
4239 if raw == "/workspace" or raw .startswith ("/workspace/" ):
4340 virtual = raw
@@ -46,12 +43,41 @@ def normalize_workspace_path(path: str | None) -> str:
4643 else :
4744 virtual = f"/workspace{ raw } " if raw .startswith ("/" ) else f"/workspace/{ raw } "
4845
46+ return _finalize_virtual_path (virtual , required_root = "workspace" )
47+
48+
49+ def normalize_backend_path (path : str | None ) -> str :
50+ """Normalize paths for workspace tools and system mounts.
51+
52+ Allows ``/workspace``, ``/skills``, and ``/memories`` virtual trees.
53+ Rejects host absolute paths and ``..`` traversal.
54+ """
55+ raw = _normalize_slashes (path )
56+ for prefix in _SYSTEM_PREFIXES :
57+ if raw == prefix or raw .startswith (f"{ prefix } /" ):
58+ return _finalize_virtual_path (raw , required_root = prefix .lstrip ("/" ))
59+ return normalize_workspace_path (path )
60+
61+
62+ def _normalize_slashes (path : str | None ) -> str :
63+ if path is None or not str (path ).strip ():
64+ raise ValueError ("Path is required; use /workspace/<relative-path>" )
65+ raw = str (path ).strip ().replace ("\\ " , "/" )
66+ if _WINDOWS_ABS .match (raw ) or raw .startswith ("//" ):
67+ raise ValueError (
68+ "Host absolute paths are not allowed. Use /workspace/<relative-path>."
69+ )
70+ if not raw .startswith ("/" ):
71+ raw = f"/{ raw } "
72+ return raw
73+
74+
75+ def _finalize_virtual_path (virtual : str , * , required_root : str ) -> str :
4976 parts = [p for p in virtual .split ("/" ) if p ]
5077 if ".." in parts :
5178 raise ValueError ("Path traversal ('..') is not allowed." )
52- if not parts or parts [0 ] != "workspace" :
79+ if not parts or parts [0 ] != required_root :
5380 raise ValueError ("Filesystem tools must use /workspace/<relative-path>." )
54-
5581 return "/" + "/" .join (parts )
5682
5783
@@ -98,7 +124,7 @@ def __getattr__(self, name: str) -> Any:
98124 return getattr (self ._inner , name )
99125
100126 def _guard (self , path : str | None ) -> str :
101- return normalize_workspace_path (path )
127+ return normalize_backend_path (path )
102128
103129 def read (self , file_path : str , offset : int = 0 , limit : int = 2000 ) -> Any :
104130 return coerce_read_result (
@@ -125,6 +151,9 @@ def edit(
125151 def ls (self , path : str = "/workspace" ) -> Any :
126152 return self ._inner .ls (self ._guard (path ))
127153
154+ async def als (self , path : str = "/workspace" ) -> Any :
155+ return await self ._inner .als (self ._guard (path ))
156+
128157 def glob (self , pattern : str , path : str = "/workspace" ) -> Any :
129158 return self ._inner .glob (pattern , self ._guard (path ))
130159
@@ -137,16 +166,53 @@ def grep(
137166 safe = self ._guard (path ) if path else "/workspace"
138167 return self ._inner .grep (pattern , path = safe , glob = glob )
139168
169+ def download_files (self , paths : list [str ]) -> Any :
170+ return self ._inner .download_files ([self ._guard (p ) for p in paths ])
171+
172+ async def adownload_files (self , paths : list [str ]) -> Any :
173+ return await self ._inner .adownload_files ([self ._guard (p ) for p in paths ])
174+
175+
176+ def _mount_system_routes (
177+ routes : dict [str , Any ],
178+ filesystem_cls : Any | None ,
179+ skills_dir : Path | None ,
180+ memories_dir : Path | None ,
181+ ) -> None :
182+ if filesystem_cls is None :
183+ return
184+ if skills_dir is not None :
185+ root = Path (skills_dir )
186+ root .mkdir (parents = True , exist_ok = True )
187+ routes [f"{ SKILLS_PREFIX } /" ] = filesystem_cls (
188+ root_dir = str (root ),
189+ virtual_mode = True ,
190+ )
191+ if memories_dir is not None :
192+ root = Path (memories_dir )
193+ root .mkdir (parents = True , exist_ok = True )
194+ routes [f"{ MEMORIES_PREFIX } /" ] = filesystem_cls (
195+ root_dir = str (root ),
196+ virtual_mode = True ,
197+ )
198+
140199
141200def make_workspace_backend_factory (
142201 validated_root ,
143202 local_shell_cls ,
144203 state_cls ,
145204 composite_cls ,
205+ * ,
206+ filesystem_cls = None ,
207+ skills_dir : Path | None = None ,
208+ memories_dir : Path | None = None ,
146209):
147- """Bind project dir under /workspace with LocalShellBackend + CompositeBackend ."""
210+ """Bind project dir under /workspace; mount global skills/memories separately ."""
148211
149212 def factory (rt ):
213+ routes : dict [str , Any ] = {}
214+ _mount_system_routes (routes , filesystem_cls , skills_dir , memories_dir )
215+
150216 if validated_root is not None :
151217 shell = local_shell_cls (
152218 root_dir = str (validated_root ),
@@ -155,21 +221,24 @@ def factory(rt):
155221 )
156222 # Route /workspace/* onto the same shell-backed root. default=shell
157223 # keeps execute() cwd at the project directory.
158- composite = composite_cls (
159- default = shell ,
160- routes = {f"{ WORKSPACE_PREFIX } /" : shell },
161- )
224+ routes [f"{ WORKSPACE_PREFIX } /" ] = shell
225+ composite = composite_cls (default = shell , routes = routes )
162226 return WorkspacePathBackend (composite )
163- return composite_cls (default = state_cls (rt ), routes = {})
227+
228+ return composite_cls (default = state_cls (rt ), routes = routes )
164229
165230 return factory
166231
167232
168233__all__ = [
234+ "MEMORY_FILE" ,
235+ "MEMORIES_PREFIX" ,
236+ "SKILLS_PREFIX" ,
169237 "WORKSPACE_PREFIX" ,
170238 "WorkspacePathBackend" ,
171239 "coerce_file_data" ,
172240 "coerce_read_result" ,
173241 "make_workspace_backend_factory" ,
242+ "normalize_backend_path" ,
174243 "normalize_workspace_path" ,
175244]
0 commit comments