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.
Files changed (34) hide show
  1. moonbridge-0.8.0/.release-please-manifest.json +3 -0
  2. {moonbridge-0.6.0 → moonbridge-0.8.0}/CHANGELOG.md +14 -0
  3. {moonbridge-0.6.0 → moonbridge-0.8.0}/CLAUDE.md +1 -0
  4. moonbridge-0.8.0/CONTRIBUTING.md +40 -0
  5. {moonbridge-0.6.0 → moonbridge-0.8.0}/PKG-INFO +29 -1
  6. {moonbridge-0.6.0 → moonbridge-0.8.0}/README.md +28 -0
  7. {moonbridge-0.6.0 → moonbridge-0.8.0}/src/moonbridge/__init__.py +1 -1
  8. {moonbridge-0.6.0 → moonbridge-0.8.0}/src/moonbridge/adapters/codex.py +1 -0
  9. moonbridge-0.8.0/src/moonbridge/sandbox.py +252 -0
  10. {moonbridge-0.6.0 → moonbridge-0.8.0}/src/moonbridge/server.py +94 -2
  11. moonbridge-0.8.0/tests/test_sandbox.py +221 -0
  12. {moonbridge-0.6.0 → moonbridge-0.8.0}/tests/test_server.py +132 -0
  13. moonbridge-0.6.0/.release-please-manifest.json +0 -3
  14. {moonbridge-0.6.0 → moonbridge-0.8.0}/.env.example +0 -0
  15. {moonbridge-0.6.0 → moonbridge-0.8.0}/.github/workflows/publish.yml +0 -0
  16. {moonbridge-0.6.0 → moonbridge-0.8.0}/.github/workflows/release-please.yml +0 -0
  17. {moonbridge-0.6.0 → moonbridge-0.8.0}/.github/workflows/test.yml +0 -0
  18. {moonbridge-0.6.0 → moonbridge-0.8.0}/.gitignore +0 -0
  19. {moonbridge-0.6.0 → moonbridge-0.8.0}/LICENSE +0 -0
  20. {moonbridge-0.6.0 → moonbridge-0.8.0}/VISION.md +0 -0
  21. {moonbridge-0.6.0 → moonbridge-0.8.0}/pyproject.toml +0 -0
  22. {moonbridge-0.6.0 → moonbridge-0.8.0}/release-please-config.json +0 -0
  23. {moonbridge-0.6.0 → moonbridge-0.8.0}/src/moonbridge/adapters/__init__.py +0 -0
  24. {moonbridge-0.6.0 → moonbridge-0.8.0}/src/moonbridge/adapters/base.py +0 -0
  25. {moonbridge-0.6.0 → moonbridge-0.8.0}/src/moonbridge/adapters/kimi.py +0 -0
  26. {moonbridge-0.6.0 → moonbridge-0.8.0}/src/moonbridge/py.typed +0 -0
  27. {moonbridge-0.6.0 → moonbridge-0.8.0}/src/moonbridge/tools.py +0 -0
  28. {moonbridge-0.6.0 → moonbridge-0.8.0}/src/moonbridge/version_check.py +0 -0
  29. {moonbridge-0.6.0 → moonbridge-0.8.0}/tests/__init__.py +0 -0
  30. {moonbridge-0.6.0 → moonbridge-0.8.0}/tests/conftest.py +0 -0
  31. {moonbridge-0.6.0 → moonbridge-0.8.0}/tests/test_adapters.py +0 -0
  32. {moonbridge-0.6.0 → moonbridge-0.8.0}/tests/test_tools.py +0 -0
  33. {moonbridge-0.6.0 → moonbridge-0.8.0}/tests/test_version_check.py +0 -0
  34. {moonbridge-0.6.0 → moonbridge-0.8.0}/uv.lock +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.8.0"
3
+ }
@@ -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.6.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.6.0"
5
+ __version__ = "0.8.0"
6
6
 
7
7
  from .server import main, run, server
8
8
 
@@ -50,6 +50,7 @@ class CodexAdapter:
50
50
  install_hint="See https://github.com/openai/codex",
51
51
  supports_thinking=False,
52
52
  known_models=(
53
+ "gpt-5.3-codex",
53
54
  "gpt-5.2-codex",
54
55
  "gpt-5.1-codex",
55
56
  "gpt-5.1-codex-mini",
@@ -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
- _run_cli_sync,
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
- _run_cli_sync,
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()
@@ -1,3 +0,0 @@
1
- {
2
- ".": "0.6.0"
3
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes