langchain-agentcore-codeinterpreter 0.0.4__tar.gz → 0.0.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (19) hide show
  1. {langchain_agentcore_codeinterpreter-0.0.4 → langchain_agentcore_codeinterpreter-0.0.5}/Makefile +2 -2
  2. {langchain_agentcore_codeinterpreter-0.0.4 → langchain_agentcore_codeinterpreter-0.0.5}/PKG-INFO +1 -1
  3. {langchain_agentcore_codeinterpreter-0.0.4 → langchain_agentcore_codeinterpreter-0.0.5}/langchain_agentcore_codeinterpreter/sandbox.py +115 -6
  4. {langchain_agentcore_codeinterpreter-0.0.4 → langchain_agentcore_codeinterpreter-0.0.5}/pyproject.toml +7 -1
  5. {langchain_agentcore_codeinterpreter-0.0.4 → langchain_agentcore_codeinterpreter-0.0.5}/tests/integration_tests/sandbox/test_sandbox.py +60 -5
  6. {langchain_agentcore_codeinterpreter-0.0.4 → langchain_agentcore_codeinterpreter-0.0.5}/tests/unit_tests/test_sandbox.py +181 -3
  7. {langchain_agentcore_codeinterpreter-0.0.4 → langchain_agentcore_codeinterpreter-0.0.5}/uv.lock +452 -423
  8. {langchain_agentcore_codeinterpreter-0.0.4 → langchain_agentcore_codeinterpreter-0.0.5}/.gitignore +0 -0
  9. {langchain_agentcore_codeinterpreter-0.0.4 → langchain_agentcore_codeinterpreter-0.0.5}/LICENSE +0 -0
  10. {langchain_agentcore_codeinterpreter-0.0.4 → langchain_agentcore_codeinterpreter-0.0.5}/README.md +0 -0
  11. {langchain_agentcore_codeinterpreter-0.0.4 → langchain_agentcore_codeinterpreter-0.0.5}/langchain_agentcore_codeinterpreter/__init__.py +0 -0
  12. {langchain_agentcore_codeinterpreter-0.0.4 → langchain_agentcore_codeinterpreter-0.0.5}/scripts/check_imports.py +0 -0
  13. {langchain_agentcore_codeinterpreter-0.0.4 → langchain_agentcore_codeinterpreter-0.0.5}/tests/__init__.py +0 -0
  14. {langchain_agentcore_codeinterpreter-0.0.4 → langchain_agentcore_codeinterpreter-0.0.5}/tests/integration_tests/__init__.py +0 -0
  15. {langchain_agentcore_codeinterpreter-0.0.4 → langchain_agentcore_codeinterpreter-0.0.5}/tests/integration_tests/sandbox/__init__.py +0 -0
  16. {langchain_agentcore_codeinterpreter-0.0.4 → langchain_agentcore_codeinterpreter-0.0.5}/tests/integration_tests/test_compile.py +0 -0
  17. {langchain_agentcore_codeinterpreter-0.0.4 → langchain_agentcore_codeinterpreter-0.0.5}/tests/unit_tests/__init__.py +0 -0
  18. {langchain_agentcore_codeinterpreter-0.0.4 → langchain_agentcore_codeinterpreter-0.0.5}/tests/unit_tests/test_imports.py +0 -0
  19. {langchain_agentcore_codeinterpreter-0.0.4 → langchain_agentcore_codeinterpreter-0.0.5}/tests/unit_tests/test_stream_parsing.py +0 -0
@@ -30,10 +30,10 @@ test: ## Run individual unit test: make test TEST_FILE=tests/unit_tests/test.py
30
30
  env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN uv run --group test pytest --disable-socket --allow-unix-socket $(TEST_FILE)
31
31
 
32
32
  integration_tests: ## Run all integration tests
33
- uv run --group test --group test_integration pytest -n auto $(TEST_FILE)
33
+ uv run --group test --group test_integration pytest -n auto --benchmark-disable $(TEST_FILE)
34
34
 
35
35
  integration_test: ## Run individual integration test: make integration_test TEST_FILE=tests/integration_tests/integ_test.py
36
- uv run --group test --group test_integration pytest -n auto $(TEST_FILE)
36
+ uv run --group test --group test_integration pytest -n auto --benchmark-disable $(TEST_FILE)
37
37
 
38
38
  coverage_tests: ## Run unit tests with coverage
39
39
  env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN uv run --group test pytest --cov=langchain_agentcore_codeinterpreter --cov-report=html --cov-report=term-missing --cov-branch tests/unit_tests/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langchain-agentcore-codeinterpreter
3
- Version: 0.0.4
3
+ Version: 0.0.5
4
4
  Summary: Amazon Bedrock AgentCore Code Interpreter sandbox integration for Deep Agents
5
5
  Project-URL: Source Code, https://github.com/langchain-ai/langchain-aws/tree/main/libs/agentcore-codeinterpreter
6
6
  Project-URL: Repository, https://github.com/langchain-ai/langchain-aws
@@ -14,6 +14,9 @@ from deepagents.backends.protocol import (
14
14
  ExecuteResponse,
15
15
  FileDownloadResponse,
16
16
  FileUploadResponse,
17
+ GlobResult,
18
+ GrepResult,
19
+ LsResult,
17
20
  ReadResult,
18
21
  WriteResult,
19
22
  )
@@ -174,7 +177,7 @@ class AgentCoreSandbox(BaseSandbox):
174
177
  against the real cwd before shell preflight commands and stripped of
175
178
  the cwd prefix before the AgentCore ``writeFiles``/``readFiles`` APIs.
176
179
  Pass the known cwd via the ``cwd`` constructor argument, or let the
177
- sandbox detect it automatically on the first ``write()`` call.
180
+ sandbox detect it automatically on the first path operation via ``pwd``.
178
181
 
179
182
  Example:
180
183
  .. code-block:: python
@@ -209,7 +212,7 @@ class AgentCoreSandbox(BaseSandbox):
209
212
  ``write()`` uses it to resolve virtual paths to real absolute
210
213
  paths and to strip the prefix before the AgentCore
211
214
  ``writeFiles`` API. When omitted, the cwd is detected
212
- automatically on the first ``write()`` call via ``pwd``.
215
+ automatically on the first path operation via ``pwd``.
213
216
  """
214
217
  self._interpreter = interpreter
215
218
  self._cwd: str | None = cwd.rstrip("/") if cwd is not None else None
@@ -439,7 +442,7 @@ class AgentCoreSandbox(BaseSandbox):
439
442
  def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
440
443
  """Upload files to the AgentCore sandbox.
441
444
 
442
- Text files are sent directly; binary files are base64-encoded.
445
+ Text files are sent directly; binary files are sent as raw blobs.
443
446
 
444
447
  Args:
445
448
  files: List of ``(path, content)`` tuples to upload.
@@ -448,7 +451,7 @@ class AgentCoreSandbox(BaseSandbox):
448
451
  List of :class:`FileUploadResponse` objects in the same order
449
452
  as the input files.
450
453
  """
451
- file_list: list[dict[str, str]] = []
454
+ file_list: list[dict[str, str | bytes]] = []
452
455
 
453
456
  for path, content in files:
454
457
  rel_path = self._to_relative_path(path)
@@ -456,8 +459,7 @@ class AgentCoreSandbox(BaseSandbox):
456
459
  text_content = content.decode("utf-8")
457
460
  file_list.append({"path": rel_path, "text": text_content})
458
461
  except UnicodeDecodeError:
459
- encoded = base64.b64encode(content).decode("ascii")
460
- file_list.append({"path": rel_path, "blob": encoded})
462
+ file_list.append({"path": rel_path, "blob": content})
461
463
 
462
464
  try:
463
465
  if file_list:
@@ -479,6 +481,53 @@ class AgentCoreSandbox(BaseSandbox):
479
481
  for path, _ in files
480
482
  ]
481
483
 
484
+ def ls(self, path: str) -> LsResult:
485
+ """List a directory, resolving ``path`` against the sandbox cwd."""
486
+ return super().ls(self._to_absolute_path(path))
487
+
488
+ def read(
489
+ self,
490
+ file_path: str,
491
+ offset: int = 0,
492
+ limit: int = 2000,
493
+ ) -> ReadResult:
494
+ """Read a file, resolving ``file_path`` against the sandbox cwd."""
495
+ return super().read(self._to_absolute_path(file_path), offset, limit)
496
+
497
+ def grep(
498
+ self,
499
+ pattern: str,
500
+ path: str | None = None,
501
+ glob: str | None = None,
502
+ ) -> GrepResult:
503
+ """Search file contents, resolving ``path`` against the sandbox cwd."""
504
+ resolved = self._to_absolute_path(path) if path else path
505
+ return super().grep(pattern, resolved, glob)
506
+
507
+ def glob(
508
+ self,
509
+ pattern: str,
510
+ path: str | None = None,
511
+ ) -> GlobResult:
512
+ """Match paths by glob, resolving ``path`` against the sandbox cwd."""
513
+ resolved = self._to_absolute_path(path) if path else path
514
+ return super().glob(pattern, resolved)
515
+
516
+ def edit(
517
+ self,
518
+ file_path: str,
519
+ old_string: str,
520
+ new_string: str,
521
+ replace_all: bool = False, # noqa: FBT001, FBT002
522
+ ) -> EditResult:
523
+ """Edit a file, resolving ``file_path`` against the sandbox cwd."""
524
+ return super().edit(
525
+ self._to_absolute_path(file_path),
526
+ old_string,
527
+ new_string,
528
+ replace_all,
529
+ )
530
+
482
531
  # ------------------------------------------------------------------
483
532
  # Async overrides — use a dedicated executor to avoid starving the
484
533
  # default asyncio thread pool with long-running boto3 stream reads.
@@ -618,3 +667,63 @@ class AgentCoreSandbox(BaseSandbox):
618
667
  _AGENTCORE_EXECUTOR,
619
668
  lambda: self.download_files(paths),
620
669
  )
670
+
671
+ async def als(self, path: str) -> LsResult:
672
+ """Async version of :meth:`ls`.
673
+
674
+ Args:
675
+ path: Directory path to list, resolved against the sandbox cwd.
676
+
677
+ Returns:
678
+ ``LsResult`` with directory entries on success or ``error`` on
679
+ failure.
680
+ """
681
+ loop = asyncio.get_running_loop()
682
+ return await loop.run_in_executor(
683
+ _AGENTCORE_EXECUTOR,
684
+ lambda: self.ls(path),
685
+ )
686
+
687
+ async def agrep(
688
+ self,
689
+ pattern: str,
690
+ path: str | None = None,
691
+ glob: str | None = None,
692
+ ) -> GrepResult:
693
+ """Async version of :meth:`grep`.
694
+
695
+ Args:
696
+ pattern: Literal string to search for.
697
+ path: Directory or file to search in, resolved against the sandbox
698
+ cwd. When ``None``, the ``BaseSandbox`` default is used.
699
+ glob: Optional file-name glob to restrict the search.
700
+
701
+ Returns:
702
+ ``GrepResult`` with a list of matches or ``error`` on failure.
703
+ """
704
+ loop = asyncio.get_running_loop()
705
+ return await loop.run_in_executor(
706
+ _AGENTCORE_EXECUTOR,
707
+ lambda: self.grep(pattern, path, glob),
708
+ )
709
+
710
+ async def aglob(
711
+ self,
712
+ pattern: str,
713
+ path: str | None = None,
714
+ ) -> GlobResult:
715
+ """Async version of :meth:`glob`.
716
+
717
+ Args:
718
+ pattern: Glob pattern to match.
719
+ path: Directory to search in, resolved against the sandbox cwd.
720
+ When ``None``, the ``BaseSandbox`` default is used.
721
+
722
+ Returns:
723
+ ``GlobResult`` with a list of matches or ``error`` on failure.
724
+ """
725
+ loop = asyncio.get_running_loop()
726
+ return await loop.run_in_executor(
727
+ _AGENTCORE_EXECUTOR,
728
+ lambda: self.glob(pattern, path),
729
+ )
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "langchain-agentcore-codeinterpreter"
7
- version = "0.0.4"
7
+ version = "0.0.5"
8
8
  description = "Amazon Bedrock AgentCore Code Interpreter sandbox integration for Deep Agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -62,6 +62,12 @@ disallow_untyped_defs = "True"
62
62
 
63
63
  [tool.pytest.ini_options]
64
64
  addopts = "--strict-markers --strict-config --durations=5"
65
+ filterwarnings = [
66
+ # `bedrock-agentcore` (third-party) still uses class-based pydantic `Config`
67
+ # in `bedrock_agentcore.runtime.context.RequestContext`. Remove once upstream
68
+ # migrates to `ConfigDict`.
69
+ "ignore:Support for class-based .config. is deprecated:DeprecationWarning",
70
+ ]
65
71
  markers = [
66
72
  "requires: mark tests as requiring a specific library",
67
73
  "asyncio: mark tests as requiring asyncio",
@@ -82,20 +82,50 @@ class TestAgentCoreSandboxIntegration:
82
82
  assert results[0].error is not None
83
83
 
84
84
  def test_binary_roundtrip(self, sandbox: AgentCoreSandbox) -> None:
85
- """Binary content uploaded via base64 may be returned as base64 text."""
86
- import base64
87
-
85
+ """Binary content uploaded as a blob should round-trip unchanged."""
88
86
  content = b"\x00\x01\x02\xff\xfe\xfd"
89
87
  sandbox.upload_files([("binary_test.bin", content)])
90
88
  results = sandbox.download_files(["binary_test.bin"])
91
89
  assert results[0].error is None
92
- # AgentCore returns blob uploads as text containing the base64 string
93
- assert results[0].content in (content, base64.b64encode(content))
90
+ assert results[0].content == content
94
91
 
95
92
  def test_session_id(self, sandbox: AgentCoreSandbox) -> None:
96
93
  """The sandbox should have a non-empty session ID."""
97
94
  assert sandbox.id != ""
98
95
 
96
+ def test_read_plane_roundtrip_virtual_path(self, sandbox: AgentCoreSandbox) -> None:
97
+ """Read plane round-trips a virtual path against a non-root cwd."""
98
+ vpath = "/workspace/readplane_roundtrip.txt"
99
+ vdir = "/workspace"
100
+ content = "needle in haystack\nsecond line\n"
101
+
102
+ write_result = sandbox.write(vpath, content)
103
+ assert write_result.error is None, write_result.error
104
+
105
+ read_result = sandbox.read(vpath)
106
+ assert read_result.error is None, read_result.error
107
+ assert read_result.file_data is not None
108
+ assert read_result.file_data["content"].strip() == content.strip()
109
+
110
+ ls_result = sandbox.ls(vdir)
111
+ assert ls_result.error is None, ls_result.error
112
+ assert ls_result.entries is not None
113
+ assert any("readplane_roundtrip.txt" in e["path"] for e in ls_result.entries)
114
+
115
+ grep_result = sandbox.grep("needle", vdir)
116
+ assert grep_result.error is None, grep_result.error
117
+ assert grep_result.matches
118
+
119
+ glob_result = sandbox.glob("*.txt", vdir)
120
+ assert glob_result.error is None, glob_result.error
121
+ assert glob_result.matches
122
+
123
+ edit_result = sandbox.edit(vpath, "needle", "pin")
124
+ assert edit_result.error is None, edit_result.error
125
+ after = sandbox.read(vpath)
126
+ assert after.file_data is not None
127
+ assert "pin in haystack" in after.file_data["content"]
128
+
99
129
  # ------------------------------------------------------------------
100
130
  # Async integration tests
101
131
  # ------------------------------------------------------------------
@@ -135,3 +165,28 @@ class TestAgentCoreSandboxIntegration:
135
165
  for i, result in enumerate(results):
136
166
  assert result.exit_code == 0
137
167
  assert f"concurrent-{i}" in result.output
168
+
169
+ def test_aread_plane_roundtrip_virtual_path(
170
+ self, sandbox: AgentCoreSandbox
171
+ ) -> None:
172
+ """Async read plane round-trips a virtual path against a non-root cwd."""
173
+ vpath = "/workspace/areadplane_roundtrip.txt"
174
+ vdir = "/workspace"
175
+
176
+ async def run() -> None:
177
+ write_result = await sandbox.awrite(vpath, "async needle\n")
178
+ assert write_result.error is None, write_result.error
179
+
180
+ read_result = await sandbox.aread(vpath)
181
+ assert read_result.error is None, read_result.error
182
+
183
+ ls_result = await sandbox.als(vdir)
184
+ assert ls_result.error is None, ls_result.error
185
+
186
+ grep_result = await sandbox.agrep("needle", vdir)
187
+ assert grep_result.error is None, grep_result.error
188
+
189
+ glob_result = await sandbox.aglob("*.txt", vdir)
190
+ assert glob_result.error is None, glob_result.error
191
+
192
+ asyncio.run(run())
@@ -9,10 +9,19 @@ from __future__ import annotations
9
9
  import asyncio
10
10
  from collections.abc import Iterator
11
11
  from typing import Any
12
- from unittest.mock import MagicMock
12
+ from unittest.mock import MagicMock, patch
13
13
 
14
14
  import pytest
15
15
  from botocore.exceptions import ClientError
16
+ from deepagents.backends.protocol import (
17
+ EditResult,
18
+ FileData,
19
+ GlobResult,
20
+ GrepResult,
21
+ LsResult,
22
+ ReadResult,
23
+ )
24
+ from deepagents.backends.sandbox import BaseSandbox
16
25
 
17
26
  from langchain_agentcore_codeinterpreter import AgentCoreSandbox
18
27
  from langchain_agentcore_codeinterpreter.sandbox import (
@@ -141,12 +150,37 @@ def test_upload_files_text() -> None:
141
150
 
142
151
 
143
152
  def test_upload_files_binary_uses_blob() -> None:
144
- """Non-UTF-8 content should be base64-encoded as a blob."""
153
+ """Non-UTF-8 content should be uploaded as raw blob bytes."""
145
154
  sandbox, mock = _make_sandbox()
146
- sandbox.upload_files([("/data.bin", b"\x80\x81\x82")])
155
+ binary_content = b"\x80\x81\x82"
156
+
157
+ sandbox.upload_files([("/data.bin", binary_content)])
158
+
147
159
  content = mock.invoke.call_args.kwargs["params"]["content"][0]
148
160
  assert "blob" in content
149
161
  assert "text" not in content
162
+ assert content["blob"] == binary_content
163
+
164
+
165
+ def test_upload_files_mixed_text_and_binary() -> None:
166
+ """Text and binary files should use the correct writeFiles fields."""
167
+ sandbox, mock = _make_sandbox()
168
+ binary_content = b"\x00\xff\xfe"
169
+
170
+ result = sandbox.upload_files(
171
+ [
172
+ ("/hello.py", b"print('hi')"),
173
+ ("/data.bin", binary_content),
174
+ ]
175
+ )
176
+
177
+ uploaded = mock.invoke.call_args.kwargs["params"]["content"]
178
+ assert uploaded == [
179
+ {"path": "hello.py", "text": "print('hi')"},
180
+ {"path": "data.bin", "blob": binary_content},
181
+ ]
182
+ assert [response.path for response in result] == ["/hello.py", "/data.bin"]
183
+ assert [response.error for response in result] == [None, None]
150
184
 
151
185
 
152
186
  def test_upload_files_empty_list() -> None:
@@ -570,6 +604,119 @@ def test_write_root_cwd_preserves_existing_behavior() -> None:
570
604
  assert uploaded_path == "hello.py"
571
605
 
572
606
 
607
+ # ------------------------------------------------------------------
608
+ # Read-plane path normalization (ls/read/grep/glob/edit)
609
+ # ------------------------------------------------------------------
610
+
611
+
612
+ @pytest.mark.parametrize(
613
+ ("method_name", "virtual_path", "expected_path", "extra_args", "return_value"),
614
+ [
615
+ (
616
+ "ls",
617
+ "/workspace",
618
+ "/opt/sandbox/workspace",
619
+ (),
620
+ LsResult(entries=[]),
621
+ ),
622
+ (
623
+ "read",
624
+ "/workspace/hello.py",
625
+ "/opt/sandbox/workspace/hello.py",
626
+ (0, 2000),
627
+ ReadResult(file_data=FileData(content="hello", encoding="utf-8")),
628
+ ),
629
+ (
630
+ "grep",
631
+ "/workspace",
632
+ "/opt/sandbox/workspace",
633
+ ("needle", None),
634
+ GrepResult(matches=[]),
635
+ ),
636
+ (
637
+ "glob",
638
+ "/workspace",
639
+ "/opt/sandbox/workspace",
640
+ ("*.py",),
641
+ GlobResult(matches=[]),
642
+ ),
643
+ (
644
+ "edit",
645
+ "/workspace/hello.py",
646
+ "/opt/sandbox/workspace/hello.py",
647
+ ("old", "new", False),
648
+ EditResult(path="/opt/sandbox/workspace/hello.py", occurrences=1),
649
+ ),
650
+ ],
651
+ )
652
+ def test_read_plane_resolves_virtual_paths(
653
+ method_name: str,
654
+ virtual_path: str,
655
+ expected_path: str,
656
+ extra_args: tuple[Any, ...],
657
+ return_value: Any,
658
+ ) -> None:
659
+ """Read-plane methods should resolve virtual paths against the sandbox cwd."""
660
+ sandbox, _ = _make_sandbox(cwd="/opt/sandbox")
661
+ result: Any
662
+
663
+ with patch.object(
664
+ BaseSandbox, method_name, return_value=return_value
665
+ ) as mock_method:
666
+ if method_name == "grep":
667
+ result = sandbox.grep(extra_args[0], virtual_path)
668
+ mock_method.assert_called_once_with(extra_args[0], expected_path, None)
669
+ elif method_name == "glob":
670
+ result = sandbox.glob(extra_args[0], virtual_path)
671
+ mock_method.assert_called_once_with(extra_args[0], expected_path)
672
+ elif method_name == "read":
673
+ result = sandbox.read(virtual_path, *extra_args)
674
+ mock_method.assert_called_once_with(expected_path, *extra_args)
675
+ elif method_name == "edit":
676
+ result = sandbox.edit(virtual_path, *extra_args)
677
+ mock_method.assert_called_once_with(expected_path, *extra_args)
678
+ else:
679
+ result = sandbox.ls(virtual_path)
680
+ mock_method.assert_called_once_with(expected_path)
681
+
682
+ assert result == return_value
683
+
684
+
685
+ def test_grep_with_none_path_passes_through() -> None:
686
+ """grep() should not resolve when path is None (BaseSandbox defaults to '.')."""
687
+ sandbox, _ = _make_sandbox(cwd="/opt/sandbox")
688
+
689
+ with patch.object(
690
+ BaseSandbox, "grep", return_value=GrepResult(matches=[])
691
+ ) as mock_grep:
692
+ sandbox.grep("needle")
693
+ mock_grep.assert_called_once_with("needle", None, None)
694
+
695
+
696
+ def test_glob_with_none_path_passes_through() -> None:
697
+ """glob() should not resolve when path is None (BaseSandbox defaults to '/')."""
698
+ sandbox, _ = _make_sandbox(cwd="/opt/sandbox")
699
+
700
+ with patch.object(
701
+ BaseSandbox, "glob", return_value=GlobResult(matches=[])
702
+ ) as mock_glob:
703
+ sandbox.glob("*.py")
704
+ mock_glob.assert_called_once_with("*.py", None)
705
+
706
+
707
+ def test_read_root_cwd_preserves_virtual_path() -> None:
708
+ """When cwd is /, read() should pass virtual paths through unchanged."""
709
+ sandbox, _ = _make_sandbox(cwd="/")
710
+
711
+ with patch.object(
712
+ BaseSandbox,
713
+ "read",
714
+ return_value=ReadResult(file_data=FileData(content="ok", encoding="utf-8")),
715
+ ) as mock_read:
716
+ sandbox.read("/workspace/hello.py")
717
+ mock_read.assert_called_once_with("/workspace/hello.py", 0, 2000)
718
+
719
+
573
720
  # ------------------------------------------------------------------
574
721
  # _invoke() — eager stream consumption
575
722
  # ------------------------------------------------------------------
@@ -778,6 +925,37 @@ def test_aexecute_handles_session_expiry() -> None:
778
925
  assert "expired" in result.output.lower()
779
926
 
780
927
 
928
+ def test_als_routes_through_cwd_aware_sync_ls() -> None:
929
+ """als() must resolve the virtual path against the cwd."""
930
+ sandbox, _ = _make_sandbox(cwd="/opt/sandbox")
931
+
932
+ with patch.object(BaseSandbox, "ls", return_value=LsResult(entries=[])) as mock_ls:
933
+ asyncio.run(sandbox.als("/workspace"))
934
+ mock_ls.assert_called_once_with("/opt/sandbox/workspace")
935
+
936
+
937
+ def test_agrep_routes_through_cwd_aware_sync_grep() -> None:
938
+ """agrep() must resolve the virtual path against the cwd (not literal)."""
939
+ sandbox, _ = _make_sandbox(cwd="/opt/sandbox")
940
+
941
+ with patch.object(
942
+ BaseSandbox, "grep", return_value=GrepResult(matches=[])
943
+ ) as mock_grep:
944
+ asyncio.run(sandbox.agrep("needle", "/workspace"))
945
+ mock_grep.assert_called_once_with("needle", "/opt/sandbox/workspace", None)
946
+
947
+
948
+ def test_aglob_routes_through_cwd_aware_sync_glob() -> None:
949
+ """aglob() must resolve the virtual path against the cwd (not literal)."""
950
+ sandbox, _ = _make_sandbox(cwd="/opt/sandbox")
951
+
952
+ with patch.object(
953
+ BaseSandbox, "glob", return_value=GlobResult(matches=[])
954
+ ) as mock_glob:
955
+ asyncio.run(sandbox.aglob("*.py", "/workspace"))
956
+ mock_glob.assert_called_once_with("*.py", "/opt/sandbox/workspace")
957
+
958
+
781
959
  # ------------------------------------------------------------------
782
960
  # Executor configuration
783
961
  # ------------------------------------------------------------------