From 4f16fd3c3b18516e2ec55645c8e9e548a6362c63 Mon Sep 17 00:00:00 2001 From: CCLCK <84182037+CCLCK@users.noreply.github.com> Date: Thu, 23 Apr 2026 02:16:05 +0800 Subject: [PATCH] Add Codex MCP guard and daemon lifecycle checks --- docs/global-codex-integration.md | 44 ++ .../plans/2026-04-19-codex-mcp-guard.md | 193 +++++++ .../2026-04-19-codex-mcp-guard-design.md | 204 +++++++ openspace/codex_mcp_guard.py | 529 ++++++++++++++++++ openspace/evolution_mcp_server.py | 7 +- openspace/mcp_preflight.py | 472 ++++++++++++++++ openspace/mcp_proxy.py | 98 +++- openspace/mcp_server.py | 7 +- openspace/shared_mcp_runtime.py | 10 + scripts/check_openspace_mcp_preflight.py | 11 + scripts/codex-desktop-evolution | 5 + scripts/codex-openspace | 5 + scripts/codex_mcp_guard.py | 13 + scripts/install-global-codex-openspace | 32 ++ tests/test_codex_mcp_guard.py | 212 +++++++ tests/test_global_mcp_wrapper_installation.py | 102 ++++ tests/test_mcp_entrypoints.py | 9 +- tests/test_mcp_preflight.py | 202 +++++++ tests/test_mcp_proxy_runtime.py | 76 +++ 19 files changed, 2215 insertions(+), 16 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-19-codex-mcp-guard.md create mode 100644 docs/superpowers/specs/2026-04-19-codex-mcp-guard-design.md create mode 100644 openspace/codex_mcp_guard.py create mode 100644 openspace/mcp_preflight.py create mode 100755 scripts/check_openspace_mcp_preflight.py create mode 100644 scripts/codex_mcp_guard.py create mode 100644 tests/test_codex_mcp_guard.py create mode 100644 tests/test_global_mcp_wrapper_installation.py create mode 100644 tests/test_mcp_preflight.py diff --git a/docs/global-codex-integration.md b/docs/global-codex-integration.md index 776b3ae..fdf1019 100644 --- a/docs/global-codex-integration.md +++ b/docs/global-codex-integration.md @@ -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//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=` 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 +``` + +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` diff --git a/docs/superpowers/plans/2026-04-19-codex-mcp-guard.md b/docs/superpowers/plans/2026-04-19-codex-mcp-guard.md new file mode 100644 index 0000000..c277275 --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-codex-mcp-guard.md @@ -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. diff --git a/docs/superpowers/specs/2026-04-19-codex-mcp-guard-design.md b/docs/superpowers/specs/2026-04-19-codex-mcp-guard-design.md new file mode 100644 index 0000000..14cc197 --- /dev/null +++ b/docs/superpowers/specs/2026-04-19-codex-mcp-guard-design.md @@ -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`. diff --git a/openspace/codex_mcp_guard.py b/openspace/codex_mcp_guard.py new file mode 100644 index 0000000..024308b --- /dev/null +++ b/openspace/codex_mcp_guard.py @@ -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", +] diff --git a/openspace/evolution_mcp_server.py b/openspace/evolution_mcp_server.py index 2587f04..467415b 100644 --- a/openspace/evolution_mcp_server.py +++ b/openspace/evolution_mcp_server.py @@ -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 diff --git a/openspace/mcp_preflight.py b/openspace/mcp_preflight.py new file mode 100644 index 0000000..4795f74 --- /dev/null +++ b/openspace/mcp_preflight.py @@ -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", +] diff --git a/openspace/mcp_proxy.py b/openspace/mcp_proxy.py index 814d317..19fd678 100644 --- a/openspace/mcp_proxy.py +++ b/openspace/mcp_proxy.py @@ -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") diff --git a/openspace/mcp_server.py b/openspace/mcp_server.py index 06b1a02..4a80c93 100644 --- a/openspace/mcp_server.py +++ b/openspace/mcp_server.py @@ -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() diff --git a/openspace/shared_mcp_runtime.py b/openspace/shared_mcp_runtime.py index 971b6b2..a539b37 100644 --- a/openspace/shared_mcp_runtime.py +++ b/openspace/shared_mcp_runtime.py @@ -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): diff --git a/scripts/check_openspace_mcp_preflight.py b/scripts/check_openspace_mcp_preflight.py new file mode 100755 index 0000000..69d256f --- /dev/null +++ b/scripts/check_openspace_mcp_preflight.py @@ -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()) diff --git a/scripts/codex-desktop-evolution b/scripts/codex-desktop-evolution index 6ff8dfe..b2211cb 100755 --- a/scripts/codex-desktop-evolution +++ b/scripts/codex-desktop-evolution @@ -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 diff --git a/scripts/codex-openspace b/scripts/codex-openspace index cb7bccf..8e7ec6d 100755 --- a/scripts/codex-openspace +++ b/scripts/codex-openspace @@ -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 diff --git a/scripts/codex_mcp_guard.py b/scripts/codex_mcp_guard.py new file mode 100644 index 0000000..c4145a7 --- /dev/null +++ b/scripts/codex_mcp_guard.py @@ -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()) diff --git a/scripts/install-global-codex-openspace b/scripts/install-global-codex-openspace index 0155d7c..c045fa5 100644 --- a/scripts/install-global-codex-openspace +++ b/scripts/install-global-codex-openspace @@ -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")" diff --git a/tests/test_codex_mcp_guard.py b/tests/test_codex_mcp_guard.py new file mode 100644 index 0000000..2ab4106 --- /dev/null +++ b/tests/test_codex_mcp_guard.py @@ -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"] diff --git a/tests/test_global_mcp_wrapper_installation.py b/tests/test_global_mcp_wrapper_installation.py new file mode 100644 index 0000000..b5b5de4 --- /dev/null +++ b/tests/test_global_mcp_wrapper_installation.py @@ -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()) diff --git a/tests/test_mcp_entrypoints.py b/tests/test_mcp_entrypoints.py index ba03e4a..2bc9594 100644 --- a/tests/test_mcp_entrypoints.py +++ b/tests/test_mcp_entrypoints.py @@ -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 diff --git a/tests/test_mcp_preflight.py b/tests/test_mcp_preflight.py new file mode 100644 index 0000000..aea9c5d --- /dev/null +++ b/tests/test_mcp_preflight.py @@ -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"] == [] diff --git a/tests/test_mcp_proxy_runtime.py b/tests/test_mcp_proxy_runtime.py index 210622e..59fd0f9 100644 --- a/tests/test_mcp_proxy_runtime.py +++ b/tests/test_mcp_proxy_runtime.py @@ -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"]