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.
Files changed (37) hide show
  1. moonbridge-0.9.0/.github/workflows/cerberus.yml +46 -0
  2. moonbridge-0.9.0/.release-please-manifest.json +3 -0
  3. {moonbridge-0.8.0 → moonbridge-0.9.0}/CHANGELOG.md +8 -0
  4. {moonbridge-0.8.0 → moonbridge-0.9.0}/PKG-INFO +52 -1
  5. {moonbridge-0.8.0 → moonbridge-0.9.0}/README.md +51 -0
  6. {moonbridge-0.8.0 → moonbridge-0.9.0}/src/moonbridge/__init__.py +1 -1
  7. {moonbridge-0.8.0 → moonbridge-0.9.0}/src/moonbridge/server.py +53 -4
  8. moonbridge-0.9.0/src/moonbridge/signals.py +68 -0
  9. {moonbridge-0.8.0 → moonbridge-0.9.0}/tests/test_sandbox.py +0 -2
  10. {moonbridge-0.8.0 → moonbridge-0.9.0}/tests/test_server.py +71 -0
  11. moonbridge-0.9.0/tests/test_signals.py +109 -0
  12. moonbridge-0.8.0/.release-please-manifest.json +0 -3
  13. {moonbridge-0.8.0 → moonbridge-0.9.0}/.env.example +0 -0
  14. {moonbridge-0.8.0 → moonbridge-0.9.0}/.github/workflows/publish.yml +0 -0
  15. {moonbridge-0.8.0 → moonbridge-0.9.0}/.github/workflows/release-please.yml +0 -0
  16. {moonbridge-0.8.0 → moonbridge-0.9.0}/.github/workflows/test.yml +0 -0
  17. {moonbridge-0.8.0 → moonbridge-0.9.0}/.gitignore +0 -0
  18. {moonbridge-0.8.0 → moonbridge-0.9.0}/CLAUDE.md +0 -0
  19. {moonbridge-0.8.0 → moonbridge-0.9.0}/CONTRIBUTING.md +0 -0
  20. {moonbridge-0.8.0 → moonbridge-0.9.0}/LICENSE +0 -0
  21. {moonbridge-0.8.0 → moonbridge-0.9.0}/VISION.md +0 -0
  22. {moonbridge-0.8.0 → moonbridge-0.9.0}/pyproject.toml +0 -0
  23. {moonbridge-0.8.0 → moonbridge-0.9.0}/release-please-config.json +0 -0
  24. {moonbridge-0.8.0 → moonbridge-0.9.0}/src/moonbridge/adapters/__init__.py +0 -0
  25. {moonbridge-0.8.0 → moonbridge-0.9.0}/src/moonbridge/adapters/base.py +0 -0
  26. {moonbridge-0.8.0 → moonbridge-0.9.0}/src/moonbridge/adapters/codex.py +0 -0
  27. {moonbridge-0.8.0 → moonbridge-0.9.0}/src/moonbridge/adapters/kimi.py +0 -0
  28. {moonbridge-0.8.0 → moonbridge-0.9.0}/src/moonbridge/py.typed +0 -0
  29. {moonbridge-0.8.0 → moonbridge-0.9.0}/src/moonbridge/sandbox.py +0 -0
  30. {moonbridge-0.8.0 → moonbridge-0.9.0}/src/moonbridge/tools.py +0 -0
  31. {moonbridge-0.8.0 → moonbridge-0.9.0}/src/moonbridge/version_check.py +0 -0
  32. {moonbridge-0.8.0 → moonbridge-0.9.0}/tests/__init__.py +0 -0
  33. {moonbridge-0.8.0 → moonbridge-0.9.0}/tests/conftest.py +0 -0
  34. {moonbridge-0.8.0 → moonbridge-0.9.0}/tests/test_adapters.py +0 -0
  35. {moonbridge-0.8.0 → moonbridge-0.9.0}/tests/test_tools.py +0 -0
  36. {moonbridge-0.8.0 → moonbridge-0.9.0}/tests/test_version_check.py +0 -0
  37. {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 }}
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.9.0"
3
+ }
@@ -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.8.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"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.8.0"
5
+ __version__ = "0.9.0"
6
6
 
7
7
  from .server import main, run, server
8
8
 
@@ -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
- return AgentResult(
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 {"status": "auth_error", "message": adapter.config.auth_message}
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
- """Handle tool calls. Exposed for testing."""
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 - delegates to handle_tool."""
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
@@ -2,8 +2,6 @@ import importlib
2
2
  from pathlib import Path
3
3
  from typing import Any
4
4
 
5
- import pytest
6
-
7
5
  from moonbridge.adapters.base import AgentResult
8
6
 
9
7
  sandbox_module = importlib.import_module("moonbridge.sandbox")
@@ -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
+ }
@@ -1,3 +0,0 @@
1
- {
2
- ".": "0.8.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
File without changes
File without changes