@@ -237,6 +237,9 @@ def __init__(
237237 self ._apply_tar_extract ()
238238 self ._exit_code = 0
239239 self ._done .set ()
240+ elif self ._is_resolve_workspace_path_command (command ):
241+ self ._resolve_workspace_path (command )
242+ self ._done .set ()
240243 elif " cat -- " in command or command .startswith ("cat -- " ):
241244 self ._stdout = self ._read_file_text (command )
242245 self ._emit (stdout_cb , self ._stdout )
@@ -275,6 +278,86 @@ def _path_relative_to_home(self, raw_path: str) -> str:
275278 rel_str = relative .as_posix ()
276279 return rel_str if rel_str else "."
277280
281+ def _is_resolve_workspace_path_command (self , command : str ) -> bool :
282+ tokens = shlex .split (command )
283+ return any (
284+ token .startswith ("/tmp/openai-agents/bin/resolve-workspace-path-" )
285+ and len (tokens ) >= index + 4
286+ for index , token in enumerate (tokens )
287+ )
288+
289+ def _resolve_fake_path (self , raw_path : str , * , depth : int = 0 ) -> PurePosixPath :
290+ if depth > 64 :
291+ raise RuntimeError (f"symlink resolution depth exceeded: { raw_path } " )
292+
293+ path = PurePosixPath (raw_path )
294+ if not path .is_absolute ():
295+ path = PurePosixPath (self ._home_dir ) / path
296+
297+ parts = path .parts
298+ current = PurePosixPath ("/" )
299+ for index , part in enumerate (parts [1 :], start = 1 ):
300+ current = current / part
301+ target = self ._devbox .symlinks .get (current .as_posix ())
302+ if target is None :
303+ continue
304+
305+ target_path = PurePosixPath (target )
306+ if not target_path .is_absolute ():
307+ target_path = current .parent / target_path
308+ for remaining in parts [index + 1 :]:
309+ target_path /= remaining
310+ return self ._resolve_fake_path (target_path .as_posix (), depth = depth + 1 )
311+
312+ return path
313+
314+ @staticmethod
315+ def _fake_path_is_under (path : PurePosixPath , root : PurePosixPath ) -> bool :
316+ return path == root or root in path .parents
317+
318+ def _resolve_workspace_path (self , command : str ) -> None :
319+ tokens = self ._command_tokens ()
320+ helper_index = next (
321+ index
322+ for index , token in enumerate (tokens )
323+ if token .startswith ("/tmp/openai-agents/bin/resolve-workspace-path-" )
324+ )
325+ root = self ._resolve_fake_path (tokens [helper_index + 1 ])
326+ candidate = self ._resolve_fake_path (tokens [helper_index + 2 ])
327+ for_write = tokens [helper_index + 3 ]
328+ grant_tokens = tokens [helper_index + 4 :]
329+
330+ if self ._fake_path_is_under (candidate , root ):
331+ self ._stdout = f"{ candidate .as_posix ()} \n "
332+ self ._exit_code = 0
333+ return
334+
335+ best_grant : tuple [PurePosixPath , str , str ] | None = None
336+ for index in range (0 , len (grant_tokens ), 2 ):
337+ grant_original = grant_tokens [index ]
338+ read_only = grant_tokens [index + 1 ]
339+ grant_root = self ._resolve_fake_path (grant_original )
340+ if not self ._fake_path_is_under (candidate , grant_root ):
341+ continue
342+ if best_grant is None or len (grant_root .parts ) > len (best_grant [0 ].parts ):
343+ best_grant = (grant_root , grant_original , read_only )
344+
345+ if best_grant is not None :
346+ _grant_root , grant_original , read_only = best_grant
347+ if for_write == "1" and read_only == "1" :
348+ self ._stderr = (
349+ f"read-only extra path grant: { grant_original } \n "
350+ f"resolved path: { candidate .as_posix ()} \n "
351+ )
352+ self ._exit_code = 114
353+ return
354+ self ._stdout = f"{ candidate .as_posix ()} \n "
355+ self ._exit_code = 0
356+ return
357+
358+ self ._stderr = f"workspace escape: { candidate .as_posix ()} \n "
359+ self ._exit_code = 111
360+
278361 def _apply_tar_extract (self ) -> None :
279362 tokens = self ._command_tokens ()
280363 tar_index = tokens .index ("tar" )
@@ -469,6 +552,7 @@ def __init__(
469552 else :
470553 self .home_dir = "/home/user"
471554 self .files : dict [str , bytes ] = {}
555+ self .symlinks : dict [str , str ] = {}
472556 self .file_download_paths : list [str ] = []
473557 self .file_upload_paths : list [str ] = []
474558 self .tunnel_key : str | None = None
@@ -2334,8 +2418,42 @@ async def test_read_and_write_extra_path_grant_use_file_api_directly(
23342418 assert devbox .files ["/tmp/output.txt" ] == b"hello"
23352419 assert devbox .file_upload_paths == ["/tmp/output.txt" ]
23362420 assert devbox .file_download_paths == ["/tmp/output.txt" ]
2337- assert len (devbox .exec_calls ) == exec_count + 1
2338- assert "mkdir -p -- /tmp" in devbox .exec_calls [- 1 ][0 ]
2421+ assert len (devbox .exec_calls ) == exec_count + 7
2422+ assert devbox .exec_calls [exec_count + 4 ][0 ] == "mkdir -p -- /tmp"
2423+
2424+ @pytest .mark .asyncio
2425+ async def test_write_rejects_workspace_symlink_to_read_only_extra_path_grant (
2426+ self ,
2427+ monkeypatch : pytest .MonkeyPatch ,
2428+ ) -> None :
2429+ runloop_module = _load_runloop_module (monkeypatch )
2430+
2431+ async with runloop_module .RunloopSandboxClient () as client :
2432+ session = await client .create (
2433+ manifest = Manifest (
2434+ root = "/home/user/project" ,
2435+ extra_path_grants = (SandboxPathGrant (path = "/tmp/protected" , read_only = True ),),
2436+ ),
2437+ options = runloop_module .RunloopSandboxClientOptions (),
2438+ )
2439+ await session .start ()
2440+ sdk = _FakeAsyncRunloopSDK .created_instances [- 1 ]
2441+ devbox = sdk .devbox .devboxes [session .state .devbox_id ]
2442+ devbox .symlinks ["/home/user/project/link" ] = "/tmp/protected"
2443+
2444+ with pytest .raises (runloop_module .WorkspaceArchiveWriteError ) as exc_info :
2445+ await session .write ("link/result.txt" , io .BytesIO (b"blocked" ))
2446+
2447+ assert devbox .file_upload_paths == []
2448+ assert str (exc_info .value ) == (
2449+ "failed to write archive for path: /home/user/project/link/result.txt"
2450+ )
2451+ assert exc_info .value .context == {
2452+ "path" : "/home/user/project/link/result.txt" ,
2453+ "reason" : "read_only_extra_path_grant" ,
2454+ "grant_path" : "/tmp/protected" ,
2455+ "resolved_path" : "/tmp/protected/result.txt" ,
2456+ }
23392457
23402458 @pytest .mark .asyncio
23412459 async def test_read_wraps_runloop_http_error_with_provider_context (
0 commit comments