Add Codex MCP guard and daemon lifecycle checks

This commit is contained in:
CCLCK 2026-04-23 02:16:05 +08:00
parent 130a780e7f
commit 4f16fd3c3b
19 changed files with 2215 additions and 16 deletions

View file

@ -52,6 +52,7 @@ The intended machine-wide setup is:
- detect the current project directory
- normalize it to the git repo root when possible
- set `OPENSPACE_WORKSPACE`
- when Codex Desktop only provides `PWD=/` and no explicit workspace, fall back to `OPENSPACE_MCP_PROXY_MODE=direct` instead of creating shared daemons scoped to `/`
- route project skills to `~/.codex/projects/<repo>/skills`
- include common global skills from `~/.codex/skills`
- call the shared `stdio` proxy entrypoint
@ -81,6 +82,20 @@ The proxy surface supports two internal overrides:
- `OPENSPACE_MCP_PROXY_MODE=direct` restores the old direct stdio behavior for debugging or rollback.
- `OPENSPACE_MCP_DAEMON_STATE_DIR=/custom/path` moves daemon state to a different local directory.
- `OPENSPACE_MCP_PROXY_IDLE_TIMEOUT_SECONDS=<seconds>` lets stdio proxy processes reap themselves sooner than the daemon timeout; it falls back to `OPENSPACE_MCP_IDLE_TIMEOUT_SECONDS`, then defaults to `180`.
### Invalid Workspace Fallback
If Codex Desktop launches the global wrappers without an explicit `OPENSPACE_WORKSPACE` and only exposes `PWD=/`, the generated wrappers now:
- do not export `OPENSPACE_WORKSPACE=/`
- force `OPENSPACE_MCP_PROXY_MODE=direct`
- route skills through the safe default bucket only:
- `~/.codex/projects/default/skills`
- `~/.codex/skills`
- print a warning that shared daemons were disabled and workspace-aware tools must rely on explicit `workspace_dir`
This is an intentional containment path to prevent shared daemon records keyed to `workspace=/`.
The repo-local `scripts/codex-openspace` helper writes the same daemon defaults into the generated profile so local and global setups stay aligned.
@ -121,6 +136,8 @@ This script recreates:
- `~/.codex/bin/openspace-global-mcp`
- `~/.codex/bin/openspace-evolution-global-mcp`
Rerun it after changing wrapper behavior such as workspace fallback or proxy idle timeout handling.
It does **not** overwrite your `~/.codex/config.toml` or `~/.codex/AGENTS.md`.
## Practical Outcome
@ -132,6 +149,33 @@ With the global integration in place:
- OpenSpace evolution MCP is available globally
- repo-scoped skill routing and sidecar evolution use the current project automatically
## MCP Guard
The repo launchers now expose a Codex MCP residue guard for operational diagnosis and bounded cleanup of stale child processes under the current Codex Desktop `app-server`.
Commands:
```bash
./scripts/codex-desktop-evolution guard status
./scripts/codex-desktop-evolution guard check
./scripts/codex-desktop-evolution guard clean --dry-run
./scripts/codex-desktop-evolution guard tail
./scripts/codex-desktop-evolution guard daemon
```
The same subcommands are also available through:
```bash
./scripts/codex-openspace guard <subcommand>
```
Scope:
- diagnoses `openspace.mcp_proxy` residue
- diagnoses `SkyComputerUseClient mcp` residue
- only targets allowlisted stale children during cleanup
- never targets the main `codex app-server`
## Related Docs
- `docs/current-routing-flow.md`

View file

@ -0,0 +1,193 @@
# Codex MCP Guard Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a launcher-friendly diagnostics and cleanup system for stale Codex Desktop MCP child processes, focused on `openspace.mcp_proxy` and `SkyComputerUseClient mcp`.
**Architecture:** Add one repo-owned Python guard script that inventories current Codex Desktop `app-server` MCP residue, writes structured state/events/samples, and supports `status`, `check`, `clean`, `tail`, `help`, and `daemon` commands. Integrate the script into the existing repo launchers as a `guard` subcommand, and keep v1 cleanup manual and allowlist-based.
**Tech Stack:** Python 3, existing shell launchers, `ps`/`lsof`/`pgrep`-style host commands, `pytest`.
---
### Task 1: Create Guard Test Coverage
**Files:**
- Create: `tests/test_codex_mcp_guard.py`
- Modify: `tests/conftest.py` if shared helpers are needed
- Test: `tests/test_codex_mcp_guard.py`
- [ ] **Step 1: Write failing tests for process classification**
```python
def test_sample_snapshot_counts_target_processes_by_type(tmp_path: Path) -> None:
snapshot = guard.build_snapshot(
process_rows=[
{"pid": 10, "ppid": 1, "etime": "10:00", "command": "/Applications/Codex.app/... app-server --analytics-default-enabled"},
{"pid": 11, "ppid": 10, "etime": "09:00", "command": "python -m openspace.mcp_proxy --kind main --transport stdio"},
{"pid": 12, "ppid": 10, "etime": "09:00", "command": "python -m openspace.mcp_proxy --kind evolution --transport stdio"},
{"pid": 13, "ppid": 10, "etime": "08:00", "command": "SkyComputerUseClient mcp"},
],
now_ts=1_700_000_000,
)
assert snapshot["counts"]["openspace_main"] == 1
assert snapshot["counts"]["openspace_evolution"] == 1
assert snapshot["counts"]["computer_use_mcp"] == 1
```
- [ ] **Step 2: Run test to verify it fails**
Run: `./.venv/bin/pytest -q tests/test_codex_mcp_guard.py -k snapshot_counts_target_processes_by_type`
Expected: FAIL because the guard module/test helper does not exist yet.
- [ ] **Step 3: Add failing tests for cleanup targeting**
```python
def test_cleanup_targets_only_stale_allowlisted_children() -> None:
candidates = [
guard.ManagedChild(pid=101, ppid=10, kind="openspace_main", age_seconds=5000, command="python -m openspace.mcp_proxy --kind main --transport stdio"),
guard.ManagedChild(pid=102, ppid=10, kind="computer_use_mcp", age_seconds=6000, command="SkyComputerUseClient mcp"),
guard.ManagedChild(pid=103, ppid=10, kind="openspace_main", age_seconds=30, command="python -m openspace.mcp_proxy --kind main --transport stdio"),
guard.ManagedChild(pid=104, ppid=10, kind="other", age_seconds=7000, command="unrelated mcp"),
]
selected = guard.select_cleanup_candidates(
candidates,
age_threshold_seconds=3600,
max_target_count=1,
include_kinds={"openspace_main", "openspace_evolution", "computer_use_mcp"},
)
assert [item.pid for item in selected] == [101, 102]
```
- [ ] **Step 4: Run test to verify it fails**
Run: `./.venv/bin/pytest -q tests/test_codex_mcp_guard.py -k cleanup_targets_only_stale_allowlisted_children`
Expected: FAIL because candidate selection is not implemented yet.
- [ ] **Step 5: Add failing launcher integration test**
```python
def test_codex_desktop_evolution_guard_subcommand_invokes_guard_script(tmp_path: Path) -> None:
# Assert that `scripts/codex-desktop-evolution guard status`
# execs the repo guard entrypoint instead of codex app/codex exec.
```
```
- [ ] **Step 6: Run test to verify it fails**
Run: `./.venv/bin/pytest -q tests/test_codex_mcp_guard.py -k guard_subcommand`
Expected: FAIL because launcher pass-through is not implemented yet.
### Task 2: Implement Guard Script
**Files:**
- Create: `scripts/codex_mcp_guard.py`
- Modify: `scripts/cleanup_openspace_daemons.py`
- Test: `tests/test_codex_mcp_guard.py`
- [ ] **Step 1: Implement normalized target model and snapshot builder**
```python
@dataclass
class ManagedChild:
pid: int
ppid: int
kind: str
age_seconds: int
command: str
def build_snapshot(process_rows: list[dict[str, object]], now_ts: int) -> dict[str, object]:
# classify app-server, openspace main/evolution, and SkyComputerUseClient mcp
...
```
- [ ] **Step 2: Run focused tests**
Run: `./.venv/bin/pytest -q tests/test_codex_mcp_guard.py -k "snapshot_counts_target_processes_by_type"`
Expected: PASS
- [ ] **Step 3: Implement status/check/tail/help/clean/daemon command handlers**
```python
def main() -> int:
parser = build_parser()
args = parser.parse_args()
if args.command == "status":
...
elif args.command == "check":
...
elif args.command == "clean":
...
```
- [ ] **Step 4: Reuse and narrow existing cleanup logic**
```python
def select_cleanup_candidates(...):
# only current app-server descendants
# only allowlisted kinds
# only age-threshold-matching stale processes
```
- [ ] **Step 5: Run focused tests**
Run: `./.venv/bin/pytest -q tests/test_codex_mcp_guard.py -k "cleanup_targets_only_stale_allowlisted_children or status"`
Expected: PASS
### Task 3: Integrate Launchers
**Files:**
- Modify: `scripts/codex-desktop-evolution`
- Modify: `scripts/codex-openspace`
- Test: `tests/test_codex_mcp_guard.py`
- [ ] **Step 1: Add `guard` subcommand pass-through**
```bash
if [[ "${1:-}" == "guard" ]]; then
shift
exec "$REPO_PYTHON" "$REPO_ROOT/scripts/codex_mcp_guard.py" "$@"
fi
```
- [ ] **Step 2: Keep existing app/exec behavior unchanged**
Run: inspect branches for `app` and default execution in both launchers.
Expected: only the new `guard` fast-path is added ahead of current logic.
- [ ] **Step 3: Run launcher-focused tests**
Run: `./.venv/bin/pytest -q tests/test_codex_mcp_guard.py -k guard_subcommand`
Expected: PASS
### Task 4: Verify End-to-End Behavior
**Files:**
- Modify: `docs/global-codex-integration.md`
- Test: `tests/test_codex_mcp_guard.py`, `tests/test_global_mcp_wrapper_installation.py`, `tests/test_mcp_preflight.py`
- [ ] **Step 1: Document the new operational commands**
```markdown
- `./scripts/codex-desktop-evolution guard status`
- `./scripts/codex-desktop-evolution guard check`
- `./scripts/codex-desktop-evolution guard clean --dry-run`
```
- [ ] **Step 2: Run the targeted test suite**
Run: `./.venv/bin/pytest -q tests/test_codex_mcp_guard.py tests/test_global_mcp_wrapper_installation.py tests/test_mcp_preflight.py`
Expected: PASS
- [ ] **Step 3: Run one manual diagnostics smoke**
Run: `./scripts/codex-desktop-evolution guard check`
Expected: emits a structured current-state snapshot without killing processes.
- [ ] **Step 4: Run one manual cleanup dry-run smoke**
Run: `./scripts/codex-desktop-evolution guard clean --dry-run`
Expected: prints only allowlisted stale candidates and no destructive action.

View file

@ -0,0 +1,204 @@
# Codex MCP Guard Design
**Date:** 2026-04-19
**Goal**
Add a repo-owned diagnostics and cleanup system for Codex Desktop MCP residue, focused on `openspace.mcp_proxy` and `SkyComputerUseClient mcp`, with a launcher-friendly command surface for status inspection, threshold checks, event history, and bounded cleanup.
**Problem**
Current evidence shows large numbers of stale MCP child processes accumulating under a single long-lived Codex Desktop `app-server`. The residue is not limited to OpenSpace; `openspace.mcp_proxy` and `SkyComputerUseClient mcp` both accumulate. This looks more like host lifecycle leakage than intentional caching, but we want a repeatable diagnostic system before treating it as a cleanup-only problem.
**Non-Goals**
- Do not modify Codex Desktop internals.
- Do not kill the main `codex app-server`.
- Do not clean unrelated MCP servers or arbitrary child processes.
- Do not auto-restart Codex Desktop in the first iteration.
- Do not assume every high process count is bad without supporting evidence.
## Architecture
The system will be a repo-owned guard utility with two layers:
1. A read-only diagnostics layer that inventories relevant MCP residue, classifies process ownership, records health state, and reports whether the situation looks like normal session activity, stale residue, or an operator-attention condition.
2. A bounded cleanup layer that only targets stale child processes matching explicit markers and thresholds, with dry-run support and a clear event trail.
This follows the same shape as the Shadowrocket Guard reference: a single script with `status / check / clean / tail / help` commands and a `daemon` mode. The first implementation will emphasize safe manual operations and state visibility; any autonomous cleanup will be gated by explicit thresholds and launcher flags.
## Components
### 1. Guard Script
Add a new script under `scripts/` that owns all MCP residue diagnostics and cleanup behavior.
Responsibilities:
- inspect the current Codex Desktop `app-server`
- enumerate target child processes under that host
- classify `openspace.mcp_proxy` processes by mode and age
- count `SkyComputerUseClient mcp` residue alongside OpenSpace residue
- write structured state and event logs
- expose manual commands and daemon mode
Proposed command surface:
- `status`: show latest known state
- `check`: perform one fresh sample, no cleanup
- `clean`: perform bounded cleanup if thresholds say it is safe/reasonable
- `tail`: show recent events
- `help`: print usage
- `daemon`: run a sampling loop and maintain state files
### 2. State Directory
Store runtime outputs in a dedicated repo-local directory, similar to the Shadowrocket Guard pattern.
Proposed directory:
- `logs/codex_mcp_guard/`
Proposed files:
- `state.json`
- `events.jsonl`
- `samples.jsonl`
Purpose:
- `state.json`: latest status summary for launcher and humans
- `events.jsonl`: transitions and cleanup actions
- `samples.jsonl`: periodic snapshots for later debugging
### 3. Launcher Integration
Integrate the guard into the existing repo launch path so the user can inspect or clean without remembering a separate script path.
Initial integration points:
- `scripts/codex-desktop-evolution`
- `scripts/codex-openspace`
Initial launcher affordances:
- pass-through subcommands like `guard status`, `guard check`, `guard clean`, `guard tail`
- a simple environment flag to enable background daemon mode in phase 2
We will not make launcher startup always block on the guard. It should remain an operational tool, not a boot gate.
## Detection Model
The diagnostics layer will classify only explicitly targeted MCP residue:
- `openspace.mcp_proxy --kind main --transport stdio`
- `openspace.mcp_proxy --kind evolution --transport stdio`
- `SkyComputerUseClient mcp`
It will capture:
- PID, PPID, elapsed time, command
- whether the process is a descendant of the current `codex app-server`
- for `openspace.mcp_proxy`, whether it is in `direct` or `daemon` mode when recoverable from env/command context
- open stdio/pipe/socket attachments where feasible
- counts grouped by type and by owning host PID
### Residue Heuristics
The guard will not equate "exists" with "bad". It will calculate:
- total count per target type
- count under current host session
- count older than a stale-age threshold
- oldest age per target type
- whether many processes share the same parent
- whether many old processes show only startup-time activity in their paired logs
Initial status levels:
- `ok`: counts below warning thresholds and no stale-age anomalies
- `warning`: counts or ages suggest residue, but no cleanup requested
- `threshold_exceeded`: thresholds crossed and cleanup is recommended
- `cleaned`: a cleanup action ran successfully
- `cleanup_failed`: a cleanup action was attempted and failed
## Cleanup Policy
Cleanup must be narrow, reversible in intent, and auditable.
Initial cleanup target:
- stale `openspace.mcp_proxy` and `SkyComputerUseClient mcp` child processes belonging to the current `codex app-server`
Initial cleanup exclusions:
- do not target `codex app-server`
- do not target unrelated MCP processes
- do not touch shared daemon metadata for unrelated state dirs
- do not kill fresh processes below age threshold unless explicitly forced
Initial cleanup decision model:
- only on explicit `clean` command in v1
- dry-run available
- age threshold plus count threshold must both be visible in state
- process markers must match a known allowlist
This keeps v1 focused on safe operator-driven remediation while preserving a clean path to threshold-triggered cleanup in phase 2.
## Read-Only Localization Goal
The diagnostics system should also help answer whether this is caching strategy or bug.
The expected evidence model is:
- if processes are intentionally cached, we should see bounded counts, reuse over time, and session-scoped ownership patterns
- if they are leaked, we should see monotonic accumulation, many old idle descendants under one long-lived host, and little or no evidence of reuse
The guard will not hardcode that verdict, but it will surface the exact facts needed to support it.
## Testing
We will add tests for:
- process parsing and classification
- threshold/status evaluation
- cleanup target filtering
- state/event file generation
- launcher pass-through behavior
Where live process behavior is too environment-specific, unit tests will operate on mocked `ps`/`lsof`-style snapshots and synthetic sample data.
## Rollout
### Phase 1
- add the guard script
- add `status / check / clean / tail / help`
- write state/events/samples
- integrate launcher pass-through
- keep cleanup manual only
### Phase 2
- add `daemon` loop
- add threshold-based recommendation and cooldown tracking
- expose an opt-in auto-clean mode only after phase 1 diagnostics prove the thresholds are stable
## Risks
- Over-cleaning active children if thresholds are too aggressive
- Misclassifying current-session processes as stale when Codex Desktop is actively using them
- Assuming all residue under one parent is safe to kill
Mitigations:
- default to read-only plus explicit `clean`
- dry-run support
- allowlist-based targeting
- threshold and age gates
- event logging for every action
## Decision
Proceed with a Shadowrocket-Guard-style MCP residue guard as a repo-owned operational tool, integrated into the launcher, with v1 prioritizing read-only diagnostics and manual bounded cleanup for `openspace.mcp_proxy` and `SkyComputerUseClient mcp`.

View file

@ -0,0 +1,529 @@
from __future__ import annotations
import argparse
import json
import os
import signal
import subprocess
import sys
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Callable
HOST_MARKER = "codex app-server"
OPENSPACE_PROXY_MARKER = "openspace.mcp_proxy"
COMPUTER_USE_MARKER = "SkyComputerUseClient mcp"
DEFAULT_STALE_AGE_SECONDS = 60 * 60
DEFAULT_WARN_TOTAL_COUNT = 24
DEFAULT_THRESHOLD_TOTAL_COUNT = 48
DEFAULT_WARN_STALE_COUNT = 8
DEFAULT_THRESHOLD_STALE_COUNT = 16
DEFAULT_TAIL_LIMIT = 20
@dataclass(frozen=True)
class ManagedChild:
pid: int
ppid: int
kind: str
age_seconds: int
command: str
is_descendant: bool
mode: str | None = None
def repo_root() -> Path:
return Path(__file__).resolve().parents[1]
def default_guard_dir() -> Path:
return repo_root() / "logs" / "codex_mcp_guard"
def parse_elapsed_seconds(value: str) -> int:
raw = (value or "").strip()
if not raw:
return 0
if "-" in raw:
days_text, time_text = raw.split("-", 1)
days = int(days_text)
return days * 86400 + parse_elapsed_seconds(time_text)
parts = [int(item) for item in raw.split(":")]
if len(parts) == 2:
minutes, seconds = parts
return minutes * 60 + seconds
if len(parts) == 3:
hours, minutes, seconds = parts
return hours * 3600 + minutes * 60 + seconds
raise ValueError(f"Unsupported elapsed time format: {value!r}")
def classify_command(command: str) -> str | None:
if HOST_MARKER in command:
return "codex_app_server"
if OPENSPACE_PROXY_MARKER in command and "--kind main" in command:
return "openspace_main"
if OPENSPACE_PROXY_MARKER in command and "--kind evolution" in command:
return "openspace_evolution"
if COMPUTER_USE_MARKER in command:
return "computer_use_mcp"
return None
def collect_process_rows() -> list[dict[str, Any]]:
proc = subprocess.run(
["ps", "-axo", "pid=,ppid=,etime=,command="],
check=False,
capture_output=True,
text=True,
)
rows: list[dict[str, Any]] = []
for line in proc.stdout.splitlines():
stripped = line.strip()
if not stripped:
continue
parts = stripped.split(None, 3)
if len(parts) != 4:
continue
pid_text, ppid_text, etime, command = parts
rows.append(
{
"pid": int(pid_text),
"ppid": int(ppid_text),
"etime": etime,
"command": command,
}
)
return rows
def proxy_mode_for_pid(pid: int) -> str | None:
proc = subprocess.run(
["ps", "eww", "-p", str(pid)],
check=False,
capture_output=True,
text=True,
)
text = proc.stdout
if "OPENSPACE_MCP_PROXY_MODE=direct" in text:
return "direct"
if "OPENSPACE_MCP_PROXY_MODE=daemon" in text:
return "daemon"
return None
def pick_host_pid(process_rows: list[dict[str, Any]], requested_host_pid: int | None = None) -> tuple[int | None, list[int]]:
candidates = sorted(
int(row["pid"])
for row in process_rows
if classify_command(str(row["command"])) == "codex_app_server"
)
if requested_host_pid is not None:
return (requested_host_pid if requested_host_pid in candidates else None), candidates
if not candidates:
return None, []
return candidates[-1], candidates
def descendant_pid_set(process_rows: list[dict[str, Any]], host_pid: int | None) -> set[int]:
if host_pid is None:
return set()
children_by_parent: dict[int, list[int]] = {}
for row in process_rows:
children_by_parent.setdefault(int(row["ppid"]), []).append(int(row["pid"]))
descendants: set[int] = set()
stack = [host_pid]
while stack:
current = stack.pop()
for child in children_by_parent.get(current, []):
if child in descendants:
continue
descendants.add(child)
stack.append(child)
return descendants
def evaluate_status(
*,
total_count: int,
stale_count: int,
warn_total_count: int,
threshold_total_count: int,
warn_stale_count: int,
threshold_stale_count: int,
) -> str:
if total_count >= threshold_total_count or stale_count >= threshold_stale_count:
return "threshold_exceeded"
if total_count >= warn_total_count or stale_count >= warn_stale_count:
return "warning"
return "ok"
def build_snapshot(
process_rows: list[dict[str, Any]],
now_ts: int,
*,
requested_host_pid: int | None = None,
stale_age_seconds: int = DEFAULT_STALE_AGE_SECONDS,
warn_total_count: int = DEFAULT_WARN_TOTAL_COUNT,
threshold_total_count: int = DEFAULT_THRESHOLD_TOTAL_COUNT,
warn_stale_count: int = DEFAULT_WARN_STALE_COUNT,
threshold_stale_count: int = DEFAULT_THRESHOLD_STALE_COUNT,
proxy_mode_lookup: Callable[[int], str | None] | None = None,
) -> dict[str, Any]:
host_pid, host_candidates = pick_host_pid(process_rows, requested_host_pid=requested_host_pid)
descendants = descendant_pid_set(process_rows, host_pid)
host_row = next((row for row in process_rows if int(row["pid"]) == host_pid), None)
targets: list[ManagedChild] = []
outside_host_total = 0
counts = {
"openspace_main": 0,
"openspace_evolution": 0,
"computer_use_mcp": 0,
"targets_total": 0,
}
stale = {
"openspace_main": 0,
"openspace_evolution": 0,
"computer_use_mcp": 0,
"total": 0,
"oldest_age_seconds": 0,
}
for row in process_rows:
kind = classify_command(str(row["command"]))
if kind not in {"openspace_main", "openspace_evolution", "computer_use_mcp"}:
continue
pid = int(row["pid"])
age_seconds = parse_elapsed_seconds(str(row["etime"]))
is_descendant = pid in descendants
if not is_descendant:
outside_host_total += 1
continue
mode = proxy_mode_lookup(pid) if proxy_mode_lookup and kind.startswith("openspace_") else None
child = ManagedChild(
pid=pid,
ppid=int(row["ppid"]),
kind=kind,
age_seconds=age_seconds,
command=str(row["command"]),
is_descendant=is_descendant,
mode=mode,
)
targets.append(child)
counts[kind] += 1
counts["targets_total"] += 1
stale["oldest_age_seconds"] = max(stale["oldest_age_seconds"], age_seconds)
if age_seconds >= stale_age_seconds:
stale[kind] += 1
stale["total"] += 1
status = evaluate_status(
total_count=counts["targets_total"],
stale_count=stale["total"],
warn_total_count=warn_total_count,
threshold_total_count=threshold_total_count,
warn_stale_count=warn_stale_count,
threshold_stale_count=threshold_stale_count,
)
if host_pid is None:
assessment = "no_codex_host_found"
elif counts["targets_total"] == 0:
assessment = "no_target_processes"
elif stale["total"] > 0:
assessment = "host_lifecycle_leak_suspected"
elif status in {"warning", "threshold_exceeded"}:
assessment = "high_live_session_footprint"
else:
assessment = "within_threshold"
return {
"sampled_at": now_ts,
"status": status,
"assessment": assessment,
"host": {
"pid": host_pid,
"command": host_row["command"] if host_row else None,
"candidate_pids": host_candidates,
},
"thresholds": {
"stale_age_seconds": stale_age_seconds,
"warn_total_count": warn_total_count,
"threshold_total_count": threshold_total_count,
"warn_stale_count": warn_stale_count,
"threshold_stale_count": threshold_stale_count,
},
"counts": counts,
"stale": stale,
"outside_host_total": outside_host_total,
"targets": [asdict(item) for item in sorted(targets, key=lambda item: (-item.age_seconds, item.pid))],
}
def select_cleanup_candidates(
candidates: list[ManagedChild],
*,
age_threshold_seconds: int,
include_kinds: set[str],
) -> list[ManagedChild]:
return sorted(
[
item
for item in candidates
if item.is_descendant and item.kind in include_kinds and item.age_seconds >= age_threshold_seconds
],
key=lambda item: (-item.age_seconds, item.pid),
)
def record_snapshot(guard_dir: Path, snapshot: dict[str, Any], *, event_type: str) -> None:
guard_dir.mkdir(parents=True, exist_ok=True)
state_path = guard_dir / "state.json"
events_path = guard_dir / "events.jsonl"
samples_path = guard_dir / "samples.jsonl"
state_path.write_text(json.dumps(snapshot, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
event = {
"ts": int(time.time()),
"type": event_type,
"status": snapshot.get("status"),
"assessment": snapshot.get("assessment"),
"counts": snapshot.get("counts", {}),
"stale": snapshot.get("stale", {}),
}
with events_path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(event, ensure_ascii=False) + "\n")
with samples_path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(snapshot, ensure_ascii=False) + "\n")
def terminate_process(pid: int, timeout_seconds: float) -> str:
try:
os.kill(pid, 0)
except OSError:
return "already-exited"
os.kill(pid, signal.SIGTERM)
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
try:
os.kill(pid, 0)
except OSError:
return "terminated"
time.sleep(0.1)
try:
os.kill(pid, signal.SIGKILL)
except OSError:
return "terminated"
return "killed"
def current_snapshot(args: argparse.Namespace) -> dict[str, Any]:
return build_snapshot(
collect_process_rows(),
int(time.time()),
requested_host_pid=args.host_pid,
stale_age_seconds=args.stale_age_seconds,
warn_total_count=args.warn_total_count,
threshold_total_count=args.threshold_total_count,
warn_stale_count=args.warn_stale_count,
threshold_stale_count=args.threshold_stale_count,
proxy_mode_lookup=proxy_mode_for_pid,
)
def read_state(guard_dir: Path) -> dict[str, Any] | None:
state_path = guard_dir / "state.json"
if not state_path.is_file():
return None
try:
return json.loads(state_path.read_text(encoding="utf-8"))
except Exception:
return None
def render_text(snapshot: dict[str, Any]) -> str:
counts = snapshot["counts"]
stale = snapshot["stale"]
host = snapshot["host"]
lines = [
"Codex MCP Guard",
f"status: {snapshot['status']}",
f"assessment: {snapshot['assessment']}",
f"host pid: {host.get('pid')}",
f"openspace main: {counts['openspace_main']}",
f"openspace evolution: {counts['openspace_evolution']}",
f"computer use mcp: {counts['computer_use_mcp']}",
f"targets total: {counts['targets_total']}",
f"stale total: {stale['total']}",
f"oldest age seconds: {stale['oldest_age_seconds']}",
f"outside host total: {snapshot['outside_host_total']}",
]
return "\n".join(lines)
def print_snapshot(snapshot: dict[str, Any], *, as_json: bool) -> None:
if as_json:
print(json.dumps(snapshot, ensure_ascii=False, indent=2))
else:
print(render_text(snapshot))
def command_status(args: argparse.Namespace) -> int:
guard_dir = Path(args.state_dir).expanduser().resolve()
snapshot = read_state(guard_dir)
if snapshot is None:
snapshot = current_snapshot(args)
record_snapshot(guard_dir, snapshot, event_type="status-bootstrap")
print_snapshot(snapshot, as_json=args.json)
return 0
def command_check(args: argparse.Namespace) -> int:
guard_dir = Path(args.state_dir).expanduser().resolve()
snapshot = current_snapshot(args)
record_snapshot(guard_dir, snapshot, event_type="check")
print_snapshot(snapshot, as_json=args.json)
return 0
def command_clean(args: argparse.Namespace) -> int:
guard_dir = Path(args.state_dir).expanduser().resolve()
snapshot = current_snapshot(args)
candidates = select_cleanup_candidates(
[ManagedChild(**item) for item in snapshot["targets"]],
age_threshold_seconds=args.stale_age_seconds,
include_kinds={"openspace_main", "openspace_evolution", "computer_use_mcp"},
)
cleanup_allowed = snapshot["status"] == "threshold_exceeded" or args.force
actions: list[dict[str, Any]] = []
if cleanup_allowed:
for item in candidates:
action = "would-terminate" if args.dry_run else terminate_process(item.pid, args.timeout_seconds)
actions.append({"pid": item.pid, "kind": item.kind, "action": action})
result = {
"cleanup_allowed": cleanup_allowed,
"dry_run": args.dry_run,
"candidates": [asdict(item) for item in candidates],
"actions": actions,
"snapshot": snapshot,
}
record_snapshot(guard_dir, snapshot, event_type="clean")
if args.json:
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
print(render_text(snapshot))
print(f"\ncleanup allowed: {'yes' if cleanup_allowed else 'no'}")
if not cleanup_allowed:
print("no cleanup performed because thresholds were not exceeded; use --force to override.")
elif not candidates:
print("no cleanup candidates matched the allowlist and age threshold.")
else:
for item in actions:
print(f"- pid={item['pid']} kind={item['kind']} action={item['action']}")
return 0
def command_tail(args: argparse.Namespace) -> int:
events_path = Path(args.state_dir).expanduser().resolve() / "events.jsonl"
if not events_path.is_file():
print("No events recorded yet.")
return 0
lines = events_path.read_text(encoding="utf-8").splitlines()
for line in lines[-args.limit :]:
print(line)
return 0
def command_daemon(args: argparse.Namespace) -> int:
guard_dir = Path(args.state_dir).expanduser().resolve()
iteration = 0
while True:
snapshot = current_snapshot(args)
record_snapshot(guard_dir, snapshot, event_type="daemon")
iteration += 1
if args.iterations and iteration >= args.iterations:
print_snapshot(snapshot, as_json=args.json)
return 0
time.sleep(args.interval_seconds)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Diagnose and clean stale Codex Desktop MCP child processes")
shared = argparse.ArgumentParser(add_help=False)
shared.add_argument("--state-dir", default=str(default_guard_dir()))
shared.add_argument("--host-pid", type=int, default=None)
shared.add_argument("--stale-age-seconds", type=int, default=DEFAULT_STALE_AGE_SECONDS)
shared.add_argument("--warn-total-count", type=int, default=DEFAULT_WARN_TOTAL_COUNT)
shared.add_argument("--threshold-total-count", type=int, default=DEFAULT_THRESHOLD_TOTAL_COUNT)
shared.add_argument("--warn-stale-count", type=int, default=DEFAULT_WARN_STALE_COUNT)
shared.add_argument("--threshold-stale-count", type=int, default=DEFAULT_THRESHOLD_STALE_COUNT)
shared.add_argument("--json", action="store_true")
subparsers = parser.add_subparsers(dest="command")
subparsers.required = True
subparsers.add_parser("status", parents=[shared])
subparsers.add_parser("check", parents=[shared])
clean_parser = subparsers.add_parser("clean", parents=[shared])
clean_parser.add_argument("--dry-run", action="store_true")
clean_parser.add_argument("--force", action="store_true")
clean_parser.add_argument("--timeout-seconds", type=float, default=3.0)
tail_parser = subparsers.add_parser("tail", parents=[shared])
tail_parser.add_argument("--limit", type=int, default=DEFAULT_TAIL_LIMIT)
daemon_parser = subparsers.add_parser("daemon", parents=[shared])
daemon_parser.add_argument("--interval-seconds", type=int, default=30)
daemon_parser.add_argument("--iterations", type=int, default=0)
subparsers.add_parser("help")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.command == "help":
parser.print_help(sys.stdout)
return 0
if args.command == "status":
return command_status(args)
if args.command == "check":
return command_check(args)
if args.command == "clean":
return command_clean(args)
if args.command == "tail":
return command_tail(args)
if args.command == "daemon":
return command_daemon(args)
parser.error(f"Unknown command: {args.command}")
return 2
__all__ = [
"ManagedChild",
"build_snapshot",
"build_parser",
"classify_command",
"default_guard_dir",
"main",
"parse_elapsed_seconds",
"record_snapshot",
"select_cleanup_candidates",
]

View file

@ -747,8 +747,13 @@ def run_mcp_server() -> None:
parser.add_argument("--port", type=int, default=8080)
args = parser.parse_args()
if args.transport == "stdio" or os.environ.get("OPENSPACE_MCP_DAEMON") == "1":
daemon_mode = os.environ.get("OPENSPACE_MCP_DAEMON") == "1"
if args.transport == "stdio" or daemon_mode:
_install_signal_handlers()
# Direct stdio servers should follow the host transport lifetime.
# Only background daemons should self-reap after idle timeouts.
if daemon_mode:
_maybe_start_idle_watchdog()
mcp.settings.port = args.port

472
openspace/mcp_preflight.py Normal file
View file

@ -0,0 +1,472 @@
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import tempfile
import tomllib
from pathlib import Path
from typing import Any
from openspace.codex_session_scenarios import (
cleanup_session_artifacts,
collect_session_family,
parse_exec_output,
)
SERVER_SPECS: dict[str, dict[str, str]] = {
"openspace": {
"kind": "main",
"expected_tool": "search_skills",
},
"openspace_evolution": {
"kind": "evolution",
"expected_tool": "evolve_from_context",
},
}
def canonical_workspace(cwd: Path) -> Path:
workspace = cwd.expanduser().resolve()
proc = subprocess.run(
["git", "-C", str(workspace), "rev-parse", "--show-toplevel"],
check=False,
capture_output=True,
text=True,
)
if proc.returncode == 0 and proc.stdout.strip():
return Path(proc.stdout.strip()).resolve()
return workspace
def _resolve_command(command: str | None, *, config_path: Path) -> tuple[str | None, bool]:
if not command:
return None, False
candidate = command.strip()
if not candidate:
return None, False
if os.path.isabs(candidate):
path = Path(candidate).expanduser().resolve()
elif "/" in candidate:
path = (config_path.parent / candidate).expanduser().resolve()
else:
resolved = shutil.which(candidate)
path = Path(resolved).resolve() if resolved else None
if path is None:
return None, False
return str(path), os.access(path, os.X_OK)
def _load_config(config_path: Path) -> dict[str, Any]:
if not config_path.is_file():
return {}
return tomllib.loads(config_path.read_text(encoding="utf-8"))
def _daemon_state_dir(codex_home: Path) -> Path:
override = os.environ.get("OPENSPACE_MCP_DAEMON_STATE_DIR", "").strip()
if override:
return Path(override).expanduser().resolve()
return (codex_home / "state" / "openspace").resolve()
def _read_daemon_record(
*,
state_dir: Path,
server_kind: str,
workspace: Path,
) -> dict[str, Any] | None:
if not state_dir.is_dir():
return None
for path in sorted(state_dir.glob("*.json")):
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except Exception:
continue
if payload.get("server_kind") != server_kind:
continue
if payload.get("workspace") != str(workspace):
continue
return {
"path": str(path),
"present": True,
"ready": bool(payload.get("ready")),
"warmed": bool(payload.get("warmed")),
"pid": payload.get("pid"),
"port": payload.get("port"),
}
return None
def _machine_status(server_statuses: list[str]) -> str:
if server_statuses and all(status == "ready" for status in server_statuses):
return "ready"
if any(status == "ready" for status in server_statuses):
return "partial"
if any(status == "broken" for status in server_statuses):
return "partial"
return "missing"
def inspect_machine(
*,
cwd: Path,
config_path: Path | None = None,
codex_home: Path | None = None,
) -> dict[str, Any]:
workspace = canonical_workspace(cwd)
codex_home = (codex_home or Path(os.environ.get("CODEX_HOME", Path.home() / ".codex"))).expanduser().resolve()
config_path = (config_path or (codex_home / "config.toml")).expanduser().resolve()
config = _load_config(config_path)
servers_cfg = config.get("mcp_servers", {}) if isinstance(config, dict) else {}
projects_cfg = config.get("projects", {}) if isinstance(config, dict) else {}
project_entry = projects_cfg.get(str(workspace), {}) if isinstance(projects_cfg, dict) else {}
trust_level = project_entry.get("trust_level") if isinstance(project_entry, dict) else None
state_dir = _daemon_state_dir(codex_home)
server_reports: dict[str, dict[str, Any]] = {}
server_statuses: list[str] = []
for server_name, spec in SERVER_SPECS.items():
server_cfg = servers_cfg.get(server_name, {}) if isinstance(servers_cfg, dict) else {}
configured = isinstance(server_cfg, dict) and bool(server_cfg)
command = server_cfg.get("command") if configured else None
resolved_command, executable = _resolve_command(command, config_path=config_path)
daemon = _read_daemon_record(
state_dir=state_dir,
server_kind=spec["kind"],
workspace=workspace,
) or {
"path": None,
"present": False,
"ready": False,
"warmed": False,
"pid": None,
"port": None,
}
if not configured:
status = "missing"
elif not resolved_command or not executable:
status = "broken"
else:
status = "ready"
server_statuses.append(status)
server_reports[server_name] = {
"status": status,
"configured": configured,
"command": command,
"resolved_command": resolved_command,
"executable": executable,
"daemon": daemon,
}
repo_python = workspace / ".venv" / "bin" / "python"
return {
"status": _machine_status(server_statuses),
"workspace": str(workspace),
"codex_home": str(codex_home),
"config_path": str(config_path),
"project_trusted": trust_level == "trusted" if trust_level is not None else None,
"repo_python": {
"path": str(repo_python),
"exists": repo_python.exists(),
"executable": os.access(repo_python, os.X_OK),
},
"servers": server_reports,
}
def summarize_session_probe(
*,
exit_code: int | None,
mcp_tool_calls: list[dict[str, Any]],
error: str | None = None,
) -> dict[str, Any]:
server_reports: dict[str, dict[str, Any]] = {}
for server_name, spec in SERVER_SPECS.items():
matches = [
call for call in mcp_tool_calls
if call.get("server") == server_name and call.get("tool") == spec["expected_tool"]
]
server_reports[server_name] = {
"observed": bool(matches),
"calls": matches,
}
observed_count = sum(1 for item in server_reports.values() if item["observed"])
if error:
status = "probe-failed"
elif observed_count == len(SERVER_SPECS) and exit_code == 0:
status = "ready"
elif observed_count:
status = "partial"
elif exit_code == 0:
status = "missing"
else:
status = "probe-failed"
return {
"status": status,
"exit_code": exit_code,
"error": error,
"servers": server_reports,
"mcp_tool_calls": mcp_tool_calls,
}
def _extract_mcp_tool_calls(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
calls: list[dict[str, Any]] = []
for event in events:
if event.get("type") != "item.completed":
continue
item = event.get("item")
if not isinstance(item, dict) or item.get("type") != "mcp_tool_call":
continue
calls.append(
{
"server": item.get("server"),
"tool": item.get("tool"),
"status": item.get("status"),
"error": item.get("error"),
}
)
return calls
def _cleanup_probe_artifacts(thread_id: str | None, session_index_path: Path) -> list[str]:
if not thread_id:
return []
family = collect_session_family(thread_id, session_index_path.parent / "sessions")
cleanup_session_artifacts(
thread_ids=family.thread_ids,
session_files=family.session_files,
session_index_path=session_index_path,
)
return sorted(str(path) for path in family.session_files)
def _session_probe_prompt(workspace: Path) -> str:
file_paths = [str(workspace / "scripts" / "check_openspace_mcp_preflight.py")]
return (
"这是 OpenSpace MCP health check。不要修改任何文件也不要使用子代理。"
"先调用 openspace 的 search_skills 工具,参数用 "
"query='OpenSpace health check'、source='local'、limit=1、auto_import=false。"
"再调用 openspace_evolution 的 evolve_from_context 工具,参数用 "
f"task='OpenSpace session probe'、summary='Zero-capture smoke test. Do not modify code. Do not create skills.'"
f"workspace_dir='{workspace}'、max_skills=0、file_paths={file_paths}"
"最后只输出一句“openspace-preflight done”。"
)
def probe_session(
*,
cwd: Path,
codex_home: Path | None = None,
codex_binary: str = "codex",
timeout_seconds: int = 180,
keep_artifacts: bool = False,
) -> dict[str, Any]:
workspace = canonical_workspace(cwd)
codex_home = (codex_home or Path(os.environ.get("CODEX_HOME", Path.home() / ".codex"))).expanduser().resolve()
env = os.environ.copy()
env["CODEX_HOME"] = str(codex_home)
output_dir = Path(tempfile.mkdtemp(prefix="openspace-mcp-preflight-"))
stdout_path = output_dir / "session.stdout.log"
stderr_path = output_dir / "session.stderr.log"
session_index_path = codex_home / "session_index.jsonl"
command_path = shutil.which(codex_binary) if os.sep not in codex_binary else codex_binary
if not command_path:
return {
"status": "probe-failed",
"exit_code": None,
"error": f"Unable to resolve codex binary: {codex_binary}",
"stdout_path": str(stdout_path),
"stderr_path": str(stderr_path),
"session_files": [],
"servers": {
server_name: {"observed": False, "calls": []}
for server_name in SERVER_SPECS
},
"mcp_tool_calls": [],
}
command = [
command_path,
"exec",
"--json",
"--skip-git-repo-check",
"--dangerously-bypass-approvals-and-sandbox",
"-C",
str(workspace),
_session_probe_prompt(workspace),
]
try:
completed = subprocess.run(
command,
capture_output=True,
text=True,
errors="replace",
timeout=timeout_seconds,
check=False,
env=env,
)
stdout_path.write_text(completed.stdout, encoding="utf-8")
stderr_path.write_text(completed.stderr, encoding="utf-8")
parsed = parse_exec_output(completed.stdout)
tool_calls = _extract_mcp_tool_calls(parsed.events)
report = summarize_session_probe(exit_code=completed.returncode, mcp_tool_calls=tool_calls)
report.update(
{
"stdout_path": str(stdout_path),
"stderr_path": str(stderr_path),
"thread_id": parsed.thread_id,
"agent_messages": parsed.agent_messages,
"session_files": [],
}
)
if not keep_artifacts:
_cleanup_probe_artifacts(parsed.thread_id, session_index_path)
report["session_files"] = []
report["stdout_path"] = None
report["stderr_path"] = None
shutil.rmtree(output_dir, ignore_errors=True)
return report
except subprocess.TimeoutExpired as exc:
stdout_path.write_text(exc.stdout or "", encoding="utf-8")
stderr_path.write_text(exc.stderr or "", encoding="utf-8")
return {
**summarize_session_probe(exit_code=None, mcp_tool_calls=[], error=f"Timed out after {timeout_seconds}s"),
"stdout_path": str(stdout_path),
"stderr_path": str(stderr_path),
"session_files": [],
}
def build_report(
*,
cwd: Path,
config_path: Path | None = None,
codex_home: Path | None = None,
probe_session_enabled: bool = False,
codex_binary: str = "codex",
timeout_seconds: int = 180,
keep_artifacts: bool = False,
) -> dict[str, Any]:
machine = inspect_machine(cwd=cwd, config_path=config_path, codex_home=codex_home)
if probe_session_enabled:
session = probe_session(
cwd=Path(machine["workspace"]),
codex_home=Path(machine["codex_home"]),
codex_binary=codex_binary,
timeout_seconds=timeout_seconds,
keep_artifacts=keep_artifacts,
)
else:
session = {
"status": "not-probed",
"exit_code": None,
"error": None,
"servers": {
server_name: {"observed": False, "calls": []}
for server_name in SERVER_SPECS
},
"mcp_tool_calls": [],
}
return {
"workspace": machine["workspace"],
"machine": machine,
"session": session,
}
def format_text_report(report: dict[str, Any]) -> str:
machine = report["machine"]
session = report["session"]
lines = [
"OpenSpace MCP preflight",
f"Workspace: {report['workspace']}",
f"Machine: {machine['status']}",
f"Session: {session['status']}",
"",
"Machine details:",
f"- config: {machine['config_path']}",
f"- CODEX_HOME: {machine['codex_home']}",
f"- project trusted: {machine['project_trusted']}",
f"- repo python: {machine['repo_python']['path']} (exists={machine['repo_python']['exists']}, executable={machine['repo_python']['executable']})",
]
for server_name, server in machine["servers"].items():
daemon = server["daemon"]
lines.append(
f"- {server_name}: status={server['status']}, configured={server['configured']}, "
f"resolved_command={server['resolved_command']}, daemon_present={daemon['present']}, daemon_ready={daemon['ready']}"
)
lines.extend(["", "Session details:"])
if session["status"] == "not-probed":
lines.append("- probe disabled; rerun with --probe-session to verify real Codex tool exposure")
else:
lines.append(f"- exit_code: {session['exit_code']}")
lines.append(f"- error: {session['error']}")
for server_name, server in session["servers"].items():
lines.append(f"- {server_name}: observed={server['observed']}")
if session.get("stdout_path"):
lines.append(f"- stdout log: {session['stdout_path']}")
if session.get("stderr_path"):
lines.append(f"- stderr log: {session['stderr_path']}")
return "\n".join(lines)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Check OpenSpace MCP machine/session health")
parser.add_argument("--cwd", type=Path, default=Path.cwd(), help="Workspace or nested repo directory to inspect")
parser.add_argument("--codex-home", type=Path, default=None, help="Override CODEX_HOME for config/session probing")
parser.add_argument("--config-path", type=Path, default=None, help="Override config.toml path")
parser.add_argument("--probe-session", action="store_true", help="Run a real Codex session smoke test")
parser.add_argument("--codex-binary", default="codex", help="Codex CLI binary to use for session probes")
parser.add_argument("--timeout-seconds", type=int, default=180, help="Timeout for the session probe")
parser.add_argument("--keep-artifacts", action="store_true", help="Keep session logs and temporary artifacts")
parser.add_argument("--json", action="store_true", help="Print machine-readable JSON")
args = parser.parse_args(argv)
report = build_report(
cwd=args.cwd.resolve(),
config_path=args.config_path,
codex_home=args.codex_home,
probe_session_enabled=args.probe_session,
codex_binary=args.codex_binary,
timeout_seconds=args.timeout_seconds,
keep_artifacts=args.keep_artifacts,
)
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2))
else:
print(format_text_report(report))
machine_ok = report["machine"]["status"] == "ready"
session_status = report["session"]["status"]
session_ok = session_status in {"ready", "not-probed"}
return 0 if machine_ok and session_ok else 1
__all__ = [
"build_report",
"canonical_workspace",
"format_text_report",
"inspect_machine",
"main",
"probe_session",
"summarize_session_probe",
]

View file

@ -2,9 +2,13 @@ from __future__ import annotations
import asyncio
import argparse
import contextlib
import inspect
import json
import os
import signal
import threading
import time
from pathlib import Path
from typing import Any
@ -23,6 +27,11 @@ from openspace.shared_mcp_runtime import ServerKind, ensure_daemon
_LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
maybe_redirect_stderr_to_file(_LOG_DIR, "mcp_proxy_stderr.log")
_proxy_activity_lock = threading.Lock()
_proxy_active_request_count = 0
_proxy_last_activity_at = time.monotonic()
_proxy_idle_watchdog_started = False
def _proxy_mode_for(server_kind: ServerKind) -> str:
raw = os.environ.get("OPENSPACE_MCP_PROXY_MODE", "").strip().lower()
@ -31,6 +40,68 @@ def _proxy_mode_for(server_kind: ServerKind) -> str:
return "daemon"
def _proxy_idle_timeout_seconds() -> int:
timeout_raw = os.environ.get("OPENSPACE_MCP_PROXY_IDLE_TIMEOUT_SECONDS", "").strip()
if not timeout_raw:
timeout_raw = os.environ.get("OPENSPACE_MCP_IDLE_TIMEOUT_SECONDS", "").strip()
if timeout_raw:
try:
return int(timeout_raw)
except ValueError:
return 0
return 180
def _mark_proxy_request_start() -> None:
global _proxy_active_request_count, _proxy_last_activity_at
with _proxy_activity_lock:
_proxy_active_request_count += 1
_proxy_last_activity_at = time.monotonic()
def _mark_proxy_request_end() -> None:
global _proxy_active_request_count, _proxy_last_activity_at
with _proxy_activity_lock:
_proxy_active_request_count = max(0, _proxy_active_request_count - 1)
_proxy_last_activity_at = time.monotonic()
def _begin_proxy_shutdown(reason: str) -> None:
with contextlib.suppress(Exception):
os.kill(os.getpid(), signal.SIGTERM)
def _proxy_idle_watchdog_loop(idle_timeout_seconds: int) -> None:
check_interval = max(1, min(max(idle_timeout_seconds // 3, 1), 60))
while True:
time.sleep(check_interval)
with _proxy_activity_lock:
active = _proxy_active_request_count
idle_for = time.monotonic() - _proxy_last_activity_at
if active == 0 and idle_for >= idle_timeout_seconds:
_begin_proxy_shutdown(f"idle timeout after {idle_for:.1f}s")
return
def _maybe_start_proxy_idle_watchdog() -> None:
global _proxy_idle_watchdog_started
if _proxy_idle_watchdog_started:
return
idle_timeout_seconds = _proxy_idle_timeout_seconds()
if idle_timeout_seconds <= 0:
return
watchdog = threading.Thread(
target=_proxy_idle_watchdog_loop,
args=(idle_timeout_seconds,),
name="openspace-mcp-proxy-idle-watchdog",
daemon=True,
)
watchdog.start()
_proxy_idle_watchdog_started = True
def _json_error(error: Any, **extra: Any) -> str:
return json.dumps({"error": str(error), **extra}, ensure_ascii=False)
@ -74,17 +145,21 @@ class _RemoteProxyBase:
return asyncio.run(self._call_remote_tool_once(tool_name, args))
async def _call_remote_tool(self, tool_name: str, args: dict[str, Any]) -> str:
for attempt in range(2):
try:
return await asyncio.to_thread(
self._call_remote_tool_blocking,
tool_name,
args,
)
except Exception as exc:
if attempt == 1:
return _json_error(exc, status="error")
return _json_error("Unreachable proxy retry path", status="error")
_mark_proxy_request_start()
try:
for attempt in range(2):
try:
return await asyncio.to_thread(
self._call_remote_tool_blocking,
tool_name,
args,
)
except Exception as exc:
if attempt == 1:
return _json_error(exc, status="error")
return _json_error("Unreachable proxy retry path", status="error")
finally:
_mark_proxy_request_end()
class _MainProxyImplementation(_RemoteProxyBase):
@ -225,6 +300,7 @@ def _run_proxy(server_kind: ServerKind) -> None:
register_main_tools(mcp, _MainProxyImplementation())
else:
register_evolution_tools(mcp, _EvolutionProxyImplementation())
_maybe_start_proxy_idle_watchdog()
mcp.run(transport="stdio")

View file

@ -1179,8 +1179,13 @@ def run_mcp_server() -> None:
parser.add_argument("--port", type=int, default=8080)
args = parser.parse_args()
if args.transport == "stdio" or os.environ.get("OPENSPACE_MCP_DAEMON") == "1":
daemon_mode = os.environ.get("OPENSPACE_MCP_DAEMON") == "1"
if args.transport == "stdio" or daemon_mode:
_install_signal_handlers()
# Direct stdio servers should live and die with the host transport.
# Only shared daemons should reap themselves after an idle timeout.
if daemon_mode:
_maybe_start_idle_watchdog()
if args.transport == "streamable-http":
_maybe_start_main_daemon_embedding_prewarm()

View file

@ -242,6 +242,15 @@ def compute_daemon_identity(server_kind: ServerKind) -> MCPDaemonIdentity:
)
def _validate_daemon_identity(identity: MCPDaemonIdentity) -> None:
workspace = identity.workspace.strip()
if workspace == "/":
raise RuntimeError(
"Refusing to create a shared OpenSpace daemon for workspace=/. "
"Provide OPENSPACE_WORKSPACE explicitly or use direct proxy mode."
)
def _read_record(path: Path) -> MCPDaemonRecord | None:
if not path.is_file():
return None
@ -551,6 +560,7 @@ async def _wait_until_ready(record: MCPDaemonRecord, timeout_seconds: float = 15
async def ensure_daemon(server_kind: ServerKind) -> MCPDaemonRecord:
identity = compute_daemon_identity(server_kind)
_validate_daemon_identity(identity)
identity.metadata_path.parent.mkdir(parents=True, exist_ok=True)
with _FileLock(identity.lock_path):

View file

@ -0,0 +1,11 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from openspace.mcp_preflight import main
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -10,6 +10,11 @@ PROJECT_NAME="$(basename "$REPO_ROOT")"
PROJECT_SKILL_DIR="$PROFILE_HOME/projects/$PROJECT_NAME/skills"
REPO_PYTHON="$REPO_ROOT/.venv/bin/python"
if [[ "${1:-}" == "guard" ]]; then
shift
exec "$REPO_PYTHON" "$REPO_ROOT/scripts/codex_mcp_guard.py" "$@"
fi
if [[ ! -f "$PRIMARY_CODEX_HOME/config.toml" ]]; then
echo "Missing $PRIMARY_CODEX_HOME/config.toml" >&2
exit 1

View file

@ -7,6 +7,11 @@ ENV_FILE="$REPO_ROOT/openspace/.env"
PRIMARY_CODEX_HOME="${PRIMARY_CODEX_HOME:-$HOME/.codex}"
PROFILE_HOME="${CODEX_HOME:-$HOME/.codex-openspace}"
if [[ "${1:-}" == "guard" ]]; then
shift
exec "$REPO_ROOT/.venv/bin/python" "$REPO_ROOT/scripts/codex_mcp_guard.py" "$@"
fi
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing $ENV_FILE" >&2
exit 1

View file

@ -0,0 +1,13 @@
#!/usr/bin/env python3
from __future__ import annotations
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from openspace.codex_mcp_guard import main
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -28,10 +28,26 @@ if [[ ! -x "\$REPO_PYTHON" ]]; then
fi
workspace="\${OPENSPACE_WORKSPACE:-\$PWD}"
workspace_explicit=0
if [[ -n "\${OPENSPACE_WORKSPACE:-}" ]]; then
workspace_explicit=1
fi
if git_root="\$(git -C "\$workspace" rev-parse --show-toplevel 2>/dev/null)"; then
workspace="\$git_root"
fi
if [[ "\$workspace_explicit" == "0" && ( -z "\$workspace" || "\$workspace" == "/" || "\$workspace" == "." ) ]]; then
project_skill_dir="\${HOME}/.codex/projects/default/skills"
mkdir -p "\$project_skill_dir" "\${HOME}/.codex/skills"
unset OPENSPACE_WORKSPACE
export OPENSPACE_HOST_SKILL_DIRS="\${HOME}/.codex/projects/default/skills,\${HOME}/.codex/skills"
export OPENSPACE_MCP_PROXY_MODE="direct"
export OPENSPACE_MCP_DAEMON_STATE_DIR="\${OPENSPACE_MCP_DAEMON_STATE_DIR:-\${CODEX_HOME}/state/openspace}"
mkdir -p "\$OPENSPACE_MCP_DAEMON_STATE_DIR"
echo "Warning: Codex Desktop did not provide a usable workspace. Shared OpenSpace daemons were disabled for safety; workspace-aware tools must rely on explicit workspace_dir." >&2
exec "\$REPO_PYTHON" -m openspace.mcp_proxy --kind main --transport stdio
fi
project_name="\$(basename "\$workspace")"
if [[ -z "\$project_name" || "\$project_name" == "/" || "\$project_name" == "." ]]; then
project_name="default"
@ -63,10 +79,26 @@ if [[ ! -x "\$REPO_PYTHON" ]]; then
fi
workspace="\${OPENSPACE_WORKSPACE:-\$PWD}"
workspace_explicit=0
if [[ -n "\${OPENSPACE_WORKSPACE:-}" ]]; then
workspace_explicit=1
fi
if git_root="\$(git -C "\$workspace" rev-parse --show-toplevel 2>/dev/null)"; then
workspace="\$git_root"
fi
if [[ "\$workspace_explicit" == "0" && ( -z "\$workspace" || "\$workspace" == "/" || "\$workspace" == "." ) ]]; then
project_skill_dir="\${HOME}/.codex/projects/default/skills"
mkdir -p "\$project_skill_dir" "\${HOME}/.codex/skills"
unset OPENSPACE_WORKSPACE
export OPENSPACE_HOST_SKILL_DIRS="\${HOME}/.codex/projects/default/skills,\${HOME}/.codex/skills"
export OPENSPACE_MCP_PROXY_MODE="direct"
export OPENSPACE_MCP_DAEMON_STATE_DIR="\${OPENSPACE_MCP_DAEMON_STATE_DIR:-\${CODEX_HOME}/state/openspace}"
mkdir -p "\$OPENSPACE_MCP_DAEMON_STATE_DIR"
echo "Warning: Codex Desktop did not provide a usable workspace. Shared OpenSpace daemons were disabled for safety; workspace-aware tools must rely on explicit workspace_dir." >&2
exec "\$REPO_PYTHON" -m openspace.mcp_proxy --kind evolution --transport stdio
fi
export OPENSPACE_WORKSPACE="\$workspace"
project_name="\$(basename "\$OPENSPACE_WORKSPACE")"

View file

@ -0,0 +1,212 @@
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
from openspace import codex_mcp_guard as guard
def _host_row(pid: int = 10) -> dict[str, object]:
return {
"pid": pid,
"ppid": 1,
"etime": "10:00:00",
"command": "/Applications/Codex.app/Contents/Resources/codex app-server --analytics-default-enabled",
}
def test_build_snapshot_counts_target_processes_by_type() -> None:
snapshot = guard.build_snapshot(
process_rows=[
_host_row(),
{
"pid": 11,
"ppid": 10,
"etime": "09:00:00",
"command": "python -m openspace.mcp_proxy --kind main --transport stdio",
},
{
"pid": 12,
"ppid": 10,
"etime": "09:00:00",
"command": "python -m openspace.mcp_proxy --kind evolution --transport stdio",
},
{
"pid": 13,
"ppid": 10,
"etime": "08:00:00",
"command": "SkyComputerUseClient mcp",
},
{
"pid": 14,
"ppid": 99,
"etime": "08:00:00",
"command": "python -m openspace.mcp_proxy --kind main --transport stdio",
},
],
now_ts=1_700_000_000,
stale_age_seconds=3600,
warn_total_count=3,
threshold_total_count=4,
warn_stale_count=2,
threshold_stale_count=3,
)
assert snapshot["host"]["pid"] == 10
assert snapshot["counts"]["openspace_main"] == 1
assert snapshot["counts"]["openspace_evolution"] == 1
assert snapshot["counts"]["computer_use_mcp"] == 1
assert snapshot["counts"]["targets_total"] == 3
assert snapshot["stale"]["total"] == 3
assert snapshot["status"] == "threshold_exceeded"
assert snapshot["assessment"] == "host_lifecycle_leak_suspected"
def test_select_cleanup_candidates_targets_only_stale_allowlisted_children() -> None:
candidates = [
guard.ManagedChild(
pid=101,
ppid=10,
kind="openspace_main",
age_seconds=5000,
command="python -m openspace.mcp_proxy --kind main --transport stdio",
is_descendant=True,
),
guard.ManagedChild(
pid=102,
ppid=10,
kind="computer_use_mcp",
age_seconds=6000,
command="SkyComputerUseClient mcp",
is_descendant=True,
),
guard.ManagedChild(
pid=103,
ppid=10,
kind="openspace_main",
age_seconds=30,
command="python -m openspace.mcp_proxy --kind main --transport stdio",
is_descendant=True,
),
guard.ManagedChild(
pid=104,
ppid=10,
kind="other",
age_seconds=7000,
command="unrelated mcp",
is_descendant=True,
),
guard.ManagedChild(
pid=105,
ppid=44,
kind="openspace_evolution",
age_seconds=7000,
command="python -m openspace.mcp_proxy --kind evolution --transport stdio",
is_descendant=False,
),
]
selected = guard.select_cleanup_candidates(
candidates,
age_threshold_seconds=3600,
include_kinds={"openspace_main", "openspace_evolution", "computer_use_mcp"},
)
assert [item.pid for item in selected] == [102, 101]
def test_record_snapshot_writes_state_event_and_sample(tmp_path: Path) -> None:
guard_dir = tmp_path / "logs" / "codex_mcp_guard"
snapshot = {
"status": "warning",
"host": {"pid": 10},
"counts": {"targets_total": 5},
"stale": {"total": 4},
"assessment": "host_lifecycle_leak_suspected",
}
guard.record_snapshot(guard_dir, snapshot, event_type="check")
state = json.loads((guard_dir / "state.json").read_text(encoding="utf-8"))
events = (guard_dir / "events.jsonl").read_text(encoding="utf-8").strip().splitlines()
samples = (guard_dir / "samples.jsonl").read_text(encoding="utf-8").strip().splitlines()
assert state["status"] == "warning"
assert len(events) == 1
assert len(samples) == 1
assert json.loads(events[0])["type"] == "check"
def test_parser_accepts_json_after_subcommand() -> None:
parser = guard.build_parser()
args = parser.parse_args(["check", "--json"])
assert args.command == "check"
assert args.json is True
def _make_stub_repo(tmp_path: Path, launcher_name: str) -> tuple[Path, Path, Path]:
repo_root = tmp_path / "stub-repo"
scripts_dir = repo_root / "scripts"
scripts_dir.mkdir(parents=True)
source_launcher = Path(__file__).resolve().parents[1] / "scripts" / launcher_name
launcher_path = scripts_dir / launcher_name
launcher_path.write_text(source_launcher.read_text(encoding="utf-8"), encoding="utf-8")
launcher_path.chmod(0o755)
python_path = repo_root / ".venv" / "bin" / "python"
capture_path = tmp_path / f"{launcher_name}.json"
python_path.parent.mkdir(parents=True)
python_path.write_text(
"#!/usr/bin/env python3\n"
"import json, os, sys\n"
f"path = {str(capture_path)!r}\n"
"payload = {\n"
" 'argv': sys.argv,\n"
" 'cwd': os.getcwd(),\n"
"}\n"
"with open(path, 'w', encoding='utf-8') as fh:\n"
" json.dump(payload, fh)\n",
encoding="utf-8",
)
python_path.chmod(0o755)
return repo_root, launcher_path, capture_path
def test_codex_desktop_evolution_guard_subcommand_invokes_guard_script(tmp_path: Path) -> None:
repo_root, launcher_path, capture_path = _make_stub_repo(tmp_path, "codex-desktop-evolution")
env = os.environ.copy()
env.pop("OPENSPACE_CODEX_HOME", None)
env.pop("PRIMARY_CODEX_HOME", None)
subprocess.run(
[str(launcher_path), "guard", "status"],
check=True,
cwd=repo_root,
env=env,
capture_output=True,
text=True,
)
payload = json.loads(capture_path.read_text(encoding="utf-8"))
assert payload["argv"][1].endswith("/scripts/codex_mcp_guard.py")
assert payload["argv"][2:] == ["status"]
def test_codex_openspace_guard_subcommand_invokes_guard_script_without_env_file(tmp_path: Path) -> None:
repo_root, launcher_path, capture_path = _make_stub_repo(tmp_path, "codex-openspace")
subprocess.run(
[str(launcher_path), "guard", "check"],
check=True,
cwd=repo_root,
env=os.environ.copy(),
capture_output=True,
text=True,
)
payload = json.loads(capture_path.read_text(encoding="utf-8"))
assert payload["argv"][1].endswith("/scripts/codex_mcp_guard.py")
assert payload["argv"][2:] == ["check"]

View file

@ -0,0 +1,102 @@
from __future__ import annotations
import os
import subprocess
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
INSTALLER = REPO_ROOT / "scripts" / "install-global-codex-openspace"
def _install_wrappers(tmp_path: Path) -> tuple[Path, Path]:
codex_home = tmp_path / ".codex"
env = os.environ.copy()
env["CODEX_HOME"] = str(codex_home)
subprocess.run(
["bash", str(INSTALLER)],
check=True,
cwd=REPO_ROOT,
env=env,
capture_output=True,
text=True,
)
return codex_home / "bin" / "openspace-global-mcp", codex_home / "bin" / "openspace-evolution-global-mcp"
def _make_stub_repo(tmp_path: Path) -> tuple[Path, Path]:
repo_root = tmp_path / "stub-repo"
python_path = repo_root / ".venv" / "bin" / "python"
capture_path = tmp_path / "capture.txt"
python_path.parent.mkdir(parents=True)
python_path.write_text(
"#!/usr/bin/env python3\n"
"import json, os, sys\n"
f"path = {str(capture_path)!r}\n"
"payload = {\n"
" 'argv': sys.argv,\n"
" 'OPENSPACE_WORKSPACE': os.environ.get('OPENSPACE_WORKSPACE'),\n"
" 'OPENSPACE_MCP_PROXY_MODE': os.environ.get('OPENSPACE_MCP_PROXY_MODE'),\n"
" 'OPENSPACE_HOST_SKILL_DIRS': os.environ.get('OPENSPACE_HOST_SKILL_DIRS'),\n"
"}\n"
"with open(path, 'w', encoding='utf-8') as fh:\n"
" json.dump(payload, fh)\n",
encoding="utf-8",
)
python_path.chmod(0o755)
return repo_root, capture_path
def _rewrite_wrapper_repo_root(wrapper_path: Path, repo_root: Path) -> None:
text = wrapper_path.read_text(encoding="utf-8")
rewritten = text.replace(f'REPO_ROOT="{REPO_ROOT}"', f'REPO_ROOT="{repo_root}"')
wrapper_path.write_text(rewritten, encoding="utf-8")
def test_generated_wrapper_uses_direct_mode_when_workspace_is_invalid(tmp_path: Path) -> None:
main_wrapper, _ = _install_wrappers(tmp_path)
stub_repo, capture_path = _make_stub_repo(tmp_path)
_rewrite_wrapper_repo_root(main_wrapper, stub_repo)
env = os.environ.copy()
env.pop("OPENSPACE_WORKSPACE", None)
proc = subprocess.run(
[str(main_wrapper)],
check=True,
cwd="/",
env=env,
capture_output=True,
text=True,
)
payload = __import__("json").loads(capture_path.read_text(encoding="utf-8"))
assert payload["OPENSPACE_MCP_PROXY_MODE"] == "direct"
assert payload["OPENSPACE_WORKSPACE"] in (None, "")
assert payload["OPENSPACE_HOST_SKILL_DIRS"] == (
f"{Path.home() / '.codex' / 'projects' / 'default' / 'skills'},{Path.home() / '.codex' / 'skills'}"
)
assert "Codex Desktop did not provide a usable workspace" in proc.stderr
def test_generated_wrapper_keeps_daemon_mode_for_valid_workspace(tmp_path: Path) -> None:
_, evolution_wrapper = _install_wrappers(tmp_path)
stub_repo, capture_path = _make_stub_repo(tmp_path)
nested = stub_repo / "nested"
nested.mkdir(parents=True)
subprocess.run(["git", "init"], cwd=stub_repo, check=True, capture_output=True, text=True)
_rewrite_wrapper_repo_root(evolution_wrapper, stub_repo)
env = os.environ.copy()
env.pop("OPENSPACE_WORKSPACE", None)
subprocess.run(
[str(evolution_wrapper)],
check=True,
cwd=nested,
env=env,
capture_output=True,
text=True,
)
payload = __import__("json").loads(capture_path.read_text(encoding="utf-8"))
assert payload["OPENSPACE_MCP_PROXY_MODE"] == "daemon"
assert payload["OPENSPACE_WORKSPACE"] == str(stub_repo.resolve())

View file

@ -14,10 +14,11 @@ ENTRYPOINT_MODULES = [
@pytest.mark.parametrize("module_name", ENTRYPOINT_MODULES)
def test_stdio_entrypoint_uses_stdio_transport(module_name, monkeypatch) -> None:
def test_stdio_entrypoint_skips_idle_watchdog_outside_daemon(module_name, monkeypatch) -> None:
module = importlib.import_module(module_name)
calls: list[tuple[tuple[object, ...], dict[str, object]]] = []
watchdog_calls: list[bool] = []
signal_handler_calls: list[bool] = []
monkeypatch.setattr(
argparse.ArgumentParser,
@ -37,12 +38,14 @@ def test_stdio_entrypoint_uses_stdio_transport(module_name, monkeypatch) -> None
monkeypatch.setattr(
module,
"_install_signal_handlers",
lambda: None,
lambda: signal_handler_calls.append(True),
)
monkeypatch.delenv("OPENSPACE_MCP_DAEMON", raising=False)
module.run_mcp_server()
assert watchdog_calls == [True]
assert signal_handler_calls == [True]
assert watchdog_calls == []
assert calls == [((), {"transport": "stdio"})]
assert module.mcp.settings.port == 9123

202
tests/test_mcp_preflight.py Normal file
View file

@ -0,0 +1,202 @@
from __future__ import annotations
import json
import stat
from pathlib import Path
from types import SimpleNamespace
from openspace import mcp_preflight
from openspace.mcp_preflight import inspect_machine, probe_session, summarize_session_probe
def _write_executable(path: Path, content: str = "#!/bin/sh\nexit 0\n") -> None:
path.write_text(content, encoding="utf-8")
path.chmod(path.stat().st_mode | stat.S_IXUSR)
def test_inspect_machine_reports_ready_for_both_servers(tmp_path: Path) -> None:
workspace = tmp_path / "repo"
workspace.mkdir()
repo_python = workspace / ".venv" / "bin" / "python"
repo_python.parent.mkdir(parents=True)
_write_executable(repo_python)
codex_home = tmp_path / ".codex"
state_dir = codex_home / "state" / "openspace"
state_dir.mkdir(parents=True)
main_cmd = tmp_path / "openspace-global-mcp"
evolution_cmd = tmp_path / "openspace-evolution-global-mcp"
_write_executable(main_cmd)
_write_executable(evolution_cmd)
config_path = codex_home / "config.toml"
config_path.write_text(
f"""
[projects."{workspace.resolve()}"]
trust_level = "trusted"
[mcp_servers.openspace]
command = "{main_cmd}"
args = []
[mcp_servers.openspace_evolution]
command = "{evolution_cmd}"
args = []
""".strip()
+ "\n",
encoding="utf-8",
)
(state_dir / "main-test.json").write_text(
json.dumps(
{
"server_kind": "main",
"workspace": str(workspace.resolve()),
"ready": True,
"warmed": True,
}
),
encoding="utf-8",
)
(state_dir / "evolution-test.json").write_text(
json.dumps(
{
"server_kind": "evolution",
"workspace": str(workspace.resolve()),
"ready": True,
"warmed": False,
}
),
encoding="utf-8",
)
report = inspect_machine(cwd=workspace, config_path=config_path, codex_home=codex_home)
assert report["status"] == "ready"
assert report["project_trusted"] is True
assert report["repo_python"]["exists"] is True
assert report["servers"]["openspace"]["status"] == "ready"
assert report["servers"]["openspace"]["daemon"]["present"] is True
assert report["servers"]["openspace_evolution"]["status"] == "ready"
assert report["servers"]["openspace_evolution"]["daemon"]["ready"] is True
def test_inspect_machine_reports_partial_when_server_is_missing(tmp_path: Path) -> None:
workspace = tmp_path / "repo"
workspace.mkdir()
repo_python = workspace / ".venv" / "bin" / "python"
repo_python.parent.mkdir(parents=True)
_write_executable(repo_python)
codex_home = tmp_path / ".codex"
codex_home.mkdir()
main_cmd = tmp_path / "openspace-global-mcp"
_write_executable(main_cmd)
config_path = codex_home / "config.toml"
config_path.write_text(
f"""
[mcp_servers.openspace]
command = "{main_cmd}"
args = []
""".strip()
+ "\n",
encoding="utf-8",
)
report = inspect_machine(cwd=workspace, config_path=config_path, codex_home=codex_home)
assert report["status"] == "partial"
assert report["servers"]["openspace"]["status"] == "ready"
assert report["servers"]["openspace_evolution"]["status"] == "missing"
def test_summarize_session_probe_reports_ready_when_both_tools_are_observed() -> None:
report = summarize_session_probe(
exit_code=0,
mcp_tool_calls=[
{"server": "openspace", "tool": "search_skills", "status": "completed"},
{
"server": "openspace_evolution",
"tool": "evolve_from_context",
"status": "completed",
},
],
)
assert report["status"] == "ready"
assert report["servers"]["openspace"]["observed"] is True
assert report["servers"]["openspace_evolution"]["observed"] is True
def test_summarize_session_probe_reports_partial_when_only_one_tool_is_observed() -> None:
report = summarize_session_probe(
exit_code=0,
mcp_tool_calls=[
{"server": "openspace", "tool": "search_skills", "status": "completed"},
],
)
assert report["status"] == "partial"
assert report["servers"]["openspace"]["observed"] is True
assert report["servers"]["openspace_evolution"]["observed"] is False
def test_probe_session_omits_artifact_paths_after_cleanup(monkeypatch, tmp_path: Path) -> None:
workspace = tmp_path / "repo"
workspace.mkdir()
monkeypatch.setattr(mcp_preflight, "canonical_workspace", lambda cwd: workspace)
monkeypatch.setattr(mcp_preflight.shutil, "which", lambda binary: "/usr/bin/codex")
class _Completed:
returncode = 0
stdout = "{}\n"
stderr = ""
monkeypatch.setattr(mcp_preflight.subprocess, "run", lambda *args, **kwargs: _Completed())
monkeypatch.setattr(
mcp_preflight,
"parse_exec_output",
lambda output: SimpleNamespace(
thread_id="thread-123",
events=[
{
"type": "item.completed",
"item": {
"type": "mcp_tool_call",
"server": "openspace",
"tool": "search_skills",
"status": "completed",
"error": None,
},
},
{
"type": "item.completed",
"item": {
"type": "mcp_tool_call",
"server": "openspace_evolution",
"tool": "evolve_from_context",
"status": "completed",
"error": None,
},
},
],
agent_messages=["openspace-preflight done"],
),
)
monkeypatch.setattr(
mcp_preflight,
"collect_session_family",
lambda thread_id, sessions_root: SimpleNamespace(thread_ids={"thread-123"}, session_files=set()),
)
monkeypatch.setattr(mcp_preflight, "cleanup_session_artifacts", lambda **kwargs: None)
report = probe_session(cwd=workspace, codex_home=tmp_path / ".codex", keep_artifacts=False)
assert report["status"] == "ready"
assert report["stdout_path"] is None
assert report["stderr_path"] is None
assert report["session_files"] == []

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import threading
from pathlib import Path
from openspace import mcp_proxy
@ -19,6 +20,20 @@ def test_proxy_mode_defaults_follow_split_rollout(monkeypatch) -> None:
assert mcp_proxy._proxy_mode_for("evolution") == "daemon"
def test_proxy_idle_timeout_prefers_proxy_specific_env(monkeypatch) -> None:
monkeypatch.setenv("OPENSPACE_MCP_PROXY_IDLE_TIMEOUT_SECONDS", "7")
monkeypatch.setenv("OPENSPACE_MCP_IDLE_TIMEOUT_SECONDS", "900")
assert mcp_proxy._proxy_idle_timeout_seconds() == 7
def test_proxy_idle_timeout_defaults_to_180(monkeypatch) -> None:
monkeypatch.delenv("OPENSPACE_MCP_PROXY_IDLE_TIMEOUT_SECONDS", raising=False)
monkeypatch.delenv("OPENSPACE_MCP_IDLE_TIMEOUT_SECONDS", raising=False)
assert mcp_proxy._proxy_idle_timeout_seconds() == 180
def test_proxy_registration_is_lazy(monkeypatch) -> None:
async def _fail_if_called(server_kind):
raise AssertionError(f"ensure_daemon should not run during tool registration ({server_kind})")
@ -179,3 +194,64 @@ def test_update_current_daemon_status_marks_warmed(monkeypatch, tmp_path) -> Non
assert updated.ready is True
assert updated.warmed is True
assert updated.warmed_at is not None
def test_ensure_daemon_rejects_root_workspace(monkeypatch) -> None:
identity = shared_mcp_runtime.MCPDaemonIdentity(
server_kind="evolution",
workspace="/",
resolved_model="model",
llm_kwargs_fingerprint="llm",
backend_scope=("shell",),
host_skill_dirs=(),
grounding_config_fingerprint="cfg",
instance_key="root-key",
state_dir="/tmp/openspace-state",
)
monkeypatch.setattr(shared_mcp_runtime, "compute_daemon_identity", lambda kind: identity)
monkeypatch.setattr(
shared_mcp_runtime,
"_spawn_daemon",
lambda identity, port: (_ for _ in ()).throw(AssertionError("spawn should not be reached")),
)
try:
asyncio.run(shared_mcp_runtime.ensure_daemon("evolution"))
except RuntimeError as exc:
assert "workspace" in str(exc)
else:
raise AssertionError("ensure_daemon should reject workspace=/")
def test_proxy_idle_watchdog_exits_only_after_idle(monkeypatch) -> None:
shutdown_reasons: list[str] = []
sleep_calls = {"count": 0}
now = {"value": 100.0}
monkeypatch.setattr(mcp_proxy.time, "monotonic", lambda: now["value"])
monkeypatch.setattr(
mcp_proxy.time,
"sleep",
lambda seconds: sleep_calls.__setitem__("count", sleep_calls["count"] + 1) or now.__setitem__("value", now["value"] + seconds),
)
monkeypatch.setattr(mcp_proxy, "_begin_proxy_shutdown", lambda reason: shutdown_reasons.append(reason))
mcp_proxy._proxy_activity_lock = threading.Lock()
mcp_proxy._proxy_active_request_count = 1
mcp_proxy._proxy_last_activity_at = 100.0
def _drop_activity():
if sleep_calls["count"] == 1:
mcp_proxy._proxy_active_request_count = 0
mcp_proxy._proxy_last_activity_at = now["value"]
original_sleep = mcp_proxy.time.sleep
def _sleep(seconds):
original_sleep(seconds)
_drop_activity()
monkeypatch.setattr(mcp_proxy.time, "sleep", _sleep)
mcp_proxy._proxy_idle_watchdog_loop(5)
assert shutdown_reasons == ["idle timeout after 5.0s"]