langchain-agentcore-codeinterpreter 0.0.3__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 (19) hide show
  1. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/PKG-INFO +1 -1
  2. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/langchain_agentcore_codeinterpreter/sandbox.py +108 -10
  3. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/pyproject.toml +2 -2
  4. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/tests/unit_tests/test_sandbox.py +295 -15
  5. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/uv.lock +528 -543
  6. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/.gitignore +0 -0
  7. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/LICENSE +0 -0
  8. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/Makefile +0 -0
  9. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/README.md +0 -0
  10. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/langchain_agentcore_codeinterpreter/__init__.py +0 -0
  11. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/scripts/check_imports.py +0 -0
  12. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/tests/__init__.py +0 -0
  13. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/tests/integration_tests/__init__.py +0 -0
  14. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/tests/integration_tests/sandbox/__init__.py +0 -0
  15. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/tests/integration_tests/sandbox/test_sandbox.py +0 -0
  16. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/tests/integration_tests/test_compile.py +0 -0
  17. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/tests/unit_tests/__init__.py +0 -0
  18. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/tests/unit_tests/test_imports.py +0 -0
  19. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/tests/unit_tests/test_stream_parsing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langchain-agentcore-codeinterpreter
3
- Version: 0.0.3
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
@@ -167,6 +167,15 @@ class AgentCoreSandbox(BaseSandbox):
167
167
  The caller is responsible for managing the interpreter lifecycle
168
168
  (``start()`` / ``stop()``).
169
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
+
170
179
  Example:
171
180
  .. code-block:: python
172
181
 
@@ -185,26 +194,114 @@ class AgentCoreSandbox(BaseSandbox):
185
194
  interpreter.stop()
186
195
  """
187
196
 
188
- def __init__(self, *, interpreter: CodeInterpreter) -> None:
197
+ def __init__(
198
+ self,
199
+ *,
200
+ interpreter: CodeInterpreter,
201
+ cwd: str | None = None,
202
+ ) -> None:
189
203
  """Create a backend wrapping an active CodeInterpreter session.
190
204
 
191
205
  Args:
192
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``.
193
213
  """
194
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
195
232
 
196
- @staticmethod
197
- def _to_relative_path(path: str) -> str:
198
- """Strip leading slashes and ``./`` prefixes for AgentCore APIs.
233
+ def _to_relative_path(self, path: str) -> str:
234
+ """Strip the cwd prefix (or leading slashes) for AgentCore file APIs.
235
+
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.
199
240
 
200
241
  Args:
201
242
  path: File path (absolute or relative).
202
243
 
203
244
  Returns:
204
- Relative path string.
245
+ Path relative to the sandbox cwd, with no leading ``/`` or ``./``.
205
246
  """
247
+ if self._cwd is not None:
248
+ cwd_prefix = self._cwd + "/"
249
+ if path.startswith(cwd_prefix):
250
+ return path[len(cwd_prefix) :]
206
251
  return _normalize_relative_path(path)
207
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)
304
+
208
305
  def _invoke(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
209
306
  """Invoke the interpreter and eagerly consume the response stream.
210
307
 
@@ -311,19 +408,20 @@ class AgentCoreSandbox(BaseSandbox):
311
408
  as the input paths.
312
409
  """
313
410
  try:
411
+ self._get_cwd()
314
412
  relative_paths = [self._to_relative_path(p) for p in paths]
315
413
  response = self._invoke(
316
414
  method="readFiles", params={"paths": relative_paths}
317
415
  )
318
- file_contents = _extract_files_from_stream(response, paths)
416
+ file_contents = _extract_files_from_stream(response, relative_paths)
319
417
 
320
418
  return [
321
419
  FileDownloadResponse(
322
- path=path,
323
- content=file_contents.get(path),
324
- 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",
325
423
  )
326
- for path in paths
424
+ for original, rel in zip(paths, relative_paths)
327
425
  ]
328
426
  except SessionExpiredError:
329
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.3"
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,7 +225,7 @@ 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
 
@@ -248,7 +250,8 @@ def test_download_files_dot_slash_path() -> None:
248
250
  }
249
251
  }
250
252
  ]
251
- }
253
+ },
254
+ cwd="/",
252
255
  )
253
256
  results = sandbox.download_files(["./data/foo.png"])
254
257
  mock.invoke.assert_called_once_with(
@@ -258,6 +261,111 @@ def test_download_files_dot_slash_path() -> None:
258
261
  assert results[0].content == fake_png
259
262
 
260
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
+
261
369
  # ------------------------------------------------------------------
262
370
  # _to_relative_path()
263
371
  # ------------------------------------------------------------------
@@ -265,16 +373,32 @@ def test_download_files_dot_slash_path() -> None:
265
373
 
266
374
  def test_relative_path_stripping() -> None:
267
375
  """Leading slashes should be stripped; relative paths left as-is."""
268
- assert AgentCoreSandbox._to_relative_path("/abs/path.txt") == "abs/path.txt"
269
- assert AgentCoreSandbox._to_relative_path("rel/path.txt") == "rel/path.txt"
270
- 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"
271
380
 
272
381
 
273
382
  def test_relative_path_strips_dot_slash() -> None:
274
383
  """./ and repeated ././ prefixes should be stripped."""
275
- assert AgentCoreSandbox._to_relative_path("./data/foo.png") == "data/foo.png"
276
- assert AgentCoreSandbox._to_relative_path("././foo.png") == "foo.png"
277
- assert AgentCoreSandbox._to_relative_path("/./data/foo.png") == "data/foo.png"
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"
278
402
 
279
403
 
280
404
  # ------------------------------------------------------------------
@@ -290,6 +414,162 @@ def test_keyword_only_init() -> None:
290
414
  assert sandbox.id == "s"
291
415
 
292
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
+
293
573
  # ------------------------------------------------------------------
294
574
  # _invoke() — eager stream consumption
295
575
  # ------------------------------------------------------------------
@@ -473,7 +753,7 @@ def test_adownload_files_uses_dedicated_executor() -> None:
473
753
  }
474
754
  ]
475
755
  }
476
- sandbox, mock = _make_sandbox(canned_response)
756
+ sandbox, mock = _make_sandbox(canned_response, cwd="/")
477
757
 
478
758
  import threading
479
759