langchain-islo 0.0.1__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.
@@ -0,0 +1,34 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ test:
14
+ name: Lint and test (py${{ matrix.python-version }})
15
+ runs-on: ubuntu-latest
16
+ strategy:
17
+ fail-fast: false
18
+ matrix:
19
+ python-version: ["3.11", "3.12", "3.13"]
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: actions/setup-python@v5
23
+ with:
24
+ python-version: ${{ matrix.python-version }}
25
+ - name: Install
26
+ run: |
27
+ python -m pip install --upgrade pip
28
+ pip install -e . ruff pytest pytest-socket "langchain-tests>=1.1.6"
29
+ - name: Lint
30
+ run: |
31
+ ruff check langchain_islo tests
32
+ ruff format langchain_islo tests --diff
33
+ - name: Unit tests
34
+ run: pytest tests/unit_tests tests/test_import.py -q --disable-socket --allow-unix-socket
@@ -0,0 +1,50 @@
1
+ name: Release
2
+
3
+ # Publishes langchain-islo to PyPI via Trusted Publishing (OIDC, no token).
4
+ # Matches the PyPI pending publisher: repo islo-labs/langchain-islo,
5
+ # workflow file workflow.yml, environment "pypi".
6
+
7
+ on:
8
+ release:
9
+ types: [published]
10
+ workflow_dispatch:
11
+
12
+ permissions:
13
+ contents: read
14
+
15
+ jobs:
16
+ build:
17
+ name: Build distribution
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - uses: actions/setup-python@v5
22
+ with:
23
+ python-version: "3.12"
24
+ - name: Build sdist and wheel
25
+ run: |
26
+ python -m pip install --upgrade build
27
+ python -m build
28
+ - name: Check metadata
29
+ run: |
30
+ python -m pip install --upgrade twine
31
+ python -m twine check dist/*
32
+ - uses: actions/upload-artifact@v4
33
+ with:
34
+ name: dist
35
+ path: dist/
36
+
37
+ publish:
38
+ name: Publish to PyPI
39
+ needs: build
40
+ runs-on: ubuntu-latest
41
+ environment: pypi
42
+ permissions:
43
+ id-token: write # required for Trusted Publishing
44
+ steps:
45
+ - uses: actions/download-artifact@v4
46
+ with:
47
+ name: dist
48
+ path: dist/
49
+ - name: Publish
50
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ dist/
3
+ *.egg-info/
4
+ __pycache__/
5
+ *.pyc
6
+ .pytest_cache/
7
+ .ruff_cache/
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 0.0.1
4
+
5
+ Initial release.
6
+
7
+ - `IsloSandbox`: a `deepagents` `BaseSandbox` backend for [Islo](https://islo.dev),
8
+ implementing `execute()`, `upload_files()`, `download_files()`, and `id`.
9
+ Filesystem tools (`ls`, `read`, `write`, `edit`, `glob`, `grep`) are inherited
10
+ from `BaseSandbox`.
11
+ - `IsloProvider`: optional lifecycle helper (`get_or_create()` / `delete()`).
12
+ - Validated against the `deepagents` `SandboxIntegrationTests` standard suite.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Islo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,60 @@
1
+ .PHONY: format lint type typecheck test tests integration_test integration_tests test_watch help lint_package
2
+
3
+ .DEFAULT_GOAL := help
4
+
5
+ .EXPORT_ALL_VARIABLES:
6
+ UV_FROZEN = true
7
+
8
+ ######################
9
+ # TESTING
10
+ ######################
11
+
12
+ TEST_FILE ?= tests/unit_tests/
13
+ PYTEST_EXTRA ?=
14
+
15
+ integration_test integration_tests: TEST_FILE=tests/integration_tests/
16
+
17
+ test: ## Run unit tests
18
+ test tests:
19
+ uv run --group test pytest -vvv $(PYTEST_EXTRA) --disable-socket --allow-unix-socket $(TEST_FILE)
20
+
21
+ integration_test: ## Run integration tests
22
+ integration_test integration_tests:
23
+ uv run --group test pytest -vvv --timeout 120 $(TEST_FILE)
24
+
25
+ test_watch: ## Run tests in watch mode
26
+ uv run --group test ptw --now . -- -vv $(TEST_FILE)
27
+
28
+ ######################
29
+ # LINTING AND FORMATTING
30
+ ######################
31
+
32
+ PYTHON_FILES=.
33
+ lint format: PYTHON_FILES=.
34
+ lint_package: ## Lint only the package
35
+ lint_package: PYTHON_FILES=langchain_islo
36
+
37
+ lint: ## Run linters and type checker
38
+ lint lint_package:
39
+ [ "$(PYTHON_FILES)" = "" ] || uv run --all-groups ruff check $(PYTHON_FILES)
40
+ [ "$(PYTHON_FILES)" = "" ] || uv run --all-groups ruff format $(PYTHON_FILES) --diff
41
+ $(MAKE) type
42
+
43
+ type: ## Run type checker
44
+ type typecheck:
45
+ uv run --all-groups ty check langchain_islo
46
+
47
+ format: ## Run code formatters
48
+ format:
49
+ [ "$(PYTHON_FILES)" = "" ] || uv run --all-groups ruff format $(PYTHON_FILES)
50
+ [ "$(PYTHON_FILES)" = "" ] || uv run --all-groups ruff check --fix $(PYTHON_FILES)
51
+
52
+ ######################
53
+ # HELP
54
+ ######################
55
+
56
+ help: ## Show this help message
57
+ @echo "Usage: make [target] [TEST_FILE=path/to/tests/]"
58
+ @echo ""
59
+ @echo "Targets:"
60
+ @awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z_-]+:.*##/ {printf " %-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: langchain-islo
3
+ Version: 0.0.1
4
+ Summary: Islo (islo.dev) sandbox integration for Deep Agents
5
+ Project-URL: Homepage, https://github.com/islo-labs/langchain-islo
6
+ Project-URL: Repository, https://github.com/islo-labs/langchain-islo
7
+ Project-URL: Documentation, https://docs.islo.dev
8
+ Project-URL: Issue Tracker, https://github.com/islo-labs/langchain-islo/issues
9
+ Author: Islo
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: agents,deepagents,islo,langchain,sandbox
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Requires-Python: <4.0,>=3.11
22
+ Requires-Dist: deepagents<0.7.0,>=0.6.8
23
+ Requires-Dist: httpx>=0.21.2
24
+ Requires-Dist: islo>=0.3.3
25
+ Description-Content-Type: text/markdown
26
+
27
+ # langchain-islo
28
+
29
+ [![PyPI - Version](https://img.shields.io/pypi/v/langchain-islo?label=%20)](https://pypi.org/project/langchain-islo/#history)
30
+ [![PyPI - License](https://img.shields.io/pypi/l/langchain-islo)](https://opensource.org/licenses/MIT)
31
+
32
+ [Islo](https://islo.dev) sandbox integration for [Deep Agents](https://github.com/langchain-ai/deepagents).
33
+
34
+ Islo provides long-running, reconnect-surviving AI sandboxes on real Linux VMs,
35
+ with pause/resume, snapshots, and a gateway layer for secret isolation and
36
+ egress policies.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install langchain-islo
42
+ ```
43
+
44
+ Set your API key (keys look like `ak_...`):
45
+
46
+ ```bash
47
+ export ISLO_API_KEY="ak_..."
48
+ ```
49
+
50
+ ## Usage
51
+
52
+ Wrap an existing Islo sandbox and use it as a Deep Agents backend:
53
+
54
+ ```python
55
+ from islo import Islo
56
+ from langchain_islo import IsloSandbox
57
+
58
+ client = Islo() # reads ISLO_API_KEY
59
+ sandbox = client.sandboxes.create_sandbox(image="ubuntu:24.04")
60
+
61
+ backend = IsloSandbox(client=client, sandbox=sandbox)
62
+
63
+ result = backend.execute("echo hello")
64
+ print(result.output) # "hello"
65
+
66
+ # Filesystem tools are inherited from BaseSandbox:
67
+ backend.write("/workspace/app.py", "print('hi')\n")
68
+ print(backend.read("/workspace/app.py").file_data["content"])
69
+ ```
70
+
71
+ ### Lifecycle helper
72
+
73
+ `IsloProvider` creates, attaches to, and deletes sandboxes for you:
74
+
75
+ ```python
76
+ from langchain_islo import IsloProvider
77
+
78
+ provider = IsloProvider() # reads ISLO_API_KEY
79
+ backend = provider.get_or_create(image="ubuntu:24.04")
80
+ try:
81
+ print(backend.execute("uname -a").output)
82
+ finally:
83
+ provider.delete(sandbox_id=backend.id)
84
+ ```
85
+
86
+ ## What you get
87
+
88
+ `IsloSandbox` subclasses `deepagents.backends.sandbox.BaseSandbox`, so it
89
+ implements the full `SandboxBackendProtocol`:
90
+
91
+ | Method | Backed by |
92
+ | --- | --- |
93
+ | `execute()` | Islo `exec_in_sandbox` + result polling (commands run via `sh -c`) |
94
+ | `upload_files()` / `download_files()` | Islo sandbox files endpoint (raw bytes) |
95
+ | `ls` / `read` / `write` / `edit` / `glob` / `grep` | Inherited from `BaseSandbox` |
96
+ | `id` | Islo sandbox id |
97
+
98
+ Async variants (`aexecute`, `aupload_files`, `adownload_files`, ...) are provided
99
+ by the base class via thread offloading.
100
+
101
+ ## Configuration
102
+
103
+ | Env var | Default | Purpose |
104
+ | --- | --- | --- |
105
+ | `ISLO_API_KEY` | — | Bearer token used to authenticate |
106
+ | `ISLO_BASE_URL` | `https://api.islo.dev` | Control-plane base URL |
107
+
108
+ ## License
109
+
110
+ MIT
@@ -0,0 +1,84 @@
1
+ # langchain-islo
2
+
3
+ [![PyPI - Version](https://img.shields.io/pypi/v/langchain-islo?label=%20)](https://pypi.org/project/langchain-islo/#history)
4
+ [![PyPI - License](https://img.shields.io/pypi/l/langchain-islo)](https://opensource.org/licenses/MIT)
5
+
6
+ [Islo](https://islo.dev) sandbox integration for [Deep Agents](https://github.com/langchain-ai/deepagents).
7
+
8
+ Islo provides long-running, reconnect-surviving AI sandboxes on real Linux VMs,
9
+ with pause/resume, snapshots, and a gateway layer for secret isolation and
10
+ egress policies.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install langchain-islo
16
+ ```
17
+
18
+ Set your API key (keys look like `ak_...`):
19
+
20
+ ```bash
21
+ export ISLO_API_KEY="ak_..."
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ Wrap an existing Islo sandbox and use it as a Deep Agents backend:
27
+
28
+ ```python
29
+ from islo import Islo
30
+ from langchain_islo import IsloSandbox
31
+
32
+ client = Islo() # reads ISLO_API_KEY
33
+ sandbox = client.sandboxes.create_sandbox(image="ubuntu:24.04")
34
+
35
+ backend = IsloSandbox(client=client, sandbox=sandbox)
36
+
37
+ result = backend.execute("echo hello")
38
+ print(result.output) # "hello"
39
+
40
+ # Filesystem tools are inherited from BaseSandbox:
41
+ backend.write("/workspace/app.py", "print('hi')\n")
42
+ print(backend.read("/workspace/app.py").file_data["content"])
43
+ ```
44
+
45
+ ### Lifecycle helper
46
+
47
+ `IsloProvider` creates, attaches to, and deletes sandboxes for you:
48
+
49
+ ```python
50
+ from langchain_islo import IsloProvider
51
+
52
+ provider = IsloProvider() # reads ISLO_API_KEY
53
+ backend = provider.get_or_create(image="ubuntu:24.04")
54
+ try:
55
+ print(backend.execute("uname -a").output)
56
+ finally:
57
+ provider.delete(sandbox_id=backend.id)
58
+ ```
59
+
60
+ ## What you get
61
+
62
+ `IsloSandbox` subclasses `deepagents.backends.sandbox.BaseSandbox`, so it
63
+ implements the full `SandboxBackendProtocol`:
64
+
65
+ | Method | Backed by |
66
+ | --- | --- |
67
+ | `execute()` | Islo `exec_in_sandbox` + result polling (commands run via `sh -c`) |
68
+ | `upload_files()` / `download_files()` | Islo sandbox files endpoint (raw bytes) |
69
+ | `ls` / `read` / `write` / `edit` / `glob` / `grep` | Inherited from `BaseSandbox` |
70
+ | `id` | Islo sandbox id |
71
+
72
+ Async variants (`aexecute`, `aupload_files`, `adownload_files`, ...) are provided
73
+ by the base class via thread offloading.
74
+
75
+ ## Configuration
76
+
77
+ | Env var | Default | Purpose |
78
+ | --- | --- | --- |
79
+ | `ISLO_API_KEY` | — | Bearer token used to authenticate |
80
+ | `ISLO_BASE_URL` | `https://api.islo.dev` | Control-plane base URL |
81
+
82
+ ## License
83
+
84
+ MIT
@@ -0,0 +1,50 @@
1
+ # Docs PR to `langchain-ai/docs`
2
+
3
+ > Do this **after** `langchain-islo` is published to PyPI. The maintainers
4
+ > (issue #3777) only accept docs PRs for *already-published* sandbox packages.
5
+
6
+ ## 1. Add the integration page
7
+
8
+ Copy [`islo.mdx`](./islo.mdx) to:
9
+
10
+ ```
11
+ src/oss/python/integrations/sandboxes/islo.mdx
12
+ ```
13
+
14
+ This mirrors the structure of the existing `daytona.mdx` / `modal.mdx` /
15
+ `runloop.mdx` pages (Installation → Create a sandbox backend → Use with Deep
16
+ Agents → Cleanup).
17
+
18
+ ## 2. Register it in the index
19
+
20
+ In `src/oss/python/integrations/sandboxes/index.mdx`, add an Islo card to the
21
+ grid (alphabetical-ish, alongside the others):
22
+
23
+ ```mdx
24
+ <a href="/oss/integrations/sandboxes/islo" className="flex items-center justify-center gap-1.5 p-2 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 no-underline">
25
+ <img className="block dark:hidden w-5 h-5" src="/images/providers/light/islo.svg" alt="" />
26
+ <img className="hidden dark:block w-5 h-5" src="/images/providers/dark/islo.svg" alt="" />
27
+ <span className="font-semibold">Islo</span>
28
+ </a>
29
+ ```
30
+
31
+ ## 3. Add the provider logo
32
+
33
+ Add light/dark SVGs (the index references both):
34
+
35
+ ```
36
+ src/images/providers/light/islo.svg
37
+ src/images/providers/dark/islo.svg
38
+ ```
39
+
40
+ (Check `docs.json` / the nav config for whether the new page must also be listed
41
+ in the sidebar navigation — grep for `daytona` to find every place it's
42
+ referenced and add `islo` in the same spots.)
43
+
44
+ ## 4. Open the PR
45
+
46
+ Title: `docs: add Islo sandbox integration`
47
+
48
+ Body: link the published PyPI package (`https://pypi.org/project/langchain-islo/`)
49
+ and the issue (`langchain-ai/deepagents#3777`), and note it follows the
50
+ [sandbox contributing guide](https://docs.langchain.com/oss/python/contributing/implement-langchain#sandboxes).
@@ -0,0 +1,49 @@
1
+ # Publishing `langchain-islo` to PyPI
2
+
3
+ The maintainer's guidance on `langchain-ai/deepagents#3777` is: **ship as a
4
+ standalone PyPI package**, then open a docs PR. This is the publish half.
5
+
6
+ ## Preconditions
7
+
8
+ - [ ] Repo created (suggested: `islo-labs/langchain-islo`) with this package at root.
9
+ - [ ] PyPI account + project owned by the Islo/Incredibuild org.
10
+ - [ ] CI green: `make lint` and `make test` (unit tests run offline).
11
+
12
+ ## Build & check
13
+
14
+ ```bash
15
+ uv build # or: python -m build
16
+ uvx twine check dist/*
17
+ ```
18
+
19
+ ## Test it from a clean env (optional but recommended)
20
+
21
+ ```bash
22
+ python -m venv /tmp/islo-check && . /tmp/islo-check/bin/activate
23
+ pip install dist/langchain_islo-*.whl
24
+ python -c "from langchain_islo import IsloSandbox, IsloProvider; print('ok')"
25
+ ```
26
+
27
+ ## Publish
28
+
29
+ Trusted publishing (recommended) via GitHub Actions, or manually:
30
+
31
+ ```bash
32
+ uvx twine upload dist/*
33
+ ```
34
+
35
+ ## Live integration test (needs a real account)
36
+
37
+ ```bash
38
+ export ISLO_API_KEY="ak_..."
39
+ make integration_test
40
+ ```
41
+
42
+ This runs the deepagents standard suite (`SandboxIntegrationTests`) against a
43
+ real Islo sandbox.
44
+
45
+ ## After publishing
46
+
47
+ 1. Confirm `pip install langchain-islo` works from PyPI.
48
+ 2. Open the docs PR — see [`DOCS_PR.md`](./DOCS_PR.md).
49
+ 3. Post the follow-up on issue #3777 — see [`issue-reply.md`](./issue-reply.md).
@@ -0,0 +1,81 @@
1
+ ---
2
+ title: "IsloSandbox integration"
3
+ description: "Integrate with the IsloSandbox sandbox backend using LangChain Python."
4
+ ---
5
+
6
+ [Islo](https://islo.dev) provides long-running, reconnect-surviving sandboxes on real Linux VMs, with pause/resume, snapshots, and a gateway layer for secret isolation and egress policies. See the [Islo docs](https://docs.islo.dev) for signup, authentication, and platform details.
7
+
8
+ ## Installation
9
+
10
+ <CodeGroup>
11
+ ```bash pip
12
+ pip install langchain-islo
13
+ ```
14
+
15
+ ```bash uv
16
+ uv add langchain-islo
17
+ ```
18
+ </CodeGroup>
19
+
20
+ Set your API key (keys look like `ak_...`):
21
+
22
+ ```bash
23
+ export ISLO_API_KEY="ak_..."
24
+ ```
25
+
26
+ ## Create a sandbox backend
27
+
28
+ In Python, you create the sandbox using the provider SDK, then wrap it with the [deepagents backend](/oss/deepagents/backends).
29
+
30
+ ```python
31
+ from islo import Islo
32
+
33
+ from langchain_islo import IsloSandbox
34
+
35
+ client = Islo() # reads ISLO_API_KEY
36
+ sandbox = client.sandboxes.create_sandbox(image="ubuntu:24.04")
37
+ backend = IsloSandbox(client=client, sandbox=sandbox)
38
+
39
+ result = backend.execute("echo hello")
40
+ print(result.output)
41
+ ```
42
+
43
+ ## Use with Deep Agents
44
+
45
+ ```python
46
+ from islo import Islo
47
+ from langchain_anthropic import ChatAnthropic
48
+
49
+ from deepagents import create_deep_agent
50
+ from langchain_islo import IsloSandbox
51
+
52
+ client = Islo()
53
+ sandbox = client.sandboxes.create_sandbox(image="ubuntu:24.04")
54
+ backend = IsloSandbox(client=client, sandbox=sandbox)
55
+
56
+ agent = create_deep_agent(
57
+ model=ChatAnthropic(model="claude-sonnet-4-20250514"),
58
+ system_prompt="You are a coding assistant with sandbox access.",
59
+ backend=backend,
60
+ )
61
+
62
+ result = agent.invoke(
63
+ {
64
+ "messages": [
65
+ {"role": "user", "content": "Create a hello world Python script and run it"}
66
+ ]
67
+ }
68
+ )
69
+ ```
70
+
71
+ ## Cleanup
72
+
73
+ You are responsible for managing the sandbox lifecycle via the Islo SDK.
74
+ When you are done, pause, stop, or delete the sandbox:
75
+
76
+ ```python
77
+ client.sandboxes.pause_sandbox(sandbox.name) # resume later with resume_sandbox
78
+ client.sandboxes.delete_sandbox(sandbox.name) # permanently delete
79
+ ```
80
+
81
+ See also: [Sandboxes](/oss/deepagents/sandboxes).
@@ -0,0 +1,36 @@
1
+ # Draft reply for langchain-ai/deepagents#3777
2
+
3
+ > Post this once the package is on PyPI and the docs PR is open. It (a) confirms
4
+ > you followed their standalone-package guidance and (b) gives the feedback
5
+ > `mdrxy` explicitly asked for on recommending community integrations.
6
+
7
+ ---
8
+
9
+ Thanks @mdrxy — totally understand the maintenance/security rationale for not
10
+ taking new sandboxes in-tree.
11
+
12
+ We went the standalone route as suggested:
13
+
14
+ - 📦 Published **`langchain-islo`** to PyPI: https://pypi.org/project/langchain-islo/
15
+ - It subclasses `BaseSandbox` and implements `execute()` / `upload_files()` /
16
+ `download_files()` / `id`, and passes the `SandboxIntegrationTests` standard
17
+ suite (`tests/integration_tests/test_sandbox.py`), per the
18
+ [contributing guide](https://docs.langchain.com/oss/python/contributing/implement-langchain#sandboxes).
19
+ - 📄 Docs PR adding the integration page: <link to langchain-ai/docs PR>
20
+
21
+ On your open question about how to surface community integration packages — a
22
+ few ideas from the implementer's seat:
23
+
24
+ 1. **A "Community" section on the Sandboxes index page** (separate from the
25
+ first-party cards) that links out to third-party PyPI packages + their docs.
26
+ Zero maintenance for you, discoverable for users.
27
+ 2. **An optional entry-point / plugin hook** so `deepagents-cli --sandbox <name>`
28
+ can discover installed third-party backends without a hardcoded `choices`
29
+ list in `sandbox_factory.py`. Today adding a CLI sandbox name requires an
30
+ in-tree edit, which is the one thing a standalone package *can't* do — an
31
+ entry-point group (e.g. `deepagents.sandboxes`) would close that gap.
32
+ 3. **A lightweight "verified" badge** keyed off the standard test suite passing
33
+ in the package's own CI, so users can tell which community packages meet the
34
+ contract.
35
+
36
+ Happy to prototype (1) or (2) as a PR if useful.
@@ -0,0 +1,6 @@
1
+ """Islo sandbox integration for Deep Agents."""
2
+
3
+ from langchain_islo.provider import IsloProvider
4
+ from langchain_islo.sandbox import IsloSandbox
5
+
6
+ __all__ = ["IsloProvider", "IsloSandbox"]
@@ -0,0 +1,5 @@
1
+ """Version information for `langchain-islo`."""
2
+
3
+ # Keep the `x-release-please-version` annotation — release-please uses it to
4
+ # bump `__version__` in sync with `pyproject.toml` on every release PR.
5
+ __version__ = "0.0.1" # x-release-please-version
@@ -0,0 +1,92 @@
1
+ """Lifecycle provider for Islo sandboxes.
2
+
3
+ [`IsloProvider`][langchain_islo.provider.IsloProvider] is an optional
4
+ convenience that creates, attaches to, and deletes Islo sandboxes and returns a
5
+ ready-to-use [`IsloSandbox`][langchain_islo.sandbox.IsloSandbox]. It is
6
+ duck-typed to the lifecycle shape deepagents expects of a sandbox provider
7
+ (``get_or_create`` / ``delete``) without importing the CLI package, mirroring the
8
+ ``RunloopProvider`` pattern.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ from islo import Islo
16
+
17
+ from langchain_islo.sandbox import IsloSandbox
18
+
19
+ _DEFAULT_IMAGE = "ubuntu:24.04"
20
+
21
+
22
+ class IsloProvider:
23
+ """Create, attach to, and delete Islo sandboxes.
24
+
25
+ Example:
26
+ ```python
27
+ from langchain_islo import IsloProvider
28
+
29
+ provider = IsloProvider(api_key="ak_...")
30
+ backend = provider.get_or_create(image="ubuntu:24.04")
31
+ try:
32
+ print(backend.execute("uname -a").output)
33
+ finally:
34
+ provider.delete(sandbox_id=backend.id)
35
+ ```
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ client: Islo | None = None,
42
+ api_key: str | None = None,
43
+ image: str = _DEFAULT_IMAGE,
44
+ ) -> None:
45
+ """Initialize the provider.
46
+
47
+ Args:
48
+ client: A pre-configured ``islo.Islo`` client. If omitted, one is
49
+ created (reading ``ISLO_API_KEY`` / ``ISLO_BASE_URL`` from the
50
+ environment, or ``api_key`` when provided).
51
+ api_key: API key used to construct a client when ``client`` is not
52
+ supplied.
53
+ image: Default container image used when creating new sandboxes.
54
+ """
55
+ if client is None:
56
+ client = Islo(api_key=api_key) if api_key else Islo()
57
+ self._client = client
58
+ self._default_image = image
59
+
60
+ def get_or_create(
61
+ self,
62
+ *,
63
+ sandbox_id: str | None = None,
64
+ **kwargs: Any,
65
+ ) -> IsloSandbox:
66
+ """Attach to an existing sandbox or create a new one.
67
+
68
+ Args:
69
+ sandbox_id: If provided, attach to the existing Islo sandbox with
70
+ this id instead of creating a new one.
71
+ kwargs: Forwarded to ``client.sandboxes.create_sandbox(...)`` when
72
+ creating (e.g. ``image``, ``vcpus``, ``memory_mb``, ``env``,
73
+ ``gateway_profile``, ``snapshot_name``).
74
+
75
+ Returns:
76
+ A ready-to-use ``IsloSandbox``.
77
+ """
78
+ if sandbox_id is not None:
79
+ sandbox = self._client.sandboxes.get_sandbox_by_id(sandbox_id)
80
+ else:
81
+ kwargs.setdefault("image", self._default_image)
82
+ sandbox = self._client.sandboxes.create_sandbox(**kwargs)
83
+ return IsloSandbox(client=self._client, sandbox=sandbox)
84
+
85
+ def delete(self, *, sandbox_id: str, **_kwargs: Any) -> None:
86
+ """Delete an Islo sandbox by id.
87
+
88
+ Islo's delete API is keyed by sandbox *name*, so the id is resolved to a
89
+ name first.
90
+ """
91
+ sandbox = self._client.sandboxes.get_sandbox_by_id(sandbox_id)
92
+ self._client.sandboxes.delete_sandbox(sandbox.name)
@@ -0,0 +1,298 @@
1
+ """Islo sandbox backend implementation.
2
+
3
+ [`IsloSandbox`][langchain_islo.sandbox.IsloSandbox] wraps an Islo sandbox
4
+ (https://islo.dev) and conforms to deepagents'
5
+ [`SandboxBackendProtocol`][deepagents.backends.protocol.SandboxBackendProtocol].
6
+
7
+ It subclasses [`BaseSandbox`][deepagents.backends.sandbox.BaseSandbox], so the
8
+ filesystem tools (``ls``, ``read``, ``write``, ``edit``, ``glob``, ``grep``) are
9
+ provided for free on top of three primitives implemented here:
10
+
11
+ - ``execute`` -- run a shell command via Islo's exec API (submit + poll).
12
+ - ``upload_files`` / ``download_files`` -- transfer raw bytes over Islo's
13
+ sandbox files endpoint.
14
+
15
+ Notes:
16
+ The deepagents base class sends *full shell command strings* (pipes,
17
+ redirects, here-docs). Islo's ``exec_in_sandbox`` executes an ``argv`` list
18
+ directly without a shell, so every command is wrapped in
19
+ ``["/bin/sh", "-c", command]``.
20
+
21
+ Islo's generated ``upload_file`` / ``download_file`` SDK methods do not carry
22
+ file bytes, so byte transfer is performed against the sandbox files endpoint
23
+ on the *compute* base URL, reusing the client's resolved auth headers (the
24
+ same approach used by the Islo SDK's own ``islo.custom.files`` helpers).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import io
30
+ import posixpath
31
+ import shlex
32
+ from typing import TYPE_CHECKING
33
+
34
+ import httpx
35
+ from deepagents.backends.protocol import (
36
+ FILE_NOT_FOUND,
37
+ INVALID_PATH,
38
+ IS_DIRECTORY,
39
+ PERMISSION_DENIED,
40
+ ExecuteResponse,
41
+ FileDownloadResponse,
42
+ FileOperationError,
43
+ FileUploadResponse,
44
+ )
45
+ from deepagents.backends.sandbox import BaseSandbox
46
+ from islo.custom.exec import exec_and_wait_sync
47
+
48
+ if TYPE_CHECKING:
49
+ from islo import Islo
50
+ from islo.types import SandboxResponse
51
+
52
+ # Islo executes an argv list, not a shell line. deepagents emits shell command
53
+ # strings (pipes, redirects, here-docs), so each command is run through `sh -c`.
54
+ _SHELL: tuple[str, str] = ("/bin/sh", "-c")
55
+
56
+ _TIMEOUT_EXIT_CODE = 124
57
+ """Exit code returned when a command exceeds its timeout (matches `timeout(1)`)."""
58
+
59
+ _HTTP_NOT_FOUND = 404
60
+ _HTTP_FORBIDDEN = 403
61
+ _HTTP_BAD_REQUEST = 400
62
+
63
+
64
+ class IsloSandbox(BaseSandbox):
65
+ """Islo sandbox backend conforming to ``SandboxBackendProtocol``.
66
+
67
+ Inherits all filesystem operations from ``BaseSandbox`` and implements
68
+ ``execute``, ``upload_files``, ``download_files``, and ``id`` using the Islo
69
+ Python SDK (https://github.com/islo-labs/python-sdk).
70
+
71
+ Example:
72
+ ```python
73
+ from islo import Islo
74
+ from langchain_islo import IsloSandbox
75
+
76
+ client = Islo() # reads ISLO_API_KEY
77
+ sandbox = client.sandboxes.create_sandbox(image="ubuntu:24.04")
78
+ backend = IsloSandbox(client=client, sandbox=sandbox)
79
+
80
+ result = backend.execute("echo hello")
81
+ print(result.output) # "hello"
82
+ ```
83
+ """
84
+
85
+ def __init__(
86
+ self,
87
+ *,
88
+ client: Islo,
89
+ sandbox: SandboxResponse,
90
+ timeout: int = 30 * 60,
91
+ poll_interval: float = 0.5,
92
+ http_timeout: float = 60.0,
93
+ ) -> None:
94
+ """Wrap an existing Islo sandbox.
95
+
96
+ Args:
97
+ client: An authenticated ``islo.Islo`` client.
98
+ sandbox: An Islo ``SandboxResponse`` (e.g. from
99
+ ``client.sandboxes.create_sandbox(...)``). Sandbox operations are
100
+ keyed by ``sandbox.name``.
101
+ timeout: Default command timeout in seconds used by ``execute()``
102
+ when no explicit timeout is given. A value of ``0`` waits
103
+ indefinitely.
104
+ poll_interval: Seconds between polls of the exec result while waiting
105
+ for a command to finish.
106
+ http_timeout: Per-request timeout in seconds for file
107
+ upload/download transfers.
108
+ """
109
+ self._client = client
110
+ self._sandbox = sandbox
111
+ self._default_timeout = timeout
112
+ self._poll_interval = poll_interval
113
+ self._http_timeout = http_timeout
114
+
115
+ @property
116
+ def id(self) -> str:
117
+ """Return the Islo sandbox id."""
118
+ return self._sandbox.id
119
+
120
+ @property
121
+ def name(self) -> str:
122
+ """Return the Islo sandbox name (the key most Islo APIs use)."""
123
+ return self._sandbox.name
124
+
125
+ # -- command execution ---------------------------------------------------
126
+
127
+ def execute(
128
+ self,
129
+ command: str,
130
+ *,
131
+ timeout: int | None = None,
132
+ ) -> ExecuteResponse:
133
+ """Execute a shell command inside the sandbox.
134
+
135
+ Args:
136
+ command: Full shell command string to execute.
137
+ timeout: Maximum seconds to wait for completion. If ``None``, uses
138
+ the backend's default timeout. A value of ``0`` waits
139
+ indefinitely.
140
+
141
+ Returns:
142
+ ``ExecuteResponse`` with combined stdout/stderr, the exit code, and a
143
+ truncation flag.
144
+ """
145
+ effective_timeout = self._default_timeout if timeout is None else timeout
146
+ # The exec helper treats `timeout=None` as "poll indefinitely", which is
147
+ # the semantic deepagents assigns to a timeout of 0.
148
+ helper_timeout = None if effective_timeout == 0 else float(effective_timeout)
149
+
150
+ result = exec_and_wait_sync(
151
+ self._client,
152
+ self._sandbox.name,
153
+ [_SHELL[0], _SHELL[1], command],
154
+ timeout=helper_timeout,
155
+ poll_interval=self._poll_interval,
156
+ )
157
+
158
+ if result.timed_out:
159
+ return ExecuteResponse(
160
+ output=f"Command timed out after {effective_timeout} seconds",
161
+ exit_code=_TIMEOUT_EXIT_CODE,
162
+ truncated=False,
163
+ )
164
+
165
+ output = result.stdout
166
+ if result.stderr:
167
+ output = f"{output}{result.stderr}" if output else result.stderr
168
+
169
+ return ExecuteResponse(
170
+ output=output,
171
+ exit_code=result.exit_code,
172
+ truncated=False,
173
+ )
174
+
175
+ # -- file transfer -------------------------------------------------------
176
+
177
+ def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
178
+ """Upload files into the sandbox.
179
+
180
+ Supports partial success: per-file failures are returned as errors on the
181
+ corresponding ``FileUploadResponse`` rather than raised. Parent
182
+ directories are created before upload.
183
+ """
184
+ responses: list[FileUploadResponse | None] = []
185
+ pending: list[tuple[int, str, bytes]] = []
186
+
187
+ for path, content in files:
188
+ if not path.startswith("/"):
189
+ responses.append(FileUploadResponse(path=path, error=INVALID_PATH))
190
+ continue
191
+ responses.append(None) # placeholder, filled after transfer
192
+ pending.append((len(responses) - 1, path, content))
193
+
194
+ if not pending:
195
+ return [r for r in responses if r is not None]
196
+
197
+ # Ensure parent directories exist (upload contract requirement).
198
+ parents = sorted(
199
+ {posixpath.dirname(p) for _, p, _ in pending if posixpath.dirname(p)}
200
+ )
201
+ if parents:
202
+ quoted = " ".join(shlex.quote(d) for d in parents)
203
+ self.execute(f"mkdir -p {quoted}")
204
+
205
+ base_url = self._compute_base_url()
206
+ headers = self._auth_headers()
207
+ with httpx.Client(timeout=self._http_timeout) as http:
208
+ for idx, path, content in pending:
209
+ filename = posixpath.basename(path) or "file"
210
+ try:
211
+ response = http.post(
212
+ f"{base_url}/sandboxes/{self._sandbox.name}/files",
213
+ params={"path": path},
214
+ headers=headers,
215
+ files={"file": (filename, io.BytesIO(content))},
216
+ )
217
+ response.raise_for_status()
218
+ except httpx.HTTPStatusError as exc:
219
+ responses[idx] = FileUploadResponse(
220
+ path=path,
221
+ error=_map_http_status(exc.response.status_code),
222
+ )
223
+ except httpx.HTTPError as exc: # network/timeout errors
224
+ responses[idx] = FileUploadResponse(path=path, error=str(exc))
225
+ else:
226
+ responses[idx] = FileUploadResponse(path=path, error=None)
227
+
228
+ return [r for r in responses if r is not None]
229
+
230
+ def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
231
+ """Download files from the sandbox.
232
+
233
+ Supports partial success: per-file failures are returned as errors on the
234
+ corresponding ``FileDownloadResponse`` rather than raised.
235
+ """
236
+ responses: list[FileDownloadResponse] = []
237
+ base_url = self._compute_base_url()
238
+ headers = self._auth_headers()
239
+
240
+ with httpx.Client(timeout=self._http_timeout) as http:
241
+ for path in paths:
242
+ if not path.startswith("/"):
243
+ responses.append(
244
+ FileDownloadResponse(
245
+ path=path, content=None, error=INVALID_PATH
246
+ )
247
+ )
248
+ continue
249
+ try:
250
+ response = http.get(
251
+ f"{base_url}/sandboxes/{self._sandbox.name}/files",
252
+ params={"path": path},
253
+ headers=headers,
254
+ )
255
+ response.raise_for_status()
256
+ except httpx.HTTPStatusError as exc:
257
+ responses.append(
258
+ FileDownloadResponse(
259
+ path=path,
260
+ content=None,
261
+ error=_map_http_status(exc.response.status_code),
262
+ )
263
+ )
264
+ except httpx.HTTPError as exc:
265
+ responses.append(
266
+ FileDownloadResponse(path=path, content=None, error=str(exc))
267
+ )
268
+ else:
269
+ responses.append(
270
+ FileDownloadResponse(
271
+ path=path, content=response.content, error=None
272
+ )
273
+ )
274
+
275
+ return responses
276
+
277
+ # -- internals -----------------------------------------------------------
278
+
279
+ def _compute_base_url(self) -> str:
280
+ """Resolve the Islo *compute* base URL hosting the exec/files endpoints."""
281
+ return self._client._client_wrapper.get_environment().compute.rstrip("/")
282
+
283
+ def _auth_headers(self) -> dict[str, str]:
284
+ """Resolve fresh auth headers (honors token refresh) for raw requests."""
285
+ return self._client._client_wrapper.get_headers()
286
+
287
+
288
+ def _map_http_status(status_code: int) -> FileOperationError | str:
289
+ """Map an HTTP status to a standardized ``FileOperationError`` literal."""
290
+ if status_code == _HTTP_NOT_FOUND:
291
+ return FILE_NOT_FOUND
292
+ if status_code == _HTTP_FORBIDDEN:
293
+ return PERMISSION_DENIED
294
+ if status_code == _HTTP_BAD_REQUEST:
295
+ return INVALID_PATH
296
+ if status_code == 409: # noqa: PLR2004 # conflict: path is a directory
297
+ return IS_DIRECTORY
298
+ return f"http_{status_code}"
@@ -0,0 +1,91 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "langchain-islo"
7
+ description = "Islo (islo.dev) sandbox integration for Deep Agents"
8
+ license = { text = "MIT" }
9
+ readme = "README.md"
10
+ authors = [{ name = "Islo" }]
11
+ keywords = ["deepagents", "langchain", "sandbox", "islo", "agents"]
12
+
13
+ classifiers = [
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
22
+ ]
23
+
24
+ version = "0.0.1"
25
+ requires-python = ">=3.11,<4.0"
26
+ dependencies = [
27
+ "deepagents>=0.6.8,<0.7.0",
28
+ "islo>=0.3.3",
29
+ "httpx>=0.21.2",
30
+ ]
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["langchain_islo"]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/islo-labs/langchain-islo"
37
+ Repository = "https://github.com/islo-labs/langchain-islo"
38
+ Documentation = "https://docs.islo.dev"
39
+ "Issue Tracker" = "https://github.com/islo-labs/langchain-islo/issues"
40
+
41
+ [dependency-groups]
42
+ test = [
43
+ "pytest>=8.3.4,<10.0.0",
44
+ "pytest-cov",
45
+ "pytest-socket",
46
+ "pytest-xdist",
47
+ "pytest-timeout>=2.3.1,<3.0.0",
48
+ "pytest-asyncio>=1.3.0",
49
+ "ruff>=0.14.0,<1.0.0",
50
+ "ty>=0.0.1,<1.0.0",
51
+ "langchain-tests>=1.1.6",
52
+ ]
53
+
54
+ [tool.ruff.format]
55
+ docstring-code-format = true
56
+
57
+ [tool.ruff.lint]
58
+ select = ["ALL"]
59
+ ignore = [
60
+ "COM812", # Messes with the formatter
61
+ "ISC001", # Messes with the formatter
62
+ "ANN401", # Dynamically typed expressions (typing.Any) — needed for **kwargs passthrough
63
+ "SLF001", # Private member access — required to reuse the Islo client's resolved env/headers
64
+ "FBT001", # Boolean positional arg — inherited signature shape from deepagents
65
+ "FBT002", # Boolean default positional arg
66
+ ]
67
+ extend-safe-fixes = ["PLR6201"]
68
+
69
+ [tool.ruff.lint.pydocstyle]
70
+ convention = "google"
71
+ ignore-var-parameters = true
72
+
73
+ [tool.ruff.lint.flake8-tidy-imports]
74
+ ban-relative-imports = "all"
75
+
76
+ [tool.ruff.lint.extend-per-file-ignores]
77
+ "tests/**/*.py" = ["D", "S101", "SLF001", "PLR2004"]
78
+
79
+ [tool.coverage.run]
80
+ omit = ["tests/*"]
81
+
82
+ [tool.pytest.ini_options]
83
+ addopts = "--strict-markers --strict-config --durations=5"
84
+ testpaths = ["tests"]
85
+ markers = [
86
+ "requires: mark tests as requiring a specific library",
87
+ "compile: mark placeholder test used to compile integration tests without running them",
88
+ "scheduled: mark tests to run in scheduled testing",
89
+ ]
90
+ asyncio_mode = "auto"
91
+ filterwarnings = []
File without changes
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import TYPE_CHECKING
5
+
6
+ import pytest
7
+ from islo import Islo
8
+ from langchain_tests.integration_tests import SandboxIntegrationTests
9
+
10
+ from langchain_islo import IsloSandbox
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Iterator
14
+
15
+ from deepagents.backends.protocol import SandboxBackendProtocol
16
+
17
+ # Requires a live Islo account: set ISLO_API_KEY before running these.
18
+ pytestmark = pytest.mark.skipif(
19
+ not os.environ.get("ISLO_API_KEY"),
20
+ reason="ISLO_API_KEY not set; skipping live Islo integration tests",
21
+ )
22
+
23
+
24
+ class TestIsloSandboxStandard(SandboxIntegrationTests):
25
+ @pytest.fixture
26
+ def sandbox(self) -> Iterator[SandboxBackendProtocol]:
27
+ client = Islo()
28
+ sandbox = client.sandboxes.create_sandbox(image="ubuntu:24.04")
29
+ backend = IsloSandbox(client=client, sandbox=sandbox)
30
+ try:
31
+ yield backend
32
+ finally:
33
+ client.sandboxes.delete_sandbox(sandbox.name)
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ import langchain_islo
4
+
5
+
6
+ def test_import_islo() -> None:
7
+ assert langchain_islo is not None
8
+ assert hasattr(langchain_islo, "IsloSandbox")
9
+ assert hasattr(langchain_islo, "IsloProvider")
File without changes
@@ -0,0 +1,183 @@
1
+ from __future__ import annotations
2
+
3
+ from types import SimpleNamespace
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ import httpx
7
+ import pytest
8
+ from islo.custom.exec import ExecResult
9
+
10
+ from langchain_islo.sandbox import IsloSandbox, _map_http_status
11
+
12
+ TIMEOUT_EXIT_CODE = 124
13
+
14
+
15
+ def _make_sandbox() -> tuple[IsloSandbox, MagicMock]:
16
+ client = MagicMock()
17
+ client._client_wrapper.get_environment.return_value = SimpleNamespace(
18
+ compute="https://ca.compute.islo.dev",
19
+ control="https://api.islo.dev",
20
+ )
21
+ client._client_wrapper.get_headers.return_value = {
22
+ "Authorization": "Bearer ak_test"
23
+ }
24
+ sandbox = SimpleNamespace(id="sb-123", name="my-sandbox", status="running")
25
+ return IsloSandbox(client=client, sandbox=sandbox), client
26
+
27
+
28
+ def test_id_and_name() -> None:
29
+ sb, _ = _make_sandbox()
30
+ assert sb.id == "sb-123"
31
+ assert sb.name == "my-sandbox"
32
+
33
+
34
+ def test_execute_combines_stdout_and_stderr() -> None:
35
+ sb, _ = _make_sandbox()
36
+ with patch("langchain_islo.sandbox.exec_and_wait_sync") as mock_exec:
37
+ mock_exec.return_value = ExecResult(stdout="out", stderr="err", exit_code=0)
38
+ result = sb.execute("echo hi")
39
+
40
+ assert result.output == "outerr"
41
+ assert result.exit_code == 0
42
+ assert result.truncated is False
43
+
44
+ # Command must be wrapped in a shell so pipes/redirects/here-docs work.
45
+ args, _ = mock_exec.call_args
46
+ assert args[0] is sb._client
47
+ assert args[1] == "my-sandbox"
48
+ assert args[2] == ["/bin/sh", "-c", "echo hi"]
49
+
50
+
51
+ def test_execute_stdout_only() -> None:
52
+ sb, _ = _make_sandbox()
53
+ with patch("langchain_islo.sandbox.exec_and_wait_sync") as mock_exec:
54
+ mock_exec.return_value = ExecResult(stdout="hello", stderr="", exit_code=0)
55
+ result = sb.execute("echo hello")
56
+ assert result.output == "hello"
57
+
58
+
59
+ def test_execute_timeout_maps_to_124() -> None:
60
+ sb, _ = _make_sandbox()
61
+ with patch("langchain_islo.sandbox.exec_and_wait_sync") as mock_exec:
62
+ mock_exec.return_value = ExecResult(
63
+ stdout="", stderr="", exit_code=-1, timed_out=True
64
+ )
65
+ result = sb.execute("sleep 999", timeout=5)
66
+ assert result.exit_code == TIMEOUT_EXIT_CODE
67
+ assert "timed out" in result.output
68
+
69
+
70
+ def test_execute_timeout_zero_means_wait_forever() -> None:
71
+ sb, _ = _make_sandbox()
72
+ with patch("langchain_islo.sandbox.exec_and_wait_sync") as mock_exec:
73
+ mock_exec.return_value = ExecResult(stdout="", stderr="", exit_code=0)
74
+ sb.execute("echo hi", timeout=0)
75
+ _, kwargs = mock_exec.call_args
76
+ assert kwargs["timeout"] is None
77
+
78
+
79
+ def test_upload_rejects_relative_path() -> None:
80
+ sb, _ = _make_sandbox()
81
+ responses = sb.upload_files([("relative/path.txt", b"data")])
82
+ assert len(responses) == 1
83
+ assert responses[0].path == "relative/path.txt"
84
+ assert responses[0].error == "invalid_path"
85
+
86
+
87
+ def test_upload_success_creates_parents_and_posts() -> None:
88
+ sb, _ = _make_sandbox()
89
+ http = MagicMock()
90
+ http.__enter__.return_value = http
91
+ response = MagicMock()
92
+ response.raise_for_status.return_value = None
93
+ http.post.return_value = response
94
+
95
+ with (
96
+ patch("langchain_islo.sandbox.httpx.Client", return_value=http),
97
+ patch.object(sb, "execute") as mock_execute,
98
+ ):
99
+ responses = sb.upload_files([("/workspace/app.py", b"print('hi')")])
100
+
101
+ assert responses[0].error is None
102
+ # mkdir -p for the parent directory should have been issued.
103
+ mkdir_cmd = mock_execute.call_args[0][0]
104
+ assert mkdir_cmd.startswith("mkdir -p ")
105
+ assert "/workspace" in mkdir_cmd
106
+ # The POST hits the compute files endpoint with the path param.
107
+ url = http.post.call_args[0][0]
108
+ assert url == "https://ca.compute.islo.dev/sandboxes/my-sandbox/files"
109
+ assert http.post.call_args[1]["params"] == {"path": "/workspace/app.py"}
110
+
111
+
112
+ def test_upload_partial_success() -> None:
113
+ sb, _ = _make_sandbox()
114
+ http = MagicMock()
115
+ http.__enter__.return_value = http
116
+ ok = MagicMock()
117
+ ok.raise_for_status.return_value = None
118
+ bad_response = MagicMock(status_code=403)
119
+ err = httpx.HTTPStatusError("forbidden", request=MagicMock(), response=bad_response)
120
+ bad = MagicMock()
121
+ bad.raise_for_status.side_effect = err
122
+ http.post.side_effect = [ok, bad]
123
+
124
+ with (
125
+ patch("langchain_islo.sandbox.httpx.Client", return_value=http),
126
+ patch.object(sb, "execute"),
127
+ ):
128
+ responses = sb.upload_files([("/ok.txt", b"a"), ("/denied.txt", b"b")])
129
+
130
+ assert responses[0].error is None
131
+ assert responses[1].error == "permission_denied"
132
+
133
+
134
+ def test_download_success_returns_bytes() -> None:
135
+ sb, _ = _make_sandbox()
136
+ http = MagicMock()
137
+ http.__enter__.return_value = http
138
+ response = MagicMock(content=b"file-bytes")
139
+ response.raise_for_status.return_value = None
140
+ http.get.return_value = response
141
+
142
+ with patch("langchain_islo.sandbox.httpx.Client", return_value=http):
143
+ responses = sb.download_files(["/workspace/app.py"])
144
+
145
+ assert responses[0].content == b"file-bytes"
146
+ assert responses[0].error is None
147
+
148
+
149
+ def test_download_not_found() -> None:
150
+ sb, _ = _make_sandbox()
151
+ http = MagicMock()
152
+ http.__enter__.return_value = http
153
+ missing = MagicMock(status_code=404)
154
+ err = httpx.HTTPStatusError("nope", request=MagicMock(), response=missing)
155
+ response = MagicMock()
156
+ response.raise_for_status.side_effect = err
157
+ http.get.return_value = response
158
+
159
+ with patch("langchain_islo.sandbox.httpx.Client", return_value=http):
160
+ responses = sb.download_files(["/missing.txt"])
161
+
162
+ assert responses[0].content is None
163
+ assert responses[0].error == "file_not_found"
164
+
165
+
166
+ def test_download_rejects_relative_path() -> None:
167
+ sb, _ = _make_sandbox()
168
+ responses = sb.download_files(["relative.txt"])
169
+ assert responses[0].error == "invalid_path"
170
+
171
+
172
+ @pytest.mark.parametrize(
173
+ ("status", "expected"),
174
+ [
175
+ (404, "file_not_found"),
176
+ (403, "permission_denied"),
177
+ (400, "invalid_path"),
178
+ (409, "is_directory"),
179
+ (500, "http_500"),
180
+ ],
181
+ )
182
+ def test_map_http_status(status: int, expected: str) -> None:
183
+ assert _map_http_status(status) == expected