moonbridge 0.6.0__tar.gz → 0.8.0__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.
- moonbridge-0.8.0/.release-please-manifest.json +3 -0
- {moonbridge-0.6.0 → moonbridge-0.8.0}/CHANGELOG.md +14 -0
- {moonbridge-0.6.0 → moonbridge-0.8.0}/CLAUDE.md +1 -0
- moonbridge-0.8.0/CONTRIBUTING.md +40 -0
- {moonbridge-0.6.0 → moonbridge-0.8.0}/PKG-INFO +29 -1
- {moonbridge-0.6.0 → moonbridge-0.8.0}/README.md +28 -0
- {moonbridge-0.6.0 → moonbridge-0.8.0}/src/moonbridge/__init__.py +1 -1
- {moonbridge-0.6.0 → moonbridge-0.8.0}/src/moonbridge/adapters/codex.py +1 -0
- moonbridge-0.8.0/src/moonbridge/sandbox.py +252 -0
- {moonbridge-0.6.0 → moonbridge-0.8.0}/src/moonbridge/server.py +94 -2
- moonbridge-0.8.0/tests/test_sandbox.py +221 -0
- {moonbridge-0.6.0 → moonbridge-0.8.0}/tests/test_server.py +132 -0
- moonbridge-0.6.0/.release-please-manifest.json +0 -3
- {moonbridge-0.6.0 → moonbridge-0.8.0}/.env.example +0 -0
- {moonbridge-0.6.0 → moonbridge-0.8.0}/.github/workflows/publish.yml +0 -0
- {moonbridge-0.6.0 → moonbridge-0.8.0}/.github/workflows/release-please.yml +0 -0
- {moonbridge-0.6.0 → moonbridge-0.8.0}/.github/workflows/test.yml +0 -0
- {moonbridge-0.6.0 → moonbridge-0.8.0}/.gitignore +0 -0
- {moonbridge-0.6.0 → moonbridge-0.8.0}/LICENSE +0 -0
- {moonbridge-0.6.0 → moonbridge-0.8.0}/VISION.md +0 -0
- {moonbridge-0.6.0 → moonbridge-0.8.0}/pyproject.toml +0 -0
- {moonbridge-0.6.0 → moonbridge-0.8.0}/release-please-config.json +0 -0
- {moonbridge-0.6.0 → moonbridge-0.8.0}/src/moonbridge/adapters/__init__.py +0 -0
- {moonbridge-0.6.0 → moonbridge-0.8.0}/src/moonbridge/adapters/base.py +0 -0
- {moonbridge-0.6.0 → moonbridge-0.8.0}/src/moonbridge/adapters/kimi.py +0 -0
- {moonbridge-0.6.0 → moonbridge-0.8.0}/src/moonbridge/py.typed +0 -0
- {moonbridge-0.6.0 → moonbridge-0.8.0}/src/moonbridge/tools.py +0 -0
- {moonbridge-0.6.0 → moonbridge-0.8.0}/src/moonbridge/version_check.py +0 -0
- {moonbridge-0.6.0 → moonbridge-0.8.0}/tests/__init__.py +0 -0
- {moonbridge-0.6.0 → moonbridge-0.8.0}/tests/conftest.py +0 -0
- {moonbridge-0.6.0 → moonbridge-0.8.0}/tests/test_adapters.py +0 -0
- {moonbridge-0.6.0 → moonbridge-0.8.0}/tests/test_tools.py +0 -0
- {moonbridge-0.6.0 → moonbridge-0.8.0}/tests/test_version_check.py +0 -0
- {moonbridge-0.6.0 → moonbridge-0.8.0}/uv.lock +0 -0
|
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.8.0](https://github.com/misty-step/moonbridge/compare/moonbridge-v0.7.0...moonbridge-v0.8.0) (2026-02-06)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add gpt-5.3-codex to known models ([#81](https://github.com/misty-step/moonbridge/issues/81)) ([7f16b5d](https://github.com/misty-step/moonbridge/commit/7f16b5dc0c0aa0f0aa7a40e99856a87c8ba49c2c))
|
|
14
|
+
|
|
15
|
+
## [0.7.0](https://github.com/misty-step/moonbridge/compare/moonbridge-v0.6.0...moonbridge-v0.7.0) (2026-02-05)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* add copy-on-run sandbox mode for agent execution ([#70](https://github.com/misty-step/moonbridge/issues/70)) ([0ae67bb](https://github.com/misty-step/moonbridge/commit/0ae67bb70ed9698791d2604074163f4d1ba3b1bc))
|
|
21
|
+
|
|
8
22
|
## [0.6.0](https://github.com/misty-step/moonbridge/compare/moonbridge-v0.5.2...moonbridge-v0.6.0) (2026-02-03)
|
|
9
23
|
|
|
10
24
|
|
|
@@ -33,6 +33,7 @@ uv build # Build package
|
|
|
33
33
|
```
|
|
34
34
|
src/moonbridge/
|
|
35
35
|
├── server.py # MCP server implementation, tool handlers, process management
|
|
36
|
+
├── sandbox.py # Copy-on-run sandbox + diff utilities
|
|
36
37
|
├── version_check.py # Update notification (24h cache)
|
|
37
38
|
└── adapters/
|
|
38
39
|
├── base.py # CLIAdapter protocol and AdapterConfig dataclass
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
## Development Setup
|
|
4
|
+
- Python 3.11+
|
|
5
|
+
- Dependency manager: `uv`
|
|
6
|
+
- Install: `uv sync --dev`
|
|
7
|
+
- Build backend: hatchling (see `pyproject.toml`)
|
|
8
|
+
|
|
9
|
+
## Running Tests
|
|
10
|
+
- All tests: `pytest -v`
|
|
11
|
+
- Single file: `pytest tests/test_server.py -v`
|
|
12
|
+
- Single test: `pytest tests/test_server.py::test_spawn_agent -v`
|
|
13
|
+
- Tests mock `subprocess` and `shutil`; no real CLI needed
|
|
14
|
+
|
|
15
|
+
## Code Quality
|
|
16
|
+
- Lint: `ruff check src/` (rules: E, F, I, UP, B, SIM)
|
|
17
|
+
- Types: `mypy src/` (strict mode)
|
|
18
|
+
- Line length: 100
|
|
19
|
+
- Target Python: 3.11
|
|
20
|
+
|
|
21
|
+
## Commit Conventions
|
|
22
|
+
- Conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, `chore:`, `test:`
|
|
23
|
+
- Scope optional: `fix(ci):`, `feat(adapter):`
|
|
24
|
+
- Issue refs: `(#N)` suffix
|
|
25
|
+
- Examples:
|
|
26
|
+
- `feat: add copy-on-run sandbox mode for agent execution (#70)`
|
|
27
|
+
- `fix: handle ProcessLookupError on SIGKILL path (#58)`
|
|
28
|
+
- `refactor: extract tool schemas to dedicated module (#60)`
|
|
29
|
+
|
|
30
|
+
## Pull Requests
|
|
31
|
+
- Branch from `master`
|
|
32
|
+
- CI runs on Python 3.11, 3.12, 3.13
|
|
33
|
+
- CI runs `ruff`, `mypy`, `pytest`
|
|
34
|
+
- Releases handled by release-please
|
|
35
|
+
|
|
36
|
+
## Architecture Overview
|
|
37
|
+
Moonbridge uses a protocol-based adapter pattern via `CLIAdapter` in `adapters/base.py`.
|
|
38
|
+
Each adapter implements `build_command()` and `check_installed()` for consistent CLI calls.
|
|
39
|
+
The MCP server lives in `server.py` and owns protocol handling plus process lifecycle.
|
|
40
|
+
Deeper architecture notes live in `CLAUDE.md`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: moonbridge
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: MCP server for spawning AI coding agents (Kimi, Codex, and more)
|
|
5
5
|
Project-URL: Homepage, https://github.com/misty-step/moonbridge
|
|
6
6
|
Project-URL: Repository, https://github.com/misty-step/moonbridge
|
|
@@ -151,6 +151,7 @@ All tools return JSON with these fields:
|
|
|
151
151
|
| `duration_ms` | int | Execution time in milliseconds |
|
|
152
152
|
| `agent_index` | int | Agent index (0 for single, 0-N for parallel) |
|
|
153
153
|
| `message` | string? | Human-readable error context (when applicable) |
|
|
154
|
+
| `raw` | object? | Optional structured metadata (e.g., sandbox diff) |
|
|
154
155
|
|
|
155
156
|
## Configuration
|
|
156
157
|
|
|
@@ -163,6 +164,10 @@ All tools return JSON with these fields:
|
|
|
163
164
|
| `MOONBRIDGE_MAX_AGENTS` | Maximum parallel agents |
|
|
164
165
|
| `MOONBRIDGE_ALLOWED_DIRS` | Colon-separated allowlist of working directories |
|
|
165
166
|
| `MOONBRIDGE_STRICT` | Set to `1` to require `ALLOWED_DIRS` (exits if unset) |
|
|
167
|
+
| `MOONBRIDGE_SANDBOX` | Set to `1` to run agents in a temp copy of cwd |
|
|
168
|
+
| `MOONBRIDGE_SANDBOX_KEEP` | Set to `1` to keep sandbox dir for inspection |
|
|
169
|
+
| `MOONBRIDGE_SANDBOX_MAX_DIFF` | Max diff size in bytes (default 500000) |
|
|
170
|
+
| `MOONBRIDGE_SANDBOX_MAX_COPY` | Max sandbox copy size in bytes (default 500MB) |
|
|
166
171
|
| `MOONBRIDGE_LOG_LEVEL` | Set to `DEBUG` for verbose logging |
|
|
167
172
|
|
|
168
173
|
## Troubleshooting
|
|
@@ -229,6 +234,29 @@ By default, Moonbridge warns at startup if no directory restrictions are configu
|
|
|
229
234
|
export MOONBRIDGE_ALLOWED_DIRS="/path/to/project:/another/path"
|
|
230
235
|
```
|
|
231
236
|
|
|
237
|
+
## Sandbox Mode (Copy-on-Run)
|
|
238
|
+
|
|
239
|
+
Enable sandbox mode to run agents in a temporary copy of the working directory:
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
export MOONBRIDGE_SANDBOX=1
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
When enabled:
|
|
246
|
+
- Agents run in a temp copy of `cwd`.
|
|
247
|
+
- Host files stay unchanged by default.
|
|
248
|
+
- A unified diff + summary is included in `raw.sandbox`.
|
|
249
|
+
|
|
250
|
+
Optional:
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
export MOONBRIDGE_SANDBOX_KEEP=1 # keep temp dir
|
|
254
|
+
export MOONBRIDGE_SANDBOX_MAX_DIFF=200000
|
|
255
|
+
export MOONBRIDGE_SANDBOX_MAX_COPY=300000000
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Limitations: this is not OS-level isolation. Agents can still read/write arbitrary host paths if they choose to. Use containers/VMs for strong isolation.
|
|
259
|
+
|
|
232
260
|
To enforce restrictions (exit instead of warn):
|
|
233
261
|
|
|
234
262
|
```bash
|
|
@@ -122,6 +122,7 @@ All tools return JSON with these fields:
|
|
|
122
122
|
| `duration_ms` | int | Execution time in milliseconds |
|
|
123
123
|
| `agent_index` | int | Agent index (0 for single, 0-N for parallel) |
|
|
124
124
|
| `message` | string? | Human-readable error context (when applicable) |
|
|
125
|
+
| `raw` | object? | Optional structured metadata (e.g., sandbox diff) |
|
|
125
126
|
|
|
126
127
|
## Configuration
|
|
127
128
|
|
|
@@ -134,6 +135,10 @@ All tools return JSON with these fields:
|
|
|
134
135
|
| `MOONBRIDGE_MAX_AGENTS` | Maximum parallel agents |
|
|
135
136
|
| `MOONBRIDGE_ALLOWED_DIRS` | Colon-separated allowlist of working directories |
|
|
136
137
|
| `MOONBRIDGE_STRICT` | Set to `1` to require `ALLOWED_DIRS` (exits if unset) |
|
|
138
|
+
| `MOONBRIDGE_SANDBOX` | Set to `1` to run agents in a temp copy of cwd |
|
|
139
|
+
| `MOONBRIDGE_SANDBOX_KEEP` | Set to `1` to keep sandbox dir for inspection |
|
|
140
|
+
| `MOONBRIDGE_SANDBOX_MAX_DIFF` | Max diff size in bytes (default 500000) |
|
|
141
|
+
| `MOONBRIDGE_SANDBOX_MAX_COPY` | Max sandbox copy size in bytes (default 500MB) |
|
|
137
142
|
| `MOONBRIDGE_LOG_LEVEL` | Set to `DEBUG` for verbose logging |
|
|
138
143
|
|
|
139
144
|
## Troubleshooting
|
|
@@ -200,6 +205,29 @@ By default, Moonbridge warns at startup if no directory restrictions are configu
|
|
|
200
205
|
export MOONBRIDGE_ALLOWED_DIRS="/path/to/project:/another/path"
|
|
201
206
|
```
|
|
202
207
|
|
|
208
|
+
## Sandbox Mode (Copy-on-Run)
|
|
209
|
+
|
|
210
|
+
Enable sandbox mode to run agents in a temporary copy of the working directory:
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
export MOONBRIDGE_SANDBOX=1
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
When enabled:
|
|
217
|
+
- Agents run in a temp copy of `cwd`.
|
|
218
|
+
- Host files stay unchanged by default.
|
|
219
|
+
- A unified diff + summary is included in `raw.sandbox`.
|
|
220
|
+
|
|
221
|
+
Optional:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
export MOONBRIDGE_SANDBOX_KEEP=1 # keep temp dir
|
|
225
|
+
export MOONBRIDGE_SANDBOX_MAX_DIFF=200000
|
|
226
|
+
export MOONBRIDGE_SANDBOX_MAX_COPY=300000000
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Limitations: this is not OS-level isolation. Agents can still read/write arbitrary host paths if they choose to. Use containers/VMs for strong isolation.
|
|
230
|
+
|
|
203
231
|
To enforce restrictions (exit instead of warn):
|
|
204
232
|
|
|
205
233
|
```bash
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Copy-on-run sandbox for agent execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable, Iterator
|
|
11
|
+
from dataclasses import dataclass, replace
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from moonbridge.adapters.base import AgentResult
|
|
15
|
+
|
|
16
|
+
SANDBOX_IGNORE_DIRS = {
|
|
17
|
+
".git",
|
|
18
|
+
".venv",
|
|
19
|
+
".tox",
|
|
20
|
+
"__pycache__",
|
|
21
|
+
".mypy_cache",
|
|
22
|
+
".pytest_cache",
|
|
23
|
+
".ruff_cache",
|
|
24
|
+
"node_modules",
|
|
25
|
+
"dist",
|
|
26
|
+
"build",
|
|
27
|
+
}
|
|
28
|
+
SANDBOX_IGNORE_FILES = {".DS_Store"}
|
|
29
|
+
MAX_COPY_BYTES = 500 * 1024 * 1024
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class SandboxResult:
|
|
34
|
+
diff: str
|
|
35
|
+
summary: dict[str, int]
|
|
36
|
+
truncated: bool
|
|
37
|
+
sandbox_path: str | None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _should_ignore(name: str) -> bool:
|
|
41
|
+
if name in SANDBOX_IGNORE_DIRS:
|
|
42
|
+
return True
|
|
43
|
+
if name in SANDBOX_IGNORE_FILES:
|
|
44
|
+
return True
|
|
45
|
+
return name.endswith((".pyc", ".pyo"))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _ignore_names(_dirpath: str, names: list[str]) -> set[str]:
|
|
49
|
+
return {name for name in names if _should_ignore(name)}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _filtered_walk(root: str) -> Iterator[tuple[str, list[str], list[str]]]:
|
|
53
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
54
|
+
dirnames[:] = [d for d in dirnames if not _should_ignore(d)]
|
|
55
|
+
filenames = [f for f in filenames if not _should_ignore(f)]
|
|
56
|
+
yield dirpath, dirnames, filenames
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _collect_files(root: str) -> set[str]:
|
|
60
|
+
files: set[str] = set()
|
|
61
|
+
for dirpath, _dirnames, filenames in _filtered_walk(root):
|
|
62
|
+
rel_dir = os.path.relpath(dirpath, root)
|
|
63
|
+
for filename in filenames:
|
|
64
|
+
rel_path = filename if rel_dir == "." else os.path.join(rel_dir, filename)
|
|
65
|
+
files.add(rel_path)
|
|
66
|
+
return files
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _read_text(path: str) -> str | None:
|
|
70
|
+
data = Path(path).read_bytes()
|
|
71
|
+
try:
|
|
72
|
+
return data.decode("utf-8")
|
|
73
|
+
except UnicodeDecodeError:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _diff_trees(
|
|
78
|
+
original: str,
|
|
79
|
+
sandbox: str,
|
|
80
|
+
max_bytes: int,
|
|
81
|
+
) -> tuple[str, dict[str, int], bool]:
|
|
82
|
+
original_files = _collect_files(original)
|
|
83
|
+
sandbox_files = _collect_files(sandbox)
|
|
84
|
+
all_files = sorted(original_files | sandbox_files)
|
|
85
|
+
diff_chunks: list[str] = []
|
|
86
|
+
size = 0
|
|
87
|
+
truncated = False
|
|
88
|
+
summary = {"added": 0, "modified": 0, "deleted": 0, "binary": 0}
|
|
89
|
+
|
|
90
|
+
def append_chunk(chunk: str) -> None:
|
|
91
|
+
nonlocal size, truncated
|
|
92
|
+
if truncated or not chunk:
|
|
93
|
+
return
|
|
94
|
+
remaining = max_bytes - size
|
|
95
|
+
if remaining <= 0:
|
|
96
|
+
truncated = True
|
|
97
|
+
return
|
|
98
|
+
if len(chunk) > remaining:
|
|
99
|
+
diff_chunks.append(chunk[:remaining])
|
|
100
|
+
truncated = True
|
|
101
|
+
size = max_bytes
|
|
102
|
+
return
|
|
103
|
+
diff_chunks.append(chunk)
|
|
104
|
+
size += len(chunk)
|
|
105
|
+
|
|
106
|
+
for rel_path in all_files:
|
|
107
|
+
original_path = os.path.join(original, rel_path)
|
|
108
|
+
sandbox_path = os.path.join(sandbox, rel_path)
|
|
109
|
+
original_exists = os.path.exists(original_path)
|
|
110
|
+
sandbox_exists = os.path.exists(sandbox_path)
|
|
111
|
+
|
|
112
|
+
if not original_exists and sandbox_exists:
|
|
113
|
+
summary["added"] += 1
|
|
114
|
+
sandbox_text = _read_text(sandbox_path)
|
|
115
|
+
if sandbox_text is None:
|
|
116
|
+
summary["binary"] += 1
|
|
117
|
+
append_chunk(f"Binary files /dev/null and b/{rel_path} differ\n")
|
|
118
|
+
continue
|
|
119
|
+
diff = difflib.unified_diff(
|
|
120
|
+
[],
|
|
121
|
+
sandbox_text.splitlines(keepends=True),
|
|
122
|
+
fromfile="/dev/null",
|
|
123
|
+
tofile=f"b/{rel_path}",
|
|
124
|
+
)
|
|
125
|
+
append_chunk("".join(diff))
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
if original_exists and not sandbox_exists:
|
|
129
|
+
summary["deleted"] += 1
|
|
130
|
+
original_text = _read_text(original_path)
|
|
131
|
+
if original_text is None:
|
|
132
|
+
summary["binary"] += 1
|
|
133
|
+
append_chunk(f"Binary files a/{rel_path} and /dev/null differ\n")
|
|
134
|
+
continue
|
|
135
|
+
diff = difflib.unified_diff(
|
|
136
|
+
original_text.splitlines(keepends=True),
|
|
137
|
+
[],
|
|
138
|
+
fromfile=f"a/{rel_path}",
|
|
139
|
+
tofile="/dev/null",
|
|
140
|
+
)
|
|
141
|
+
append_chunk("".join(diff))
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
if not original_exists or not sandbox_exists:
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
original_bytes = Path(original_path).read_bytes()
|
|
148
|
+
sandbox_bytes = Path(sandbox_path).read_bytes()
|
|
149
|
+
if original_bytes == sandbox_bytes:
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
original_text = None
|
|
153
|
+
sandbox_text = None
|
|
154
|
+
try:
|
|
155
|
+
original_text = original_bytes.decode("utf-8")
|
|
156
|
+
sandbox_text = sandbox_bytes.decode("utf-8")
|
|
157
|
+
except UnicodeDecodeError:
|
|
158
|
+
summary["binary"] += 1
|
|
159
|
+
append_chunk(f"Binary files a/{rel_path} and b/{rel_path} differ\n")
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
summary["modified"] += 1
|
|
163
|
+
diff = difflib.unified_diff(
|
|
164
|
+
original_text.splitlines(keepends=True),
|
|
165
|
+
sandbox_text.splitlines(keepends=True),
|
|
166
|
+
fromfile=f"a/{rel_path}",
|
|
167
|
+
tofile=f"b/{rel_path}",
|
|
168
|
+
)
|
|
169
|
+
append_chunk("".join(diff))
|
|
170
|
+
|
|
171
|
+
if truncated:
|
|
172
|
+
diff_chunks.append("\n... diff truncated ...\n")
|
|
173
|
+
return ("".join(diff_chunks), summary, truncated)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _estimate_copy_size(root: str, max_bytes: int) -> int:
|
|
177
|
+
total = 0
|
|
178
|
+
for dirpath, _dirnames, filenames in _filtered_walk(root):
|
|
179
|
+
for filename in filenames:
|
|
180
|
+
path = os.path.join(dirpath, filename)
|
|
181
|
+
total += os.path.getsize(path)
|
|
182
|
+
if total > max_bytes:
|
|
183
|
+
return total
|
|
184
|
+
return total
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _agent_index(fn: Callable[[str], AgentResult]) -> int:
|
|
188
|
+
value = getattr(fn, "agent_index", 0)
|
|
189
|
+
return value if isinstance(value, int) else 0
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def run_sandboxed(
|
|
193
|
+
fn: Callable[[str], AgentResult],
|
|
194
|
+
cwd: str,
|
|
195
|
+
*,
|
|
196
|
+
max_diff_bytes: int = 500_000,
|
|
197
|
+
max_copy_bytes: int = MAX_COPY_BYTES,
|
|
198
|
+
keep: bool = False,
|
|
199
|
+
) -> tuple[AgentResult, SandboxResult | None]:
|
|
200
|
+
"""Run fn in a copy of cwd. Returns (agent_result, sandbox_result).
|
|
201
|
+
|
|
202
|
+
On sandbox infrastructure error, returns (error_result, None).
|
|
203
|
+
"""
|
|
204
|
+
start = time.monotonic()
|
|
205
|
+
sandbox_root: str | None = None
|
|
206
|
+
agent_index = _agent_index(fn)
|
|
207
|
+
|
|
208
|
+
def error_result(reason: str) -> AgentResult:
|
|
209
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
210
|
+
return AgentResult(
|
|
211
|
+
status="error",
|
|
212
|
+
output="",
|
|
213
|
+
stderr=f"sandbox error: {reason}",
|
|
214
|
+
returncode=-1,
|
|
215
|
+
duration_ms=duration_ms,
|
|
216
|
+
agent_index=agent_index,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
total_bytes = _estimate_copy_size(cwd, max_copy_bytes)
|
|
221
|
+
if total_bytes > max_copy_bytes:
|
|
222
|
+
return error_result(
|
|
223
|
+
f"copy size {total_bytes} exceeds max {max_copy_bytes}"
|
|
224
|
+
), None
|
|
225
|
+
|
|
226
|
+
sandbox_root = tempfile.mkdtemp(prefix="moonbridge-sandbox-")
|
|
227
|
+
sandbox_cwd = os.path.join(sandbox_root, "workspace")
|
|
228
|
+
shutil.copytree(cwd, sandbox_cwd, symlinks=False, ignore=_ignore_names)
|
|
229
|
+
|
|
230
|
+
result = fn(sandbox_cwd)
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
diff, summary, truncated = _diff_trees(cwd, sandbox_cwd, max_diff_bytes)
|
|
234
|
+
sandbox_result = SandboxResult(
|
|
235
|
+
diff=diff,
|
|
236
|
+
summary=summary,
|
|
237
|
+
truncated=truncated,
|
|
238
|
+
sandbox_path=sandbox_root if keep else None,
|
|
239
|
+
)
|
|
240
|
+
return result, sandbox_result
|
|
241
|
+
except Exception as exc:
|
|
242
|
+
raw = dict(result.raw or {})
|
|
243
|
+
sandbox_payload: dict[str, object] = {"enabled": True, "error": str(exc)}
|
|
244
|
+
if keep:
|
|
245
|
+
sandbox_payload["path"] = sandbox_root
|
|
246
|
+
raw["sandbox"] = sandbox_payload
|
|
247
|
+
return replace(result, raw=raw), None
|
|
248
|
+
except Exception as exc:
|
|
249
|
+
return error_result(str(exc)), None
|
|
250
|
+
finally:
|
|
251
|
+
if not keep and sandbox_root:
|
|
252
|
+
shutil.rmtree(sandbox_root, ignore_errors=True)
|
|
@@ -11,6 +11,7 @@ import signal
|
|
|
11
11
|
import sys
|
|
12
12
|
import time
|
|
13
13
|
import weakref
|
|
14
|
+
from dataclasses import replace
|
|
14
15
|
from subprocess import PIPE, Popen, TimeoutExpired
|
|
15
16
|
from typing import Any
|
|
16
17
|
|
|
@@ -36,6 +37,17 @@ ALLOWED_DIRS = [
|
|
|
36
37
|
if path
|
|
37
38
|
]
|
|
38
39
|
MAX_PROMPT_LENGTH = 100_000
|
|
40
|
+
_SANDBOX_ENV = os.environ.get("MOONBRIDGE_SANDBOX", "").strip().lower()
|
|
41
|
+
SANDBOX_MODE = _SANDBOX_ENV in {"1", "true", "yes", "copy"}
|
|
42
|
+
SANDBOX_KEEP = os.environ.get("MOONBRIDGE_SANDBOX_KEEP", "").strip().lower() in {
|
|
43
|
+
"1",
|
|
44
|
+
"true",
|
|
45
|
+
"yes",
|
|
46
|
+
}
|
|
47
|
+
SANDBOX_MAX_DIFF_BYTES = int(os.environ.get("MOONBRIDGE_SANDBOX_MAX_DIFF", "500000"))
|
|
48
|
+
SANDBOX_MAX_COPY_BYTES = int(
|
|
49
|
+
os.environ.get("MOONBRIDGE_SANDBOX_MAX_COPY", str(500 * 1024 * 1024))
|
|
50
|
+
)
|
|
39
51
|
|
|
40
52
|
_active_processes: set[weakref.ref[Popen[str]]] = set()
|
|
41
53
|
|
|
@@ -194,6 +206,53 @@ def _auth_error(stderr: str | None, adapter: CLIAdapter) -> bool:
|
|
|
194
206
|
return any(pattern in lowered for pattern in adapter.config.auth_patterns)
|
|
195
207
|
|
|
196
208
|
|
|
209
|
+
def _run_cli_sandboxed(
|
|
210
|
+
adapter: CLIAdapter,
|
|
211
|
+
prompt: str,
|
|
212
|
+
thinking: bool,
|
|
213
|
+
cwd: str,
|
|
214
|
+
timeout_seconds: int,
|
|
215
|
+
agent_index: int,
|
|
216
|
+
model: str | None = None,
|
|
217
|
+
reasoning_effort: str | None = None,
|
|
218
|
+
) -> AgentResult:
|
|
219
|
+
from moonbridge.sandbox import run_sandboxed
|
|
220
|
+
|
|
221
|
+
def run_agent(sandbox_cwd: str) -> AgentResult:
|
|
222
|
+
return _run_cli_sync(
|
|
223
|
+
adapter,
|
|
224
|
+
prompt,
|
|
225
|
+
thinking,
|
|
226
|
+
sandbox_cwd,
|
|
227
|
+
timeout_seconds,
|
|
228
|
+
agent_index,
|
|
229
|
+
model,
|
|
230
|
+
reasoning_effort,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
run_agent.agent_index = agent_index # type: ignore[attr-defined]
|
|
234
|
+
|
|
235
|
+
result, sandbox_result = run_sandboxed(
|
|
236
|
+
run_agent,
|
|
237
|
+
cwd,
|
|
238
|
+
max_diff_bytes=SANDBOX_MAX_DIFF_BYTES,
|
|
239
|
+
max_copy_bytes=SANDBOX_MAX_COPY_BYTES,
|
|
240
|
+
keep=SANDBOX_KEEP,
|
|
241
|
+
)
|
|
242
|
+
if sandbox_result:
|
|
243
|
+
raw = dict(result.raw or {})
|
|
244
|
+
raw["sandbox"] = {
|
|
245
|
+
"enabled": True,
|
|
246
|
+
"summary": sandbox_result.summary,
|
|
247
|
+
"diff": sandbox_result.diff,
|
|
248
|
+
"truncated": sandbox_result.truncated,
|
|
249
|
+
}
|
|
250
|
+
if sandbox_result.sandbox_path:
|
|
251
|
+
raw["sandbox"]["path"] = sandbox_result.sandbox_path
|
|
252
|
+
return replace(result, raw=raw)
|
|
253
|
+
return result
|
|
254
|
+
|
|
255
|
+
|
|
197
256
|
def _run_cli_sync(
|
|
198
257
|
adapter: CLIAdapter,
|
|
199
258
|
prompt: str,
|
|
@@ -304,6 +363,39 @@ def _run_cli_sync(
|
|
|
304
363
|
_untrack_process(proc)
|
|
305
364
|
|
|
306
365
|
|
|
366
|
+
def _run_cli(
|
|
367
|
+
adapter: CLIAdapter,
|
|
368
|
+
prompt: str,
|
|
369
|
+
thinking: bool,
|
|
370
|
+
cwd: str,
|
|
371
|
+
timeout_seconds: int,
|
|
372
|
+
agent_index: int,
|
|
373
|
+
model: str | None = None,
|
|
374
|
+
reasoning_effort: str | None = None,
|
|
375
|
+
) -> AgentResult:
|
|
376
|
+
if SANDBOX_MODE:
|
|
377
|
+
return _run_cli_sandboxed(
|
|
378
|
+
adapter,
|
|
379
|
+
prompt,
|
|
380
|
+
thinking,
|
|
381
|
+
cwd,
|
|
382
|
+
timeout_seconds,
|
|
383
|
+
agent_index,
|
|
384
|
+
model,
|
|
385
|
+
reasoning_effort,
|
|
386
|
+
)
|
|
387
|
+
return _run_cli_sync(
|
|
388
|
+
adapter,
|
|
389
|
+
prompt,
|
|
390
|
+
thinking,
|
|
391
|
+
cwd,
|
|
392
|
+
timeout_seconds,
|
|
393
|
+
agent_index,
|
|
394
|
+
model,
|
|
395
|
+
reasoning_effort,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
307
399
|
def _json_text(payload: Any) -> list[TextContent]:
|
|
308
400
|
return [TextContent(type="text", text=json.dumps(payload, ensure_ascii=True))]
|
|
309
401
|
|
|
@@ -378,7 +470,7 @@ async def handle_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]
|
|
|
378
470
|
try:
|
|
379
471
|
result = await loop.run_in_executor(
|
|
380
472
|
None,
|
|
381
|
-
|
|
473
|
+
_run_cli,
|
|
382
474
|
adapter,
|
|
383
475
|
prompt,
|
|
384
476
|
thinking,
|
|
@@ -416,7 +508,7 @@ async def handle_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]
|
|
|
416
508
|
tasks.append(
|
|
417
509
|
loop.run_in_executor(
|
|
418
510
|
None,
|
|
419
|
-
|
|
511
|
+
_run_cli,
|
|
420
512
|
adapter,
|
|
421
513
|
prompt,
|
|
422
514
|
thinking,
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from moonbridge.adapters.base import AgentResult
|
|
8
|
+
|
|
9
|
+
sandbox_module = importlib.import_module("moonbridge.sandbox")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _success_result(agent_index: int = 0) -> AgentResult:
|
|
13
|
+
return AgentResult(
|
|
14
|
+
status="success",
|
|
15
|
+
output="ok",
|
|
16
|
+
stderr=None,
|
|
17
|
+
returncode=0,
|
|
18
|
+
duration_ms=1,
|
|
19
|
+
agent_index=agent_index,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_diff_trees_no_changes(tmp_path: Path) -> None:
|
|
24
|
+
original = tmp_path / "original"
|
|
25
|
+
sandbox = tmp_path / "sandbox"
|
|
26
|
+
original.mkdir()
|
|
27
|
+
sandbox.mkdir()
|
|
28
|
+
(original / "a.txt").write_text("same", encoding="utf-8")
|
|
29
|
+
(sandbox / "a.txt").write_text("same", encoding="utf-8")
|
|
30
|
+
|
|
31
|
+
diff, summary, truncated = sandbox_module._diff_trees(
|
|
32
|
+
str(original), str(sandbox), 500_000
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
assert diff == ""
|
|
36
|
+
assert summary == {"added": 0, "modified": 0, "deleted": 0, "binary": 0}
|
|
37
|
+
assert truncated is False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_diff_trees_truncation(tmp_path: Path) -> None:
|
|
41
|
+
original = tmp_path / "original"
|
|
42
|
+
sandbox = tmp_path / "sandbox"
|
|
43
|
+
original.mkdir()
|
|
44
|
+
sandbox.mkdir()
|
|
45
|
+
(sandbox / "big.txt").write_text("x" * 1000, encoding="utf-8")
|
|
46
|
+
|
|
47
|
+
diff, summary, truncated = sandbox_module._diff_trees(str(original), str(sandbox), 50)
|
|
48
|
+
|
|
49
|
+
assert truncated is True
|
|
50
|
+
assert "... diff truncated ..." in diff
|
|
51
|
+
assert summary["added"] == 1
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_diff_trees_binary_file(tmp_path: Path) -> None:
|
|
55
|
+
original = tmp_path / "original"
|
|
56
|
+
sandbox = tmp_path / "sandbox"
|
|
57
|
+
original.mkdir()
|
|
58
|
+
sandbox.mkdir()
|
|
59
|
+
(sandbox / "img.bin").write_bytes(b"\x89PNG\r\n\x1a\n\x00\x80\xff")
|
|
60
|
+
|
|
61
|
+
diff, summary, truncated = sandbox_module._diff_trees(
|
|
62
|
+
str(original), str(sandbox), 500_000
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
assert summary["added"] == 1
|
|
66
|
+
assert summary["binary"] == 1
|
|
67
|
+
assert "Binary files" in diff
|
|
68
|
+
assert truncated is False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_run_sandboxed_keep_preserves_dir(
|
|
72
|
+
monkeypatch: Any,
|
|
73
|
+
tmp_path: Path,
|
|
74
|
+
) -> None:
|
|
75
|
+
workspace = tmp_path / "workspace"
|
|
76
|
+
workspace.mkdir()
|
|
77
|
+
(workspace / "keep.txt").write_text("keep", encoding="utf-8")
|
|
78
|
+
|
|
79
|
+
sandbox_root = tmp_path / "sandbox"
|
|
80
|
+
|
|
81
|
+
def fake_mkdtemp(*_args: Any, **_kwargs: Any) -> str:
|
|
82
|
+
sandbox_root.mkdir()
|
|
83
|
+
return str(sandbox_root)
|
|
84
|
+
|
|
85
|
+
monkeypatch.setattr(sandbox_module.tempfile, "mkdtemp", fake_mkdtemp)
|
|
86
|
+
|
|
87
|
+
result, sandbox_result = sandbox_module.run_sandboxed(
|
|
88
|
+
lambda _cwd: _success_result(),
|
|
89
|
+
str(workspace),
|
|
90
|
+
keep=True,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
assert result.status == "success"
|
|
94
|
+
assert sandbox_result is not None
|
|
95
|
+
assert sandbox_result.sandbox_path == str(sandbox_root)
|
|
96
|
+
assert sandbox_root.exists()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_run_sandboxed_cleanup_on_success(
|
|
100
|
+
monkeypatch: Any,
|
|
101
|
+
tmp_path: Path,
|
|
102
|
+
) -> None:
|
|
103
|
+
workspace = tmp_path / "workspace"
|
|
104
|
+
workspace.mkdir()
|
|
105
|
+
(workspace / "keep.txt").write_text("keep", encoding="utf-8")
|
|
106
|
+
|
|
107
|
+
sandbox_root = tmp_path / "sandbox"
|
|
108
|
+
|
|
109
|
+
def fake_mkdtemp(*_args: Any, **_kwargs: Any) -> str:
|
|
110
|
+
sandbox_root.mkdir()
|
|
111
|
+
return str(sandbox_root)
|
|
112
|
+
|
|
113
|
+
monkeypatch.setattr(sandbox_module.tempfile, "mkdtemp", fake_mkdtemp)
|
|
114
|
+
|
|
115
|
+
result, sandbox_result = sandbox_module.run_sandboxed(
|
|
116
|
+
lambda _cwd: _success_result(),
|
|
117
|
+
str(workspace),
|
|
118
|
+
keep=False,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
assert result.status == "success"
|
|
122
|
+
assert sandbox_result is not None
|
|
123
|
+
assert not sandbox_root.exists()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_run_sandboxed_cleanup_on_error(
|
|
127
|
+
monkeypatch: Any,
|
|
128
|
+
tmp_path: Path,
|
|
129
|
+
) -> None:
|
|
130
|
+
workspace = tmp_path / "workspace"
|
|
131
|
+
workspace.mkdir()
|
|
132
|
+
(workspace / "keep.txt").write_text("keep", encoding="utf-8")
|
|
133
|
+
|
|
134
|
+
sandbox_root = tmp_path / "sandbox"
|
|
135
|
+
|
|
136
|
+
def fake_mkdtemp(*_args: Any, **_kwargs: Any) -> str:
|
|
137
|
+
sandbox_root.mkdir()
|
|
138
|
+
return str(sandbox_root)
|
|
139
|
+
|
|
140
|
+
monkeypatch.setattr(sandbox_module.tempfile, "mkdtemp", fake_mkdtemp)
|
|
141
|
+
|
|
142
|
+
def boom(_cwd: str) -> AgentResult:
|
|
143
|
+
raise RuntimeError("boom")
|
|
144
|
+
|
|
145
|
+
result, sandbox_result = sandbox_module.run_sandboxed(boom, str(workspace))
|
|
146
|
+
|
|
147
|
+
assert result.status == "error"
|
|
148
|
+
assert sandbox_result is None
|
|
149
|
+
assert not sandbox_root.exists()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_diff_failure_returns_error_in_sandbox(
|
|
153
|
+
monkeypatch: Any,
|
|
154
|
+
tmp_path: Path,
|
|
155
|
+
) -> None:
|
|
156
|
+
workspace = tmp_path / "workspace"
|
|
157
|
+
workspace.mkdir()
|
|
158
|
+
(workspace / "keep.txt").write_text("keep", encoding="utf-8")
|
|
159
|
+
|
|
160
|
+
def raise_diff(*_args: Any, **_kwargs: Any) -> Any:
|
|
161
|
+
raise RuntimeError("boom")
|
|
162
|
+
|
|
163
|
+
monkeypatch.setattr(sandbox_module, "_diff_trees", raise_diff)
|
|
164
|
+
|
|
165
|
+
result, sandbox_result = sandbox_module.run_sandboxed(
|
|
166
|
+
lambda _cwd: _success_result(),
|
|
167
|
+
str(workspace),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
assert result.status == "success"
|
|
171
|
+
assert sandbox_result is None
|
|
172
|
+
assert result.raw is not None
|
|
173
|
+
assert "sandbox" in result.raw
|
|
174
|
+
assert "error" in result.raw["sandbox"]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_max_copy_size_exceeded(
|
|
178
|
+
monkeypatch: Any,
|
|
179
|
+
tmp_path: Path,
|
|
180
|
+
) -> None:
|
|
181
|
+
workspace = tmp_path / "workspace"
|
|
182
|
+
workspace.mkdir()
|
|
183
|
+
(workspace / "big.txt").write_bytes(b"x" * 20)
|
|
184
|
+
|
|
185
|
+
def no_copy(*_args: Any, **_kwargs: Any) -> Any:
|
|
186
|
+
raise AssertionError("copy should not start")
|
|
187
|
+
|
|
188
|
+
monkeypatch.setattr(sandbox_module.tempfile, "mkdtemp", no_copy)
|
|
189
|
+
|
|
190
|
+
def should_not_run(_cwd: str) -> AgentResult:
|
|
191
|
+
raise AssertionError("agent should not run")
|
|
192
|
+
|
|
193
|
+
result, sandbox_result = sandbox_module.run_sandboxed(
|
|
194
|
+
should_not_run,
|
|
195
|
+
str(workspace),
|
|
196
|
+
max_copy_bytes=10,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
assert result.status == "error"
|
|
200
|
+
assert result.stderr
|
|
201
|
+
assert "exceeds max" in result.stderr
|
|
202
|
+
assert sandbox_result is None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_ignore_patterns_unified(tmp_path: Path) -> None:
|
|
206
|
+
workspace = tmp_path / "workspace"
|
|
207
|
+
workspace.mkdir()
|
|
208
|
+
(workspace / ".DS_Store").write_text("ignored", encoding="utf-8")
|
|
209
|
+
|
|
210
|
+
def run_agent(sandbox_cwd: str) -> AgentResult:
|
|
211
|
+
sandbox_path = Path(sandbox_cwd)
|
|
212
|
+
assert not sandbox_path.joinpath(".DS_Store").exists()
|
|
213
|
+
sandbox_path.joinpath(".DS_Store").write_text("new", encoding="utf-8")
|
|
214
|
+
return _success_result()
|
|
215
|
+
|
|
216
|
+
result, sandbox_result = sandbox_module.run_sandboxed(run_agent, str(workspace))
|
|
217
|
+
|
|
218
|
+
assert result.status == "success"
|
|
219
|
+
assert sandbox_result is not None
|
|
220
|
+
assert sandbox_result.diff == ""
|
|
221
|
+
assert sandbox_result.summary == {"added": 0, "modified": 0, "deleted": 0, "binary": 0}
|
|
@@ -6,6 +6,7 @@ import os
|
|
|
6
6
|
import threading
|
|
7
7
|
import time
|
|
8
8
|
from collections.abc import Iterator
|
|
9
|
+
from pathlib import Path
|
|
9
10
|
from subprocess import Popen, TimeoutExpired
|
|
10
11
|
from typing import Any
|
|
11
12
|
from unittest.mock import MagicMock, call
|
|
@@ -599,6 +600,137 @@ def test_validate_cwd_traversal_attempt(monkeypatch: Any, tmp_path: Any) -> None
|
|
|
599
600
|
server_module._validate_cwd(traversal)
|
|
600
601
|
|
|
601
602
|
|
|
603
|
+
def test_run_cli_sandboxed_diff_and_preserves_host(
|
|
604
|
+
monkeypatch: Any,
|
|
605
|
+
tmp_path: Any,
|
|
606
|
+
) -> None:
|
|
607
|
+
from moonbridge.adapters import get_adapter
|
|
608
|
+
|
|
609
|
+
workspace = tmp_path / "workspace"
|
|
610
|
+
workspace.mkdir()
|
|
611
|
+
(workspace / "keep.txt").write_text("keep", encoding="utf-8")
|
|
612
|
+
(workspace / "edit.txt").write_text("old", encoding="utf-8")
|
|
613
|
+
(workspace / "remove.txt").write_text("bye", encoding="utf-8")
|
|
614
|
+
|
|
615
|
+
def fake_run_cli_sync(
|
|
616
|
+
adapter: Any,
|
|
617
|
+
prompt: str,
|
|
618
|
+
thinking: bool,
|
|
619
|
+
cwd: str,
|
|
620
|
+
timeout_seconds: int,
|
|
621
|
+
agent_index: int,
|
|
622
|
+
model: str | None = None,
|
|
623
|
+
reasoning_effort: str | None = None,
|
|
624
|
+
) -> AgentResult:
|
|
625
|
+
sandbox_cwd = Path(cwd)
|
|
626
|
+
(sandbox_cwd / "edit.txt").write_text("new", encoding="utf-8")
|
|
627
|
+
(sandbox_cwd / "add.txt").write_text("added", encoding="utf-8")
|
|
628
|
+
(sandbox_cwd / "remove.txt").unlink()
|
|
629
|
+
return AgentResult(
|
|
630
|
+
status="success",
|
|
631
|
+
output="ok",
|
|
632
|
+
stderr=None,
|
|
633
|
+
returncode=0,
|
|
634
|
+
duration_ms=1,
|
|
635
|
+
agent_index=agent_index,
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
monkeypatch.setattr(server_module, "_run_cli_sync", fake_run_cli_sync)
|
|
639
|
+
adapter = get_adapter("kimi")
|
|
640
|
+
|
|
641
|
+
result = server_module._run_cli_sandboxed(
|
|
642
|
+
adapter,
|
|
643
|
+
"prompt",
|
|
644
|
+
False,
|
|
645
|
+
str(workspace),
|
|
646
|
+
60,
|
|
647
|
+
0,
|
|
648
|
+
None,
|
|
649
|
+
None,
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
sandbox = result.raw["sandbox"]
|
|
653
|
+
assert sandbox["summary"]["added"] == 1
|
|
654
|
+
assert sandbox["summary"]["modified"] == 1
|
|
655
|
+
assert sandbox["summary"]["deleted"] == 1
|
|
656
|
+
assert "edit.txt" in sandbox["diff"]
|
|
657
|
+
|
|
658
|
+
assert (workspace / "edit.txt").read_text(encoding="utf-8") == "old"
|
|
659
|
+
assert not (workspace / "add.txt").exists()
|
|
660
|
+
assert (workspace / "remove.txt").exists()
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def test_run_cli_sandboxed_copytree_error(
|
|
664
|
+
monkeypatch: Any,
|
|
665
|
+
tmp_path: Any,
|
|
666
|
+
) -> None:
|
|
667
|
+
from moonbridge.adapters import get_adapter
|
|
668
|
+
|
|
669
|
+
adapter = get_adapter("kimi")
|
|
670
|
+
result = server_module._run_cli_sandboxed(
|
|
671
|
+
adapter,
|
|
672
|
+
"prompt",
|
|
673
|
+
False,
|
|
674
|
+
str(tmp_path / "nonexistent"),
|
|
675
|
+
60,
|
|
676
|
+
0,
|
|
677
|
+
None,
|
|
678
|
+
None,
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
assert result.status == "error"
|
|
682
|
+
assert "sandbox error" in result.stderr
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def test_sandbox_ignores_git_dir(
|
|
686
|
+
monkeypatch: Any,
|
|
687
|
+
tmp_path: Any,
|
|
688
|
+
) -> None:
|
|
689
|
+
from moonbridge.adapters import get_adapter
|
|
690
|
+
|
|
691
|
+
workspace = tmp_path / "workspace"
|
|
692
|
+
workspace.mkdir()
|
|
693
|
+
(workspace / ".git").mkdir()
|
|
694
|
+
(workspace / ".git" / "config").write_text("gitconf", encoding="utf-8")
|
|
695
|
+
(workspace / "code.txt").write_text("code", encoding="utf-8")
|
|
696
|
+
|
|
697
|
+
def fake_run_cli_sync(
|
|
698
|
+
adapter: Any,
|
|
699
|
+
prompt: str,
|
|
700
|
+
thinking: bool,
|
|
701
|
+
cwd: str,
|
|
702
|
+
timeout_seconds: int,
|
|
703
|
+
agent_index: int,
|
|
704
|
+
model: str | None = None,
|
|
705
|
+
reasoning_effort: str | None = None,
|
|
706
|
+
) -> AgentResult:
|
|
707
|
+
assert not Path(cwd).joinpath(".git").exists()
|
|
708
|
+
return AgentResult(
|
|
709
|
+
status="success",
|
|
710
|
+
output="ok",
|
|
711
|
+
stderr=None,
|
|
712
|
+
returncode=0,
|
|
713
|
+
duration_ms=1,
|
|
714
|
+
agent_index=agent_index,
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
monkeypatch.setattr(server_module, "_run_cli_sync", fake_run_cli_sync)
|
|
718
|
+
adapter = get_adapter("kimi")
|
|
719
|
+
|
|
720
|
+
result = server_module._run_cli_sandboxed(
|
|
721
|
+
adapter,
|
|
722
|
+
"prompt",
|
|
723
|
+
False,
|
|
724
|
+
str(workspace),
|
|
725
|
+
60,
|
|
726
|
+
0,
|
|
727
|
+
None,
|
|
728
|
+
None,
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
assert result.status == "success"
|
|
732
|
+
|
|
733
|
+
|
|
602
734
|
@pytest.fixture
|
|
603
735
|
def reset_active_processes() -> Iterator[None]:
|
|
604
736
|
server_module._active_processes.clear()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|