langchain-agentcore-codeinterpreter 0.0.2__tar.gz → 0.0.4__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 (20) hide show
  1. {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/PKG-INFO +1 -1
  2. {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/langchain_agentcore_codeinterpreter/sandbox.py +130 -15
  3. {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/pyproject.toml +2 -2
  4. langchain_agentcore_codeinterpreter-0.0.4/tests/unit_tests/__init__.py +0 -0
  5. {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/tests/unit_tests/test_sandbox.py +328 -11
  6. {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/tests/unit_tests/test_stream_parsing.py +48 -0
  7. langchain_agentcore_codeinterpreter-0.0.4/uv.lock +2556 -0
  8. langchain_agentcore_codeinterpreter-0.0.2/uv.lock +0 -2470
  9. {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/.gitignore +0 -0
  10. {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/LICENSE +0 -0
  11. {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/Makefile +0 -0
  12. {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/README.md +0 -0
  13. {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/langchain_agentcore_codeinterpreter/__init__.py +0 -0
  14. {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/scripts/check_imports.py +0 -0
  15. {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/tests/__init__.py +0 -0
  16. {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/tests/integration_tests/__init__.py +0 -0
  17. {langchain_agentcore_codeinterpreter-0.0.2/tests/unit_tests → langchain_agentcore_codeinterpreter-0.0.4/tests/integration_tests/sandbox}/__init__.py +0 -0
  18. /langchain_agentcore_codeinterpreter-0.0.2/tests/integration_tests/test_integration.py → /langchain_agentcore_codeinterpreter-0.0.4/tests/integration_tests/sandbox/test_sandbox.py +0 -0
  19. {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/tests/integration_tests/test_compile.py +0 -0
  20. {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/tests/unit_tests/test_imports.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langchain-agentcore-codeinterpreter
3
- Version: 0.0.2
3
+ Version: 0.0.4
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
@@ -32,6 +32,21 @@ _AGENTCORE_EXECUTOR = ThreadPoolExecutor(
32
32
  )
33
33
 
34
34
 
35
+ def _normalize_relative_path(path: str) -> str:
36
+ """Strip leading slashes and ``./`` prefixes to a canonical relative path.
37
+
38
+ Args:
39
+ path: File path (absolute or relative).
40
+
41
+ Returns:
42
+ Canonical relative path string with no leading ``/`` or ``./``.
43
+ """
44
+ path = path.lstrip("/")
45
+ while path.startswith("./"):
46
+ path = path[2:]
47
+ return path
48
+
49
+
35
50
  class SessionExpiredError(Exception):
36
51
  """Raised when the AgentCore session has expired or been terminated."""
37
52
 
@@ -104,8 +119,7 @@ def _extract_files_from_stream(
104
119
  """
105
120
  path_lookup: dict[str, str] = {}
106
121
  for path in requested_paths:
107
- stripped = path.lstrip("/")
108
- path_lookup[stripped] = path
122
+ path_lookup[_normalize_relative_path(path)] = path
109
123
 
110
124
  files: dict[str, bytes] = {}
111
125
 
@@ -117,13 +131,16 @@ def _extract_files_from_stream(
117
131
  continue
118
132
  resource = item.get("resource", {})
119
133
  uri = resource.get("uri", "")
120
- file_path = uri.replace("file://", "").lstrip("/")
134
+ file_path = _normalize_relative_path(uri.replace("file://", ""))
121
135
 
122
136
  content: bytes | None = None
123
137
  if "text" in resource:
124
138
  content = resource["text"].encode("utf-8")
125
139
  elif "blob" in resource:
126
- content = base64.b64decode(resource["blob"])
140
+ blob = resource["blob"]
141
+ # The AgentCore stream may deliver blob as already-decoded bytes.
142
+ # Only base64-decode when it arrives as encoded text.
143
+ content = blob if isinstance(blob, bytes) else base64.b64decode(blob)
127
144
 
128
145
  if content is not None:
129
146
  original_path = path_lookup.get(file_path, file_path)
@@ -150,6 +167,15 @@ class AgentCoreSandbox(BaseSandbox):
150
167
  The caller is responsible for managing the interpreter lifecycle
151
168
  (``start()`` / ``stop()``).
152
169
 
170
+ !!! note
171
+
172
+ When the sandbox working directory is not ``/`` (e.g.
173
+ ``/opt/amazon/genesis1p-tools/var/``), paths must be resolved
174
+ against the real cwd before shell preflight commands and stripped of
175
+ the cwd prefix before the AgentCore ``writeFiles``/``readFiles`` APIs.
176
+ Pass the known cwd via the ``cwd`` constructor argument, or let the
177
+ sandbox detect it automatically on the first ``write()`` call.
178
+
153
179
  Example:
154
180
  .. code-block:: python
155
181
 
@@ -168,25 +194,113 @@ class AgentCoreSandbox(BaseSandbox):
168
194
  interpreter.stop()
169
195
  """
170
196
 
171
- def __init__(self, *, interpreter: CodeInterpreter) -> None:
197
+ def __init__(
198
+ self,
199
+ *,
200
+ interpreter: CodeInterpreter,
201
+ cwd: str | None = None,
202
+ ) -> None:
172
203
  """Create a backend wrapping an active CodeInterpreter session.
173
204
 
174
205
  Args:
175
206
  interpreter: A started :class:`CodeInterpreter` instance.
207
+ cwd: The sandbox working directory (e.g.
208
+ ``"/opt/amazon/genesis1p-tools/var"``). When provided,
209
+ ``write()`` uses it to resolve virtual paths to real absolute
210
+ paths and to strip the prefix before the AgentCore
211
+ ``writeFiles`` API. When omitted, the cwd is detected
212
+ automatically on the first ``write()`` call via ``pwd``.
176
213
  """
177
214
  self._interpreter = interpreter
215
+ self._cwd: str | None = cwd.rstrip("/") if cwd is not None else None
216
+
217
+ def _get_cwd(self) -> str:
218
+ """Return the sandbox working directory, detecting it lazily if needed.
219
+
220
+ Returns:
221
+ The working directory with any trailing slash stripped.
222
+ """
223
+ if self._cwd is None:
224
+ result = self.execute("pwd")
225
+ if result.exit_code != 0 or not result.output.strip():
226
+ raise RuntimeError(
227
+ f"Failed to detect sandbox working directory: "
228
+ f"exit_code={result.exit_code}, output={result.output!r}"
229
+ )
230
+ self._cwd = result.output.strip().rstrip("/")
231
+ return self._cwd
232
+
233
+ def _to_relative_path(self, path: str) -> str:
234
+ """Strip the cwd prefix (or leading slashes) for AgentCore file APIs.
178
235
 
179
- @staticmethod
180
- def _to_relative_path(path: str) -> str:
181
- """Strip leading slashes so paths are relative for AgentCore APIs.
236
+ When the sandbox cwd is known and ``path`` starts with it, the cwd
237
+ prefix is removed so the AgentCore ``writeFiles``/``readFiles`` APIs
238
+ receive a cwd-relative path. For paths that do not start with the cwd,
239
+ the standard leading-slash stripping is applied.
182
240
 
183
241
  Args:
184
242
  path: File path (absolute or relative).
185
243
 
186
244
  Returns:
187
- Relative path string.
245
+ Path relative to the sandbox cwd, with no leading ``/`` or ``./``.
188
246
  """
189
- return path.lstrip("/")
247
+ if self._cwd is not None:
248
+ cwd_prefix = self._cwd + "/"
249
+ if path.startswith(cwd_prefix):
250
+ return path[len(cwd_prefix) :]
251
+ return _normalize_relative_path(path)
252
+
253
+ def _to_absolute_path(self, path: str) -> str:
254
+ """Resolve a path to a real absolute path under the sandbox cwd.
255
+
256
+ Paths already under the cwd are returned unchanged. Virtual paths
257
+ (e.g. ``/workspace/hello.py``) and relative paths are prepended with
258
+ the cwd so that shell commands (``makedirs``, etc.) operate on the
259
+ real filesystem location.
260
+
261
+ Args:
262
+ path: File path to resolve.
263
+
264
+ Returns:
265
+ Absolute path under the sandbox cwd.
266
+ """
267
+ cwd = self._get_cwd()
268
+ if not cwd:
269
+ return path
270
+ if path.startswith(cwd + "/") or path == cwd:
271
+ return path
272
+ return cwd + "/" + path.lstrip("/")
273
+
274
+ def write(self, file_path: str, content: str) -> WriteResult:
275
+ """Create a new file in the sandbox, failing if it already exists.
276
+
277
+ Normalizes ``file_path`` to a real absolute path under the sandbox cwd
278
+ before the preflight shell command (so ``makedirs`` operates on the
279
+ correct location) and before uploading (so the AgentCore ``writeFiles``
280
+ API receives a cwd-relative path rather than a doubled absolute path).
281
+
282
+ Args:
283
+ file_path: Destination path for the new file. May be a real
284
+ absolute path under the sandbox cwd, a virtual absolute path
285
+ (e.g. ``/workspace/hello.py``), or a relative path.
286
+ content: UTF-8 text content to write.
287
+
288
+ Returns:
289
+ ``WriteResult`` with ``path`` set to the resolved absolute path on
290
+ success, or ``error`` on failure.
291
+ """
292
+ abs_path = self._to_absolute_path(file_path)
293
+ preflight_error = self._write_preflight(abs_path)
294
+ if preflight_error is not None:
295
+ return preflight_error
296
+ responses = self.upload_files([(abs_path, content.encode("utf-8"))])
297
+ assert responses, "upload_files returned no responses"
298
+ response = responses[0]
299
+ if response.error:
300
+ return WriteResult(
301
+ error=f"Failed to write file '{abs_path}': {response.error}"
302
+ )
303
+ return WriteResult(path=abs_path)
190
304
 
191
305
  def _invoke(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
192
306
  """Invoke the interpreter and eagerly consume the response stream.
@@ -294,19 +408,20 @@ class AgentCoreSandbox(BaseSandbox):
294
408
  as the input paths.
295
409
  """
296
410
  try:
411
+ self._get_cwd()
297
412
  relative_paths = [self._to_relative_path(p) for p in paths]
298
413
  response = self._invoke(
299
414
  method="readFiles", params={"paths": relative_paths}
300
415
  )
301
- file_contents = _extract_files_from_stream(response, paths)
416
+ file_contents = _extract_files_from_stream(response, relative_paths)
302
417
 
303
418
  return [
304
419
  FileDownloadResponse(
305
- path=path,
306
- content=file_contents.get(path),
307
- error=None if path in file_contents else "file_not_found",
420
+ path=original,
421
+ content=file_contents.get(rel),
422
+ error=None if rel in file_contents else "file_not_found",
308
423
  )
309
- for path in paths
424
+ for original, rel in zip(paths, relative_paths)
310
425
  ]
311
426
  except SessionExpiredError:
312
427
  logger.error("AgentCore session expired while downloading files: %s", paths)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "langchain-agentcore-codeinterpreter"
7
- version = "0.0.2"
7
+ version = "0.0.4"
8
8
  description = "Amazon Bedrock AgentCore Code Interpreter sandbox integration for Deep Agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -46,7 +46,7 @@ dev = [
46
46
  ]
47
47
 
48
48
  [tool.uv]
49
- prerelease = "allow"
49
+ prerelease = "if-necessary-or-explicit"
50
50
 
51
51
  [tool.ruff.lint]
52
52
  select = [
@@ -24,15 +24,16 @@ from langchain_agentcore_codeinterpreter.sandbox import (
24
24
  def _make_sandbox(
25
25
  invoke_return: dict[str, Any] | None = None,
26
26
  session_id: str = "test-session-123",
27
+ cwd: str | None = None,
27
28
  ) -> tuple[AgentCoreSandbox, MagicMock]:
28
29
  """Create a sandbox with a mocked interpreter."""
29
30
  interpreter = MagicMock()
30
31
  interpreter.session_id = session_id
31
32
  interpreter.invoke.return_value = invoke_return or {"stream": []}
32
- return AgentCoreSandbox(interpreter=interpreter), interpreter
33
+ return AgentCoreSandbox(interpreter=interpreter, cwd=cwd), interpreter
33
34
 
34
35
 
35
- def _make_expired_sandbox() -> tuple[AgentCoreSandbox, MagicMock]:
36
+ def _make_expired_sandbox(cwd: str | None = None) -> tuple[AgentCoreSandbox, MagicMock]:
36
37
  """Create a sandbox whose interpreter raises ResourceNotFoundException."""
37
38
  interpreter = MagicMock()
38
39
  interpreter.session_id = "expired-session"
@@ -45,7 +46,7 @@ def _make_expired_sandbox() -> tuple[AgentCoreSandbox, MagicMock]:
45
46
  },
46
47
  "InvokeCodeInterpreter",
47
48
  )
48
- return AgentCoreSandbox(interpreter=interpreter), interpreter
49
+ return AgentCoreSandbox(interpreter=interpreter, cwd=cwd), interpreter
49
50
 
50
51
 
51
52
  # ------------------------------------------------------------------
@@ -195,7 +196,8 @@ def test_download_files() -> None:
195
196
  }
196
197
  }
197
198
  ]
198
- }
199
+ },
200
+ cwd="/",
199
201
  )
200
202
  results = sandbox.download_files(["/test.txt"])
201
203
  mock.invoke.assert_called_once_with(
@@ -207,7 +209,7 @@ def test_download_files() -> None:
207
209
 
208
210
  def test_download_files_missing() -> None:
209
211
  """Missing files should be reported as file_not_found."""
210
- sandbox, _ = _make_sandbox({"stream": [{"result": {"content": []}}]})
212
+ sandbox, _ = _make_sandbox({"stream": [{"result": {"content": []}}]}, cwd="/")
211
213
  results = sandbox.download_files(["/missing.txt"])
212
214
  assert results[0].error == "file_not_found"
213
215
  assert results[0].content is None
@@ -215,7 +217,7 @@ def test_download_files_missing() -> None:
215
217
 
216
218
  def test_download_files_handles_exception() -> None:
217
219
  """SDK errors during download should return file_not_found."""
218
- sandbox, mock = _make_sandbox()
220
+ sandbox, mock = _make_sandbox(cwd="/")
219
221
  mock.invoke.side_effect = RuntimeError("read failed")
220
222
  results = sandbox.download_files(["/a.txt"])
221
223
  assert results[0].error == "file_not_found"
@@ -223,11 +225,147 @@ def test_download_files_handles_exception() -> None:
223
225
 
224
226
  def test_download_files_handles_session_expiry() -> None:
225
227
  """Session expiry during download should return permission_denied."""
226
- sandbox, _ = _make_expired_sandbox()
228
+ sandbox, _ = _make_expired_sandbox(cwd="/")
227
229
  results = sandbox.download_files(["/a.txt"])
228
230
  assert results[0].error == "permission_denied"
229
231
 
230
232
 
233
+ def test_download_files_dot_slash_path() -> None:
234
+ """./-prefixed paths must round-trip through readFiles and lookup."""
235
+ fake_png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
236
+ sandbox, mock = _make_sandbox(
237
+ {
238
+ "stream": [
239
+ {
240
+ "result": {
241
+ "content": [
242
+ {
243
+ "type": "resource",
244
+ "resource": {
245
+ "uri": "file:///data/foo.png",
246
+ "blob": fake_png,
247
+ },
248
+ }
249
+ ]
250
+ }
251
+ }
252
+ ]
253
+ },
254
+ cwd="/",
255
+ )
256
+ results = sandbox.download_files(["./data/foo.png"])
257
+ mock.invoke.assert_called_once_with(
258
+ method="readFiles", params={"paths": ["data/foo.png"]}
259
+ )
260
+ assert results[0].error is None
261
+ assert results[0].content == fake_png
262
+
263
+
264
+ def test_download_files_strips_cwd_prefix() -> None:
265
+ """Absolute paths under cwd should have the prefix stripped for readFiles."""
266
+ cwd = "/opt/sandbox"
267
+ sandbox, mock = _make_sandbox(
268
+ {
269
+ "stream": [
270
+ {
271
+ "result": {
272
+ "content": [
273
+ {
274
+ "type": "resource",
275
+ "resource": {
276
+ "uri": "file:///workspace/hello.py",
277
+ "text": "hello",
278
+ },
279
+ }
280
+ ]
281
+ }
282
+ }
283
+ ]
284
+ },
285
+ cwd=cwd,
286
+ )
287
+ results = sandbox.download_files([f"{cwd}/workspace/hello.py"])
288
+ mock.invoke.assert_called_once_with(
289
+ method="readFiles", params={"paths": ["workspace/hello.py"]}
290
+ )
291
+ assert results[0].content == b"hello"
292
+ assert results[0].error is None
293
+ assert results[0].path == f"{cwd}/workspace/hello.py"
294
+
295
+
296
+ def test_download_files_virtual_path_with_cwd() -> None:
297
+ """Virtual paths not under cwd should fall back to leading-slash stripping."""
298
+ sandbox, mock = _make_sandbox(
299
+ {
300
+ "stream": [
301
+ {
302
+ "result": {
303
+ "content": [
304
+ {
305
+ "type": "resource",
306
+ "resource": {
307
+ "uri": "file:///workspace/hello.py",
308
+ "text": "hello",
309
+ },
310
+ }
311
+ ]
312
+ }
313
+ }
314
+ ]
315
+ },
316
+ cwd="/opt/sandbox",
317
+ )
318
+ results = sandbox.download_files(["/workspace/hello.py"])
319
+ mock.invoke.assert_called_once_with(
320
+ method="readFiles", params={"paths": ["workspace/hello.py"]}
321
+ )
322
+ assert results[0].content == b"hello"
323
+ assert results[0].error is None
324
+
325
+
326
+ def test_download_files_lazy_cwd_detection() -> None:
327
+ """When cwd is not provided, download_files should detect it via pwd."""
328
+ cwd = "/opt/sandbox"
329
+
330
+ def invoke(**kwargs: Any) -> dict[str, Any]:
331
+ if kwargs.get("method") == "executeCommand":
332
+ return {
333
+ "stream": [
334
+ {
335
+ "result": {
336
+ "exitCode": 0,
337
+ "content": [{"type": "text", "text": cwd}],
338
+ }
339
+ }
340
+ ]
341
+ }
342
+ return {
343
+ "stream": [
344
+ {
345
+ "result": {
346
+ "content": [
347
+ {
348
+ "type": "resource",
349
+ "resource": {
350
+ "uri": "file:///workspace/hello.py",
351
+ "text": "hello",
352
+ },
353
+ }
354
+ ]
355
+ }
356
+ }
357
+ ]
358
+ }
359
+
360
+ sandbox, mock = _make_sandbox()
361
+ mock.invoke.side_effect = invoke
362
+
363
+ results = sandbox.download_files([f"{cwd}/workspace/hello.py"])
364
+ assert sandbox._cwd == cwd
365
+ assert results[0].content == b"hello"
366
+ assert results[0].error is None
367
+
368
+
231
369
  # ------------------------------------------------------------------
232
370
  # _to_relative_path()
233
371
  # ------------------------------------------------------------------
@@ -235,9 +373,32 @@ def test_download_files_handles_session_expiry() -> None:
235
373
 
236
374
  def test_relative_path_stripping() -> None:
237
375
  """Leading slashes should be stripped; relative paths left as-is."""
238
- assert AgentCoreSandbox._to_relative_path("/abs/path.txt") == "abs/path.txt"
239
- assert AgentCoreSandbox._to_relative_path("rel/path.txt") == "rel/path.txt"
240
- assert AgentCoreSandbox._to_relative_path("///triple.txt") == "triple.txt"
376
+ sandbox, _ = _make_sandbox()
377
+ assert sandbox._to_relative_path("/abs/path.txt") == "abs/path.txt"
378
+ assert sandbox._to_relative_path("rel/path.txt") == "rel/path.txt"
379
+ assert sandbox._to_relative_path("///triple.txt") == "triple.txt"
380
+
381
+
382
+ def test_relative_path_strips_dot_slash() -> None:
383
+ """./ and repeated ././ prefixes should be stripped."""
384
+ sandbox, _ = _make_sandbox()
385
+ assert sandbox._to_relative_path("./data/foo.png") == "data/foo.png"
386
+ assert sandbox._to_relative_path("././foo.png") == "foo.png"
387
+ assert sandbox._to_relative_path("/./data/foo.png") == "data/foo.png"
388
+
389
+
390
+ def test_relative_path_strips_cwd_prefix() -> None:
391
+ """When cwd is known, paths under cwd should have the prefix stripped."""
392
+ sandbox, _ = _make_sandbox(cwd="/opt/sandbox")
393
+ result = sandbox._to_relative_path("/opt/sandbox/workspace/hello.py")
394
+ assert result == "workspace/hello.py"
395
+ assert sandbox._to_relative_path("/opt/sandbox/hello.py") == "hello.py"
396
+
397
+
398
+ def test_relative_path_virtual_path_falls_back_to_strip() -> None:
399
+ """Paths outside cwd should fall back to leading-slash stripping."""
400
+ sandbox, _ = _make_sandbox(cwd="/opt/sandbox")
401
+ assert sandbox._to_relative_path("/workspace/hello.py") == "workspace/hello.py"
241
402
 
242
403
 
243
404
  # ------------------------------------------------------------------
@@ -253,6 +414,162 @@ def test_keyword_only_init() -> None:
253
414
  assert sandbox.id == "s"
254
415
 
255
416
 
417
+ def test_cwd_constructor_stores_stripped_cwd() -> None:
418
+ """cwd passed at construction should be stored with trailing slash removed."""
419
+ sandbox, _ = _make_sandbox(cwd="/opt/sandbox/")
420
+ assert sandbox._cwd == "/opt/sandbox"
421
+
422
+
423
+ def test_cwd_defaults_to_none() -> None:
424
+ """When cwd is not passed, _cwd should start as None (lazy detection)."""
425
+ sandbox, _ = _make_sandbox()
426
+ assert sandbox._cwd is None
427
+
428
+
429
+ # ------------------------------------------------------------------
430
+ # _to_absolute_path()
431
+ # ------------------------------------------------------------------
432
+
433
+
434
+ def test_to_absolute_path_already_under_cwd() -> None:
435
+ """Paths already under the cwd should be returned unchanged."""
436
+ sandbox, _ = _make_sandbox(cwd="/opt/sandbox")
437
+ path = "/opt/sandbox/workspace/hello.py"
438
+ assert sandbox._to_absolute_path(path) == path
439
+
440
+
441
+ def test_to_absolute_path_virtual_path_prepends_cwd() -> None:
442
+ """Virtual paths like /workspace/hello.py should be resolved under cwd."""
443
+ sandbox, _ = _make_sandbox(cwd="/opt/sandbox")
444
+ result = sandbox._to_absolute_path("/workspace/hello.py")
445
+ assert result == "/opt/sandbox/workspace/hello.py"
446
+
447
+
448
+ def test_to_absolute_path_relative_path_prepends_cwd() -> None:
449
+ """Relative paths should be resolved under cwd."""
450
+ sandbox, _ = _make_sandbox(cwd="/opt/sandbox")
451
+ result = sandbox._to_absolute_path("workspace/hello.py")
452
+ assert result == "/opt/sandbox/workspace/hello.py"
453
+
454
+
455
+ def test_to_absolute_path_root_cwd_returns_as_is() -> None:
456
+ """When cwd is / (stored as empty string), absolute paths are returned as-is."""
457
+ sandbox, _ = _make_sandbox(cwd="/")
458
+ assert sandbox._to_absolute_path("/workspace/hello.py") == "/workspace/hello.py"
459
+
460
+
461
+ # ------------------------------------------------------------------
462
+ # write() — path normalization (issue #1055)
463
+ # ------------------------------------------------------------------
464
+
465
+
466
+ def _make_successful_invoke(cwd: str = "") -> Any:
467
+ """Return an invoke side_effect that succeeds for all methods."""
468
+
469
+ def invoke(**kwargs: Any) -> dict[str, Any]:
470
+ if kwargs.get("method") == "executeCommand":
471
+ return {
472
+ "stream": [
473
+ {
474
+ "result": {
475
+ "exitCode": 0,
476
+ "content": [{"type": "text", "text": cwd}],
477
+ }
478
+ }
479
+ ]
480
+ }
481
+ return {"stream": []}
482
+
483
+ return invoke
484
+
485
+
486
+ def test_write_strips_cwd_prefix_from_upload_path() -> None:
487
+ """upload path must be cwd-relative so writeFiles doesn't double the prefix."""
488
+ cwd = "/opt/amazon/genesis1p-tools/var"
489
+ sandbox, mock = _make_sandbox(cwd=cwd)
490
+ mock.invoke.side_effect = _make_successful_invoke(cwd)
491
+
492
+ abs_path = f"{cwd}/workspace/hello.py"
493
+ result = sandbox.write(abs_path, "hello")
494
+
495
+ write_files_calls = [
496
+ c for c in mock.invoke.call_args_list if c.kwargs.get("method") == "writeFiles"
497
+ ]
498
+ assert len(write_files_calls) == 1
499
+ uploaded_path = write_files_calls[0].kwargs["params"]["content"][0]["path"]
500
+ assert uploaded_path == "workspace/hello.py"
501
+ assert result.path == abs_path
502
+
503
+
504
+ def test_write_resolves_virtual_path_for_preflight() -> None:
505
+ """Virtual paths must be resolved to real absolute paths before preflight."""
506
+ cwd = "/opt/sandbox"
507
+ sandbox, mock = _make_sandbox(cwd=cwd)
508
+ mock.invoke.side_effect = _make_successful_invoke(cwd)
509
+
510
+ result = sandbox.write("/workspace/hello.py", "hello")
511
+
512
+ # The resolved absolute path should be returned and used for the upload.
513
+ assert result.path == "/opt/sandbox/workspace/hello.py"
514
+ write_files_calls = [
515
+ c for c in mock.invoke.call_args_list if c.kwargs.get("method") == "writeFiles"
516
+ ]
517
+ assert len(write_files_calls) == 1
518
+ uploaded_path = write_files_calls[0].kwargs["params"]["content"][0]["path"]
519
+ assert uploaded_path == "workspace/hello.py"
520
+
521
+
522
+ def test_write_lazy_cwd_detection() -> None:
523
+ """When cwd is not passed at construction, write() detects it via pwd."""
524
+ cwd = "/opt/sandbox"
525
+ sandbox, mock = _make_sandbox()
526
+ mock.invoke.side_effect = _make_successful_invoke(cwd)
527
+
528
+ result = sandbox.write("/workspace/hello.py", "hello")
529
+
530
+ assert sandbox._cwd == cwd
531
+ assert result.path == "/opt/sandbox/workspace/hello.py"
532
+
533
+
534
+ def test_write_returns_resolved_path_not_virtual_path() -> None:
535
+ """WriteResult.path must be the resolved absolute path, not the virtual path.
536
+
537
+ Regression test for the execute()-after-write() mismatch: when the LLM writes
538
+ to "/tmp/script.py" and then runs execute("python /tmp/script.py"), it must
539
+ use the same path that was returned by write() — otherwise the shell cannot
540
+ find the file because AgentCore resolves uploads relative to cwd, not to "/".
541
+ """
542
+ cwd = "/opt/amazon/genesis1p-tools/var"
543
+ sandbox, mock = _make_sandbox(cwd=cwd)
544
+ mock.invoke.side_effect = _make_successful_invoke(cwd)
545
+
546
+ result = sandbox.write("/tmp/script.py", "print('hello')")
547
+
548
+ # The returned path must be the real location so execute() can find the file.
549
+ assert result.path == f"{cwd}/tmp/script.py"
550
+ write_files_calls = [
551
+ c for c in mock.invoke.call_args_list if c.kwargs.get("method") == "writeFiles"
552
+ ]
553
+ uploaded_path = write_files_calls[0].kwargs["params"]["content"][0]["path"]
554
+ # AgentCore receives a cwd-relative path, resolving to {cwd}/tmp/script.py.
555
+ assert uploaded_path == "tmp/script.py"
556
+
557
+
558
+ def test_write_root_cwd_preserves_existing_behavior() -> None:
559
+ """When cwd is /, write() should behave as it did before the fix."""
560
+ sandbox, mock = _make_sandbox(cwd="/")
561
+ mock.invoke.side_effect = _make_successful_invoke("/")
562
+
563
+ result = sandbox.write("/hello.py", "hello")
564
+
565
+ assert result.path == "/hello.py"
566
+ write_files_calls = [
567
+ c for c in mock.invoke.call_args_list if c.kwargs.get("method") == "writeFiles"
568
+ ]
569
+ uploaded_path = write_files_calls[0].kwargs["params"]["content"][0]["path"]
570
+ assert uploaded_path == "hello.py"
571
+
572
+
256
573
  # ------------------------------------------------------------------
257
574
  # _invoke() — eager stream consumption
258
575
  # ------------------------------------------------------------------
@@ -436,7 +753,7 @@ def test_adownload_files_uses_dedicated_executor() -> None:
436
753
  }
437
754
  ]
438
755
  }
439
- sandbox, mock = _make_sandbox(canned_response)
756
+ sandbox, mock = _make_sandbox(canned_response, cwd="/")
440
757
 
441
758
  import threading
442
759