moonbridge 0.8.0__tar.gz → 0.9.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.9.0/.github/workflows/cerberus.yml +46 -0
- moonbridge-0.9.0/.release-please-manifest.json +3 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/CHANGELOG.md +8 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/PKG-INFO +52 -1
- {moonbridge-0.8.0 → moonbridge-0.9.0}/README.md +51 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/src/moonbridge/__init__.py +1 -1
- {moonbridge-0.8.0 → moonbridge-0.9.0}/src/moonbridge/server.py +53 -4
- moonbridge-0.9.0/src/moonbridge/signals.py +68 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/tests/test_sandbox.py +0 -2
- {moonbridge-0.8.0 → moonbridge-0.9.0}/tests/test_server.py +71 -0
- moonbridge-0.9.0/tests/test_signals.py +109 -0
- moonbridge-0.8.0/.release-please-manifest.json +0 -3
- {moonbridge-0.8.0 → moonbridge-0.9.0}/.env.example +0 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/.github/workflows/publish.yml +0 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/.github/workflows/release-please.yml +0 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/.github/workflows/test.yml +0 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/.gitignore +0 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/CLAUDE.md +0 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/CONTRIBUTING.md +0 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/LICENSE +0 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/VISION.md +0 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/pyproject.toml +0 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/release-please-config.json +0 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/src/moonbridge/adapters/__init__.py +0 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/src/moonbridge/adapters/base.py +0 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/src/moonbridge/adapters/codex.py +0 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/src/moonbridge/adapters/kimi.py +0 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/src/moonbridge/py.typed +0 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/src/moonbridge/sandbox.py +0 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/src/moonbridge/tools.py +0 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/src/moonbridge/version_check.py +0 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/tests/__init__.py +0 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/tests/conftest.py +0 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/tests/test_adapters.py +0 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/tests/test_tools.py +0 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/tests/test_version_check.py +0 -0
- {moonbridge-0.8.0 → moonbridge-0.9.0}/uv.lock +0 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Cerberus AI Code Review Council
|
|
2
|
+
# https://github.com/misty-step/cerberus
|
|
3
|
+
name: Cerberus Council
|
|
4
|
+
|
|
5
|
+
on:
|
|
6
|
+
pull_request:
|
|
7
|
+
types: [opened, synchronize, reopened]
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
pull-requests: write
|
|
12
|
+
|
|
13
|
+
concurrency:
|
|
14
|
+
group: cerberus-${{ github.event.pull_request.number }}
|
|
15
|
+
cancel-in-progress: true
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
review:
|
|
19
|
+
name: "${{ matrix.reviewer }}"
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
strategy:
|
|
22
|
+
matrix:
|
|
23
|
+
include:
|
|
24
|
+
- { reviewer: APOLLO, perspective: correctness }
|
|
25
|
+
- { reviewer: ATHENA, perspective: architecture }
|
|
26
|
+
- { reviewer: SENTINEL, perspective: security }
|
|
27
|
+
- { reviewer: VULCAN, perspective: performance }
|
|
28
|
+
- { reviewer: ARTEMIS, perspective: maintainability }
|
|
29
|
+
fail-fast: false
|
|
30
|
+
steps:
|
|
31
|
+
- uses: actions/checkout@v4
|
|
32
|
+
- uses: misty-step/cerberus@v1
|
|
33
|
+
with:
|
|
34
|
+
perspective: ${{ matrix.perspective }}
|
|
35
|
+
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
36
|
+
kimi-api-key: ${{ secrets.MOONSHOT_API_KEY }}
|
|
37
|
+
|
|
38
|
+
verdict:
|
|
39
|
+
name: "Council Verdict"
|
|
40
|
+
needs: review
|
|
41
|
+
if: always()
|
|
42
|
+
runs-on: ubuntu-latest
|
|
43
|
+
steps:
|
|
44
|
+
- uses: misty-step/cerberus/verdict@v1
|
|
45
|
+
with:
|
|
46
|
+
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
@@ -5,6 +5,14 @@ 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.9.0](https://github.com/misty-step/moonbridge/compare/moonbridge-v0.8.0...moonbridge-v0.9.0) (2026-02-06)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add structured output parsing with quality signals ([#76](https://github.com/misty-step/moonbridge/issues/76)) ([1318a03](https://github.com/misty-step/moonbridge/commit/1318a03f42556fa6d9366bd8e67ae465fd8a235a))
|
|
14
|
+
* validate MOONBRIDGE_ALLOWED_DIRS and expose config health ([#67](https://github.com/misty-step/moonbridge/issues/67)) ([#78](https://github.com/misty-step/moonbridge/issues/78)) ([bf5af9b](https://github.com/misty-step/moonbridge/commit/bf5af9b7e5d13e8c24776d3e4ff154af04e1b2a7))
|
|
15
|
+
|
|
8
16
|
## [0.8.0](https://github.com/misty-step/moonbridge/compare/moonbridge-v0.7.0...moonbridge-v0.8.0) (2026-02-06)
|
|
9
17
|
|
|
10
18
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: moonbridge
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.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
|
|
@@ -90,6 +90,32 @@ export MOONBRIDGE_SKIP_UPDATE_CHECK=1
|
|
|
90
90
|
|
|
91
91
|
**Best for:** Tasks that benefit from parallel execution or volume.
|
|
92
92
|
|
|
93
|
+
## How it Works
|
|
94
|
+
|
|
95
|
+
### Connection Flow
|
|
96
|
+
1. MCP client (Claude Code, Cursor, etc.) connects to Moonbridge over stdio
|
|
97
|
+
2. Client discovers available tools via `list_tools`
|
|
98
|
+
3. Client calls `spawn_agent` or `spawn_agents_parallel`
|
|
99
|
+
|
|
100
|
+
### Spawn Process
|
|
101
|
+
1. Moonbridge validates the prompt and working directory
|
|
102
|
+
2. Resolves which adapter to use (Kimi, Codex)
|
|
103
|
+
3. Adapter builds the CLI command with appropriate flags
|
|
104
|
+
4. Spawns subprocess in a separate process group
|
|
105
|
+
5. Captures stdout/stderr, enforces timeout
|
|
106
|
+
6. Returns structured JSON result
|
|
107
|
+
|
|
108
|
+
### Parallel Execution
|
|
109
|
+
- `spawn_agents_parallel` runs up to 10 agents concurrently via `asyncio.gather`
|
|
110
|
+
- Each agent is independent (separate process, separate output)
|
|
111
|
+
- All results returned together when the last agent finishes (or times out)
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
MCP Client → stdio → Moonbridge → adapter → CLI subprocess
|
|
115
|
+
→ CLI subprocess (parallel)
|
|
116
|
+
→ CLI subprocess (parallel)
|
|
117
|
+
```
|
|
118
|
+
|
|
93
119
|
## Tools
|
|
94
120
|
|
|
95
121
|
| Tool | Use case |
|
|
@@ -170,6 +196,31 @@ All tools return JSON with these fields:
|
|
|
170
196
|
| `MOONBRIDGE_SANDBOX_MAX_COPY` | Max sandbox copy size in bytes (default 500MB) |
|
|
171
197
|
| `MOONBRIDGE_LOG_LEVEL` | Set to `DEBUG` for verbose logging |
|
|
172
198
|
|
|
199
|
+
## Security
|
|
200
|
+
|
|
201
|
+
### 1. Directory Restrictions (`MOONBRIDGE_ALLOWED_DIRS`)
|
|
202
|
+
|
|
203
|
+
Default: agents can operate in any directory. Set `MOONBRIDGE_ALLOWED_DIRS` to restrict: colon-separated allowed paths. Symlinks resolved via `os.path.realpath` before checking. Strict mode (`MOONBRIDGE_STRICT=1`) exits on startup if no valid allowed directories are configured.
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
export MOONBRIDGE_ALLOWED_DIRS="/home/user/projects:/home/user/work"
|
|
207
|
+
export MOONBRIDGE_STRICT=1 # require restrictions
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### 2. Environment Sanitization
|
|
211
|
+
|
|
212
|
+
Only whitelisted env vars are passed to spawned agents. Each adapter defines its own allowlist (`PATH`, `HOME`, plus adapter-specific like `OPENAI_API_KEY` for Codex). Your shell environment (secrets, tokens, SSH keys) is not inherited by default.
|
|
213
|
+
|
|
214
|
+
### 3. Input Validation
|
|
215
|
+
|
|
216
|
+
Model parameters are validated to prevent flag injection (values starting with `-` are rejected). Prompts are capped at 100,000 characters and cannot be empty.
|
|
217
|
+
|
|
218
|
+
### 4. Process Isolation
|
|
219
|
+
|
|
220
|
+
Agents run in separate process groups (`start_new_session=True`). Orphan cleanup on exit. Sandbox mode available (`MOONBRIDGE_SANDBOX=1`) for copy-on-run isolation.
|
|
221
|
+
|
|
222
|
+
> **Not OS-level sandboxing.** Agents can still read arbitrary host files. For strong isolation, use containers/VMs.
|
|
223
|
+
|
|
173
224
|
## Troubleshooting
|
|
174
225
|
|
|
175
226
|
### "CLI not found"
|
|
@@ -61,6 +61,32 @@ export MOONBRIDGE_SKIP_UPDATE_CHECK=1
|
|
|
61
61
|
|
|
62
62
|
**Best for:** Tasks that benefit from parallel execution or volume.
|
|
63
63
|
|
|
64
|
+
## How it Works
|
|
65
|
+
|
|
66
|
+
### Connection Flow
|
|
67
|
+
1. MCP client (Claude Code, Cursor, etc.) connects to Moonbridge over stdio
|
|
68
|
+
2. Client discovers available tools via `list_tools`
|
|
69
|
+
3. Client calls `spawn_agent` or `spawn_agents_parallel`
|
|
70
|
+
|
|
71
|
+
### Spawn Process
|
|
72
|
+
1. Moonbridge validates the prompt and working directory
|
|
73
|
+
2. Resolves which adapter to use (Kimi, Codex)
|
|
74
|
+
3. Adapter builds the CLI command with appropriate flags
|
|
75
|
+
4. Spawns subprocess in a separate process group
|
|
76
|
+
5. Captures stdout/stderr, enforces timeout
|
|
77
|
+
6. Returns structured JSON result
|
|
78
|
+
|
|
79
|
+
### Parallel Execution
|
|
80
|
+
- `spawn_agents_parallel` runs up to 10 agents concurrently via `asyncio.gather`
|
|
81
|
+
- Each agent is independent (separate process, separate output)
|
|
82
|
+
- All results returned together when the last agent finishes (or times out)
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
MCP Client → stdio → Moonbridge → adapter → CLI subprocess
|
|
86
|
+
→ CLI subprocess (parallel)
|
|
87
|
+
→ CLI subprocess (parallel)
|
|
88
|
+
```
|
|
89
|
+
|
|
64
90
|
## Tools
|
|
65
91
|
|
|
66
92
|
| Tool | Use case |
|
|
@@ -141,6 +167,31 @@ All tools return JSON with these fields:
|
|
|
141
167
|
| `MOONBRIDGE_SANDBOX_MAX_COPY` | Max sandbox copy size in bytes (default 500MB) |
|
|
142
168
|
| `MOONBRIDGE_LOG_LEVEL` | Set to `DEBUG` for verbose logging |
|
|
143
169
|
|
|
170
|
+
## Security
|
|
171
|
+
|
|
172
|
+
### 1. Directory Restrictions (`MOONBRIDGE_ALLOWED_DIRS`)
|
|
173
|
+
|
|
174
|
+
Default: agents can operate in any directory. Set `MOONBRIDGE_ALLOWED_DIRS` to restrict: colon-separated allowed paths. Symlinks resolved via `os.path.realpath` before checking. Strict mode (`MOONBRIDGE_STRICT=1`) exits on startup if no valid allowed directories are configured.
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
export MOONBRIDGE_ALLOWED_DIRS="/home/user/projects:/home/user/work"
|
|
178
|
+
export MOONBRIDGE_STRICT=1 # require restrictions
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### 2. Environment Sanitization
|
|
182
|
+
|
|
183
|
+
Only whitelisted env vars are passed to spawned agents. Each adapter defines its own allowlist (`PATH`, `HOME`, plus adapter-specific like `OPENAI_API_KEY` for Codex). Your shell environment (secrets, tokens, SSH keys) is not inherited by default.
|
|
184
|
+
|
|
185
|
+
### 3. Input Validation
|
|
186
|
+
|
|
187
|
+
Model parameters are validated to prevent flag injection (values starting with `-` are rejected). Prompts are capped at 100,000 characters and cannot be empty.
|
|
188
|
+
|
|
189
|
+
### 4. Process Isolation
|
|
190
|
+
|
|
191
|
+
Agents run in separate process groups (`start_new_session=True`). Orphan cleanup on exit. Sandbox mode available (`MOONBRIDGE_SANDBOX=1`) for copy-on-run isolation.
|
|
192
|
+
|
|
193
|
+
> **Not OS-level sandboxing.** Agents can still read arbitrary host files. For strong isolation, use containers/VMs.
|
|
194
|
+
|
|
144
195
|
## Troubleshooting
|
|
145
196
|
|
|
146
197
|
### "CLI not found"
|
|
@@ -21,6 +21,7 @@ from mcp.types import TextContent, Tool
|
|
|
21
21
|
|
|
22
22
|
from moonbridge.adapters import ADAPTER_REGISTRY, CLIAdapter, get_adapter
|
|
23
23
|
from moonbridge.adapters.base import AgentResult
|
|
24
|
+
from moonbridge.signals import extract_quality_signals
|
|
24
25
|
from moonbridge.tools import build_tools
|
|
25
26
|
|
|
26
27
|
server = Server("moonbridge")
|
|
@@ -78,6 +79,22 @@ def _warn_if_unrestricted() -> None:
|
|
|
78
79
|
print(message, file=sys.stderr)
|
|
79
80
|
|
|
80
81
|
|
|
82
|
+
def _validate_allowed_dirs() -> None:
|
|
83
|
+
if not ALLOWED_DIRS:
|
|
84
|
+
return
|
|
85
|
+
missing_count = 0
|
|
86
|
+
for path in ALLOWED_DIRS:
|
|
87
|
+
if os.path.isdir(path):
|
|
88
|
+
continue
|
|
89
|
+
missing_count += 1
|
|
90
|
+
logger.warning("MOONBRIDGE_ALLOWED_DIRS entry does not exist: %s", path)
|
|
91
|
+
if missing_count == len(ALLOWED_DIRS) and STRICT_MODE:
|
|
92
|
+
message = "MOONBRIDGE_ALLOWED_DIRS entries do not exist"
|
|
93
|
+
logger.error(message)
|
|
94
|
+
print(message, file=sys.stderr)
|
|
95
|
+
sys.exit(1)
|
|
96
|
+
|
|
97
|
+
|
|
81
98
|
def _safe_env(adapter: CLIAdapter) -> dict[str, str]:
|
|
82
99
|
env = {key: os.environ[key] for key in adapter.config.safe_env_keys if key in os.environ}
|
|
83
100
|
if "PATH" not in env and "PATH" in os.environ:
|
|
@@ -327,7 +344,7 @@ def _run_cli_sync(
|
|
|
327
344
|
)
|
|
328
345
|
status = "success" if proc.returncode == 0 else "error"
|
|
329
346
|
logger.info("Agent %s completed with status: %s", agent_index, status)
|
|
330
|
-
|
|
347
|
+
result = AgentResult(
|
|
331
348
|
status=status,
|
|
332
349
|
output=stdout,
|
|
333
350
|
stderr=stderr_value,
|
|
@@ -335,6 +352,13 @@ def _run_cli_sync(
|
|
|
335
352
|
duration_ms=duration_ms,
|
|
336
353
|
agent_index=agent_index,
|
|
337
354
|
)
|
|
355
|
+
if result.status == "success":
|
|
356
|
+
signals = extract_quality_signals(result.output, result.stderr)
|
|
357
|
+
if signals:
|
|
358
|
+
raw = dict(result.raw or {})
|
|
359
|
+
raw["quality_signals"] = signals
|
|
360
|
+
result = replace(result, raw=raw)
|
|
361
|
+
return result
|
|
338
362
|
except TimeoutExpired:
|
|
339
363
|
_terminate_process(proc)
|
|
340
364
|
duration_ms = int((time.monotonic() - start) * 1000)
|
|
@@ -401,6 +425,12 @@ def _json_text(payload: Any) -> list[TextContent]:
|
|
|
401
425
|
|
|
402
426
|
|
|
403
427
|
def _status_check(cwd: str, adapter: CLIAdapter) -> dict[str, Any]:
|
|
428
|
+
config = {
|
|
429
|
+
"strict_mode": STRICT_MODE,
|
|
430
|
+
"allowed_dirs": ALLOWED_DIRS or None,
|
|
431
|
+
"unrestricted": not ALLOWED_DIRS,
|
|
432
|
+
"cwd": cwd,
|
|
433
|
+
}
|
|
404
434
|
installed, _path = adapter.check_installed()
|
|
405
435
|
if not installed:
|
|
406
436
|
return {
|
|
@@ -408,20 +438,27 @@ def _status_check(cwd: str, adapter: CLIAdapter) -> dict[str, Any]:
|
|
|
408
438
|
"message": (
|
|
409
439
|
f"{adapter.config.name} CLI not found. Install: {adapter.config.install_hint}"
|
|
410
440
|
),
|
|
441
|
+
"config": config,
|
|
411
442
|
}
|
|
412
443
|
timeout = min(DEFAULT_TIMEOUT, 60)
|
|
413
444
|
result = _run_cli_sync(adapter, "status check", False, cwd, timeout, 0)
|
|
414
445
|
if result.status == "auth_error":
|
|
415
|
-
return {
|
|
446
|
+
return {
|
|
447
|
+
"status": "auth_error",
|
|
448
|
+
"message": adapter.config.auth_message,
|
|
449
|
+
"config": config,
|
|
450
|
+
}
|
|
416
451
|
if result.status == "success":
|
|
417
452
|
return {
|
|
418
453
|
"status": "success",
|
|
419
454
|
"message": f"{adapter.config.name} CLI available and authenticated",
|
|
455
|
+
"config": config,
|
|
420
456
|
}
|
|
421
457
|
return {
|
|
422
458
|
"status": "error",
|
|
423
459
|
"message": f"{adapter.config.name} CLI error",
|
|
424
460
|
"details": result.to_dict(),
|
|
461
|
+
"config": config,
|
|
425
462
|
}
|
|
426
463
|
|
|
427
464
|
|
|
@@ -444,6 +481,7 @@ def _adapter_info(cwd: str, adapter: CLIAdapter) -> dict[str, Any]:
|
|
|
444
481
|
|
|
445
482
|
@server.list_tools()
|
|
446
483
|
async def list_tools() -> list[Tool]:
|
|
484
|
+
"""Build MCP tool metadata for the active adapter."""
|
|
447
485
|
adapter = get_adapter()
|
|
448
486
|
tool_desc = adapter.config.tool_description
|
|
449
487
|
status_desc = f"Verify {adapter.config.name} CLI is installed and authenticated"
|
|
@@ -456,7 +494,15 @@ async def list_tools() -> list[Tool]:
|
|
|
456
494
|
|
|
457
495
|
|
|
458
496
|
async def handle_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
|
459
|
-
"""
|
|
497
|
+
"""Dispatch a tool invocation with validation and stable error payloads.
|
|
498
|
+
|
|
499
|
+
Separated from ``call_tool`` so tests can invoke tool logic without the
|
|
500
|
+
MCP decorator.
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
name: MCP tool name (``spawn_agent``, ``spawn_agents_parallel``, etc.).
|
|
504
|
+
arguments: Tool argument payload from the MCP client.
|
|
505
|
+
"""
|
|
460
506
|
try:
|
|
461
507
|
cwd = _validate_cwd(None)
|
|
462
508
|
if name == "spawn_agent":
|
|
@@ -556,11 +602,12 @@ async def handle_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]
|
|
|
556
602
|
|
|
557
603
|
@server.call_tool()
|
|
558
604
|
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
|
559
|
-
"""MCP tool handler
|
|
605
|
+
"""MCP tool handler -- delegates to ``handle_tool`` for testability."""
|
|
560
606
|
return await handle_tool(name, arguments)
|
|
561
607
|
|
|
562
608
|
|
|
563
609
|
async def run() -> None:
|
|
610
|
+
"""Run the MCP server over stdio until the client disconnects."""
|
|
564
611
|
async with stdio_server() as (read_stream, write_stream):
|
|
565
612
|
await server.run(
|
|
566
613
|
read_stream,
|
|
@@ -570,8 +617,10 @@ async def run() -> None:
|
|
|
570
617
|
|
|
571
618
|
|
|
572
619
|
def main() -> None:
|
|
620
|
+
"""CLI entry point that validates prerequisites then starts the server."""
|
|
573
621
|
_configure_logging()
|
|
574
622
|
_warn_if_unrestricted()
|
|
623
|
+
_validate_allowed_dirs()
|
|
575
624
|
from moonbridge import __version__
|
|
576
625
|
from moonbridge.version_check import check_for_updates
|
|
577
626
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Heuristic extraction of quality signals from agent output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
# Diff markers at line start.
|
|
9
|
+
_DIFF_MARKER_RE = re.compile(r"^(?:\+\+\+ |--- |@@ )", re.MULTILINE)
|
|
10
|
+
# File headers in unified diffs.
|
|
11
|
+
_DIFF_FILE_RE = re.compile(r"^(?:\+\+\+ b/|--- a/)(.+)$", re.MULTILINE)
|
|
12
|
+
# Git-style summary lines.
|
|
13
|
+
_FILES_CHANGED_RE = re.compile(r"\b(\d+)\s+files?\s+changed\b", re.IGNORECASE)
|
|
14
|
+
_MODIFIED_FILES_RE = re.compile(r"\bModified\s+(\d+)\s+files?\b", re.IGNORECASE)
|
|
15
|
+
# Pytest-style summaries.
|
|
16
|
+
_PASSED_RE = re.compile(r"(?<!\w)(\d+)\s+passed\b", re.IGNORECASE)
|
|
17
|
+
_FAILED_RE = re.compile(r"(?<!\w)(\d+)\s+failed\b", re.IGNORECASE)
|
|
18
|
+
# stderr error markers.
|
|
19
|
+
_ERROR_RE = re.compile(r"(Traceback \(most recent call last\)|\berror:)", re.IGNORECASE)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _last_int(pattern: re.Pattern[str], text: str) -> int | None:
|
|
23
|
+
matches = pattern.findall(text)
|
|
24
|
+
if not matches:
|
|
25
|
+
return None
|
|
26
|
+
return int(matches[-1])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _count_files_changed(output: str) -> int:
|
|
30
|
+
paths = {path for path in _DIFF_FILE_RE.findall(output) if path and path != "/dev/null"}
|
|
31
|
+
if paths:
|
|
32
|
+
return len(paths)
|
|
33
|
+
match = _FILES_CHANGED_RE.search(output) or _MODIFIED_FILES_RE.search(output)
|
|
34
|
+
if match:
|
|
35
|
+
return int(match.group(1))
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def extract_quality_signals(output: str, stderr: str | None = None) -> dict[str, Any]:
|
|
40
|
+
"""Extract heuristic quality signals from agent output."""
|
|
41
|
+
signals: dict[str, Any] = {}
|
|
42
|
+
if not output and not stderr:
|
|
43
|
+
return signals
|
|
44
|
+
|
|
45
|
+
has_diff = bool(_DIFF_MARKER_RE.search(output))
|
|
46
|
+
if has_diff:
|
|
47
|
+
signals["has_diff"] = True
|
|
48
|
+
|
|
49
|
+
files_changed = _count_files_changed(output)
|
|
50
|
+
if files_changed:
|
|
51
|
+
signals["files_changed"] = files_changed
|
|
52
|
+
|
|
53
|
+
combined = output
|
|
54
|
+
if stderr:
|
|
55
|
+
combined = f"{output}\n{stderr}"
|
|
56
|
+
|
|
57
|
+
tests_passed = _last_int(_PASSED_RE, combined)
|
|
58
|
+
if tests_passed is not None:
|
|
59
|
+
signals["tests_passed"] = tests_passed
|
|
60
|
+
|
|
61
|
+
tests_failed = _last_int(_FAILED_RE, combined)
|
|
62
|
+
if tests_failed is not None:
|
|
63
|
+
signals["tests_failed"] = tests_failed
|
|
64
|
+
|
|
65
|
+
if stderr and _ERROR_RE.search(stderr):
|
|
66
|
+
signals["has_errors"] = True
|
|
67
|
+
|
|
68
|
+
return signals
|
|
@@ -56,6 +56,19 @@ async def test_spawn_agent_thinking_adds_flag(mock_popen: Any) -> None:
|
|
|
56
56
|
assert "--thinking" in args[0]
|
|
57
57
|
|
|
58
58
|
|
|
59
|
+
@pytest.mark.asyncio
|
|
60
|
+
async def test_spawn_agent_adds_quality_signals(mock_popen: Any) -> None:
|
|
61
|
+
process = mock_popen.return_value
|
|
62
|
+
process.communicate.return_value = ("== 5 passed in 0.12s ==", "")
|
|
63
|
+
process.returncode = 0
|
|
64
|
+
|
|
65
|
+
result = await server_module.handle_tool("spawn_agent", {"prompt": "Hello"})
|
|
66
|
+
payload = json.loads(result[0].text)
|
|
67
|
+
|
|
68
|
+
assert payload["status"] == "success"
|
|
69
|
+
assert payload["raw"]["quality_signals"] == {"tests_passed": 5}
|
|
70
|
+
|
|
71
|
+
|
|
59
72
|
@pytest.mark.asyncio
|
|
60
73
|
async def test_spawn_agents_parallel_runs_concurrently(monkeypatch: Any) -> None:
|
|
61
74
|
starts: list[float] = []
|
|
@@ -261,6 +274,25 @@ async def test_check_status_not_installed(mock_which_no_kimi: Any) -> None:
|
|
|
261
274
|
assert payload["status"] == "error"
|
|
262
275
|
|
|
263
276
|
|
|
277
|
+
@pytest.mark.asyncio
|
|
278
|
+
async def test_check_status_includes_config(
|
|
279
|
+
mock_which_no_kimi: Any, monkeypatch: Any
|
|
280
|
+
) -> None:
|
|
281
|
+
monkeypatch.setattr(server_module, "ALLOWED_DIRS", [])
|
|
282
|
+
monkeypatch.setattr(server_module, "STRICT_MODE", True)
|
|
283
|
+
monkeypatch.setattr(server_module.os, "getcwd", lambda: "/workdir")
|
|
284
|
+
|
|
285
|
+
result = await server_module.handle_tool("check_status", {})
|
|
286
|
+
payload = json.loads(result[0].text)
|
|
287
|
+
|
|
288
|
+
assert "config" in payload
|
|
289
|
+
config = payload["config"]
|
|
290
|
+
assert config["strict_mode"] is True
|
|
291
|
+
assert config["allowed_dirs"] is None
|
|
292
|
+
assert config["unrestricted"] is True
|
|
293
|
+
assert config["cwd"] == server_module.os.path.realpath("/workdir")
|
|
294
|
+
|
|
295
|
+
|
|
264
296
|
@pytest.mark.asyncio
|
|
265
297
|
async def test_list_adapters_tool_output(monkeypatch: Any) -> None:
|
|
266
298
|
def fake_run(
|
|
@@ -434,6 +466,45 @@ def test_warn_if_unrestricted_no_warning_when_restricted(
|
|
|
434
466
|
assert not caplog.records
|
|
435
467
|
|
|
436
468
|
|
|
469
|
+
def test_validate_allowed_dirs_warns_missing(monkeypatch: Any, caplog: Any) -> None:
|
|
470
|
+
monkeypatch.setattr(server_module, "ALLOWED_DIRS", ["/missing"])
|
|
471
|
+
monkeypatch.setattr(server_module.os.path, "isdir", lambda _path: False)
|
|
472
|
+
caplog.set_level(logging.WARNING, logger="moonbridge")
|
|
473
|
+
|
|
474
|
+
server_module._validate_allowed_dirs()
|
|
475
|
+
|
|
476
|
+
assert any(
|
|
477
|
+
record.levelno == logging.WARNING
|
|
478
|
+
and record.getMessage() == "MOONBRIDGE_ALLOWED_DIRS entry does not exist: /missing"
|
|
479
|
+
for record in caplog.records
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def test_validate_allowed_dirs_strict_all_missing_exits(
|
|
484
|
+
monkeypatch: Any, caplog: Any, mocker: Any
|
|
485
|
+
) -> None:
|
|
486
|
+
monkeypatch.setattr(server_module, "ALLOWED_DIRS", ["/missing", "/missing2"])
|
|
487
|
+
monkeypatch.setattr(server_module, "STRICT_MODE", True)
|
|
488
|
+
monkeypatch.setattr(server_module.os.path, "isdir", lambda _path: False)
|
|
489
|
+
exit_mock = mocker.patch("moonbridge.server.sys.exit")
|
|
490
|
+
caplog.set_level(logging.ERROR, logger="moonbridge")
|
|
491
|
+
|
|
492
|
+
server_module._validate_allowed_dirs()
|
|
493
|
+
|
|
494
|
+
exit_mock.assert_called_once_with(1)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def test_validate_allowed_dirs_some_valid_no_exit(monkeypatch: Any, mocker: Any) -> None:
|
|
498
|
+
monkeypatch.setattr(server_module, "ALLOWED_DIRS", ["/missing", "/valid"])
|
|
499
|
+
monkeypatch.setattr(server_module, "STRICT_MODE", True)
|
|
500
|
+
monkeypatch.setattr(server_module.os.path, "isdir", lambda path: path == "/valid")
|
|
501
|
+
exit_mock = mocker.patch("moonbridge.server.sys.exit")
|
|
502
|
+
|
|
503
|
+
server_module._validate_allowed_dirs()
|
|
504
|
+
|
|
505
|
+
exit_mock.assert_not_called()
|
|
506
|
+
|
|
507
|
+
|
|
437
508
|
def test_resolve_timeout_uses_adapter_default(monkeypatch: Any) -> None:
|
|
438
509
|
"""Adapter-specific default takes precedence over global default."""
|
|
439
510
|
from moonbridge.adapters import get_adapter
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from moonbridge.signals import extract_quality_signals
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_extract_quality_signals_empty_output() -> None:
|
|
5
|
+
assert extract_quality_signals("", None) == {}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_extract_quality_signals_pytest_counts() -> None:
|
|
9
|
+
output = "== 5 passed, 2 failed in 0.12s =="
|
|
10
|
+
assert extract_quality_signals(output) == {"tests_passed": 5, "tests_failed": 2}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_extract_quality_signals_diff_markers() -> None:
|
|
14
|
+
output = (
|
|
15
|
+
"diff --git a/foo.py b/foo.py\n"
|
|
16
|
+
"index 123..456 100644\n"
|
|
17
|
+
"--- a/foo.py\n"
|
|
18
|
+
"+++ b/foo.py\n"
|
|
19
|
+
"@@ -1 +1 @@\n"
|
|
20
|
+
"-old\n"
|
|
21
|
+
"+new\n"
|
|
22
|
+
"diff --git a/bar.py b/bar.py\n"
|
|
23
|
+
"--- a/bar.py\n"
|
|
24
|
+
"+++ b/bar.py\n"
|
|
25
|
+
"@@ -1 +1 @@\n"
|
|
26
|
+
"-old\n"
|
|
27
|
+
"+new\n"
|
|
28
|
+
)
|
|
29
|
+
assert extract_quality_signals(output) == {"has_diff": True, "files_changed": 2}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_extract_quality_signals_traceback() -> None:
|
|
33
|
+
stderr = "Traceback (most recent call last):\n boom\n"
|
|
34
|
+
assert extract_quality_signals("", stderr) == {"has_errors": True}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_extract_quality_signals_combined() -> None:
|
|
38
|
+
output = "2 passed, 1 failed\n--- a/app.py\n+++ b/app.py\n@@ -1 +1 @@\n"
|
|
39
|
+
stderr = "error: something went wrong\n"
|
|
40
|
+
assert extract_quality_signals(output, stderr) == {
|
|
41
|
+
"tests_passed": 2,
|
|
42
|
+
"tests_failed": 1,
|
|
43
|
+
"has_diff": True,
|
|
44
|
+
"files_changed": 1,
|
|
45
|
+
"has_errors": True,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_extract_quality_signals_zero_passed() -> None:
|
|
50
|
+
"""Zero passed should be reported, not silently dropped."""
|
|
51
|
+
output = "== 0 passed, 3 failed in 0.05s =="
|
|
52
|
+
assert extract_quality_signals(output) == {"tests_passed": 0, "tests_failed": 3}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_extract_quality_signals_zero_failed() -> None:
|
|
56
|
+
"""Zero failed is a meaningful signal (all tests passed)."""
|
|
57
|
+
output = "== 5 passed, 0 failed in 0.12s =="
|
|
58
|
+
assert extract_quality_signals(output) == {"tests_passed": 5, "tests_failed": 0}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_extract_quality_signals_files_changed_summary() -> None:
|
|
62
|
+
"""Fallback to git summary line when no diff headers present."""
|
|
63
|
+
output = "3 files changed, 10 insertions(+), 2 deletions(-)\n"
|
|
64
|
+
assert extract_quality_signals(output) == {"files_changed": 3}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_extract_quality_signals_modified_files_summary() -> None:
|
|
68
|
+
"""Fallback to Modified N files format."""
|
|
69
|
+
output = "Modified 2 files\n"
|
|
70
|
+
assert extract_quality_signals(output) == {"files_changed": 2}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_extract_quality_signals_last_match_wins() -> None:
|
|
74
|
+
"""When output has multiple test runs, the last result is used."""
|
|
75
|
+
output = (
|
|
76
|
+
"== 3 passed in 0.1s ==\n"
|
|
77
|
+
"== 5 passed, 1 failed in 0.2s ==\n"
|
|
78
|
+
)
|
|
79
|
+
assert extract_quality_signals(output) == {"tests_passed": 5, "tests_failed": 1}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_extract_quality_signals_no_signals_on_plain_output() -> None:
|
|
83
|
+
output = "All done, no issues found.\n"
|
|
84
|
+
assert extract_quality_signals(output) == {}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_extract_quality_signals_real_worldish_codex_output() -> None:
|
|
88
|
+
output = (
|
|
89
|
+
"Running: uv run pytest -v\n"
|
|
90
|
+
"============================= test session starts ==============================\n"
|
|
91
|
+
"collected 7 items\n"
|
|
92
|
+
"tests/test_server.py ....F..\n"
|
|
93
|
+
"=========================== short test summary info ============================\n"
|
|
94
|
+
"FAILED tests/test_server.py::test_spawn_agent - AssertionError\n"
|
|
95
|
+
"========================= 6 passed, 1 failed in 0.45s =========================\n"
|
|
96
|
+
"diff --git a/src/app.py b/src/app.py\n"
|
|
97
|
+
"index 123..456 100644\n"
|
|
98
|
+
"--- a/src/app.py\n"
|
|
99
|
+
"+++ b/src/app.py\n"
|
|
100
|
+
"@@ -1,2 +1,2 @@\n"
|
|
101
|
+
"-old\n"
|
|
102
|
+
"+new\n"
|
|
103
|
+
)
|
|
104
|
+
assert extract_quality_signals(output) == {
|
|
105
|
+
"tests_passed": 6,
|
|
106
|
+
"tests_failed": 1,
|
|
107
|
+
"has_diff": True,
|
|
108
|
+
"files_changed": 1,
|
|
109
|
+
}
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|