diff --git a/TODO.md b/TODO.md index b7c03ff..2e15d57 100644 --- a/TODO.md +++ b/TODO.md @@ -8,6 +8,10 @@ Ostatni przegląd: 2026-07-23. ## Aktywne +- [ ] Dostarczyć [ticket-010](project/ticket-010/README.md): poprawić wykrywanie + głównej JVM JetBrains i dodać bezpieczne sterowanie dokładnymi wtyczkami AI + oraz helperami Qoder bez zamykania okien IDE. + - [x] Dostarczyć [ticket-009](project/ticket-009/README.md): dodać trwałe, zarządzalne przypięcia katalogów projektów Compose, które pozostają widoczne w diagnostyce, ale są chronione przed czyszczeniem osieroconych obciążeń. diff --git a/fixos/cli/jetbrains_cmd.py b/fixos/cli/jetbrains_cmd.py index 230076d..85e08f9 100644 --- a/fixos/cli/jetbrains_cmd.py +++ b/fixos/cli/jetbrains_cmd.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from pathlib import Path from typing import Any import click @@ -13,6 +14,7 @@ JetBrainsRecoveryResult, JetBrainsRecoverySafetyError, ) +from fixos.diagnostics.jetbrains_ai import JetBrainsAiControl, JetBrainsAiSafetyError def _diagnosis_payload(diagnosis: JetBrainsDiagnosis) -> dict[str, Any]: @@ -234,3 +236,123 @@ def doctor( if applied: status = "potwierdzona poprawa" if applied.verified_improvement else "brak potwierdzonej poprawy" click.echo(f"GC.run wykonano: {status}.") + + +def _render_ai_status(payload: dict[str, Any]) -> None: + click.echo(click.style("JetBrains AI — stan", fg="yellow", bold=True)) + helpers = payload["helpers"] + if helpers: + for helper in helpers: + click.echo( + f" Qoder PID {helper['pid']} (rodzic {helper['ppid']}), " + f"RSS={helper['rss_bytes'] / 1024**2:.1f} MB" + ) + else: + click.echo(" Brak aktywnych helperów Qoder.") + configs = payload["configs"] + if not configs: + click.echo(" Nie znaleziono konfiguracji aktywnego produktu JetBrains.") + for config in configs: + disabled = ", ".join(config["disabled"]) or "brak" + click.echo(f" {config['directory']}: wyłączone AI: {disabled}") + + +@jetbrains.command(name="ai") +@click.option( + "--config-dir", + type=click.Path(path_type=Path, file_okay=False, exists=True), + help="Dokładny katalog konfiguracji produktu JetBrains; domyślnie aktywny produkt", +) +@click.option( + "--disable-plugins", + is_flag=True, + help="Dodaj com.qoder i com.intellij.ml.llm do disabled_plugins.txt", +) +@click.option( + "--stop-qoder", + is_flag=True, + help="Zakończ TERM wyłącznie helpery Qoder należące do głównego IDE", +) +@click.option( + "--apply", + is_flag=True, + help="Wykonaj wybrane zmiany; bez tej opcji pokazuje tylko plan", +) +@click.option("--yes", is_flag=True, help="Pomiń potwierdzenie z --apply") +@click.option("--json", "json_output", is_flag=True, help="Zwróć wynik JSON") +def ai_control( + config_dir: Path | None, + disable_plugins: bool, + stop_qoder: bool, + apply: bool, + yes: bool, + json_output: bool, +) -> None: + """Diagnozuj i ogranicz dodatki AI bez zamykania okien IDE.""" + if apply and not (disable_plugins or stop_qoder): + raise click.UsageError("--apply wymaga --disable-plugins lub --stop-qoder") + control = JetBrainsAiControl() + try: + before = control.status(config_dir=config_dir) + except JetBrainsAiSafetyError as exc: + raise click.ClickException(str(exc)) from exc + + if not (disable_plugins or stop_qoder): + if json_output: + click.echo(json.dumps(before, indent=2)) + else: + _render_ai_status(before) + return + if apply and not yes and not click.confirm( + "Wyłączyć wskazane wtyczki i/lub zakończyć dokładne helpery Qoder?", + default=False, + ): + click.echo("Pominięto zmiany JetBrains AI.") + return + + result: dict[str, Any] = { + "service": "jetbrains-ai-control", + "dry_run": not apply, + "plugin_changes": [], + "helper_change": None, + } + try: + if disable_plugins: + if not before["configs"]: + raise JetBrainsAiSafetyError( + "no exact JetBrains config directory was discovered" + ) + result["plugin_changes"] = [ + control.disable_plugins( + Path(config["directory"]), + apply=apply, + ) + for config in before["configs"] + ] + if stop_qoder: + result["helper_change"] = control.stop_qoder_helpers( + [ + (helper["pid"], helper["create_time"]) + for helper in before["helpers"] + ], + apply=apply, + ) + result["after"] = control.status(config_dir=config_dir) + except (JetBrainsAiSafetyError, ValueError) as exc: + raise click.ClickException(str(exc)) from exc + + if json_output: + click.echo(json.dumps(result, indent=2)) + return + mode = "WYKONANO" if apply else "PLAN" + click.echo(click.style(f"JetBrains AI [{mode}]", fg="green" if apply else "cyan")) + for change in result["plugin_changes"]: + added = ", ".join(change["added"]) or "brak nowych wpisów" + click.echo(f" Wtyczki: {added}; restart IDE wymagany do pełnego efektu.") + if result["helper_change"] is not None: + helper = result["helper_change"] + click.echo( + f" Qoder: wybrane={helper['selected']}, zatrzymane={helper['stopped']}, " + f"błędy={len(helper['failed'])}" + ) + click.echo(" Główna JVM i okna IDE nie są zatrzymywane.") diff --git a/fixos/diagnostics/jetbrains_ai.py b/fixos/diagnostics/jetbrains_ai.py new file mode 100644 index 0000000..51da2b3 --- /dev/null +++ b/fixos/diagnostics/jetbrains_ai.py @@ -0,0 +1,294 @@ +"""Exact, window-preserving control of JetBrains AI plugins and Qoder helpers.""" + +from __future__ import annotations + +import os +import tempfile +import time +from collections.abc import Callable, Collection, Sequence +from pathlib import Path +from typing import Any + +import psutil + +from fixos.diagnostics.jetbrains_recovery import ( + JETBRAINS_PRODUCTS, + is_main_jetbrains_process, + jetbrains_product_marker, +) +from fixos.diagnostics.process_chains import ProcessRecord, collect_processes + + +AI_PLUGIN_IDS = ("com.intellij.ml.llm", "com.qoder") + + +class JetBrainsAiSafetyError(RuntimeError): + """Raised when an AI-control target cannot be proven exact and safe.""" + + +class JetBrainsAiControl: + """Inspect or explicitly disable AI plugins and stop exact Qoder helpers.""" + + def __init__( + self, + *, + process_provider: Callable[[], Sequence[ProcessRecord]] | None = None, + config_root: Path | None = None, + identity_provider: Callable[[int], float | None] | None = None, + rss_provider: Callable[[int], int] | None = None, + terminator: Callable[[int], None] | None = None, + alive_provider: Callable[[int, float], bool] | None = None, + clock: Callable[[], float] = time.monotonic, + sleeper: Callable[[float], None] = time.sleep, + ) -> None: + self._process_provider = process_provider or collect_processes + self._config_root = config_root or Path.home() / ".config" / "JetBrains" + self._identity_provider = identity_provider or self._identity + self._rss_provider = rss_provider or self._rss + self._terminator = terminator or self._terminate + self._alive_provider = alive_provider or self._alive + self._clock = clock + self._sleeper = sleeper + + @staticmethod + def _identity(pid: int) -> float | None: + try: + return psutil.Process(pid).create_time() + except (psutil.Error, OSError): + return None + + @staticmethod + def _rss(pid: int) -> int: + try: + return int(psutil.Process(pid).memory_info().rss) + except (psutil.Error, OSError): + return 0 + + @staticmethod + def _terminate(pid: int) -> None: + psutil.Process(pid).terminate() + + @staticmethod + def _alive(pid: int, expected_create_time: float) -> bool: + try: + process = psutil.Process(pid) + return ( + abs(process.create_time() - expected_create_time) < 0.001 + and process.is_running() + and process.status() != psutil.STATUS_ZOMBIE + ) + except psutil.AccessDenied: + return True + except (psutil.NoSuchProcess, psutil.ZombieProcess, OSError): + return False + + @staticmethod + def _is_qoder_helper( + process: ProcessRecord, by_pid: dict[int, ProcessRecord] + ) -> bool: + command = process.cmdline or (process.name,) + if Path(command[0]).name.casefold() != "qoder": + return False + if len(command) < 2 or command[1].casefold() != "start": + return False + parent = by_pid.get(process.ppid) + return parent is not None and is_main_jetbrains_process(parent) + + def find_qoder_helpers( + self, records: Sequence[ProcessRecord] | None = None + ) -> list[ProcessRecord]: + snapshot = list(records) if records is not None else list(self._process_provider()) + by_pid = {process.pid: process for process in snapshot} + return sorted( + ( + process + for process in snapshot + if self._is_qoder_helper(process, by_pid) + ), + key=lambda process: process.pid, + ) + + @staticmethod + def _validate_config_dir(config_dir: Path) -> Path: + path = config_dir.expanduser().absolute() + if path.parent.name != "JetBrains": + raise JetBrainsAiSafetyError("config directory is not under JetBrains") + if not any(path.name.startswith(prefix) for prefix in JETBRAINS_PRODUCTS.values()): + raise JetBrainsAiSafetyError("config directory is not a JetBrains product") + if not path.is_dir(): + raise JetBrainsAiSafetyError("JetBrains config directory does not exist") + return path + + def find_config_dirs( + self, + records: Sequence[ProcessRecord] | None = None, + explicit: Path | None = None, + ) -> list[Path]: + if explicit is not None: + return [self._validate_config_dir(explicit)] + snapshot = list(records) if records is not None else list(self._process_provider()) + prefixes = { + JETBRAINS_PRODUCTS[marker] + for process in snapshot + if (marker := jetbrains_product_marker(process)) is not None + } + found: list[Path] = [] + for prefix in sorted(prefixes): + candidates = [ + path + for path in self._config_root.glob(f"{prefix}*") + if path.is_dir() + ] + if candidates: + found.append(max(candidates, key=lambda path: path.name)) + return found + + @staticmethod + def _disabled_plugins(config_dir: Path) -> tuple[str, ...]: + path = config_dir / "disabled_plugins.txt" + try: + return tuple( + line.strip() + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() + ) + except FileNotFoundError: + return () + except OSError as exc: + raise JetBrainsAiSafetyError( + f"cannot read disabled plugin configuration: {exc}" + ) from exc + + def status(self, *, config_dir: Path | None = None) -> dict[str, Any]: + snapshot = list(self._process_provider()) + helpers = self.find_qoder_helpers(snapshot) + configs = self.find_config_dirs(snapshot, explicit=config_dir) + return { + "service": "jetbrains-ai-control", + "read_only": True, + "plugin_ids": list(AI_PLUGIN_IDS), + "helpers": [ + { + "pid": helper.pid, + "ppid": helper.ppid, + "create_time": helper.create_time, + "command": list(helper.cmdline), + "rss_bytes": self._rss_provider(helper.pid), + } + for helper in helpers + ], + "configs": [ + { + "directory": str(path), + "disabled_plugins_file": str(path / "disabled_plugins.txt"), + "disabled": [ + plugin + for plugin in AI_PLUGIN_IDS + if plugin in self._disabled_plugins(path) + ], + } + for path in configs + ], + } + + def disable_plugins(self, config_dir: Path, *, apply: bool) -> dict[str, Any]: + directory = self._validate_config_dir(config_dir) + path = directory / "disabled_plugins.txt" + entries = list(self._disabled_plugins(directory)) + added = [plugin for plugin in AI_PLUGIN_IDS if plugin not in entries] + if apply and added: + self._atomic_write(path, [*entries, *added]) + return { + "config_dir": str(directory), + "plugin_ids": list(AI_PLUGIN_IDS), + "added": added, + "changed": bool(apply and added), + "dry_run": not apply, + "restart_required": bool(added) or apply, + } + + @staticmethod + def _atomic_write(path: Path, entries: Collection[str]) -> None: + temporary: Path | None = None + try: + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + delete=False, + ) as handle: + temporary = Path(handle.name) + if path.exists(): + os.chmod(temporary, path.stat().st_mode & 0o777) + for entry in entries: + handle.write(f"{entry}\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + except OSError as exc: + raise JetBrainsAiSafetyError( + f"cannot update disabled plugin configuration: {exc}" + ) from exc + finally: + if temporary is not None and temporary.exists(): + temporary.unlink() + + def stop_qoder_helpers( + self, + identities: Collection[tuple[int, float]], + *, + apply: bool, + grace_seconds: float = 5.0, + ) -> dict[str, Any]: + if grace_seconds < 0: + raise ValueError("grace_seconds must be non-negative") + fresh = { + (helper.pid, helper.create_time): helper + for helper in self.find_qoder_helpers() + } + stopped: list[int] = [] + failed: list[dict[str, Any]] = [] + selected: list[tuple[ProcessRecord, float]] = [] + for pid, create_time in sorted(set(identities)): + helper = fresh.get((int(pid), float(create_time))) + current_identity = self._identity_provider(int(pid)) + if ( + helper is None + or current_identity is None + or abs(current_identity - float(create_time)) >= 0.001 + ): + failed.append({"pid": pid, "error": "helper identity is no longer exact"}) + continue + selected.append((helper, float(create_time))) + if apply: + for helper, _ in selected: + try: + self._terminator(helper.pid) + except (OSError, psutil.Error) as exc: + failed.append({"pid": helper.pid, "error": str(exc)}) + deadline = self._clock() + grace_seconds + pending = list(selected) + while pending and self._clock() < deadline: + pending = [ + item + for item in pending + if self._alive_provider(item[0].pid, item[1]) + ] + if pending: + self._sleeper(min(0.1, max(0.0, deadline - self._clock()))) + for helper, create_time in selected: + if self._alive_provider(helper.pid, create_time): + failed.append( + {"pid": helper.pid, "error": "helper did not exit after TERM"} + ) + elif not any(item["pid"] == helper.pid for item in failed): + stopped.append(helper.pid) + return { + "dry_run": not apply, + "selected": [helper.pid for helper, _ in selected], + "stopped": stopped, + "failed": failed, + "success": not failed, + } diff --git a/fixos/diagnostics/jetbrains_recovery.py b/fixos/diagnostics/jetbrains_recovery.py index 8c46fc4..45a4c32 100644 --- a/fixos/diagnostics/jetbrains_recovery.py +++ b/fixos/diagnostics/jetbrains_recovery.py @@ -148,11 +148,24 @@ class JetBrainsRecoveryResult: def is_main_jetbrains_process(process: ProcessRecord) -> bool: """Return true only for a main IDE process, never a helper/server.""" - tokens = (process.name, *process.cmdline) - normalized = " ".join(tokens).casefold() - if any(helper in normalized for helper in JETBRAINS_HELPERS): - return False - return any(product in normalized for product in JETBRAINS_PRODUCTS) + return jetbrains_product_marker(process) is not None + + +def jetbrains_product_marker(process: ProcessRecord) -> str | None: + """Identify a product only from its launcher, never arbitrary arguments.""" + + command = process.cmdline or (process.name,) + launcher = Path(command[0]).name.casefold().removesuffix(".exe") + process_name = Path(process.name).name.casefold().removesuffix(".exe") + if launcher in JETBRAINS_HELPERS or process_name in JETBRAINS_HELPERS: + return None + if any(str(argument).casefold() == "stdiomcpserver" for argument in command[1:]): + return None + for product in JETBRAINS_PRODUCTS: + accepted = {product, f"{product}64", f"{product}.sh"} + if launcher in accepted or process_name in accepted: + return product + return None def analyze_idea_log( @@ -332,11 +345,8 @@ def find_main_processes( @staticmethod def _product_prefix(process: ProcessRecord) -> str | None: - normalized = " ".join((process.name, *process.cmdline)).casefold() - for marker, prefix in JETBRAINS_PRODUCTS.items(): - if marker in normalized: - return prefix - return None + marker = jetbrains_product_marker(process) + return JETBRAINS_PRODUCTS.get(marker) if marker else None def _discover_log(self, process: ProcessRecord) -> Path | None: prefix = self._product_prefix(process) diff --git a/project/TICKETS.md b/project/TICKETS.md index cff44e5..e5fed66 100644 --- a/project/TICKETS.md +++ b/project/TICKETS.md @@ -15,4 +15,5 @@ This file indexes governance tickets without taking ownership of | **ticket-007** | [`README.md`](./ticket-007/README.md) | [`preprompt.md`](./ticket-007/preprompt.md) | - | [`ai-codex.md`](./ticket-007/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-007/ai-codex-logs.txt) | [`changelog.md`](./ticket-007/changelog.md) | | **ticket-008** | [`README.md`](./ticket-008/README.md) | [`preprompt.md`](./ticket-008/preprompt.md) | - | [`ai-codex.md`](./ticket-008/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-008/ai-codex-logs.txt) | [`changelog.md`](./ticket-008/changelog.md) | | **ticket-009** | [`README.md`](./ticket-009/README.md) | [`preprompt.md`](./ticket-009/preprompt.md) | - | [`ai-codex.md`](./ticket-009/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-009/ai-codex-logs.txt) | [`changelog.md`](./ticket-009/changelog.md) | +| **ticket-010** | [`README.md`](./ticket-010/README.md) | [`preprompt.md`](./ticket-010/preprompt.md) | - | [`ai-codex.md`](./ticket-010/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-010/ai-codex-logs.txt) | [`changelog.md`](./ticket-010/changelog.md) | diff --git a/project/ticket-010/README.md b/project/ticket-010/README.md new file mode 100644 index 0000000..9ca767e --- /dev/null +++ b/project/ticket-010/README.md @@ -0,0 +1,36 @@ +# Ticket 010: Harden JetBrains AI helper control + +- **ID**: ticket-010 +- **Owner**: unresolved:human +- **Status**: IN_PROGRESS +- **Workflow state**: PUBLICATION +- **Created**: 2026-08-20 + +## Goal and scope + +Correct JetBrains main-process discovery so terminal commands and helpers are +never diagnosed as IDE JVMs. Add a read-only-by-default AI control that can +persistently disable the exact Qoder and JetBrains AI Assistant plugin IDs and +gracefully stop only identity-verified Qoder helpers, without terminating the +main JVM or closing IDE windows. + +## Acceptance criteria + +- [x] AC-01: Main IDE discovery uses the executable identity, not arbitrary + command arguments, and excludes terminal shells plus MCP/native helpers. +- [x] AC-02: `fixos jetbrains ai` reports exact active Qoder helpers, inferred + JetBrains configuration and disabled-plugin state without mutation by default. +- [x] AC-03: Plugin changes target only `com.qoder` and `com.intellij.ml.llm`, + use atomic writes, preserve unrelated entries and require explicit apply. +- [x] AC-04: Helper termination revalidates PID creation time, executable, + parent relationship and parent main-IDE identity before sending TERM. +- [x] AC-05: Human/JSON output, dry-run, confirmation, idempotence and refusal + paths are covered by deterministic tests. +- [x] AC-06: Live verification reports only PyCharm PID 143907, confirms exact + Qoder respawns can be stopped and both plugin IDs are disabled for the next + IDE start; full validation passes. + +## Participants + +- Human participant: unresolved; no user-* file was created by this script. +- Agent participant: [ai-codex.md](ai-codex.md) diff --git a/project/ticket-010/ai-codex-logs.txt b/project/ticket-010/ai-codex-logs.txt new file mode 100644 index 0000000..8cd639b --- /dev/null +++ b/project/ticket-010/ai-codex-logs.txt @@ -0,0 +1,21 @@ +2026-08-20 focused validation +pytest -q tests/unit/test_jetbrains_recovery.py tests/unit/test_jetbrains_cmd.py +25 passed, 2 warnings + +2026-08-20 live diagnosis and control +fixos jetbrains doctor --pid 143907 --no-thread-dump --json +diagnosis_count=1 pids=[143907] reasons=[ai-quota-refresh-loop, high-ide-cpu] + +fixos jetbrains ai --disable-plugins --stop-qoder --apply --yes --json +disabled=[com.intellij.ml.llm, com.qoder] +stopped exact respawn pids=[3221424, 3290938] +main PyCharm pid=143907 remained alive; no JVM/window signal was sent +The already-loaded Qoder plugin respawns until the next ordinary IDE restart. + +2026-08-20 full validation +pytest -q +535 passed, 5 skipped, 16 deselected, 3 warnings +ruff check changed Python files: passed +python -m compileall -q fixos: passed +./project/governance-check.sh +GOV-PASS: passed (0 errors, 0 warnings) diff --git a/project/ticket-010/ai-codex.md b/project/ticket-010/ai-codex.md new file mode 100644 index 0000000..d90265a --- /dev/null +++ b/project/ticket-010/ai-codex.md @@ -0,0 +1,56 @@ +--- +participant-id: agent:codex +participant: codex +role: agent +ticket: ticket-010 +--- +# Participant: codex (AI agent) + +## Understanding + +Live diagnosis found a Qoder child using about 1.2 GB RSS and 10--11% CPU and a +separate JetBrains AI quota failure every 23 seconds in the main JVM. Qoder was +already pending disablement but remained loaded in the long-running IDE. The +doctor also falsely selected terminal shells because it searched every command +argument for the word `pycharm`. + +## Execution plan + +1. Match a main JetBrains process only from its launcher identity. +2. Add an evidence-led AI status/disable/helper-stop service with exact identity + revalidation and atomic plugin configuration writes. +3. Expose it as a read-only-by-default `fixos jetbrains ai` command. +4. Add deterministic discovery, dry-run, mutation, idempotence and safety tests. +5. Verify live state without closing windows and publish through Validator App. + +## Actual changes + +- Initialized the bounded ticket and recorded SESSION_EXECUTION_AUTHORIZATION + from the request to execute this work. +- Recorded authorization to stop the exact Qoder helper and disable the two + confirmed plugin IDs while preserving the main IDE JVM and every open window. +- Before implementation, revalidated and terminated Qoder PID 156099 with TERM. + PyCharm PID 143907 remained alive and Qoder did not restart. +- Added `com.intellij.ml.llm` to the existing disabled plugin configuration; + `com.qoder` was already present. This takes full effect at the next IDE start. +- Replaced command-text matching with exact JetBrains launcher matching. Live + doctor output now contains only the real PyCharm PID 143907 instead of shell, + terminal, MCP and diagnostic-command false positives. +- Added `fixos jetbrains ai` status, dry-run and explicit-apply flows. It infers + only active-product configuration, atomically preserves disabled plugin + entries and revalidates Qoder PID, creation time, executable, parent PID and + parent IDE identity before TERM. It never uses SIGKILL or signals the JVM. +- Live application discovered that the already-loaded plugin respawns Qoder + about once per minute until IDE restart. FixOS successfully stopped exact + respawn PIDs 3221424 and 3290938; PyCharm PID 143907 stayed alive. Both plugin + IDs are persistently disabled, so respawn and the quota loop will not return + after the next ordinary IDE start. +- Focused validation passes 25 tests. Full validation passes 535 tests with 5 + skipped and 16 deselected; scoped Ruff, compileall and governance are clean. +- Advanced the validated implementation to PUBLICATION for exact-head review. + +## Blockers + +- None inside the recorded intent; proceed without a second confirmation. +- New authority remains required for destructive action, secret access, new + external coordination, material objective expansion and trusted merge. diff --git a/project/ticket-010/changelog.md b/project/ticket-010/changelog.md new file mode 100644 index 0000000..371ddab --- /dev/null +++ b/project/ticket-010/changelog.md @@ -0,0 +1,13 @@ +# Ticket Changelog (ticket-010) + +## [0.1.0] - 2026-08-20 + +- Initial governance scaffold created. +- No human participant identity or content was generated. +- Accepted the bounded main-process discovery and AI helper-control repair. +- Recorded the successful live Qoder stop and pending plugin disablement without + closing or restarting the PyCharm JVM. +- Fixed false-positive main-JVM discovery from terminal and command arguments. +- Added read-only status plus explicit plugin-disable and exact Qoder TERM flows. +- Verified both disabled plugin IDs, one exact PyCharm diagnosis and safe handling + of helpers respawned by the already-loaded plugin until the next IDE restart. diff --git a/project/ticket-010/intent.json b/project/ticket-010/intent.json new file mode 100644 index 0000000..15c5b34 --- /dev/null +++ b/project/ticket-010/intent.json @@ -0,0 +1,109 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-010", + "summary": "Harden JetBrains AI helper control", + "workstream": "application", + "classification": { + "kind": "BUG", + "priority": "P0", + "origin": "requested" + }, + "allowedPaths": [ + "project/ticket-010/**", + "TODO.md", + "project/TICKETS.md", + "fixos/diagnostics/jetbrains_recovery.py", + "fixos/diagnostics/jetbrains_ai.py", + "fixos/cli/jetbrains_cmd.py", + "tests/unit/test_jetbrains_recovery.py", + "tests/unit/test_jetbrains_cmd.py" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md", + "docs/**", + "README.md", + "CHANGELOG.md", + "VERSION", + "pyproject.toml", + "uv.lock", + ".env", + "**/*.pem", + "**/*secret*" + ], + "stacks": ["python"], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "3f52751b4f7f745091ba6fa59aee0c371ffeffa8", + "targetBranch": "main", + "outcome": "FixOS diagnoses only real JetBrains JVMs and safely controls exact AI plugins and Qoder helpers without closing IDE windows", + "nonGoals": [ + "No main JetBrains JVM or IDE window termination", + "No automatic IDE restart or live plugin unload", + "No plugin directory deletion", + "No SIGKILL escalation", + "No runtime dependency or public API path change" + ], + "complexity": "S", + "estimatedMinutes": 30, + "budgets": { + "maxImplementationFiles": 5, + "maxAffectedComponents": 2, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Keep executable identity in JetBrainsRecovery and isolate exact plugin/helper mutation in a dedicated service behind a thin Click adapter", + "components": [ + { + "name": "jetbrains-process-safety", + "paths": [ + "fixos/diagnostics/jetbrains_recovery.py", + "tests/unit/test_jetbrains_recovery.py" + ] + }, + { + "name": "jetbrains-ai-control", + "paths": [ + "fixos/diagnostics/jetbrains_ai.py", + "fixos/cli/jetbrains_cmd.py", + "tests/unit/test_jetbrains_cmd.py" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [], + "ui": { + "impact": "multi-state", + "states": ["empty", "error", "success"], + "evidence": ["focused CLI tests", "live read-only status"] + }, + "rollback": "Revert exact launcher matching, AI control service, CLI adapter and focused tests; existing disabled-plugin lines remain valid JetBrains configuration" + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-05", + "commands": [ + "pytest -q tests/unit/test_jetbrains_recovery.py tests/unit/test_jetbrains_cmd.py", + "fixos jetbrains ai --json", + "fixos jetbrains doctor --pid 143907 --json" + ], + "evidence": "project/ticket-010/ai-codex-logs.txt" + }, + { + "criterion": "AC-06", + "commands": [ + "ruff check on changed Python files", + "python -m compileall -q fixos", + "pytest -q", + "./project/governance-check.sh" + ], + "evidence": "project/ticket-010/ai-codex-logs.txt" + } + ] + } +} diff --git a/project/ticket-010/preprompt.md b/project/ticket-010/preprompt.md new file mode 100644 index 0000000..6c4f160 --- /dev/null +++ b/project/ticket-010/preprompt.md @@ -0,0 +1,12 @@ +# Ticket preprompt + +- **Task ID**: ticket-010 +- **Task title**: Harden JetBrains AI helper control +- **Created**: 2026-08-20T11:32:38Z + +Keep executable implementation outside this governance/evidence directory. +Read a human-owned user-*.md file only when one exists. +The request to execute this work creates SESSION_EXECUTION_AUTHORIZATION; +proceed within the recorded intent without a redundant confirmation prompt. +Require new authority for destructive action, secrets, external coordination, +material objective expansion and trusted merge approval. diff --git a/tests/unit/test_jetbrains_cmd.py b/tests/unit/test_jetbrains_cmd.py index 937a09a..33c9281 100644 --- a/tests/unit/test_jetbrains_cmd.py +++ b/tests/unit/test_jetbrains_cmd.py @@ -3,6 +3,7 @@ from click.testing import CliRunner from fixos.cli import jetbrains_cmd +from fixos.diagnostics.jetbrains_ai import JetBrainsAiControl from fixos.diagnostics.jetbrains_recovery import ( EdtThreadState, JetBrainsDiagnosis, @@ -137,3 +138,156 @@ def test_apply_gc_yes_returns_verified_result(monkeypatch): assert '"executed": true' in result.output assert '"verified_improvement": true' in result.output assert FakeRecovery.recover_calls == [(100, True)] + + +class FakeAiControl: + disable_calls = [] + stop_calls = [] + + def status(self, *, config_dir=None): + return { + "service": "jetbrains-ai-control", + "read_only": True, + "plugin_ids": ["com.intellij.ml.llm", "com.qoder"], + "helpers": [ + { + "pid": 200, + "ppid": 100, + "create_time": 2000.0, + "command": ["/opt/Qoder", "start"], + "rss_bytes": 1024, + } + ], + "configs": [ + { + "directory": "/tmp/JetBrains/PyCharm2026.2", + "disabled_plugins_file": "/tmp/JetBrains/PyCharm2026.2/disabled_plugins.txt", + "disabled": ["com.qoder"], + } + ], + } + + def disable_plugins(self, config_dir, *, apply): + self.disable_calls.append((config_dir, apply)) + return { + "added": ["com.intellij.ml.llm"], + "changed": apply, + "dry_run": not apply, + } + + def stop_qoder_helpers(self, identities, *, apply): + self.stop_calls.append((identities, apply)) + return { + "selected": [200], + "stopped": [200] if apply else [], + "failed": [], + "success": True, + "dry_run": not apply, + } + + +def test_ai_status_is_read_only_json(monkeypatch): + monkeypatch.setattr(jetbrains_cmd, "JetBrainsAiControl", FakeAiControl) + + result = CliRunner().invoke(jetbrains_cmd.jetbrains, ["ai", "--json"]) + + assert result.exit_code == 0, result.output + assert '"read_only": true' in result.output + assert '"pid": 200' in result.output + assert '"com.qoder"' in result.output + + +def test_ai_actions_are_dry_run_without_apply(monkeypatch): + FakeAiControl.disable_calls = [] + FakeAiControl.stop_calls = [] + monkeypatch.setattr(jetbrains_cmd, "JetBrainsAiControl", FakeAiControl) + + result = CliRunner().invoke( + jetbrains_cmd.jetbrains, + ["ai", "--disable-plugins", "--stop-qoder", "--json"], + ) + + assert result.exit_code == 0, result.output + assert FakeAiControl.disable_calls == [(Path("/tmp/JetBrains/PyCharm2026.2"), False)] + assert FakeAiControl.stop_calls == [([(200, 2000.0)], False)] + assert '"dry_run": true' in result.output + + +def test_ai_apply_confirmation_can_decline(monkeypatch): + FakeAiControl.disable_calls = [] + monkeypatch.setattr(jetbrains_cmd, "JetBrainsAiControl", FakeAiControl) + + result = CliRunner().invoke( + jetbrains_cmd.jetbrains, + ["ai", "--disable-plugins", "--apply"], + input="n\n", + ) + + assert result.exit_code == 0 + assert "Pominięto zmiany" in result.output + assert FakeAiControl.disable_calls == [] + + +def test_ai_apply_yes_routes_exact_helper_identity(monkeypatch): + FakeAiControl.disable_calls = [] + FakeAiControl.stop_calls = [] + monkeypatch.setattr(jetbrains_cmd, "JetBrainsAiControl", FakeAiControl) + + result = CliRunner().invoke( + jetbrains_cmd.jetbrains, + ["ai", "--disable-plugins", "--stop-qoder", "--apply", "--yes"], + ) + + assert result.exit_code == 0, result.output + assert FakeAiControl.disable_calls == [(Path("/tmp/JetBrains/PyCharm2026.2"), True)] + assert FakeAiControl.stop_calls == [([(200, 2000.0)], True)] + assert "Główna JVM i okna IDE nie są zatrzymywane" in result.output + + +def test_ai_service_preserves_plugins_and_stops_only_exact_qoder(tmp_path): + config_root = tmp_path / "JetBrains" + config_dir = config_root / "PyCharm2026.2" + config_dir.mkdir(parents=True) + disabled = config_dir / "disabled_plugins.txt" + disabled.write_text("unrelated.plugin\ncom.qoder\n", encoding="utf-8") + ide = _process() + helper = ProcessRecord( + pid=200, + ppid=ide.pid, + name="Qoder", + cmdline=("/opt/Qoder", "start"), + create_time=2000.0, + username="tester", + ) + unrelated = ProcessRecord( + pid=300, + ppid=1, + name="Qoder", + cmdline=("/opt/Qoder", "start"), + create_time=3000.0, + username="tester", + ) + alive = {200} + terminated = [] + control = JetBrainsAiControl( + process_provider=lambda: [ide, helper, unrelated], + config_root=config_root, + identity_provider=lambda pid: {200: 2000.0, 300: 3000.0}.get(pid), + rss_provider=lambda pid: 4096, + terminator=lambda pid: (terminated.append(pid), alive.discard(pid)), + alive_provider=lambda pid, created: pid in alive, + ) + + status = control.status() + plugin_result = control.disable_plugins(config_dir, apply=True) + helper_result = control.stop_qoder_helpers([(200, 2000.0)], apply=True) + + assert [item["pid"] for item in status["helpers"]] == [200] + assert plugin_result["added"] == ["com.intellij.ml.llm"] + assert disabled.read_text().splitlines() == [ + "unrelated.plugin", + "com.qoder", + "com.intellij.ml.llm", + ] + assert terminated == [200] + assert helper_result["stopped"] == [200] diff --git a/tests/unit/test_jetbrains_recovery.py b/tests/unit/test_jetbrains_recovery.py index 3ce1919..fc55056 100644 --- a/tests/unit/test_jetbrains_recovery.py +++ b/tests/unit/test_jetbrains_recovery.py @@ -78,6 +78,18 @@ def test_main_ide_detection_excludes_mcp_and_native_helpers(): ) is False ) + assert ( + is_main_jetbrains_process( + _process(command=("/bin/bash", "--rcfile", "/opt/pycharm/bash.rc")) + ) + is False + ) + assert ( + is_main_jetbrains_process( + _process(command=("/bin/bash", "-lc", "fixos jetbrains doctor pycharm")) + ) + is False + ) def test_log_analysis_correlates_write_waits_edt_disposal_and_stale_directory():