mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-08-28 05:15:00 +00:00
Add Codex MCP guard and daemon lifecycle checks
This commit is contained in:
parent
130a780e7f
commit
4f16fd3c3b
19 changed files with 2215 additions and 16 deletions
|
|
@ -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`
|
||||
|
|
|
|||
193
docs/superpowers/plans/2026-04-19-codex-mcp-guard.md
Normal file
193
docs/superpowers/plans/2026-04-19-codex-mcp-guard.md
Normal 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.
|
||||
204
docs/superpowers/specs/2026-04-19-codex-mcp-guard-design.md
Normal file
204
docs/superpowers/specs/2026-04-19-codex-mcp-guard-design.md
Normal 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`.
|
||||
529
openspace/codex_mcp_guard.py
Normal file
529
openspace/codex_mcp_guard.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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
472
openspace/mcp_preflight.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
11
scripts/check_openspace_mcp_preflight.py
Executable file
11
scripts/check_openspace_mcp_preflight.py
Executable 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())
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
13
scripts/codex_mcp_guard.py
Normal file
13
scripts/codex_mcp_guard.py
Normal 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())
|
||||
|
|
@ -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")"
|
||||
|
|
|
|||
212
tests/test_codex_mcp_guard.py
Normal file
212
tests/test_codex_mcp_guard.py
Normal 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"]
|
||||
102
tests/test_global_mcp_wrapper_installation.py
Normal file
102
tests/test_global_mcp_wrapper_installation.py
Normal 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())
|
||||
|
|
@ -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
202
tests/test_mcp_preflight.py
Normal 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"] == []
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue