diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c52149..78a0f80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,93 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added +- Guarded action log (linc-nerve-1k7f): `denied_label` refusals now include a `hint` explaining how + to override the default deny list (or the session's custom `deny_words`) in place. Calling + `nerve_actionlog_start` on an active guarded session with the same resolved `path` and + `target_package` atomically swaps a fresh `GuardConfig` in place, appends a `guard_updated` JSONL + event, and returns `rearmed: True` with updated guard metadata instead of failing `"already + active"`. Conflicting starts include a `hint` to call `nerve_actionlog_stop` first, and + `nerve_replay` skips `guard_updated` lines. +- Output retention & cleanup (linc-nerve-si3y): `_next_output_path` caps each process `nerve-output-*` + directory at `NERVE_OUTPUT_KEEP` (default `200`) files via `_prune_output_dir`, the first + `_output_dir()` creation sweeps current-UID non-symlink `nerve-output-*` directories older than + `NERVE_OUTPUT_MAX_AGE_S` (default `86400` s) via `_sweep_stale_output_dirs`, and the new CLI-only + `nerve cleanup [--all] [--human]` command wipes idle/stale `nerve-output-*` directories on demand. + Each process holds an exclusive `flock` on `/.nerve-output.lock`, and neither the sweep nor + `nerve cleanup` (even with `--all`) removes a directory whose lock is held or unreadable, so an + idle daemon's output directory is never deleted; `_output_dir()` re-creates its directory if it + vanishes. `NERVE_OUTPUT_SWEEP=0` disables the sweep (off under pytest unless set to `1`). +- `nerve_logcat` (linc-nerve-jdt9): optional `package=` reads only that app's lines (via its + running pid; an error if it is not running) and `grep=` keeps lines containing a + case-insensitive substring, matched in Python and never sent to the device. A `tag=` that + matches nothing exactly now falls back to the app's pid (package-shaped tag, app running) or a + case-insensitive substring search over the last 5000 lines, and says so in `matched_via` + + `hint`. `lines` must be >= 1. CLI: `nerve logcat --package/--grep`. +- Input aliases (linc-nerve-vwkl): `nerve_key` accepts `keycode=` as an alias for `key`, and + `nerve_swipe` accepts `x1`/`y1`/`x2`/`y2` as aliases for `start_x`/`start_y`/`end_x`/`end_y`. + Aliases fold into the canonical names before the guard, the action log and `nerve_replay` + see the call. Missing, conflicting or mixed canonical/alias arguments are a + `validation_error` naming the parameters. CLI: `nerve key --keycode`, `nerve swipe --x1 ..`. +- `nerve_navigate` (linc-nerve-vwkl): an unknown-app error carries a `hint` pointing at + `nerve_launch(device, package, uri=)` for apps outside the route registry. +- CLI: `nerve launch --uri ` (linc-nerve-o2ro) opens a deep link (e.g. + `market://details?id=` or an https app link) with an Android VIEW intent; PACKAGE confines + the intent. With no `--uri` the request payload is unchanged. + +### Security +- Launch URI validation (linc-nerve-o2ro): an RFC 3986 shape check (`validate_launch_uri`, + `re.fullmatch`, max 2048 chars) is enforced on `uri` in `AndroidController.launch`, + `nerve_launch` (Android and iOS) and the daemon HTTP router (`_reject_bad_uri`, HTTP 400). + `&` stays legal, so multi-param deep links pass; `_q(uri)` quoting remains the second layer. + +### Fixed +- Coordinate bounds (linc-nerve-v6fn): `nerve_tap`, `nerve_long_press` and `nerve_swipe` + (canonical `start_x`..`end_y` and `x1`..`y2` aliases) require integer coordinates in + `0..32767` (`COORDINATE_MAX`, `coordinate_in_range`). Values outside `0..32767` return a + `validation_error` (`"Invalid coordinates (integers in 0..32767 required): ..."`) on every + path (MCP, daemon, CLI) and are refused with `guard: "target_unreadable"` under a guarded + action-log session without calling the device controller. +- Guard / coordinates (linc-nerve-vwkl): a guarded session refuses a `nerve_tap`, + `nerve_long_press` or `nerve_swipe` whose coordinates are missing or not integers + (`guard: "target_unreadable"`, a guard line in the log) instead of skipping the label check. + The tool bodies refuse non-integer coordinates with a `validation_error` on every path. The + daemon coerces and type-checks `X | None` / `Optional[X]` params like plain `X` (a bad value + is a 400), and `nerve schema --json` types the swipe coordinate options as `INT`. + +### Changed +- `nerve_ensure_ready`, `nerve_launch`, `nerve_release` (linc-nerve-6xem): `device` now defaults to + `""` and is auto-selected in the `device_tool` wrapper when the caller holds exactly one active + reservation (`_pick_reserved_device`), adding `"device": ` and + `"device_auto_selected": True` to the result. Zero or multiple reservations held by the caller + return an explicit error; non-opted tools continue to require `device`. +- `nerve_logcat` (linc-nerve-g92x): returns up to 250 lines and 24 KB (sum of UTF-8 line lengths) + inline before spilling to a file, and when output spills to a file also returns `tail_lines` + (the last 50 lines, each truncated to 500 chars with a trailing `…` when cut) plus `tail_count` + and an updated `hint`. +- Reservations (linc-nerve-l7pm): `nerve daemon` stops auto-renewing anonymous (session-id-owned) + leases so an abandoned CLI lease expires after `timeout_seconds` unless re-reserved; stdio + bellows keeps renewing its own. `DeviceManager` accepts keyword-only `renew_anonymous` (False + in daemon mode). `nerve_reserve` reports `"renewal": "daemon" | "caller"`. +- Reservations (linc-nerve-jtt8): `nerve_reserve` with `takeover=True` but no `owner` and no + `owner_pid` is refused when the device is held, leaving the lease intact. The conflict refusal no + longer suggests `takeover=True`; it says to re-run with the same owner to renew. `owner_pid` + without `owner` now stores owner `pid:` (release with that owner). The CLI fills + `owner` for `reserve`/`release` from `NERVE_OWNER` (an explicit `--owner` wins; an invalid + value exits 2). The reserve result gains `shared_owner` (+ a `warning` for a session-id-owned + lease), `renewed` and `expires_in_s`. +- SECURITY.md names the published v0.1.0 release: public tag `v0.1.0` of LincForge/nerve (commit `547786321703`) and the SHA-256 of the wheel PyPI serves. + +### Fixed +- `nerve doctor` (linc-nerve-yml1): stops the `adb` server only when its own probe started it — + the pre-probe connect to the server address (`ANDROID_ADB_SERVER_PORT` / `ADB_SERVER_SOCKET`) was + positively refused AND the `adb devices` stderr reports `daemon started successfully`. It then runs + `adb kill-server` once and notes `(server started for the check and stopped)` in the `adb` check + detail. Every other outcome — an already-running server, a connect timeout, an unexpected probe + error, an `ADB_SERVER_SOCKET` nerve rejects, or a server another process started in between — leaves + the server running. An explicit `nerve doctor --fix` start stays running. `nerve doctor --human` + is accepted as an alias for the default text output (`--human --json` exits 2 with a usage error). + ## [0.1.0] First public release. diff --git a/README.md b/README.md index 4a7f3c7..3d17a66 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Field-tested device control for AI coding agents — the self-healing tricks we learned keeping a physical Android/iOS bench alive, as an MCP server and a plain CLI. -nerve exposes 48 tools — screenshots, the UI +nerve exposes 49 tools — screenshots, the UI element tree, tap/swipe/type, app install/launch, logs, recording — to any MCP client, and the same tools as `nerve ` commands that print JSON. @@ -158,6 +158,17 @@ supported. | `LINC_NERVE_DAEMON` | `http://127.0.0.1:8111` | Daemon URL that CLI commands call before falling back to in-process | | `ANDROID_ADB_SERVER_PORT` / `ADB_SERVER_SOCKET` | `127.0.0.1:5037` | adb server nerve talks to (`ADB_SERVER_SOCKET` as `tcp:` or `tcp::` wins; loopback hosts only) — isolates nerve from the host's devices | | `NERVE_ALLOWED_HOSTS` / `NERVE_ALLOWED_ORIGINS` | *(none)* | Extra Hosts / browser Origins the daemon accepts | +| `NERVE_OUTPUT_KEEP` | `200` | Maximum number of output files retained per process `nerve-output-*` temp directory (`0` or invalid falls back to `200`) | +| `NERVE_OUTPUT_MAX_AGE_S` | `86400` | Maximum age in seconds (`24h` default; `0` or invalid falls back to `86400`) before an idle `nerve-output-*` directory is swept on the next process's first output | + +### Output files + +Screenshots (`nerve_screenshot`, `nerve_stream_screenshot`, `nerve_navigate_screenshot`), state/diff captures (`nerve_state`, `nerve_diff`), and large `nerve_logcat` dumps are written to a per-process private (`0700`) directory `/nerve-output-/__.` (under `$TMPDIR` on macOS, `/tmp` on Linux), created lazily on first output and kept on process exit so callers can read returned paths after a CLI invocation finishes: + +- **Retention cap:** Each process directory keeps at most `NERVE_OUTPUT_KEEP` (default `200`) `__.` files, pruning the oldest (by counter, then mtime) before writing a new one. +- **Owner lock:** Each process holds an exclusive `flock` on `/.nerve-output.lock` for its lifetime (the OS drops it when the process exits). A directory whose lock is held — or whose lock state cannot be read — is never removed by the sweep or by `nerve cleanup`, however long its process (an idle daemon or MCP server) has gone without writing. If a process's directory is deleted anyway, nerve creates a fresh one on the next output. +- **Stale sweep:** On the first output directory creation in a process, nerve sweeps `/nerve-output-*` directories owned by the current UID (never following symlinks) that no live process holds and whose newest entry is older than `NERVE_OUTPUT_MAX_AGE_S` seconds (default `86400`). Set `NERVE_OUTPUT_SWEEP=0` to disable it (it is also off under pytest unless `NERVE_OUTPUT_SWEEP=1`). +- **On-demand cleanup:** `nerve cleanup` removes all current-UID, non-symlink `nerve-output-*` directories in the temp root except those held by a running nerve process and those modified in the last 60 seconds (`nerve cleanup --all` removes the recently modified ones too, never a live process's), printing JSON `{"removed": [...], "skipped": [...], "bytes_freed": N}` (or text with `nerve --human cleanup`). ### HTTP daemon (optional) @@ -168,7 +179,7 @@ one is running and otherwise run the tool in-process. A few stateful tools (reco reservations, `diff`, `session-stats`) need the daemon, and say so instead of answering wrongly. -## MCP Tools (48) +## MCP Tools (49) Tools are registered via `@device_tool` (requires a device serial) or `@global_tool` (no device needed) in `src/linc_nerve/mcp/*_tools.py`. This section — including the count — is generated from those registrations by `python -m linc_nerve.docs_gen`; `tests/test_docs_sync.py` fails CI when it is stale. Call `nerve_capabilities` for the per-platform support matrix. @@ -196,13 +207,14 @@ Tools are registered via `@device_tool` (requires a device serial) or `@global_t |------|------|-------------| | `nerve_diff` | device | Return UI changes since the last nerve_state snapshot on this device | | `nerve_elements` | device | Return the UI element tree only (clickable + text widgets) — no image bytes | +| `nerve_ground` | device | Locate UI controls visually by natural-language concept prompt using Meta SAM 3.1 | | `nerve_state` | device | Capture full device state in one call (screenshot + UI elements + foreground) | ### Navigation | Tool | Type | Description | |------|------|-------------| -| `nerve_navigate` | device | Navigate to a named screen in an app using its registered deep link | +| `nerve_navigate` | device | Navigate to a named screen of a REGISTERED app (route registry) by its deep link | | `nerve_navigate_screenshot` | device | Navigate to a screen and capture a screenshot after the UI settles | ### Input @@ -213,7 +225,7 @@ Tools are registered via `@device_tool` (requires a device serial) or `@global_t | `nerve_key` | device | Press a hardware/system key | | `nerve_long_press` | device | Long press at screen coordinates | | `nerve_swipe` | device | Two-point gesture from one xy coordinate to another — swipe, drag, or scroll | -| `nerve_tap` | device | Tap a single point on the device display at known x/y coordinates | +| `nerve_tap` | device | Tap a single point on the device display at known x/y coordinates or by visual prompt | | `nerve_type` | device | Type text into the currently focused input field via the soft keyboard | ### App Management @@ -304,6 +316,14 @@ Tools are registered via `@device_tool` (requires a device serial) or `@global_t that reaches a device shell is validated or shell-quoted where it is used. Details and how to report a vulnerability: [SECURITY.md](https://github.com/LincForge/nerve/blob/main/SECURITY.md). +## Related projects + +| Package | Role | +|---------|------| +| [`linc-crucible`](https://github.com/LincForge/crucible) | Quality gates, build-and-deploy routing, and visual/a11y baselines (delegates device I/O to nerve) | +| [`linc-spectra`](https://github.com/LincForge/spectra) | Runtime debugger (Android JDWP via `kotlin-debug-adapter` or zero-config `jdb`; iOS Simulator `lldb-dap`) | +| [`mobile-agent-bench`](https://github.com/LincForge/mobile-agent-bench) | Pre-registered 600-cell benchmark comparing `nerve + crucible + spectra` against Maestro, `mobile-mcp`, and `agent-device` ([lincforge.com](https://lincforge.com)) | + ## How this repo works This is a release mirror of LINC's private repository. The code is built by LINC's AI software diff --git a/SECURITY.md b/SECURITY.md index 6b1fa75..20cfd04 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -55,6 +55,7 @@ Results: 37 checks performed; 28 PASS, 9 FAIL (0 High, 1 Medium, 8 Low). All 9 f The reviewed v0.1.0 candidate contains all fixes above: - Source: private linc-nerve commit `ff33e7b` (tree `04abc4c39af9…`). Commits after it and before the v0.1.0 tag change only this file and tests, not the package. - Reviewed candidate wheel SHA-256: `931663ad655060222760af5c3e98d5de9350cdd5045ee43ba47dce1786054c81` -- The wheel published on PyPI will have a different SHA-256 even though its contents are the same, because a wheel records each file's timestamp and the published build's timestamps differ from the candidate's. -- After v0.1.0 is published, this section names the public v0.1.0 tag, its commit, and the SHA-256 of the wheel PyPI serves. To check that hash: `pip download linc-nerve==0.1.0 --no-deps -d .` then `shasum -a 256 linc_nerve-0.1.0-py3-none-any.whl`. - +- Public release: tag `v0.1.0` of LincForge/nerve, commit `547786321703b7c8640e3f8a610e34f561fe7c9e` (tree `321e50e2aba0e668107b364a94852ee7796b42c1`), exported from private commit `c40f9be`. Its `src` tree (`d529522e15dd…`) is the same as the reviewed candidate's at `ff33e7b`. +- Published wheel SHA-256 (PyPI, `linc_nerve-0.1.0-py3-none-any.whl`): `747798cf8c869e4e0f6d49426faada97221cfd55e70fef540afa3fe2d949ca19` +- The published wheel's SHA-256 differs from the candidate's even though its contents are the same, because a wheel records each file's timestamp and the published build's timestamps differ from the candidate's. +- Verify: `pip download linc-nerve==0.1.0 --no-deps -d .` then `shasum -a 256 linc_nerve-0.1.0-py3-none-any.whl`, and compare the result with the published hash above. diff --git a/pyproject.toml b/pyproject.toml index 936f3d5..9835706 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "linc-cli-kit>=0.1", "linc-device-kit>=1.0", "mcp>=1.0.0", + "meta-sam-parser>=0.1.0", "pillow>=11.0", "pyyaml>=6.0", "rich>=13.7.0", diff --git a/src/linc_nerve/cli/cleanup.py b/src/linc_nerve/cli/cleanup.py new file mode 100644 index 0000000..887231d --- /dev/null +++ b/src/linc_nerve/cli/cleanup.py @@ -0,0 +1,52 @@ +"""`nerve cleanup` — remove stale or idle `nerve-output-*` temp directories (linc-nerve-si3y). + +The scanning/removal logic lives in :mod:`linc_nerve.core.output_retention` (shared with the +first-output sweep in ``mcp/_core``); this module is only the click command. +""" + +from __future__ import annotations + +import json +from typing import Any + +import click +from linc_cli_kit import current_mode + +from linc_nerve.core.output_retention import cleanup_output_dirs + + +def _render_text(payload: dict[str, Any]) -> str: + removed = payload["removed"] + skipped = payload["skipped"] + lines = [ + f"Removed {len(removed)} dir(s), skipped {len(skipped)}, " + f"freed {payload['bytes_freed']} bytes." + ] + lines.extend(f" removed: {p}" for p in removed) + lines.extend(f" skipped: {p}" for p in skipped) + return "\n".join(lines) + + +@click.command("cleanup") +@click.option( + "--all", + "remove_all", + is_flag=True, + help=( + "Also remove nerve-output-* directories modified in the last 60 seconds. A directory " + "whose nerve process is still running is always kept." + ), +) +@click.option( + "--human", + "human_output", + is_flag=True, + help="Emit human-readable text instead of JSON.", +) +def cleanup(remove_all: bool, human_output: bool) -> None: + """Remove stale or idle nerve-output-* directories from the temp root.""" + payload = cleanup_output_dirs(remove_all=remove_all) + if human_output or current_mode() == "human": + click.echo(_render_text(payload)) + else: + click.echo(json.dumps(payload)) diff --git a/src/linc_nerve/cli/doctor.py b/src/linc_nerve/cli/doctor.py index 9c50ed8..6793297 100644 --- a/src/linc_nerve/cli/doctor.py +++ b/src/linc_nerve/cli/doctor.py @@ -20,19 +20,26 @@ from __future__ import annotations +import contextlib import dataclasses +import enum +import errno import json as _json import os import platform import shutil +import socket import subprocess import sys +from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path from typing import Any, Literal import click +from linc_nerve.core.adb_path import ADB_PATH +from linc_nerve.core.adb_server import AdbServerAddress, adb_server_address from linc_nerve.core.http_port import DEFAULT_HTTP_PORT, resolve_http_port from linc_nerve.core.observability import linc_home @@ -99,6 +106,7 @@ class ProbeReport: linc_home_exists: bool = True missing_linc_dirs: tuple[str, ...] = field(default_factory=tuple) linc_home: str = "" # resolved $LINC_HOME; "" → resolve at check time + adb_server_listening: bool = True # False only when doctor stopped a server it started # --------------------------------------------------------------------------- @@ -107,7 +115,10 @@ class ProbeReport: def check_adb( - adb_path: str, is_executable: bool, devices_output: str | None + adb_path: str, + is_executable: bool, + devices_output: str | None, + server_listening: bool = True, ) -> CheckResult: """adb binary present + server responsive.""" if not is_executable: @@ -127,7 +138,10 @@ def check_adb( detail=f"adb present at {adb_path} but server unresponsive", hint="Run: adb start-server (or `nerve doctor --fix`)", ) - return CheckResult(name="adb", status="ok", detail=f"adb responsive ({adb_path})") + suffix = "" if server_listening else " (server started for the check and stopped)" + return CheckResult( + name="adb", status="ok", detail=f"adb responsive ({adb_path}){suffix}" + ) def check_device_auth(devices_output: str | None) -> CheckResult: @@ -295,7 +309,12 @@ def check_linc_layout( def run_checks(probes: ProbeReport) -> list[CheckResult]: """Run every diagnostic check over the gathered probes. Pure.""" return [ - check_adb(probes.adb_path, probes.adb_is_executable, probes.adb_devices_output), + check_adb( + probes.adb_path, + probes.adb_is_executable, + probes.adb_devices_output, + probes.adb_server_listening, + ), check_device_auth(probes.adb_devices_output), check_scrcpy_server(probes.scrcpy_server_exists, probes.scrcpy_server_path), check_wda_project(probes.wda_project_dir, probes.wda_checkout_exists), @@ -357,8 +376,6 @@ def _execute_fix(fix: FixAction) -> None: for d in fix.args: Path(d).expanduser().mkdir(parents=True, exist_ok=True) elif fix.kind == "adb-start-server": - from linc_nerve.core.adb_path import ADB_PATH - subprocess.run([ADB_PATH, "start-server"], check=False, timeout=15) @@ -381,17 +398,106 @@ def apply_fixes( # --------------------------------------------------------------------------- -def _probe_adb_devices(adb_path: str) -> str | None: - """One `adb devices` run — proves the server is (or can get) responsive.""" +class AdbProbe(enum.Enum): + """Outcome of the pre-``adb devices`` connect to the adb server address. + + Only ``REFUSED`` is positive evidence that no server was running; every + other failure (timeout, unexpected error, an address nerve rejects) is + ``UNKNOWN`` and must be treated as "a server may be running". + """ + + LISTENING = "listening" + REFUSED = "refused" + UNKNOWN = "unknown" + + +_DAEMON_STARTED = "daemon started successfully" + + +def classify_connect_error(exc: BaseException) -> AdbProbe: + """Map a connect failure to ``REFUSED`` only on a positive ECONNREFUSED. Pure.""" + if isinstance(exc, ConnectionRefusedError): + return AdbProbe.REFUSED + if isinstance(exc, OSError) and exc.errno == errno.ECONNREFUSED: + return AdbProbe.REFUSED + return AdbProbe.UNKNOWN + + +def should_stop(probe: AdbProbe, devices_stderr: str | None) -> bool: + """True only when no server was running AND our ``adb devices`` started one. Pure. + + The stderr match is case- and whitespace-insensitive. A server someone + else started between the probe and ``adb devices`` (no phrase in our + stderr) is left running. + """ + if probe is not AdbProbe.REFUSED or not isinstance(devices_stderr, str): + return False + return _DAEMON_STARTED in " ".join(devices_stderr.lower().split()) + + +def _adb_server_probe(address: AdbServerAddress) -> AdbProbe: + """Connect once to ``address`` and classify the outcome.""" try: - proc = subprocess.run( - [adb_path, "devices"], capture_output=True, text=True, timeout=10 - ) + with socket.create_connection((address.host, address.port), timeout=0.5): + return AdbProbe.LISTENING + except Exception as exc: + return classify_connect_error(exc) + + +def _probe_adb_listening( + listening_probe: Callable[[AdbServerAddress], AdbProbe] | None = None, +) -> AdbProbe: + """Guard ``adb_server_address()`` + the probe: any failure reads ``UNKNOWN``.""" + try: + probe = listening_probe or _adb_server_probe + result = probe(adb_server_address()) except Exception: - return None - if proc.returncode != 0: - return None - return proc.stdout + return AdbProbe.UNKNOWN + return result if isinstance(result, AdbProbe) else AdbProbe.UNKNOWN + + +def _probe_adb_devices(adb_path: str) -> tuple[str | None, str | None]: + """One `adb devices` run -> (stdout on success else None, stderr).""" + try: + proc = subprocess.run([adb_path, "devices"], capture_output=True, text=True, timeout=10) + except Exception: + return None, None + stdout = proc.stdout if proc.returncode == 0 else None + return stdout, proc.stderr + + +def _stop_probe_started_server(adb_path: str) -> None: + """Best-effort ``adb kill-server`` for an adb server started by our own probe.""" + with contextlib.suppress(Exception): + subprocess.run( + [adb_path, "kill-server"], + env=os.environ.copy(), + capture_output=True, + text=True, + timeout=10, + check=False, + ) + + +def _run_adb_probe( + adb_path: str, + adb_executable: bool, + listening_probe: Callable[[AdbServerAddress], AdbProbe] | None = None, +) -> tuple[bool, str | None]: + """Probe the server address, run ``adb devices``, and stop the server only + when ``should_stop`` proves this call started it. + + Returns ``(server_left_running, devices_stdout)``; the flag is False only + when doctor stopped a server it started. + """ + probe = _probe_adb_listening(listening_probe) + if not adb_executable: + return True, None + devices_output, devices_stderr = _probe_adb_devices(adb_path) + if should_stop(probe, devices_stderr): + _stop_probe_started_server(adb_path) + return False, devices_output + return True, devices_output def _probe_xcode_clt() -> str | None: @@ -436,19 +542,24 @@ def _probe_installed_version() -> str: return "unknown" -def gather_probes(port: int | None = None) -> ProbeReport: +def gather_probes( + port: int | None = None, + *, + listening_probe: Callable[[AdbServerAddress], AdbProbe] | None = None, +) -> ProbeReport: """Collect raw host facts for the pure checks. Each probe is guarded. ``port`` resolves like the server binds: explicit > NERVE_HTTP_PORT > 8111. """ port = resolve_http_port(port) from linc_nerve.core import scrcpy - from linc_nerve.core.adb_path import ADB_PATH adb_executable = shutil.which(ADB_PATH) is not None or ( os.path.isabs(ADB_PATH) and os.access(ADB_PATH, os.X_OK) ) - devices_output = _probe_adb_devices(ADB_PATH) if adb_executable else None + adb_listening, devices_output = _run_adb_probe( + ADB_PATH, adb_executable, listening_probe + ) scrcpy_path = scrcpy._SERVER_DIR / scrcpy._SERVER_FILENAME @@ -479,6 +590,7 @@ def gather_probes(port: int | None = None) -> ProbeReport: linc_home_exists=home.is_dir(), missing_linc_dirs=missing_dirs, linc_home=str(home), + adb_server_listening=adb_listening, ) @@ -508,8 +620,19 @@ def _render_text(results: list[CheckResult], fixes: list[FixAction], dry_run: bo return "\n".join(lines) +def _validate_output_flags(human_output: bool, json_output: bool) -> None: + if human_output and json_output: + raise click.UsageError("--human and --json are mutually exclusive") + + @click.command() @click.option("--json", "json_output", is_flag=True, help="Machine-readable report") +@click.option( + "--human", + "human_output", + is_flag=True, + help="Human-readable text report (default)", +) @click.option("--fix", "do_fix", is_flag=True, help="Apply safe fixes (mkdir, adb start-server)") @click.option("--dry-run", is_flag=True, help="With --fix: print the plan, execute nothing") @click.option( @@ -518,12 +641,19 @@ def _render_text(results: list[CheckResult], fixes: list[FixAction], dry_run: bo default=None, help=f"Daemon port to probe [default: $NERVE_HTTP_PORT or {DAEMON_PORT}]", ) -def doctor(json_output: bool, do_fix: bool, dry_run: bool, port: int | None) -> None: +def doctor( + json_output: bool, + do_fix: bool, + dry_run: bool, + port: int | None, + human_output: bool = False, +) -> None: """Diagnose the nerve environment — one-command bootstrap entry point. Exits 0 when all checks pass (warnings allowed), 1 when any check fails; every failure carries a one-line fix-it hint. """ + _validate_output_flags(human_output, json_output) probes = gather_probes(port=port) results = run_checks(probes) fixes = plan_fixes(results) if (do_fix or dry_run) else [] diff --git a/src/linc_nerve/cli/main.py b/src/linc_nerve/cli/main.py index 34255d0..dded226 100644 --- a/src/linc_nerve/cli/main.py +++ b/src/linc_nerve/cli/main.py @@ -4,7 +4,10 @@ import asyncio import functools +import os +import re import signal +from collections.abc import Mapping from typing import Any import click @@ -42,6 +45,7 @@ from linc_nerve.core.ios import IOSController # noqa: E402 from linc_nerve.daemon.polling import DeviceDiff, poll_devices # noqa: E402 from linc_nerve.daemon.wda_manager import DaemonWDAManager # noqa: E402 +from linc_nerve.mcp import _core # noqa: E402 from linc_nerve.mcp._core import _ensure_initialized # noqa: E402 from linc_nerve.mcp.sse_server import ( # noqa: E402 _get_tool_executor, @@ -61,11 +65,13 @@ def main() -> None: install_globals(main) -# Registered via add_command so doctor.py stays importable (and unit-testable) +# Registered via add_command so doctor.py/cleanup.py stay importable (and unit-testable) # without pulling in the controller/daemon imports above. +from linc_nerve.cli.cleanup import cleanup # noqa: E402 from linc_nerve.cli.doctor import doctor # noqa: E402 main.add_command(doctor) +main.add_command(cleanup) def _registry_server() -> Any: @@ -274,6 +280,7 @@ async def run_daemon_async(port: int) -> bool: ) return False + _core.set_daemon_mode() _ensure_initialized() loop = asyncio.get_event_loop() @@ -380,6 +387,32 @@ def _local_before() -> None: _ensure_initialized() +# Grammar for a NERVE_OWNER default owner: one leading alphanumeric, then up to 127 of +# [A-Za-z0-9_.:@-]. Applied with re.fullmatch (``$`` would accept a trailing newline). +_OWNER_RE = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9_.:@-]{0,127}\Z") +_OWNER_TOOLS = frozenset({"nerve_reserve", "nerve_release"}) + + +def _with_default_owner( + tool: str, payload: dict[str, Any], env: Mapping[str, str] +) -> dict[str, Any]: + """Fill ``owner`` for reserve/release from ``NERVE_OWNER`` when the caller gave none. + + Without it every owner-less CLI call shares the daemon's one session id, so any + caller on the host could renew, take over or release the lease (p0-05). An explicit + ``--owner`` always wins. Returns a copy; the input payload is never mutated. + """ + default = env.get("NERVE_OWNER", "") + if tool not in _OWNER_TOOLS or payload.get("owner") or not default: + return payload + if _OWNER_RE.fullmatch(default) is None: + raise UsageError( + f"NERVE_OWNER={default!r} is not a valid owner: it must match " + "[A-Za-z0-9][A-Za-z0-9_.:@-]{0,127}" + ) + return {**payload, "owner": default} + + class NerveTransport(HttpDaemonTransport): """HttpDaemonTransport that raises a precise ToolError for nerve's well-known error shapes. @@ -391,9 +424,8 @@ class NerveTransport(HttpDaemonTransport): `super().call` (the daemon's HTTP 400 -> kit `UsageError` path). """ - def call( - self, spec: Any, payload: dict[str, Any], *, timeout: float | None = None - ) -> Any: + def call(self, spec: Any, payload: dict[str, Any], *, timeout: float | None = None) -> Any: + payload = _with_default_owner(spec.name, payload, os.environ) try: result = super().call(spec, payload, timeout=timeout) except ToolError as exc: @@ -506,11 +538,23 @@ def screenshot(device: str, output: str | None) -> None: @main.command() @click.argument("device") @click.argument("package") +@click.option( + "--uri", + default="", + help=( + "Deep link to open with an Android VIEW intent (e.g. market://details?id=org.example.app " + 'or https://example.com/path); PACKAGE confines the intent to that app. Pass "" as ' + "PACKAGE to let the system pick a handler, which may show a chooser." + ), +) @_contract -def launch(device: str, package: str) -> None: - """Launch an app on a device. Example: nerve launch SERIAL com.example.app""" +def launch(device: str, package: str, uri: str = "") -> None: + """Launch an app on a device. Example: nerve launch SERIAL com.android.vending --uri 'market://details?id=org.example.app'""" ctx = click.get_current_context() - res = _call("nerve_launch", {"device": device, "package": package}) + payload: dict[str, Any] = {"device": device, "package": package} + if uri: + payload["uri"] = uri + res = _call("nerve_launch", payload) ctx.exit(_emit(res, tool="launch", renderer=lambda r: f"Launched {package} on {device}")) @@ -549,9 +593,28 @@ def tap(device: str, x: int, y: int) -> None: ctx.exit(_emit(res, tool="tap", renderer=lambda r: f"Tapped ({x}, {y}) on {device}")) +def _is_int_only_union(param_type: Any) -> bool: + """A kit union type whose only non-null member is an integer (an ``int | None`` arg).""" + members = getattr(param_type, "members", None) + return bool(members) and all(m.get("type") == "integer" for m in members) + + +def _type_optional_ints(group: click.Group) -> None: + """Give ``int | None`` options click.INT, not the kit's generic VALUE union type. + + linc-cli-kit maps any anyOf union to its VALUE type, so `nerve schema --json` + would advertise swipe's --start-x / --x1.. as VALUE rather than INT. + """ + for command in group.commands.values(): + for param in command.params: + if isinstance(param, click.Option) and _is_int_only_union(param.type): + param.type = click.INT + + def _mount() -> None: """Reflect every remaining MCP tool onto the group (hand-written commands win).""" mount_tools(main, _registry_server(), prefix="nerve_", transport=TRANSPORT) + _type_optional_ints(main) _mount() diff --git a/src/linc_nerve/core/action_log.py b/src/linc_nerve/core/action_log.py index ab1c9d6..d353080 100644 --- a/src/linc_nerve/core/action_log.py +++ b/src/linc_nerve/core/action_log.py @@ -115,6 +115,7 @@ def _wallclock() -> datetime: "nerve_elements", "nerve_state", "nerve_diff", + "nerve_ground", "nerve_density", "nerve_apps", "nerve_logcat", @@ -545,6 +546,7 @@ class GuardConfig: allowed_packages: frozenset[str] deny_words: tuple[str, ...] pattern: re.Pattern[str] = field(compare=False, repr=False) + custom_deny: bool = False def describe(self) -> dict[str, Any]: return { @@ -564,14 +566,45 @@ def build_guard( """ if not target_package: return None - words = tuple(split_csv(deny_words)) or DEFAULT_DENY_WORDS + custom = tuple(split_csv(deny_words)) + words = custom or DEFAULT_DENY_WORDS allowed = frozenset({target_package, *split_csv(allow_packages)}) | BUILTIN_ALLOWED_PACKAGES - return GuardConfig(target_package, allowed, words, deny_pattern(words)) + return GuardConfig( + target_package, allowed, words, deny_pattern(words), custom_deny=bool(custom) + ) + + +def _denied_label_hint(word: str, deny_words: Sequence[str] | None = None) -> str: + if deny_words: + joined = ", ".join(deny_words) + source = f"the session's deny_words list ({joined!r})" + else: + source = "the default deny list" + return ( + f"'{word}' is on {source}. If tapping it is intended in this session, " + "call nerve_actionlog_start again with the same path and deny_words set " + "to the full list you want (deny_words replaces the defaults; pass " + "'delete account' to keep only that)." + ) -def refusal(reason: str, detail: str, message: str) -> dict[str, Any]: +def refusal( + reason: str, + detail: str, + message: str, + *, + deny_words: Sequence[str] | None = None, +) -> dict[str, Any]: """The tool result for a guard refusal (a validation error, not a tool failure).""" - return {"error": message, "guard": reason, "detail": detail, "validation_error": True} + result: dict[str, Any] = { + "error": message, + "guard": reason, + "detail": detail, + "validation_error": True, + } + if reason == "denied_label": + result["hint"] = _denied_label_hint(detail, deny_words) + return result def package_refusal(guard: GuardConfig, tool: str, args: dict[str, Any]) -> dict[str, Any] | None: @@ -644,6 +677,53 @@ def _ints(args: dict[str, Any], *keys: str) -> tuple[int, ...] | None: return None +# Coordinate args of each touch tool; the guard can only place a touch it can parse. +COORDINATE_KEYS: dict[str, tuple[str, ...]] = { + "nerve_tap": ("x", "y"), + "nerve_long_press": ("x", "y"), + "nerve_swipe": ("start_x", "start_y", "end_x", "end_y"), +} +# Larger than any shipping panel dimension; fits in signed int16 so device-side +# input injection cannot overflow. +COORDINATE_MAX = 32767 +_INT_TEXT_RE = re.compile(r"-?[0-9]+") + + +def is_integer_coordinate(value: Any) -> bool: + """An int (not bool), an integral float, or a plain ``-?[0-9]+`` string.""" + if isinstance(value, bool): + return False + if isinstance(value, int): + return True + if isinstance(value, float): + return value.is_integer() + return isinstance(value, str) and _INT_TEXT_RE.fullmatch(value) is not None + + +def coordinate_in_range(value: Any) -> bool: + """An integer coordinate in ``0..COORDINATE_MAX``.""" + return is_integer_coordinate(value) and 0 <= int(value) <= COORDINATE_MAX + + +def non_integer_coordinates(tool: str, args: Mapping[str, Any]) -> list[str]: + """Coordinate args of *tool* that are missing, non-integer, or out of ``0..COORDINATE_MAX``.""" + keys = COORDINATE_KEYS.get(tool, ()) + return [key for key in keys if not coordinate_in_range(args.get(key))] + + +def coordinate_refusal(tool: str, args: Mapping[str, Any]) -> dict[str, Any] | None: + """Refuse a guarded touch whose coordinates are outside ``0..COORDINATE_MAX`` (fail closed). + + Without valid coordinates the guard cannot find the element under the + touch, so it must not let the touch through unchecked. + """ + bad = non_integer_coordinates(tool, args) + if not bad: + return None + detail = f"invalid coordinates (integers in 0..{COORDINATE_MAX} required): {', '.join(bad)}" + return refusal("target_unreadable", detail, f"guarded session: input refused, {detail}") + + def touch_point(tool: str, args: dict[str, Any]) -> tuple[int, ...] | None: """Where *tool* would click: a tap / long press point, or a short swipe's start. @@ -699,15 +779,15 @@ def needs_label_check(tool: str, args: dict[str, Any]) -> bool: """Whether a guarded *tool* call may activate an element (and needs the tree). Taps, every swipe (a short one is a click; a long one may stay inside one - button), activation keys, and text that types ENTER / SPACE. + button), activation keys, and text that types ENTER / SPACE. A touch always + needs it, even with coordinates that do not parse (fail closed: see + :func:`coordinate_refusal`). """ if tool == "nerve_key": return is_activation_key(args.get("key")) if tool == "nerve_type": return _types_keys(_typed_text(args)) - if tool == "nerve_swipe": - return _ints(args, *_SWIPE_KEYS) is not None - return touch_point(tool, args) is not None + return tool in COORDINATE_KEYS def _in_button_swipe_start( @@ -892,14 +972,25 @@ def _denied_word(guard: GuardConfig, labels: Iterable[dict[str, str]]) -> str | return None +def _custom_deny_words(guard: GuardConfig) -> tuple[str, ...] | None: + if guard.custom_deny or guard.deny_words != DEFAULT_DENY_WORDS: + return guard.deny_words + return None + + def label_refusal( guard: GuardConfig | None, labels: Iterable[dict[str, str]] ) -> dict[str, Any] | None: """Refuse a tap when any of *labels* (see :func:`guard_labels`) hits a deny word.""" word = None if guard is None else _denied_word(guard, labels) - if word is None: + if word is None or guard is None: return None - return refusal("denied_label", word, f"guarded session: input on a {word!r} label refused") + return refusal( + "denied_label", + word, + f"guarded session: input on a {word!r} label refused", + deny_words=_custom_deny_words(guard), + ) def unreadable_refusal(detail: str) -> dict[str, Any]: @@ -917,6 +1008,12 @@ def build_guard_entry(tool: str, result: dict[str, Any]) -> dict[str, Any]: # Sessions # --------------------------------------------------------------------------- +ALREADY_ACTIVE_HINT = ( + "Call nerve_actionlog_stop first to start a new log, or call " + "nerve_actionlog_start with the active path and target_package to " + "re-arm the guard in place." +) + class ActionLogSession: """One open JSONL file for one serial; every write is flushed.""" @@ -984,6 +1081,20 @@ def write_guard(self, entry: dict[str, Any], at: float, rec_started: float | Non except (OSError, ValueError) as exc: # closed by a racing stop log.warning("action_log_write_failed", serial=self.serial, error=str(exc)) + def update_guard(self, guard: GuardConfig, at: float) -> dict[str, Any]: + """Swap *guard* into this session and append one ``guard_updated`` line.""" + with self._lock: + self.guard = guard + record = { + "type": "guard_updated", + "event": "guard_updated", + "deny_words": list(guard.deny_words), + "allowed_packages": sorted(guard.allowed_packages), + "t_session_s": round(at - self.started, 3), + } + self.write(record) + return record + def stop_info(self) -> dict[str, Any]: info: dict[str, Any] = {"serial": self.serial, "path": str(self.path), "entries": self.seq} if self.guard is not None: @@ -995,6 +1106,13 @@ def close(self) -> None: self._fh.close() +def _can_rearm(session: ActionLogSession, path: Path, guard: GuardConfig | None) -> bool: + """Whether *session* can be re-armed in place with *guard* at *path*.""" + if session.guard is None or guard is None: + return False + return session.path == path and session.guard.target_package == guard.target_package + + class ActionLogRegistry: """Thread-safe serial → session map, plus per-serial recording start times.""" @@ -1003,6 +1121,25 @@ def __init__(self) -> None: self._sessions: dict[str, ActionLogSession] = {} self._rec_started: dict[str, float] = {} + def _rearm_or_raise( + self, + serial: str, + existing: ActionLogSession, + path: Path, + guard: GuardConfig | None, + ) -> dict[str, Any]: + if not _can_rearm(existing, path, guard) or guard is None: + raise ActionLogError(f"action log already active on {serial}: {existing.path}") + existing.update_guard(guard, _clock()) + log.info("action_log_rearmed", serial=serial, path=str(path)) + return { + "serial": serial, + "path": str(path), + "started_at": existing.started_at, + "rearmed": True, + **guard.describe(), + } + def start( self, serial: str, @@ -1013,8 +1150,7 @@ def start( path = Path(path) with self._lock: if serial in self._sessions: - active = self._sessions[serial].path - raise ActionLogError(f"action log already active on {serial}: {active}") + return self._rearm_or_raise(serial, self._sessions[serial], path, guard) session = ActionLogSession(serial, path, dict(meta or {}), guard) session.write(session.header()) self._sessions[serial] = session diff --git a/src/linc_nerve/core/android.py b/src/linc_nerve/core/android.py index 5bcc4ca..b0197f7 100644 --- a/src/linc_nerve/core/android.py +++ b/src/linc_nerve/core/android.py @@ -62,6 +62,7 @@ "server_available", "shlex", "subprocess", + "validate_launch_uri", ] # Minimum screenshot size in bytes to consider valid (API 35 transparency issue) @@ -132,7 +133,10 @@ def _ready_adb_serials(client: Any) -> tuple[str, ...]: # AndroidController is composed from sibling mixin modules (split in #53). The # mixins reach this module's patched globals via ``_android.`` so the # suite's ``patch("linc_nerve.core.android.*")`` calls still take effect. -from .android_apps import _AppsMixin # noqa: E402 +# ``validate_launch_uri`` is re-exported so callers outside core import it from +# here: importing ``core.android_apps`` first is a circular-import error (see the +# ``_ROUTER_IDENTIFIER_RE`` note in daemon/http_router.py). +from .android_apps import _AppsMixin, validate_launch_uri # noqa: E402 from .android_input import _InputMixin # noqa: E402 from .android_network import _NetworkMixin # noqa: E402 from .android_reliability import _ReliabilityMixin # noqa: E402 diff --git a/src/linc_nerve/core/android_apps.py b/src/linc_nerve/core/android_apps.py index a3d9b70..64e1576 100644 --- a/src/linc_nerve/core/android_apps.py +++ b/src/linc_nerve/core/android_apps.py @@ -27,6 +27,24 @@ # string starts a second command. ``\Z`` keeps a stray ``.match`` safe too. _SAFE_IDENTIFIER_RE = re.compile(r"\A[A-Za-z][A-Za-z0-9_.]*\Z") _ACTIVITY_RE = re.compile(r"\A[A-Za-z_.][A-Za-z0-9_.$]*\Z") +# Deep-link URIs go into ``am start -d`` (Android) and ``simctl openurl`` / WDA +# ``/url`` (iOS). The grammar is an RFC 3986 scheme followed only by RFC 3986 +# unreserved / reserved / percent characters: no whitespace, no control +# characters, no quotes other than the sub-delim ``'``, no backslash or +# backtick. ``&`` is legal, so multi-param deep links pass (finding 7). Applied +# with ``fullmatch``; ``_q(uri)`` quoting stays as the second layer. +_URI_RE = re.compile(r"\A[A-Za-z][A-Za-z0-9+.-]*:[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+\Z") +_URI_MAX_LEN = 2048 + + +def validate_launch_uri(uri: str) -> None: + """Raise ``ValueError`` unless ``uri`` is an RFC 3986-shaped deep link (<= 2048 chars).""" + if len(uri) > _URI_MAX_LEN or not _URI_RE.fullmatch(uri): + raise ValueError( + f"Invalid uri {uri!r}: must be an RFC 3986 URI (scheme ':' then only " + f"unreserved/reserved/percent-encoded characters; no whitespace, control " + f"characters, quotes, backslash or backtick) of at most {_URI_MAX_LEN} characters" + ) def _q(token: str) -> str: @@ -202,6 +220,7 @@ def launch( _validate_activity(activity) extra_flags = self._format_extras(extras) if uri: + validate_launch_uri(uri) self._shell( serial, self._with_extras( diff --git a/src/linc_nerve/core/device_manager.py b/src/linc_nerve/core/device_manager.py index 67aab2c..000b501 100644 --- a/src/linc_nerve/core/device_manager.py +++ b/src/linc_nerve/core/device_manager.py @@ -60,6 +60,46 @@ def _resolve_owner(owner: str, session_id: str) -> str: return owner or session_id +def _default_owner(owner: str, owner_pid: int, session_id: str) -> str: + """The owner ``reserve`` stores: the caller's ``owner`` if given, else + ``pid:`` when the caller named its process, else the session id + (today's semantics — under stdio MCP each bellows process has its own + DeviceManager, so the session id IS per caller there).""" + if owner: + return owner + if owner_pid > 0: + return f"pid:{owner_pid}" + return session_id + + +def _has_explicit_identity(owner: str, owner_pid: int) -> bool: + """True when the caller named itself — an ``owner`` or an ``owner_pid``. + A caller with neither resolves to the shared session id, which every + owner-less caller of this process shares.""" + return bool(owner) or owner_pid > 0 + + +def _conflict_refusal( + serial: str, held: str, *, takeover: bool, explicit_identity: bool +) -> str | None: + """Refusal for a reserve that hits a live row held by a different owner, + or None when an explicit-identity takeover may proceed. Both refusals keep + `` is held by `` so cli/errors.py maps them to DEVICE_RESERVED.""" + if not takeover: + return ( + f"Device '{serial}' is {held}. Wait for release or timeout. If this is " + "your own lease, re-run with the same owner to renew it. Do not take " + "over a lease you did not create." + ) + if not explicit_identity: + return ( + f"Device '{serial}' is {held}. takeover needs an explicit owner: pass " + "owner (CLI --owner, or set NERVE_OWNER) so the lease is not shared by " + "every caller of this nerve process." + ) + return None + + def _owner_mismatch(requested: str, lease_owner: str, session_id: str) -> bool: """True when a fenced caller (non-empty ``requested``) is not the lease's effective owner. An empty ``requested`` is unfenced — never a mismatch.""" @@ -87,6 +127,12 @@ class DeviceReservation: purpose: str owner: str + # Not a dataclass field (no annotation), so the pinned 8-field contract + # holds: ``reserve`` sets it True on the instance it returns when the call + # renewed this owner's existing lease rather than creating/taking one. + renewed = False + owner_pid = None + class DeviceManager: """Manages device reservations across nerve process instances. @@ -96,8 +142,9 @@ class DeviceManager: reserve, release, and guard devices. """ - def __init__(self, db_path: Path | None = None) -> None: + def __init__(self, db_path: Path | None = None, *, renew_anonymous: bool = True) -> None: self._db_path = db_path if db_path is not None else _default_db_path() + self.renew_anonymous: bool = renew_anonymous self._session_id = uuid.uuid4().hex[:12] self._pid = os.getpid() self._closed = False @@ -193,9 +240,14 @@ def reserve( ``DeviceManager`` (and therefore one session_id) — without a caller-supplied owner, a second lane's reserve() on a live row would silently succeed and steal the lease. ``owner`` defaults to this - session's id, preserving pre-owner behavior for callers that don't - pass one. A live row held by a *different* owner blocks unless - ``takeover=True``, which force-claims it. + session's id, preserving pre-owner behavior for callers that pass + neither ``owner`` nor ``owner_pid``; with only ``owner_pid`` it + defaults to ``pid:``. A live row held by a *different* + owner blocks unless ``takeover=True``, which force-claims it — but + only for a caller with an explicit identity (``owner`` or + ``owner_pid``); an anonymous takeover is refused and the row is left + untouched. The returned reservation's ``renewed`` is True when this + call renewed the same owner's existing lease. ``owner_pid`` (0 = not supplied) names the caller's own process. The daemon reaps the lease on its tick once that process is gone, and @@ -208,7 +260,8 @@ def reserve( """ now = time.time() stored_owner_pid = owner_pid or None - resolved_owner = _resolve_owner(owner, self._session_id) + resolved_owner = _default_owner(owner, owner_pid, self._session_id) + renewed = False with self._lock: try: self._conn.execute( @@ -232,7 +285,7 @@ def reserve( ) self._conn.commit() except sqlite3.IntegrityError: - error = self._claim_existing_reservation( + claim = self._claim_existing_reservation( serial, resolved_owner, purpose, @@ -240,9 +293,11 @@ def reserve( now, takeover, stored_owner_pid, + explicit_identity=_has_explicit_identity(owner, owner_pid), ) - if error is not None: - return error + if isinstance(claim, str): + return claim + renewed = claim logger.info( "device_reserved", @@ -251,7 +306,7 @@ def reserve( purpose=purpose, owner=resolved_owner, ) - return DeviceReservation( + reservation = DeviceReservation( device_serial=serial, session_id=self._session_id, pid=self._pid, @@ -261,6 +316,9 @@ def reserve( purpose=purpose, owner=resolved_owner, ) + reservation.renewed = renewed + reservation.owner_pid = stored_owner_pid + return reservation def _claim_existing_reservation( self, @@ -271,10 +329,15 @@ def _claim_existing_reservation( now: float, takeover: bool, owner_pid: int | None = None, - ) -> str | None: + *, + explicit_identity: bool = True, + ) -> str | bool: """Handle the INSERT IntegrityError path of ``reserve`` under ``self._lock``: update the row in place for an idempotent same-owner - reserve or an explicit takeover, or return a refusal string. + reserve or an explicit-identity takeover, or return a refusal string. + + Returns the refusal string, else True for a same-owner renewal and + False for a takeover. """ row = self._conn.execute( "SELECT session_id, purpose, owner, reserved_at " @@ -288,11 +351,13 @@ def _claim_existing_reservation( existing_owner = _effective_owner(existing_raw_owner, existing_session_id) is_takeover = existing_owner != resolved_owner - if is_takeover and not takeover: + if is_takeover: held = _held_by(existing_owner, existing_reserved_at, existing_purpose) - return ( - f"Device '{serial}' is {held}. Wait for release or timeout, or pass takeover=True." + refusal = _conflict_refusal( + serial, held, takeover=takeover, explicit_identity=explicit_identity ) + if refusal is not None: + return refusal # Idempotent same-owner update and explicit takeover both re-point the # row at this session/pid — a same-owner reserve from a *different* @@ -347,7 +412,7 @@ def _claim_existing_reservation( ), ) self._conn.commit() - return None + return not is_takeover def release(self, serial: str, *, owner: str = "") -> bool: """Release a reservation held by ``owner`` (default: this session's id). @@ -605,11 +670,12 @@ def _renew_live_leases(self, now: float) -> None: rows this manager holds for itself, and rows whose owner_pid is set and alive. Caller-owned leases without an owner_pid are NOT renewed here; the caller renews by re-reserving. Caller holds ``self._lock``.""" - self._conn.execute( - "UPDATE reservations SET last_heartbeat = ? " - "WHERE session_id = ? AND (owner = '' OR owner = session_id)", - (now, self._session_id), - ) + if self.renew_anonymous: + self._conn.execute( + "UPDATE reservations SET last_heartbeat = ? " + "WHERE session_id = ? AND (owner = '' OR owner = session_id)", + (now, self._session_id), + ) pid_rows = self._conn.execute( "SELECT device_serial, owner_pid FROM reservations WHERE owner_pid IS NOT NULL" ).fetchall() diff --git a/src/linc_nerve/core/grounding.py b/src/linc_nerve/core/grounding.py new file mode 100644 index 0000000..e9c6168 --- /dev/null +++ b/src/linc_nerve/core/grounding.py @@ -0,0 +1,379 @@ +"""Visual UI grounding via Meta SAM 3.1 Promptable Concept Segmentation. + +When `nerve_elements` / `nerve_state` return an empty accessibility tree +(e.g. `uiautomator dump` killed on Android 14+, Flutter/Unity/WebView custom +canvases, or unlabelled icon buttons), callers can ground natural-language +visual descriptions ("the green Check Status chip", "the gear settings icon") +directly on the screenshot frame using SAM 3.1 (`POST /v1/responses`) and decode +1-bit masks via `meta-sam-parser`. + +Coordinate invariant (LINC rule 99f5b331): +SAM 3.1 boxes and masks are returned in the pixel coordinate space of the image +sent to the model `(W_img, H_img)`, whereas `nerve_tap(x, y)` expects physical +device display coordinates `(W_screen, H_screen)` from `ctrl.get_screen_size()`. +All returned `bounds` and `center` coordinates are scaled by +`(W_screen / W_img, H_screen / H_img)` when `screen_size` is provided. +""" + +from __future__ import annotations + +import base64 +import io +import math +import os +import subprocess +from typing import Any + +import httpx +import structlog +from meta_sam_parser import ( + CompletedOutcome, + SegmentationBoxRecord, + SegmentationMaskRecord, + decode_mask_to_raster, + image_segmentation_format, +) +from PIL import Image, ImageDraw + +from linc_nerve.core.action_log import COORDINATE_MAX, parse_bounds + +log = structlog.get_logger().bind(domain="grounding") + +DEFAULT_META_BASE_URL = "https://api.meta.ai/v1" +SAM_MODEL_ID = "sam-3.1" +DEFAULT_TIMEOUT_S = 15.0 + + +def _keychain_secret(account: str, service: str = "linc-env") -> str | None: + """Read an API key from macOS Keychain without logging or echoing it.""" + try: + proc = subprocess.run( + ["security", "find-generic-password", "-s", service, "-a", account, "-w"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return None + if proc.returncode != 0: + return None + secret = (proc.stdout or "").strip() + return secret or None + + +def resolve_meta_api_key(api_key: str | None = None) -> str: + """Resolve `META_AI_API_KEY` from argument, environment, or macOS Keychain.""" + if isinstance(api_key, str) and api_key.strip(): + return api_key.strip() + for env_name in ("META_AI_API_KEY", "META_API_KEY"): + val = (os.environ.get(env_name) or "").strip() + if val: + return val + for account in ("META_AI_API_KEY", "META_API_KEY"): + secret = _keychain_secret(account) + if secret: + return secret + raise ValueError( + "Missing Meta API key: set META_AI_API_KEY in environment or Keychain (service 'linc-env')" + ) + + +def _clamp_coord(value: float, upper: int) -> int: + """Round and clamp a finite coordinate into `0..min(upper, COORDINATE_MAX)`.""" + if not math.isfinite(value): + return 0 + limit = max(0, min(int(upper), COORDINATE_MAX)) + return max(0, min(limit, round(value))) + + +def _mask_centroid(mask_rec: SegmentationMaskRecord) -> tuple[float, float] | None: + """Compute the center of mass of a 1-bit SAM 3.1 mask in source image pixels.""" + try: + raster = decode_mask_to_raster(mask_rec.mask) + except Exception as exc: + log.debug("sam_mask_decode_failed", error=str(exc)) + return None + + width = int(mask_rec.mask.width) + height = int(mask_rec.mask.height) + if width <= 0 or height <= 0 or len(raster) < width * height: + return None + + offset_x = int(mask_rec.bounds.left) + offset_y = int(mask_rec.bounds.top) + sum_x = 0 + sum_y = 0 + count = 0 + for idx, pixel in enumerate(raster[: width * height]): + if pixel: + sum_x += offset_x + (idx % width) + sum_y += offset_y + (idx // width) + count += 1 + if count == 0: + return None + return (sum_x / count, sum_y / count) + + +def _scale_pair( + img_size: tuple[int, int], screen_size: dict[str, int] | None +) -> tuple[float, float, int, int]: + """Return `(scale_x, scale_y, max_x, max_y)` from image size to device screen size.""" + img_w = max(1, int(img_size[0])) + img_h = max(1, int(img_size[1])) + if isinstance(screen_size, dict): + sw = int(screen_size.get("width") or 0) + sh = int(screen_size.get("height") or 0) + if sw > 0 and sh > 0: + return sw / img_w, sh / img_h, sw - 1, sh - 1 + return 1.0, 1.0, img_w - 1, img_h - 1 + + +def _extract_response_text(payload: dict[str, Any]) -> str: + """Concatenate all text segments from a `/v1/responses` JSON payload.""" + chunks: list[str] = [] + for item in payload.get("output") or []: + if not isinstance(item, dict): + continue + for block in item.get("content") or []: + if isinstance(block, dict) and isinstance(block.get("text"), str): + chunks.append(block["text"]) + return "".join(chunks) + + +def _call_sam31_api( + png_bytes: bytes, + prompt: str, + api_key: str, + base_url: str | None = None, + timeout_s: float = DEFAULT_TIMEOUT_S, +) -> str: + """Call `POST /v1/responses` with `model="sam-3.1"` and return the segmentation wire string.""" + resolved_base = ( + base_url or os.environ.get("META_API_BASE_URL") or DEFAULT_META_BASE_URL + ).rstrip("/") + b64_img = base64.b64encode(png_bytes).decode("ascii") + body = { + "model": SAM_MODEL_ID, + "stream": False, + "metadata": {"include_confidence": "true", "mask_encoding": "one_bit"}, + "input": [ + { + "role": "user", + "content": [ + {"type": "input_text", "text": prompt}, + {"type": "input_image", "image_url": f"data:image/png;base64,{b64_img}"}, + ], + } + ], + } + resp = httpx.post( + f"{resolved_base}/responses", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + json=body, + timeout=timeout_s, + ) + resp.raise_for_status() + data = resp.json() + if not isinstance(data, dict): + raise ValueError("SAM 3.1 API returned non-object JSON") + return _extract_response_text(data) + + +def _build_target_node( + object_id: str, + prompt: str, + bounds_half_open: tuple[int, int, int, int], + centroid_img: tuple[float, float], + confidence: float, + scale_info: tuple[float, float, int, int], +) -> dict[str, Any]: + """Scale one grounded object into an `ElementNode`-compatible dict.""" + scale_x, scale_y, max_x, max_y = scale_info + left, top, right, bottom = bounds_half_open + x2_inc = max(left, right - 1) + y2_inc = max(top, bottom - 1) + sx1 = _clamp_coord(left * scale_x, max_x) + sy1 = _clamp_coord(top * scale_y, max_y) + sx2 = max(sx1, _clamp_coord(x2_inc * scale_x, max_x)) + sy2 = max(sy1, _clamp_coord(y2_inc * scale_y, max_y)) + scx = _clamp_coord(centroid_img[0] * scale_x, max_x) + scy = _clamp_coord(centroid_img[1] * scale_y, max_y) + safe_conf = confidence if (math.isfinite(confidence) and 0.0 <= confidence <= 1.0) else 0.0 + return { + "text": prompt, + "desc": prompt, + "class": "VisualTarget", + "bounds": f"[{sx1},{sy1}][{sx2},{sy2}]", + "center": {"x": scx, "y": scy}, + "confidence": round(safe_conf, 4), + "object_id": object_id, + "clickable": True, + "source": SAM_MODEL_ID, + } + + +def parse_sam31_records( + raw_text: str, + prompt: str, + img_size: tuple[int, int], + screen_size: dict[str, int] | None = None, +) -> list[dict[str, Any]]: + """Parse SAM 3.1 wire text into `ElementNode`-compatible dicts sorted by confidence.""" + if not isinstance(raw_text, str) or not raw_text.strip(): + return [] + + parser = image_segmentation_format().create_parser() + parser.push(raw_text.strip()) + finish = parser.finish(CompletedOutcome()) + if finish.result is None: + return [] + + boxes: dict[str, SegmentationBoxRecord] = {} + masks: dict[str, SegmentationMaskRecord] = {} + order: list[str] = [] + for rec in finish.result.records: + if isinstance(rec, SegmentationBoxRecord): + boxes[rec.object_id] = rec + if rec.object_id not in order: + order.append(rec.object_id) + elif isinstance(rec, SegmentationMaskRecord): + masks[rec.object_id] = rec + if rec.object_id not in order: + order.append(rec.object_id) + + scale_info = _scale_pair(img_size, screen_size) + targets: list[dict[str, Any]] = [] + for obj_id in order: + box_rec = boxes.get(obj_id) + mask_rec = masks.get(obj_id) + if box_rec is not None: + half_open = (box_rec.left, box_rec.top, box_rec.right, box_rec.bottom) + elif mask_rec is not None: + b = mask_rec.bounds + half_open = (b.left, b.top, b.right, b.bottom) + else: + continue + + centroid = _mask_centroid(mask_rec) if mask_rec is not None else None + if centroid is None: + left, top, right, bottom = half_open + centroid = ((left + max(left, right - 1)) / 2.0, (top + max(top, bottom - 1)) / 2.0) + + if box_rec is not None and box_rec.confidence is not None: + conf = box_rec.confidence + elif mask_rec is not None and mask_rec.confidence is not None: + conf = mask_rec.confidence + else: + conf = 0.0 + targets.append( + _build_target_node(obj_id, prompt, half_open, centroid, float(conf), scale_info) + ) + + targets.sort(key=lambda item: float(item.get("confidence", 0.0)), reverse=True) + return targets + + +def ground_visual_targets( + image_bytes: bytes, + prompt: str, + screen_size: dict[str, int] | None = None, + api_key: str | None = None, +) -> list[dict[str, Any]]: + """Ground *prompt* on *image_bytes* using SAM 3.1 and return scaled target nodes.""" + if not isinstance(image_bytes, (bytes, bytearray)) or not image_bytes: + raise ValueError("image_bytes must be non-empty bytes") + if not isinstance(prompt, str) or not prompt.strip(): + raise ValueError("prompt must be a non-empty string") + + clean_prompt = prompt.strip() + resolved_key = resolve_meta_api_key(api_key) + + with Image.open(io.BytesIO(image_bytes)) as img: + img_w, img_h = img.size + buf = io.BytesIO() + img.convert("RGB").save(buf, format="PNG") + png_bytes = buf.getvalue() + + raw_text = _call_sam31_api(png_bytes, clean_prompt, resolved_key) + return parse_sam31_records(raw_text, clean_prompt, (img_w, img_h), screen_size=screen_size) + + +def render_grounded_overlay( + image_bytes: bytes, + targets: list[dict[str, Any]], + screen_size: dict[str, int] | None = None, +) -> bytes: + """Render translucent green bounding boxes and red centroid crosshairs onto *image_bytes*.""" + with Image.open(io.BytesIO(image_bytes)) as src: + base = src.convert("RGBA") + img_w, img_h = base.size + scale_x, scale_y, _, _ = _scale_pair((img_w, img_h), screen_size) + inv_x = 1.0 / scale_x if scale_x > 0 else 1.0 + inv_y = 1.0 / scale_y if scale_y > 0 else 1.0 + + overlay = Image.new("RGBA", base.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + + for idx, target in enumerate(targets): + rect = parse_bounds(str(target.get("bounds") or "")) + if rect is not None: + x1, y1, x2, y2 = rect + ix1, iy1 = round(x1 * inv_x), round(y1 * inv_y) + ix2, iy2 = max(ix1 + 1, round(x2 * inv_x)), max(iy1 + 1, round(y2 * inv_y)) + draw.rectangle( + [ix1, iy1, ix2, iy2], + fill=(0, 220, 90, 56), + outline=(0, 220, 90, 235), + width=2, + ) + conf = target.get("confidence", 0.0) + label = f"#{idx} {conf:.2f}" if isinstance(conf, (int, float)) else f"#{idx}" + label_box = [ix1, max(0, iy1 - 14), ix1 + len(label) * 7 + 6, iy1] + draw.rectangle(label_box, fill=(0, 160, 65, 230)) + draw.text((ix1 + 3, max(0, iy1 - 13)), label, fill=(255, 255, 255, 255)) + + center = target.get("center") + if isinstance(center, dict) and "x" in center and "y" in center: + cx = round(float(center["x"]) * inv_x) + cy = round(float(center["y"]) * inv_y) + r = 5 + draw.ellipse( + [cx - r, cy - r, cx + r, cy + r], + fill=(255, 50, 50, 240), + outline=(255, 255, 255, 255), + ) + + composited = Image.alpha_composite(base, overlay).convert("RGB") + out = io.BytesIO() + composited.save(out, format="PNG") + return out.getvalue() + + +def should_suggest_visual_grounding( + elements: list[dict[str, Any]] | None, + elements_error: str | None = None, +) -> bool: + """Return True when the UI tree is empty, errored, or has unlabelled clickable nodes.""" + if elements_error or not elements: + return True + clickable = [el for el in elements if isinstance(el, dict) and el.get("clickable")] + if not clickable: + return False + unlabelled = [ + el + for el in clickable + if not (el.get("text") or el.get("desc") or el.get("id") or el.get("resource_id")) + ] + return len(unlabelled) > 0 + + +VISUAL_GROUNDING_HINT = ( + "UI hierarchy is empty or contains unlabelled clickable controls on this screen. " + "Use nerve_ground(device, prompt=..., annotate=True) or nerve_tap(device, prompt=...) " + "for Meta SAM 3.1 visual concept grounding." +) diff --git a/src/linc_nerve/core/output_retention.py b/src/linc_nerve/core/output_retention.py new file mode 100644 index 0000000..dc75689 --- /dev/null +++ b/src/linc_nerve/core/output_retention.py @@ -0,0 +1,299 @@ +"""nerve-output retention: per-dir file cap, owner lock, stale sweep, cleanup (linc-nerve-si3y). + +Each process writes tool output to its own private ``/nerve-output-/`` directory +and holds an exclusive ``flock`` on ``/.nerve-output.lock`` for its whole lifetime. The +kernel drops that lock when the process exits, so "lock held" is an exact liveness test that +survives an idle daemon and needs no pid bookkeeping (the pid in the file is informational). + +Liveness fails SAFE: if the lock state cannot be determined (the lock path is a symlink or a +directory, ``open``/``flock`` errors), the directory is treated as live and never removed. A +directory with no lock file at all (written by an older nerve, or in the instant between +``mkdtemp`` and taking the lock) falls back to the age rules. + +Pure stdlib — no click — so ``mcp/_core`` can import it without pulling in the CLI. +""" + +from __future__ import annotations + +import contextlib +import fcntl +import os +import re +import shutil +import stat +import tempfile +import time +from collections.abc import Mapping +from pathlib import Path +from typing import Any, BinaryIO + +import structlog + +log = structlog.get_logger().bind(domain="output_retention") + +OUTPUT_DIR_PREFIX = "nerve-output-" +LOCK_NAME = ".nerve-output.lock" +CLEANUP_ACTIVE_WINDOW_S = 60.0 +DEFAULT_OUTPUT_KEEP = 200 +DEFAULT_OUTPUT_MAX_AGE_S = 86400 +_OUTPUT_FILE_RE = re.compile(r"\A.+_(\d{4,})_[A-Za-z][A-Za-z0-9_]*\.[A-Za-z0-9]+\Z") +# Never follow a symlinked lock path, never block on a FIFO planted there, never leak the fd. +_NOFOLLOW = os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + + +def positive_int_env(name: str, default: int) -> int: + """Parse a positive int from env *name*, falling back to *default* on 0/invalid.""" + try: + val = int(os.environ.get(name, "")) + except ValueError: + return default + return val if val > 0 else default + + +def output_keep() -> int: + return positive_int_env("NERVE_OUTPUT_KEEP", DEFAULT_OUTPUT_KEEP) + + +def output_max_age_s() -> int: + return positive_int_env("NERVE_OUTPUT_MAX_AGE_S", DEFAULT_OUTPUT_MAX_AGE_S) + + +def sweep_enabled(environ: Mapping[str, str]) -> bool: + """Whether the first-output stale sweep runs. + + ``NERVE_OUTPUT_SWEEP=0`` disables it, ``=1`` forces it. Under pytest it is off by default + so a test run can never delete the real temp root's ``nerve-output-*`` directories. + """ + flag = environ.get("NERVE_OUTPUT_SWEEP", "") + if flag in ("0", "1"): + return flag == "1" + return "PYTEST_CURRENT_TEST" not in environ + + +# --------------------------------------------------------------------------- +# Per-dir retention cap +# --------------------------------------------------------------------------- + + +def matching_output_files(dir_path: str | os.PathLike[str]) -> list[tuple[int, int, str, str]]: + """Return sorted ``(counter, mtime_ns, name, path)`` for regular nerve output files.""" + items: list[tuple[int, int, str, str]] = [] + with contextlib.suppress(OSError), os.scandir(dir_path) as it: + for entry in it: + match = _OUTPUT_FILE_RE.fullmatch(entry.name) + if match is None: + continue + with contextlib.suppress(OSError): + if entry.is_file(follow_symlinks=False): + st = entry.stat(follow_symlinks=False) + items.append((int(match.group(1)), st.st_mtime_ns, entry.name, entry.path)) + items.sort() + return items + + +def prune_output_dir(dir: str | os.PathLike[str], keep: int = DEFAULT_OUTPUT_KEEP) -> list[str]: + """Delete oldest regular nerve output files beyond ``keep - 1`` in *dir*.""" + limit = keep if keep > 0 else DEFAULT_OUTPUT_KEEP + files = matching_output_files(dir) + excess = max(0, len(files) - (limit - 1)) + removed: list[str] = [] + for _, _, _, path in files[:excess]: + with contextlib.suppress(OSError): + os.unlink(path) + removed.append(path) + return removed + + +# --------------------------------------------------------------------------- +# Owner lock (liveness) +# --------------------------------------------------------------------------- + + +def acquire_dir_lock(dir_path: str) -> BinaryIO | None: + """Take this process's exclusive owner lock on *dir_path*; None if it cannot be taken. + + The returned handle must stay open for the life of the process (closing it releases the + lock). Failure never raises: the dir still works, it is just unprotected. + """ + try: + fd = os.open(os.path.join(dir_path, LOCK_NAME), os.O_RDWR | os.O_CREAT | _NOFOLLOW, 0o600) + except OSError: + return None + handle = os.fdopen(fd, "r+b", buffering=0) + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + handle.truncate(0) + handle.write(f"{os.getpid()}\n".encode()) + except OSError: + handle.close() + return None + return handle + + +def _lock_is_held(fd: int) -> bool: + """True unless *fd* is a regular file a non-blocking shared flock succeeds on. + + Anything else (not a regular file, any ``fstat``/``flock`` error) is unknown -> held. + """ + try: + if not stat.S_ISREG(os.fstat(fd).st_mode): + return True + fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB) + except OSError: + return True + with contextlib.suppress(OSError): + fcntl.flock(fd, fcntl.LOCK_UN) + return False + + +def dir_is_live(dir_path: str) -> bool: + """True if a live process holds *dir_path*'s owner lock, or its state is unknown.""" + try: + fd = os.open(os.path.join(dir_path, LOCK_NAME), os.O_RDONLY | _NOFOLLOW) + except FileNotFoundError: + return False + except OSError: + return True + try: + return _lock_is_held(fd) + finally: + os.close(fd) + + +def create_locked_output_dir(tmp_root: str | None = None) -> tuple[str, BinaryIO | None]: + """Create a fresh private ``nerve-output-*`` dir and take its owner lock.""" + path = tempfile.mkdtemp(prefix=OUTPUT_DIR_PREFIX, dir=tmp_root) + return path, acquire_dir_lock(path) + + +# --------------------------------------------------------------------------- +# Directory scanning +# --------------------------------------------------------------------------- + + +def is_owned_output_dir(entry_path: str, uid: int) -> os.stat_result | None: + """Return ``lstat`` for *entry_path* if it is a real directory owned by *uid*, else None. + + Uses ``os.lstat`` so symlinks are never followed (`stat.S_ISDIR` is False on symlinks). + """ + try: + st = os.lstat(entry_path) + except OSError: + return None + if not stat.S_ISDIR(st.st_mode) or st.st_uid != uid: + return None + return st + + +def newest_entry_mtime(dir_path: str, dir_st: os.stat_result) -> float: + """Return the newest mtime of *dir_path* and its direct entries (never following symlinks).""" + newest = dir_st.st_mtime + with contextlib.suppress(OSError), os.scandir(dir_path) as it: + for child in it: + with contextlib.suppress(OSError): + newest = max(newest, child.stat(follow_symlinks=False).st_mtime) + return newest + + +def dir_regular_bytes(dir_path: str) -> int: + """Sum ``st_size`` of non-symlink regular files directly inside *dir_path*.""" + total = 0 + with contextlib.suppress(OSError), os.scandir(dir_path) as it: + for child in it: + with contextlib.suppress(OSError): + st = child.stat(follow_symlinks=False) + if stat.S_ISREG(st.st_mode): + total += st.st_size + return total + + +def candidate_output_dirs(root: Path) -> list[str]: + """Return sorted full paths of ``nerve-output-*`` entries directly under *root*.""" + paths: list[str] = [] + with contextlib.suppress(OSError), os.scandir(root) as it: + for entry in it: + if entry.name.startswith(OUTPUT_DIR_PREFIX): + paths.append(os.path.join(str(root), entry.name)) + paths.sort() + return paths + + +def _is_own_dir(entry_path: str, own_dir: str | None) -> bool: + return own_dir is not None and os.path.abspath(entry_path) == os.path.abspath(own_dir) + + +def _removable_age(entry_path: str, uid: int, now: float) -> float | None: + """Age of an owned, non-symlink, unlocked dir; None if it must be kept regardless of age.""" + st = is_owned_output_dir(entry_path, uid) + if st is None or dir_is_live(entry_path): + return None + return now - newest_entry_mtime(entry_path, st) + + +# --------------------------------------------------------------------------- +# Stale sweep (first output) and on-demand cleanup (`nerve cleanup`) +# --------------------------------------------------------------------------- + + +def sweep_stale_output_dirs( + tmp_root: str | os.PathLike[str], + own_dir: str | None, + max_age_s: float, + now: float, +) -> list[str]: + """Remove owned, non-symlink, unlocked ``nerve-output-*`` dirs older than *max_age_s*.""" + uid = os.getuid() + removed: list[str] = [] + errors: list[str] = [] + for entry_path in candidate_output_dirs(Path(tmp_root)): + if _is_own_dir(entry_path, own_dir): + continue + try: + age = _removable_age(entry_path, uid, now) + if age is not None and age > max_age_s: + shutil.rmtree(entry_path) + removed.append(entry_path) + except Exception as exc: + errors.append(str(exc)) + if errors: + log.warning("stale_output_dir_sweep_error", error=errors[0], count=len(errors)) + return removed + + +def cleanup_output_dirs( + tmp_root: str | os.PathLike[str] | None = None, + *, + remove_all: bool = False, + now: float | None = None, +) -> dict[str, Any]: + """Remove current-uid, non-symlink ``nerve-output-*`` dirs under *tmp_root*. + + A dir whose owner process is alive (lock held, or lock state unknown) is always skipped. + Dirs modified within the last 60 s are also skipped unless *remove_all* is True. + """ + root = Path(tmp_root if tmp_root is not None else tempfile.gettempdir()) + current_time = time.time() if now is None else float(now) + window = -1.0 if remove_all else CLEANUP_ACTIVE_WINDOW_S + uid = os.getuid() + removed: list[str] = [] + skipped: list[str] = [] + bytes_freed = 0 + for entry_path in candidate_output_dirs(root): + age = _removable_age(entry_path, uid, current_time) + if age is None or age < window: + skipped.append(entry_path) + continue + dir_bytes = dir_regular_bytes(entry_path) + try: + shutil.rmtree(entry_path) + except OSError: + skipped.append(entry_path) + continue + removed.append(entry_path) + bytes_freed += dir_bytes + return {"removed": removed, "skipped": skipped, "bytes_freed": bytes_freed} diff --git a/src/linc_nerve/core/replay.py b/src/linc_nerve/core/replay.py index bb303cb..7471555 100644 --- a/src/linc_nerve/core/replay.py +++ b/src/linc_nerve/core/replay.py @@ -158,7 +158,8 @@ def plan_lines(lines: Iterable[tuple[int, Any]]) -> list[Step]: Keeps successful (``ok``), non-readonly ``action`` lines of the :data:`REPLAYABLE_TOOLS` (tap, long press, swipe, type, key, launch); drops - the header, guard lines, failed actions and every other tool. Taps and long + the header, guard and guard_updated lines, failed actions and every other + tool. Taps and long presses carry their recorded ``target`` as a locator. A kept line whose ``args`` is not an object, ``seq`` not an integer, or ``duration_ms`` / ``t_session_s`` not a finite number raises ValueError naming its line. diff --git a/src/linc_nerve/daemon/http_router.py b/src/linc_nerve/daemon/http_router.py index 93679f6..b006be0 100644 --- a/src/linc_nerve/daemon/http_router.py +++ b/src/linc_nerve/daemon/http_router.py @@ -13,6 +13,7 @@ import re import subprocess import time +import types import typing from collections.abc import Callable from typing import Any @@ -20,6 +21,7 @@ import structlog from linc_nerve.core import adb_health +from linc_nerve.core.android import validate_launch_uri from linc_nerve.core.emulator_console import is_allowed_sensor_type from linc_nerve.core.observability import log_mcp_call @@ -77,8 +79,9 @@ # `_validate_shell_identifier`/`_validate_activity` with `fullmatch`, and # shlex-quote each token — 2026-10-02 security review H1), so this router # check is the outer layer of two, not the only one. A field like `uri` -# legitimately contains `&` in multi-param deep links (guarding it here would -# repeat the finding-7 false-positive mistake); core shell-quotes it. +# is validated against RFC 3986 grammar via `validate_launch_uri` +# (`_reject_bad_uri`), which admits `&` so multi-param deep links pass +# without repeating the finding-7 false positive, and core shell-quotes it. # `package` is the one field this PR can safely close at the router # chokepoint. NOTE: round 0 justified that with "Android package # identifiers are always `[a-zA-Z0-9_.]+`, so there is no legitimate value @@ -87,8 +90,7 @@ # bundle IDs, which legitimately contain `-`, and linc-crucible sends # `package=""` on every BVT navigate step. The shipped grammar at # `_ROUTER_IDENTIFIER_RE` reflects those collisions, not the original claim. -# (Historical: the activity/uri class this comment once deferred to a -# follow-up is closed in core — activity is allowlisted, uri is quoted.) +# (Historical: activity is allowlisted, uri is RFC 3986-validated and quoted.) # # round-0 repair used a DENYLIST (`_SHELL_METACHAR_PATTERN = # re.compile(r"[;&|`$()\n\r]")`) that missed `>`, `<`, `*`, quotes, and @@ -556,6 +558,22 @@ def _reject_bad_extras_key(tool_name: str, body: dict[str, Any]) -> dict[str, An } +def _reject_bad_uri(tool_name: str, body: dict[str, Any]) -> dict[str, Any] | None: + """400 rejection if a top-level uri is present, non-empty, and invalid.""" + uri = body.get("uri") + if not isinstance(uri, str) or not uri: + return None + try: + validate_launch_uri(uri) + except ValueError: + log.warning("tool_call_rejected_bad_uri", tool=tool_name) + return { + "status": 400, + "body": {"error": "Request body contains a disallowed uri value"}, + } + return None + + def _reject_bad_filter(tool_name: str, body: dict[str, Any]) -> dict[str, Any] | None: """400 rejection if a log-filter field doesn't match the safe grammar, else None.""" if not _contains_bad_filter(body): @@ -600,6 +618,7 @@ def _check_reservation_conflict(tool_name: str, body: dict[str, Any]) -> dict[st _reject_bad_path, _reject_shell_metacharacters, _reject_bad_extras_key, + _reject_bad_uri, _reject_bad_filter, _reject_bad_sensor_type, _check_reservation_conflict, @@ -711,18 +730,43 @@ def _json_type_name(value: Any) -> str: return _JSON_TYPE_NAMES.get(type(value), type(value).__name__) +_UNION_ORIGINS = (typing.Union, types.UnionType) + + +def _coerced_type(hint: Any) -> Any: + """The int/bool/float a param coerces to: the hint itself, or X of ``X | None``. + + ``Optional[X]`` is the same union. Any other hint (str, a wider union) → None. + """ + if hint in _COERCERS: + return hint + if typing.get_origin(hint) not in _UNION_ORIGINS: + return None + members = [arg for arg in typing.get_args(hint) if arg is not type(None)] + if len(members) == 1 and members[0] in _COERCERS: + return members[0] + return None + + +def _coerce_param(hint: Any, value: Any) -> Any: + """`value` coerced to `hint` (None stays None for ``X | None``), or `_INVALID`.""" + target = _coerced_type(hint) + if target is None or (value is None and target is not hint): + return value + return _COERCERS[target](value) + + def _coerce_typed_params(fn: Callable[..., Any], body: dict[str, Any]) -> dict[str, Any] | None: - """Coerce int/bool/float-annotated params of `fn` in `body`, in place. + """Coerce int/bool/float (or ``X | None``) params of `fn` in `body`, in place. Returns None when every typed param is (now) the annotated type, else a 400 rejection naming the FIRST bad field. """ hints = _type_hints_for(fn) for name, value in body.items(): - coerce = _COERCERS.get(hints.get(name)) - coerced = value if coerce is None else coerce(value) + coerced = _coerce_param(hints.get(name), value) if coerced is _INVALID: - expected = hints[name].__name__ + expected = _coerced_type(hints[name]).__name__ error = f"Invalid parameters: {name} must be {expected}, got {_json_type_name(value)}" return {"status": 400, "body": {"error": error}} body[name] = coerced diff --git a/src/linc_nerve/mcp/_core.py b/src/linc_nerve/mcp/_core.py index 27d3cf2..e74417d 100644 --- a/src/linc_nerve/mcp/_core.py +++ b/src/linc_nerve/mcp/_core.py @@ -11,19 +11,22 @@ import tempfile import threading import time +from collections.abc import Iterable, Mapping from functools import partial, wraps -from typing import Any +from typing import Any, BinaryIO import anyio import structlog +from linc_cli_kit import strict_tools from mcp.server.fastmcp import FastMCP, Image from mcp.types import ToolAnnotations from linc_nerve.core import action_log +from linc_nerve.core import output_retention as _retention from linc_nerve.core.android import AndroidController from linc_nerve.core.contracts import require from linc_nerve.core.device import NerveDevice, Platform -from linc_nerve.core.device_manager import DeviceManager +from linc_nerve.core.device_manager import DeviceManager, _resolve_owner from linc_nerve.core.emulator import EmulatorManager from linc_nerve.core.ios import IOSController from linc_nerve.core.metrics import MetricsCollector @@ -35,6 +38,7 @@ log = structlog.get_logger().bind(domain="device_control") server = FastMCP("linc-nerve") +strict_tools(server) # --------------------------------------------------------------------------- # Singletons @@ -47,6 +51,15 @@ _device_mgr: DeviceManager | None = None _emulator: EmulatorManager | None = None +_daemon_mode: bool = False + + +def set_daemon_mode(enabled: bool = True) -> None: + """Flag that this process is the long-lived nerve daemon.""" + global _daemon_mode + _daemon_mode = enabled + + # Per-device async semaphores — serialize MCP tool calls per device so # concurrent tools don't exhaust ADB's per-device connection capacity. _device_semaphores: dict[str, anyio.abc.Semaphore] = {} @@ -79,6 +92,8 @@ # import, and ``mkdtemp()`` gives an unpredictable, owner-only (0700) directory # instead of a shared ``/nerve-output`` parent (security review, Info). _OUTPUT_DIR: str | None = None +# Open handle holding the exclusive owner flock on _OUTPUT_DIR for the process lifetime. +_OUTPUT_LOCK: BinaryIO | None = None _output_dir_lock = threading.Lock() _file_counter = 0 @@ -427,7 +442,14 @@ def _trusted_tree(device: str) -> list[Any] | dict[str, Any]: def _guarded_context( guard: action_log.GuardConfig, tool_name: str, device: str, call_args: dict[str, Any] ) -> tuple[dict[str, str] | None, dict[str, Any] | None]: - """(target, label refusal) for a guarded call that activates an element.""" + """(target, label refusal) for a guarded call that activates an element. + + A touch whose coordinates do not parse is refused before the label check + (fail closed), since the element under it cannot be found. + """ + bad_coords = action_log.coordinate_refusal(tool_name, call_args) + if bad_coords is not None: + return None, bad_coords if not action_log.needs_label_check(tool_name, call_args): return None, None tree = _trusted_tree(device) @@ -560,6 +582,183 @@ def _call_with_action_log( action_log.REGISTRY.append(device, entry, at=t0, rec_started=rec_started) +_SWIPE_CANONICAL = ("start_x", "start_y", "end_x", "end_y") +_SWIPE_ALIAS = ("x1", "y1", "x2", "y2") +_ALIAS_TOOLS = frozenset({"nerve_key", "nerve_swipe", "nerve_tap"}) +_LAST_GROUNDED_TAP: dict[tuple[str, int, int], dict[str, Any]] = {} + + +def _resolve_tap_prompt(device: str, prompt: str) -> tuple[int, int, dict[str, Any]] | str: + """Resolve a semantic visual tap prompt to ``(x, y, meta)`` or an error string.""" + from linc_nerve.core.grounding import ground_visual_targets + + dev, platform = _resolve_device(device) + if dev is None: + return platform + guard_err = _check_guard(device) + if guard_err: + return str(guard_err.get("error") or "Device reserved") + ctrl = _get_controller(platform) + try: + png_bytes = ctrl.screenshot(device) + screen_size = ctrl.get_screen_size(device) if hasattr(ctrl, "get_screen_size") else None + targets = ground_visual_targets(png_bytes, prompt, screen_size=screen_size) + except Exception as exc: + return f"Visual grounding failed: {exc}" + if not targets: + return f"No visual match found for prompt: {prompt!r}" + top = targets[0] + center = top.get("center") or {} + cx, cy = int(center.get("x", -1)), int(center.get("y", -1)) + meta = { + "prompt": prompt, + "confidence": top.get("confidence", 0.0), + "bounds": top.get("bounds", ""), + "source": top.get("source", "sam-3.1"), + } + return cx, cy, meta + + +def _canonical_tap_args(kwargs: dict[str, Any]) -> dict[str, Any]: + """Fold a semantic ``prompt`` on ``nerve_tap`` into ``(x, y)`` coordinates.""" + prompt_raw = kwargs.get("prompt") + prompt = prompt_raw.strip() if isinstance(prompt_raw, str) else "" + if not prompt: + if "prompt" not in kwargs: + return dict(kwargs) + return {k: v for k, v in kwargs.items() if k != "prompt"} + if kwargs.get("x") is not None or kwargs.get("y") is not None: + return { + "error": "Conflicting tap arguments: pass x/y coordinates or prompt, not both.", + "validation_error": True, + } + device = str(kwargs.get("device") or "") + resolved = _resolve_tap_prompt(device, prompt) + if isinstance(resolved, str): + session = action_log.REGISTRY.active(device) if device else None + if session is not None and session.guard is not None: + return {k: v for k, v in kwargs.items() if k != "prompt"} + return {"error": resolved, "prompt": prompt} + cx, cy, meta = resolved + _LAST_GROUNDED_TAP[(device, cx, cy)] = meta + base = {k: v for k, v in kwargs.items() if k != "prompt"} + return {**base, "x": cx, "y": cy} + + +def _canonical_key_args(kwargs: dict[str, Any]) -> dict[str, Any]: + """Fold ``keycode`` into ``key`` or return a validation error dict.""" + key = kwargs.get("key") or "" + keycode = kwargs.get("keycode") or "" + if not key and not keycode: + return { + "error": "Missing key argument: provide 'key' or 'keycode'.", + "validation_error": True, + } + if key and keycode and key != keycode: + return { + "error": f"Conflicting key arguments: key={key!r} and keycode={keycode!r} differ.", + "validation_error": True, + } + out = {k: v for k, v in kwargs.items() if k != "keycode"} + out["key"] = key or keycode + return out + + +def _canonical_swipe_args(kwargs: dict[str, Any]) -> dict[str, Any]: + """Fold ``x1..y2`` into ``start_x..end_y`` or return a validation error dict.""" + canon_missing = [k for k in _SWIPE_CANONICAL if kwargs.get(k) is None] + alias_missing = [k for k in _SWIPE_ALIAS if kwargs.get(k) is None] + has_canon = len(canon_missing) < 4 + has_alias = len(alias_missing) < 4 + if has_canon and not has_alias and not canon_missing: + return {k: v for k, v in kwargs.items() if k not in _SWIPE_ALIAS} + if has_alias and not has_canon and not alias_missing: + coords = dict(zip(_SWIPE_CANONICAL, (kwargs[k] for k in _SWIPE_ALIAS), strict=True)) + rest = { + k: v for k, v in kwargs.items() if k not in _SWIPE_CANONICAL and k not in _SWIPE_ALIAS + } + return {**rest, **coords} + if has_canon and has_alias: + missing = [*canon_missing, *alias_missing] + detail = f" (missing: {', '.join(missing)})" if missing else "" + return { + "error": ( + "Conflicting swipe coordinates: pass start_x/start_y/end_x/end_y " + f"or x1/y1/x2/y2, not both{detail}." + ), + "validation_error": True, + } + missing = ( + canon_missing + if has_canon + else (alias_missing if has_alias else [*_SWIPE_CANONICAL, *_SWIPE_ALIAS]) + ) + return { + "error": f"Missing swipe coordinates: {', '.join(missing)}.", + "validation_error": True, + } + + +def _canonical_input_args(tool_name: str, kwargs: dict[str, Any]) -> dict[str, Any]: + """Fold input-tool aliases into canonical kwargs before the guard or action log sees them.""" + if tool_name == "nerve_key": + return _canonical_key_args(kwargs) + if tool_name == "nerve_swipe": + return _canonical_swipe_args(kwargs) + if tool_name == "nerve_tap": + return _canonical_tap_args(kwargs) + return dict(kwargs) + + +def _with_canonical_input(fn: Any, raw_fn: Any, tool_name: str) -> Any: + """Bind and fold input aliases before ``_with_action_log`` / ``_run_in_session``.""" + if tool_name not in _ALIAS_TOOLS: + return fn + sig = inspect.signature(raw_fn) + + @wraps(raw_fn) + def canonicalized(*args: Any, **kwargs: Any) -> Any: + try: + call_kwargs = dict(sig.bind(*args, **kwargs).arguments) + except TypeError: + return fn(*args, **kwargs) + bound = _canonical_input_args(tool_name, call_kwargs) + if bound.get("validation_error") or "error" in bound: + return bound + return fn(**bound) + + return canonicalized + + +def _coordinate_error(tool_name: str, call_args: dict[str, Any]) -> dict[str, Any] | None: + """A validation error naming the invalid coordinates of a touch call, or None.""" + bad = action_log.non_integer_coordinates(tool_name, call_args) + if not bad: + return None + shown = ", ".join(f"{name}={call_args.get(name)!r}" for name in bad) + msg = f"Invalid coordinates (integers in 0..{action_log.COORDINATE_MAX} required): {shown}" + return {"error": msg, "validation_error": True} + + +def _with_coordinate_check(fn: Any, tool_name: str) -> Any: + """Refuse a touch tool call whose coordinates are not integers, before *fn* runs. + + Sits inside ``_with_action_log``, so a guarded session refuses first (with a + guard line) and every other path — MCP, daemon, CLI, a direct call — still + gets a ``validation_error`` instead of a touch at a coordinate nothing checked. + """ + if tool_name not in action_log.COORDINATE_KEYS: + return fn + sig = inspect.signature(fn) + + @wraps(fn) + def checked(*args: Any, **kwargs: Any) -> Any: + invalid = _coordinate_error(tool_name, _call_args(sig, args, kwargs)) + return fn(*args, **kwargs) if invalid is None else invalid + + return checked + + def _with_action_log(fn: Any, tool_name: str) -> Any: """Thin sync wrapper that logs *fn* calls while an action log is active. @@ -584,6 +783,128 @@ def logged(*args: Any, **kwargs: Any) -> Any: return logged +_AUTO_DEVICE_TOOLS: frozenset[str] = frozenset( + { + "nerve_ensure_ready", + "nerve_launch", + "nerve_release", + } +) + + +def _is_live_entry(item: Mapping[str, Any]) -> bool: + """Return False when a ``pool()`` dict marks the lease expired or owner-dead.""" + expires = item.get("expires_in_s") + if isinstance(expires, int | float) and expires <= 0: + return False + return item.get("owner_alive") is not False + + +def _reservation_entry(item: Any) -> tuple[str, str]: + """Extract ``(serial, owner)`` from a live ``pool()`` dict or ``DeviceReservation``.""" + if isinstance(item, Mapping): + if not _is_live_entry(item): + return "", "" + serial = item.get("device_serial") or item.get("device") or "" + return str(serial), str(item.get("owner", "")) + serial = getattr(item, "device_serial", "") or getattr(item, "device", "") + return str(serial), str(getattr(item, "owner", "")) + + +def _pick_reserved_device( + reservations: Mapping[str, Any] | Iterable[Any], + caller: str, +) -> str | dict[str, Any]: + """Return the caller's sole reserved device serial, or an error dict.""" + items = reservations.values() if isinstance(reservations, Mapping) else reservations + held = sorted( + { + serial + for item in items + for serial, owner in (_reservation_entry(item),) + if serial and owner == caller + } + ) + if not caller or not held: + return { + "error": ( + "device is required: you hold no reservation; pass device or nerve_reserve first" + ), + "validation_error": True, + } + if len(held) == 1: + return held[0] + serials = ", ".join(held) + return { + "error": ( + f"device is required: you hold multiple reservations ({serials}); " + "pass device explicitly" + ), + "devices": held, + "validation_error": True, + } + + +def _select_auto_device( + tool_name: str, + bound: dict[str, Any], +) -> str | dict[str, Any]: + """Pick the caller's reserved device for an opted-in tool call.""" + if _device_mgr is None: + _ensure_initialized() + device_mgr = require(_device_mgr, "device manager") + owner_arg = bound.get("owner", "") if tool_name == "nerve_release" else "" + caller = _resolve_owner( + owner_arg if isinstance(owner_arg, str) else "", + device_mgr.session_id, + ) + return _pick_reserved_device(device_mgr.pool(), caller) + + +def _inject_device_arg( + args: tuple[Any, ...], + kwargs: dict[str, Any], + device: str, +) -> tuple[tuple[Any, ...], dict[str, Any]]: + """Return ``(args, kwargs)`` with ``device`` populated.""" + if args: + return (device, *args[1:]), kwargs + return args, {**kwargs, "device": device} + + +def _resolve_wrapper_device( + tool_name: str, + sig: inspect.Signature, + args: tuple[Any, ...], + kwargs: dict[str, Any], +) -> tuple[str | dict[str, Any], bool, tuple[Any, ...], dict[str, Any]]: + """Resolve ``device`` for ``device_tool`` wrapper before ``_run_on_device``.""" + if tool_name not in _AUTO_DEVICE_TOOLS: + device = args[0] if args else kwargs["device"] + return device, False, args, kwargs + bound = _call_args(sig, args, kwargs) + raw = bound.get("device") + device = raw.strip() if isinstance(raw, str) else "" + if device: + return device, False, args, kwargs + picked = _select_auto_device(tool_name, bound) + if isinstance(picked, dict): + return picked, False, args, kwargs + new_args, new_kwargs = _inject_device_arg(args, kwargs, picked) + return picked, True, new_args, new_kwargs + + +def _tag_auto_selected( + result: Any, + device: str | dict[str, Any], + auto_selected: bool, +) -> Any: + """Attach ``device_auto_selected`` and ``device`` when auto-selection ran.""" + if auto_selected and isinstance(result, dict) and isinstance(device, str): + return {**result, "device": device, "device_auto_selected": True} + return result + + def device_tool(fn: Any) -> Any: """Register a sync device function as an MCP tool. @@ -595,22 +916,31 @@ def device_tool(fn: Any) -> Any: and the daemon import and call it like the original. """ tool_name = _record_tool(fn, "device") - logged = _with_action_log(fn, tool_name) + sig = inspect.signature(fn) + checked = _with_coordinate_check(fn, tool_name) + logged = _with_canonical_input(_with_action_log(checked, tool_name), fn, tool_name) @wraps(fn) async def wrapper(*args: Any, **kwargs: Any) -> Any: - device = args[0] if args else kwargs["device"] + device, auto_selected, call_args, call_kwargs = _resolve_wrapper_device( + tool_name, sig, args, kwargs + ) t0 = time.monotonic() error_msg = None validation_only = False try: - result = await _run_on_device(device, partial(logged, *args, **kwargs)) + result = ( + device + if isinstance(device, dict) + else await _run_on_device(device, partial(logged, *call_args, **call_kwargs)) + ) + result = _tag_auto_selected(result, device, auto_selected) if isinstance(result, dict) and "error" in result: error_msg = str(result["error"]) validation_only = bool(result.get("validation_error")) return result if isinstance(result, dict): - result = _apply_agent_image_budget(result, kwargs) + result = _apply_agent_image_budget(result, call_kwargs) if _INLINE_IMAGE_KEY in result: return _wrap_inline_image(result) return result @@ -624,7 +954,7 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: success = error_msg is None or validation_only if _metrics is not None: _metrics.record_tool_call(tool_name, success) - log_mcp_call("linc-nerve", tool_name, kwargs, duration_ms, success, error_msg) + log_mcp_call("linc-nerve", tool_name, call_kwargs, duration_ms, success, error_msg) _register_tool(wrapper, fn, tool_name) return logged @@ -690,7 +1020,7 @@ def _init_singletons() -> None: if _state_mgr is None: _state_mgr = StateSessionManager() if _device_mgr is None: - _device_mgr = DeviceManager() + _device_mgr = DeviceManager(renew_anonymous=not _daemon_mode) if _emulator is None: _emulator = EmulatorManager() atexit.register(_shutdown_cleanup) @@ -879,9 +1209,25 @@ def _check_guard(serial: str) -> dict[str, str] | None: # --------------------------------------------------------------------------- -# Output path helper +# Output path helper + retention & stale-dir sweep # --------------------------------------------------------------------------- +# Thin aliases over core/output_retention (kept here so callers/tests can reach them on _core). +_output_keep = _retention.output_keep +_output_max_age_s = _retention.output_max_age_s +_prune_output_dir = _retention.prune_output_dir + + +def _sweep_stale_output_dirs( + tmp_root: str | os.PathLike[str], + max_age_s: float | None = None, + now: float | None = None, +) -> list[str]: + """Remove this user's stale, unlocked ``nerve-output-*`` dirs, never this process's own.""" + age_limit = float(_output_max_age_s() if max_age_s is None else max_age_s) + current_time = time.time() if now is None else float(now) + return _retention.sweep_stale_output_dirs(tmp_root, _OUTPUT_DIR, age_limit, current_time) + def _next_output_path(serial: str, tool: str, ext: str = "png") -> str: """Generate a unique output file path for a device tool result. @@ -891,16 +1237,42 @@ def _next_output_path(serial: str, tool: str, ext: str = "png") -> str: """ global _file_counter safe_serial = serial.replace("/", "_").replace(":", "_") + out_dir = _output_dir() with _counter_lock: _file_counter += 1 counter = _file_counter - return os.path.join(_output_dir(), f"{safe_serial}_{counter:04d}_{tool}.{ext}") + _prune_output_dir(out_dir, _output_keep()) + return os.path.join(out_dir, f"{safe_serial}_{counter:04d}_{tool}.{ext}") + + +def _release_output_lock() -> None: + """Close the owner-lock handle at exit (the OS would drop the flock anyway).""" + if _OUTPUT_LOCK is not None: + _OUTPUT_LOCK.close() + + +atexit.register(_release_output_lock) + + +def _output_dir_usable(path: str | None) -> bool: + """True if *path* is still this user's real (non-symlink) directory.""" + return path is not None and _retention.is_owned_output_dir(path, os.getuid()) is not None def _output_dir() -> str: - """Return this process's private output dir, creating it on first use.""" - global _OUTPUT_DIR + """Return this process's private, owner-locked output dir, creating it on first use. + + If the dir has vanished (an external ``rm``, or an older nerve's cleanup), a fresh one is + created and locked so later spills keep working. Only the first creation sweeps. + """ + global _OUTPUT_DIR, _OUTPUT_LOCK with _output_dir_lock: - if _OUTPUT_DIR is None: - _OUTPUT_DIR = tempfile.mkdtemp(prefix="nerve-output-") + if _output_dir_usable(_OUTPUT_DIR): + return require(_OUTPUT_DIR, "output dir") + first = _OUTPUT_DIR is None + if _OUTPUT_LOCK is not None: + _OUTPUT_LOCK.close() + _OUTPUT_DIR, _OUTPUT_LOCK = _retention.create_locked_output_dir() + if first and _retention.sweep_enabled(os.environ): + _sweep_stale_output_dirs(tempfile.gettempdir()) return _OUTPUT_DIR diff --git a/src/linc_nerve/mcp/_ui_helpers.py b/src/linc_nerve/mcp/_ui_helpers.py new file mode 100644 index 0000000..242b41f --- /dev/null +++ b/src/linc_nerve/mcp/_ui_helpers.py @@ -0,0 +1,72 @@ +"""Internal UI hierarchy helpers for ``ui_tools.py``.""" + +from __future__ import annotations + +import re +from typing import Any + + +def _scroll_and_merge( + ctrl: Any, + device: str, + elements: list[dict[str, Any]], + scroll_depth: int, +) -> dict[str, Any]: + bounds_re = re.compile(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]") + scroll_bounds = None + for el in elements: + if el.get("scrollable"): + m = bounds_re.match(el.get("bounds", "")) + if m: + scroll_bounds = ( + int(m.group(1)), + int(m.group(2)), + int(m.group(3)), + int(m.group(4)), + ) + break + + if scroll_bounds is None: + return { + "elements": elements, + "count": len(elements), + "device": device, + "scroll_steps": 0, + "note": "no scrollable container found", + } + + x1, y1, x2, y2 = scroll_bounds + cx = (x1 + x2) // 2 + margin = (y2 - y1) // 10 + swipe_from_y = y2 - margin + swipe_to_y = y1 + margin + + seen: set[tuple[str, str, str]] = set() + merged: list[dict[str, Any]] = [] + for el in elements: + key = (el.get("id", ""), el.get("text", ""), el.get("bounds", "")) + if key not in seen: + seen.add(key) + merged.append(el) + + actual_steps = 0 + for _ in range(scroll_depth): + ctrl.swipe(device, cx, swipe_from_y, cx, swipe_to_y, 300) + new_elements = ctrl.elements(device) + new_count = 0 + for el in new_elements: + key = (el.get("id", ""), el.get("text", ""), el.get("bounds", "")) + if key not in seen: + seen.add(key) + merged.append(el) + new_count += 1 + actual_steps += 1 + if new_count == 0: + break + + return { + "elements": merged, + "count": len(merged), + "device": device, + "scroll_steps": actual_steps, + } diff --git a/src/linc_nerve/mcp/actionlog_tools.py b/src/linc_nerve/mcp/actionlog_tools.py index efa99ff..ce893a4 100644 --- a/src/linc_nerve/mcp/actionlog_tools.py +++ b/src/linc_nerve/mcp/actionlog_tools.py @@ -108,7 +108,8 @@ def _nerve_actionlog_start_sync( allow_packages: Comma list of extra packages a guarded session may act in (e.g. a login provider). deny_words: Comma list of tap labels to refuse; replaces the default - list when non-empty. + list when non-empty (call again with the same path and + target_package to re-arm in place). Returns: Dict with success/serial/path/started_at (plus guarded/ @@ -131,7 +132,7 @@ def _nerve_actionlog_start_sync( try: info = action_log.REGISTRY.start(device, resolved, meta, guard) except action_log.ActionLogError as exc: - return {"error": str(exc)} + return {"error": str(exc), "hint": action_log.ALREADY_ACTIVE_HINT} except OSError as exc: return {"error": f"Cannot open action log: {exc}"} return {"success": True, "action": "actionlog_start", **info, **_guard_info(guard)} @@ -141,7 +142,12 @@ def _guard_info(guard: action_log.GuardConfig | None) -> dict[str, Any]: """Extra start-result keys for a guarded session (none when unguarded).""" if guard is None: return {} - return {"guarded": True, "allowed_packages": sorted(guard.allowed_packages)} + return { + "guarded": True, + "target_package": guard.target_package, + "allowed_packages": sorted(guard.allowed_packages), + "deny_words": list(guard.deny_words), + } @device_tool diff --git a/src/linc_nerve/mcp/app_tools.py b/src/linc_nerve/mcp/app_tools.py index 91db8c3..83562ea 100644 --- a/src/linc_nerve/mcp/app_tools.py +++ b/src/linc_nerve/mcp/app_tools.py @@ -3,8 +3,11 @@ from __future__ import annotations import os -from typing import Any +from typing import Annotated, Any +from pydantic import Field + +from linc_nerve.core.android import validate_launch_uri from linc_nerve.mcp import _core from linc_nerve.mcp._confirm import confirmation_gate from linc_nerve.mcp._core import device_tool @@ -70,7 +73,7 @@ def _nerve_install_sync(device: str, path: str) -> dict[str, Any]: @device_tool def _nerve_launch_sync( - device: str, + device: Annotated[str, Field(default="")], package: str, uri: str = "", extras: dict[str, Any] | None = None, @@ -84,7 +87,7 @@ def _nerve_launch_sync( package names (com.lincmobile.level), iOS uses bundle IDs. Args: - device: Device serial. + device: Device serial (optional when the caller holds a single reservation). package: Android package name or iOS bundle ID. uri: Optional deep link URI (e.g. "precisionlevel://settings"). When provided, launches the app directly to the specified screen. @@ -93,6 +96,12 @@ def _nerve_launch_sync( Lets consent-gated apps run unattended BVTs without a manual am-start priming step. iOS support is platform-dependent — see core/ios.py launch(). """ + if uri: + try: + validate_launch_uri(uri) + except ValueError as exc: + return {"error": f"Launch failed: {exc}"} + dev, platform = _core._resolve_device(device) if dev is None: return {"error": platform} diff --git a/src/linc_nerve/mcp/diagnostic_tools.py b/src/linc_nerve/mcp/diagnostic_tools.py index f876317..e3f06dc 100644 --- a/src/linc_nerve/mcp/diagnostic_tools.py +++ b/src/linc_nerve/mcp/diagnostic_tools.py @@ -14,6 +14,7 @@ from linc_nerve.core.android import AndroidController from linc_nerve.core.android_apps import _validate_shell_identifier +from linc_nerve.core.android_ui import _validate_log_tag from linc_nerve.core.contracts import require from linc_nerve.core.inspect_helpers import ( _detect_crashes, @@ -25,21 +26,116 @@ from linc_nerve.mcp import _core from linc_nerve.mcp._core import device_tool, global_tool -# Max logcat lines to return inline. Larger dumps go to a file. -_LOGCAT_INLINE_MAX = 100 +# Max logcat lines / bytes to return inline. Larger dumps go to a file. +_LOGCAT_INLINE_MAX = 250 +_LOGCAT_INLINE_MAX_BYTES = 24 * 1024 +_LOGCAT_TAIL_LINES = 50 +_LOGCAT_TAIL_LINE_MAX_CHARS = 500 +_GREP_MAX_LEN = 200 +_GREP_WINDOW_FACTOR = 20 +_GREP_WINDOW_MIN = 2000 +_GREP_WINDOW_MAX = 20000 +_SUBSTRING_FALLBACK_WINDOW = 5000 +_PACKAGE_LIKE_RE = re.compile(r"\A[A-Za-z][\w]*(\.[A-Za-z][\w]*)+\Z") +_LINE_TAG_RE = re.compile( + r"\A(?:" + r"\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d+\s+\d+\s+\d+\s+[VDIWEFA]\s+([^\s:]+)\s*:" + r"|[VDIWEFA]/([^\s(:]+)\s*\(\s*\d+\)\s*:" + r"|[VDIWEFA]\s+([^\s:]+)\s*:" + r"|([^\s:]+)\s*:" + r")" +) -def _process_log_lines(device: str, platform: str, log_lines: list[str]) -> dict[str, Any]: +def _line_has_tag(line: str, tag: str) -> bool: + """Return True if a logcat line's tag field equals ``tag``.""" + m = _LINE_TAG_RE.match(line.lstrip()) + # Every alternative captures exactly one group, so the tag is the one non-None group. + return bool(m) and tag in m.groups() + + +def _validate_grep(grep: str) -> dict[str, Any] | None: + """Validate ``grep`` parameter; return a validation_error dict if invalid.""" + if not grep: + return None + if len(grep) > _GREP_MAX_LEN or not grep.isprintable(): + return { + "error": ( + f"Invalid grep filter: must be printable characters and <= {_GREP_MAX_LEN} chars" + ), + "validation_error": True, + } + return None + + +def _read_window(lines: int, grep: str) -> int: + """Compute the log read window when ``grep`` is active vs inactive.""" + if grep: + return min(max(int(lines) * _GREP_WINDOW_FACTOR, _GREP_WINDOW_MIN), _GREP_WINDOW_MAX) + return int(lines) + + +def _apply_grep(raw_lines: list[str], grep: str, lines: int) -> list[str]: + """Filter ``raw_lines`` by case-insensitive substring ``grep`` and keep last ``lines``.""" + if grep: + needle = grep.lower() + raw_lines = [line for line in raw_lines if needle in line.lower()] + return raw_lines[-lines:] + + +def _matched_via(tag: str, grep: str) -> str: + """Return ``matched_via`` value for direct (non-package, non-fallback) logcat reads.""" + if grep: + return "grep" + if tag: + return "tag" + return "" + + +def _utf8_line_bytes(log_lines: list[str]) -> int: + """Return the sum of UTF-8 byte lengths across ``log_lines``.""" + return sum(len(line.encode("utf-8")) for line in log_lines) + + +def _should_spill_logcat(log_lines: list[str]) -> bool: + """Return True when ``log_lines`` exceeds either inline line or byte limit.""" if len(log_lines) > _LOGCAT_INLINE_MAX: + return True + return _utf8_line_bytes(log_lines) > _LOGCAT_INLINE_MAX_BYTES + + +def _truncate_tail_line(line: str) -> str: + """Truncate a tail line to ``_LOGCAT_TAIL_LINE_MAX_CHARS`` chars with a trailing ``…``.""" + if len(line) > _LOGCAT_TAIL_LINE_MAX_CHARS: + return f"{line[:_LOGCAT_TAIL_LINE_MAX_CHARS]}…" + return line + + +def _build_tail_lines(log_lines: list[str]) -> list[str]: + """Return the last ``_LOGCAT_TAIL_LINES`` lines, each truncated at 500 chars.""" + return [_truncate_tail_line(line) for line in log_lines[-_LOGCAT_TAIL_LINES:]] + + +def _process_log_lines(device: str, platform: str, log_lines: list[str]) -> dict[str, Any]: + if _should_spill_logcat(log_lines): path = _core._next_output_path(device, "logcat", ext="txt") with open(path, "w") as f: f.write("\n".join(log_lines)) + tail_lines = _build_tail_lines(log_lines) + count = len(log_lines) + tail_count = len(tail_lines) return { "logcat": path, - "count": len(log_lines), + "count": count, + "tail_lines": tail_lines, + "tail_count": tail_count, "device": device, "platform": platform, - "hint": "Large output saved to file. Use Read tool to view.", + "hint": ( + f"Full output ({count} lines) saved to {path}; " + f"tail_lines holds the last {tail_count}. " + "Read the file only if you need earlier lines." + ), } return { "lines": log_lines, @@ -49,14 +145,149 @@ def _process_log_lines(device: str, platform: str, log_lines: list[str]) -> dict } +def _format_log_result( + device: str, + platform: str, + log_lines: list[str], + matched_via: str = "", + fallback_hint: str = "", +) -> dict[str, Any]: + """Build the logcat result and attach ``matched_via`` / a fallback ``hint`` when set.""" + res = _process_log_lines(device, platform, log_lines) + if matched_via: + res["matched_via"] = matched_via + if fallback_hint: + existing = res.get("hint") + res["hint"] = f"{existing} {fallback_hint}" if existing else fallback_hint + return res + + +def _logcat_by_package( + android: Any, + serial: str, + pkg: str, + lines: int, + tag_filter: str = "", + grep: str = "", + *, + is_fallback: bool = False, + pid: int | None = None, +) -> dict[str, Any]: + """Filter Android logcat by package PID (shared by explicit package= and tag fallback). + + The tag fallback passes the ``pid`` it already resolved; ``package=`` resolves it here. + """ + try: + _validate_shell_identifier(pkg, "package") + if tag_filter: + _validate_log_tag(tag_filter) + except ValueError as exc: + return {"error": str(exc), "validation_error": True} + + if pid is None: + pid = _get_android_pid(android, serial, pkg) + if pid is None: + return { + "error": f"package '{pkg}' is not running (no pid); launch it first or omit package", + "validation_error": True, + } + + raw_lines = _read_pid_logcat(android, serial, pid, _read_window(lines, grep or tag_filter)) + if tag_filter: + raw_lines = [line for line in raw_lines if _line_has_tag(line, tag_filter)] + log_lines = _apply_grep(raw_lines, grep, lines) + if is_fallback: + hint = ( + f"Tag {pkg!r} matched 0 lines with exact -s filter; " + f"matched via package_fallback (--pid={pid})." + ) + return _format_log_result( + serial, "android", log_lines, matched_via="package_fallback", fallback_hint=hint + ) + return _format_log_result(serial, "android", log_lines, matched_via="package") + + +def _logcat_tag_fallback( + android: Any, + serial: str, + tag: str, + lines: int, +) -> dict[str, Any]: + """Fallback when exact ``logcat -d -s `` returns 0 lines (no package/grep).""" + # A package-shaped tag whose app is not running falls through to the substring search. + pid = _get_android_pid(android, serial, tag) if _PACKAGE_LIKE_RE.fullmatch(tag) else None + if pid is not None: + return _logcat_by_package(android, serial, tag, lines, is_fallback=True, pid=pid) + fb_out = android._shell(serial, f"logcat -d -t {_SUBSTRING_FALLBACK_WINDOW}") + raw_lines = fb_out.strip().splitlines() if fb_out.strip() else [] + log_lines = _apply_grep(raw_lines, tag, lines) + hint = ( + f"Tag {tag!r} matched 0 lines with exact -s filter; " + f"matched via substring_fallback (case-insensitive substring over last " + f"{_SUBSTRING_FALLBACK_WINDOW} lines)." + ) + return _format_log_result( + serial, "android", log_lines, matched_via="substring_fallback", fallback_hint=hint + ) + + +def _logcat_android( + android: Any, + serial: str, + lines: int, + tag: str, + package: str, + grep: str, +) -> dict[str, Any]: + if package: + return _logcat_by_package(android, serial, package, lines, tag_filter=tag, grep=grep) + output = android.logcat(serial, lines=_read_window(lines, grep), tag=tag) + raw_lines = output.strip().splitlines() if output.strip() else [] + log_lines = _apply_grep(raw_lines, grep, lines) + if tag and not grep and not log_lines: + return _logcat_tag_fallback(android, serial, tag, lines) + return _format_log_result(serial, "android", log_lines, matched_via=_matched_via(tag, grep)) + + +def _logcat_ios( + ios: Any, + device: str, + lines: int, + tag: str, + package: str, + grep: str, +) -> dict[str, Any]: + if package: + return { + "error": "package filter is Android-only; on iOS pass the bundle ID via tag=", + "validation_error": True, + } + try: + output = ios.get_logs(device, lines=_read_window(lines, grep), filter=tag) + raw_lines = output.strip().splitlines() if output.strip() else [] + log_lines = _apply_grep(raw_lines, grep, lines) + return _format_log_result(device, "ios", log_lines, matched_via=_matched_via(tag, grep)) + except ValueError as exc: + return {"error": str(exc)} + except Exception as exc: + return {"error": f"iOS log retrieval failed: {exc}"} + + @device_tool -def _nerve_logcat_sync(device: str, lines: int = 50, tag: str = "") -> dict[str, Any]: +def _nerve_logcat_sync( + device: str, + lines: int = 50, + tag: str = "", + package: str = "", + grep: str = "", +) -> dict[str, Any]: """Tail the last N lines from a device's logcat buffer or iOS system log. USE FOR: Tailing recent log lines, runtime warnings, errors, and exceptions - emitted by apps. Returns a static one-shot snapshot of the most recent - log entries from the device's main logcat buffer. Tail logs, dump - recent log output, read logcat messages from the buffer. + emitted by apps; pass package= to see one app's lines. Returns a static + one-shot snapshot of the most recent log entries from the device's main + logcat buffer. Tail logs, dump recent log output, read logcat messages + from the buffer. NOT FOR: pushing logs through a subscription channel (nerve_stream_logs). Reading a process's PID, memory map, or crash signature (nerve_inspect). Stepping through code with breakpoints (spectra_attach). @@ -67,11 +298,19 @@ def _nerve_logcat_sync(device: str, lines: int = 50, tag: str = "") -> dict[str, device: Device serial (Android or iOS). lines: Number of recent lines to return (default 50). tag: Optional logcat tag (Android) or process name / bundle ID (iOS) filter. + package: Optional Android package name to filter by its running PID. + grep: Optional case-insensitive substring filter applied in Python. Returns: A dictionary containing the log lines from the device's buffer. """ - android, _ = _core._ensure_initialized() + if int(lines) < 1: + return {"error": "lines must be >= 1", "validation_error": True} + grep_err = _validate_grep(grep) + if grep_err: + return grep_err + + android, ios = _core._ensure_initialized() dev, platform = _core._resolve_device(device) if dev is None: return {"error": platform} @@ -81,21 +320,10 @@ def _nerve_logcat_sync(device: str, lines: int = 50, tag: str = "") -> dict[str, return guard if platform != "android": - # iOS log support via simctl/devicectl - _, ios = _core._ensure_initialized() - try: - output = ios.get_logs(device, lines=lines, filter=tag) - log_lines = output.strip().splitlines()[-lines:] if output.strip() else [] - return _process_log_lines(device, "ios", log_lines) - except ValueError as exc: - return {"error": str(exc)} - except Exception as exc: - return {"error": f"iOS log retrieval failed: {exc}"} + return _logcat_ios(ios, device, lines, tag, package, grep) try: - output = android.logcat(device, lines=lines, tag=tag) - log_lines = output.strip().splitlines()[-lines:] if output.strip() else [] - return _process_log_lines(device, "android", log_lines) + return _logcat_android(android, device, lines, tag, package, grep) except Exception as exc: return {"error": f"Logcat failed: {exc}"} @@ -302,12 +530,16 @@ def _get_android_pid(android: Any, serial: str, package: str) -> int | None: return None +def _read_pid_logcat(android: Any, serial: str, pid: int, window: int) -> list[str]: + log_out = android._shell(serial, f"logcat -d -t {int(window)} --pid={int(pid)}", timeout=10) + return log_out.strip().splitlines() if log_out.strip() else [] + + def _get_android_logs(android: Any, serial: str, pid: int | None, lines: int) -> list[str]: try: if pid is not None: - log_out = android._shell(serial, f"logcat -d -t {lines} --pid={pid}", timeout=10) - else: - log_out = android.logcat(serial, lines=lines) + return _read_pid_logcat(android, serial, pid, lines)[-lines:] + log_out = android.logcat(serial, lines=lines) return log_out.strip().splitlines()[-lines:] if log_out.strip() else [] except Exception: return [] diff --git a/src/linc_nerve/mcp/input_tools.py b/src/linc_nerve/mcp/input_tools.py index b1309c4..ffd33f3 100644 --- a/src/linc_nerve/mcp/input_tools.py +++ b/src/linc_nerve/mcp/input_tools.py @@ -9,12 +9,18 @@ @device_tool -def _nerve_tap_sync(device: str, x: int, y: int) -> dict[str, Any]: - """Tap a single point on the device display at known x/y coordinates. +def _nerve_tap_sync( + device: str, + x: int | None = None, + y: int | None = None, + prompt: str = "", +) -> dict[str, Any]: + """Tap a single point on the device display at known x/y coordinates or by visual prompt. A one-shot single-touch gesture: routes a click to one specific pixel position. Use when an agent already knows where to tap — bounds from - nerve_elements, an annotated screenshot, or a fixed UI position. + nerve_elements, an annotated screenshot, a fixed UI position, or a + semantic visual concept prompt grounded via SAM 3.1 when the UI tree is empty. USE FOR: tapping a single point on the screen, clicking a coordinate, pressing a single position on the device display, single touch @@ -26,12 +32,14 @@ def _nerve_tap_sync(device: str, x: int, y: int) -> dict[str, Any]: pressing a hardware key like HOME/BACK/ENTER (use nerve_key instead), or a multi-second hold (use nerve_long_press instead). TIP: call nerve_elements first if you need an element's bounds, then - tap the rect center. Returns ~30-80 tokens. + tap the rect center. When the UI tree is empty or unlabelled, pass + prompt='' to ground via SAM 3.1. Returns ~30-80 tokens. Args: device: Device serial. - x: X coordinate in pixels. - y: Y coordinate in pixels. + x: X coordinate in pixels (optional when prompt is provided). + y: Y coordinate in pixels (optional when prompt is provided). + prompt: Visual concept description grounded via SAM 3.1 when x and y are omitted. Returns: Dict with success/action/x/y on success, or {"error": ...} on failure. @@ -47,7 +55,12 @@ def _nerve_tap_sync(device: str, x: int, y: int) -> dict[str, Any]: ctrl = _core._get_controller(platform) try: ctrl.tap(device, x, y) - return {"success": True, "action": "tap", "x": x, "y": y} + res: dict[str, Any] = {"success": True, "action": "tap", "x": x, "y": y} + tap_key = (device, int(x), int(y)) if (x is not None and y is not None) else None + grounded = _core._LAST_GROUNDED_TAP.pop(tap_key, None) if tap_key is not None else None + if grounded is not None: + res["grounded"] = grounded + return res except NotImplementedError as exc: return {"error": str(exc)} except Exception as exc: @@ -105,11 +118,15 @@ def _nerve_long_press_sync( @device_tool def _nerve_swipe_sync( device: str, - start_x: int, - start_y: int, - end_x: int, - end_y: int, + start_x: int | None = None, + start_y: int | None = None, + end_x: int | None = None, + end_y: int | None = None, duration_ms: int = 300, + x1: int | None = None, + y1: int | None = None, + x2: int | None = None, + y2: int | None = None, ) -> dict[str, Any]: """Two-point gesture from one xy coordinate to another — swipe, drag, or scroll. @@ -123,14 +140,18 @@ def _nerve_swipe_sync( Args: device: Device serial. - start_x: Start X coordinate. - start_y: Start Y coordinate. - end_x: End X coordinate. - end_y: End Y coordinate. - duration_ms: Swipe duration in milliseconds (default 300). + start_x: Start X. + start_y: Start Y. + end_x: End X. + end_y: End Y. + duration_ms: Duration in ms (default 300). + x1: Alias for start_x. + y1: Alias for start_y. + x2: Alias for end_x. + y2: Alias for end_y. Returns: - Dict with success/action/from/to on success, or {"error": ...} on failure. + Dict with success/action/from/to, or {"error": ...}. """ dev, platform = _core._resolve_device(device) if dev is None: @@ -204,7 +225,7 @@ def _scrub_secret(message: str, text: str, secret: bool) -> str: @device_tool -def _nerve_key_sync(device: str, key: str) -> dict[str, Any]: +def _nerve_key_sync(device: str, key: str = "", keycode: str = "") -> dict[str, Any]: """Press a hardware/system key. USE FOR: HOME, BACK, ENTER, VOLUME_UP, VOLUME_DOWN, POWER, TAB, DELETE. @@ -214,6 +235,7 @@ def _nerve_key_sync(device: str, key: str) -> dict[str, Any]: Args: device: Device serial. key: Key name (HOME, BACK, ENTER, etc.). + keycode: Alias for key (HOME, BACK, ENTER, etc.). """ dev, platform = _core._resolve_device(device) if dev is None: diff --git a/src/linc_nerve/mcp/navigation_tools.py b/src/linc_nerve/mcp/navigation_tools.py index 2e4e576..bdbad6e 100644 --- a/src/linc_nerve/mcp/navigation_tools.py +++ b/src/linc_nerve/mcp/navigation_tools.py @@ -84,7 +84,7 @@ def _load_route_registry() -> tuple[dict[str, dict[str, str]], dict[str, str]]: @device_tool def _nerve_navigate_sync(device: str, app_id: str, route_id: str) -> dict[str, Any]: - """Navigate to a named screen in an app using its registered deep link. + """Navigate to a named screen of a REGISTERED app (route registry) by its deep link. USE FOR: Going to a specific screen by name (e.g. "settings", "calibration"). NOT FOR: Opening arbitrary URIs (use nerve_launch with uri param). @@ -104,6 +104,11 @@ def _nerve_navigate_sync(device: str, app_id: str, route_id: str) -> dict[str, A "error": (f"Unknown app '{app_id}'. Available apps: {', '.join(available_apps)}"), "validation_error": True, "available_apps": available_apps, + "hint": ( + "nerve_navigate only knows apps listed in its route registry " + "(routes.yaml / NERVE_ROUTES_FILE). For any other app use " + "nerve_launch(device, package, uri=)." + ), } uri = app_routes.get(route_id) if uri is None: diff --git a/src/linc_nerve/mcp/reservation_tools.py b/src/linc_nerve/mcp/reservation_tools.py index 0fbbb7c..acb2d46 100644 --- a/src/linc_nerve/mcp/reservation_tools.py +++ b/src/linc_nerve/mcp/reservation_tools.py @@ -3,10 +3,13 @@ from __future__ import annotations from datetime import UTC, datetime -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Annotated, Any + +from pydantic import Field from linc_nerve.core.contracts import require from linc_nerve.core.device_manager import _held_by, _resolve_owner +from linc_nerve.core.procs import pid_alive from linc_nerve.mcp import _core from linc_nerve.mcp._core import device_tool @@ -41,6 +44,61 @@ def _release_error( } +def _renewal_mode( + renew_anonymous: bool | DeviceManager, + shared_owner: bool | DeviceReservation = False, + owner_pid: int | None = 0, +) -> str: + """Determine lease renewal authority: 'daemon' if auto-renewed, else 'caller'.""" + if not isinstance(renew_anonymous, bool): + mgr = renew_anonymous + res = shared_owner + r_anon = getattr(mgr, "renew_anonymous", True) + s_owner = res.owner == mgr.session_id if hasattr(res, "owner") else False + pid = owner_pid or getattr(res, "owner_pid", None) or 0 + return _renewal_mode(r_anon, s_owner, pid) + + pid = owner_pid or 0 + if (pid > 0 and pid_alive(pid)) or (renew_anonymous and shared_owner): + return "daemon" + return "caller" + + +def _reserve_result( + device_mgr: DeviceManager, + result: DeviceReservation, + owner_pid: int = 0, +) -> dict[str, Any]: + """Success dict for ``nerve_reserve``: the lease plus whether it is + anonymous (owned by this process's shared session id) and renewed.""" + shared_owner = result.owner == device_mgr.session_id + effective_pid = owner_pid or getattr(result, "owner_pid", None) or 0 + renewal = _renewal_mode( + getattr(device_mgr, "renew_anonymous", True), + shared_owner, + effective_pid, + ) + out: dict[str, Any] = { + "success": True, + "device": result.device_serial, + "session_id": result.session_id, + "timeout_seconds": result.timeout_seconds, + "purpose": result.purpose, + "owner": result.owner, + "shared_owner": shared_owner, + "renewed": result.renewed, + "expires_in_s": result.timeout_seconds, + "renewal": renewal, + } + if shared_owner: + out["warning"] = ( + "anonymous lease: owned by this nerve process's session id " + f"{device_mgr.session_id}; every caller of this process without an owner " + "shares it and can renew or release it. Pass owner (CLI --owner or NERVE_OWNER)." + ) + return out + + @device_tool def _nerve_reserve_sync( device: str, @@ -67,11 +125,17 @@ def _nerve_reserve_sync( Args: device: Device serial (from nerve_discover). purpose: Free text describing what you're using the device for. - timeout_seconds: Seconds before reservation auto-expires (default 300). - owner: Stable caller identity; defaults to this session's id. - takeover: Force-claim a device held by a different owner. + timeout_seconds: Seconds before reservation auto-expires (default 300); + under the daemon, anonymous leases expire after this timeout + unless re-reserved (or kept alive with owner_pid). + owner: Stable caller identity (CLI: --owner, else $NERVE_OWNER); + defaults to pid: if owner_pid is set, else this + session's id (shared by every owner-less caller). + takeover: Force-claim a device held by a different owner; refused + unless owner or owner_pid names you. owner_pid: Caller's own process id (0 = not supplied); the lease is - reaped when this process exits. + reaped when this process exits. Without owner, the lease is + owned by pid: (release with that owner). """ _core._ensure_initialized() device_mgr = require(_core._device_mgr, "device manager") @@ -88,18 +152,14 @@ def _nerve_reserve_sync( return {"error": result} if _core._metrics is not None: _core._metrics.record_reserve(device) - return { - "success": True, - "device": result.device_serial, - "session_id": result.session_id, - "timeout_seconds": result.timeout_seconds, - "purpose": result.purpose, - "owner": result.owner, - } + return _reserve_result(_core._device_mgr, result, owner_pid=owner_pid) @device_tool -def _nerve_release_sync(device: str, owner: str = "") -> dict[str, Any]: +def _nerve_release_sync( + device: Annotated[str, Field(default="")], + owner: str = "", +) -> dict[str, Any]: """Release a device reservation. USE FOR: Freeing a device when done with it. @@ -108,9 +168,10 @@ def _nerve_release_sync(device: str, owner: str = "") -> dict[str, Any]: out. Pass the same owner you reserved with, if you set one. Args: - device: Device serial to release. - owner: Stable caller identity that holds the reservation; defaults - to this session's id. + device: Device serial to release (optional when the caller holds a single reservation). + owner: Stable caller identity that holds the reservation (CLI: + --owner, else $NERVE_OWNER); defaults to this session's id. A + lease reserved with only owner_pid is owned by pid:. """ _core._ensure_initialized() device_mgr = require(_core._device_mgr, "device manager") diff --git a/src/linc_nerve/mcp/server.py b/src/linc_nerve/mcp/server.py index cfff0e9..60c16e7 100644 --- a/src/linc_nerve/mcp/server.py +++ b/src/linc_nerve/mcp/server.py @@ -147,6 +147,8 @@ def _sigterm_handler(signum: int, frame: Any) -> None: "_metrics", "_device_mgr", "_emulator", + "_daemon_mode", + "set_daemon_mode", "_cached_devices", "_cache_timestamp", "_cleanup_done", diff --git a/src/linc_nerve/mcp/state_tools.py b/src/linc_nerve/mcp/state_tools.py index be4544d..092a3ba 100644 --- a/src/linc_nerve/mcp/state_tools.py +++ b/src/linc_nerve/mcp/state_tools.py @@ -14,7 +14,7 @@ @device_tool -def _nerve_ensure_ready_sync(device: str, require_network: bool = False) -> dict[str, Any]: +def _nerve_ensure_ready_sync(device: str = "", require_network: bool = False) -> dict[str, Any]: """Ensure a device is awake + unlocked before UI interaction, and report readiness. Wakes an asleep/dozing Android device (KEYCODE_WAKEUP), dismisses a non-secure @@ -30,7 +30,7 @@ def _nerve_ensure_ready_sync(device: str, require_network: bool = False) -> dict Returns ~60-120 tokens. Args: - device: Device serial. + device: Device serial (optional when the caller holds a single reservation). require_network: Also gate ``ready`` on a default network with validated internet (Android only). Off by default: ``ready``/``reason`` are then unchanged and ``network`` is report-only. When on and the device is diff --git a/src/linc_nerve/mcp/ui_tools.py b/src/linc_nerve/mcp/ui_tools.py index c7503d8..64378bb 100644 --- a/src/linc_nerve/mcp/ui_tools.py +++ b/src/linc_nerve/mcp/ui_tools.py @@ -7,86 +7,17 @@ from __future__ import annotations -import re from typing import Any +from linc_nerve.core.grounding import ( + VISUAL_GROUNDING_HINT, + ground_visual_targets, + render_grounded_overlay, + should_suggest_visual_grounding, +) from linc_nerve.mcp import _core from linc_nerve.mcp._core import device_tool - -# --------------------------------------------------------------------------- -# Scroll-and-merge helper (used by nerve_elements with scroll_depth) -# --------------------------------------------------------------------------- - - -def _scroll_and_merge( - ctrl: Any, - device: str, - elements: list[dict], - scroll_depth: int, -) -> dict[str, Any]: - bounds_re = re.compile(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]") - scroll_bounds = None - for el in elements: - if el.get("scrollable"): - m = bounds_re.match(el.get("bounds", "")) - if m: - scroll_bounds = ( - int(m.group(1)), - int(m.group(2)), - int(m.group(3)), - int(m.group(4)), - ) - break - - if scroll_bounds is None: - return { - "elements": elements, - "count": len(elements), - "device": device, - "scroll_steps": 0, - "note": "no scrollable container found", - } - - x1, y1, x2, y2 = scroll_bounds - cx = (x1 + x2) // 2 - margin = (y2 - y1) // 10 - swipe_from_y = y2 - margin - swipe_to_y = y1 + margin - - seen: set[tuple[str, str, str]] = set() - merged: list[dict] = [] - for el in elements: - key = (el.get("id", ""), el.get("text", ""), el.get("bounds", "")) - if key not in seen: - seen.add(key) - merged.append(el) - - actual_steps = 0 - for _ in range(scroll_depth): - ctrl.swipe(device, cx, swipe_from_y, cx, swipe_to_y, 300) - new_elements = ctrl.elements(device) - new_count = 0 - for el in new_elements: - key = (el.get("id", ""), el.get("text", ""), el.get("bounds", "")) - if key not in seen: - seen.add(key) - merged.append(el) - new_count += 1 - actual_steps += 1 - if new_count == 0: - break - - return { - "elements": merged, - "count": len(merged), - "device": device, - "scroll_steps": actual_steps, - } - - -# --------------------------------------------------------------------------- -# UI Hierarchy -# --------------------------------------------------------------------------- +from linc_nerve.mcp._ui_helpers import _scroll_and_merge @device_tool @@ -151,15 +82,21 @@ def _nerve_elements_sync( if scroll_depth > 0: return _scroll_and_merge(ctrl, device, elements, scroll_depth) - return { + out: dict[str, Any] = { "elements": elements, "count": len(elements), "device": device, } + if should_suggest_visual_grounding(elements): + out["visual_grounding_hint"] = VISUAL_GROUNDING_HINT + return out except NotImplementedError as exc: return {"error": str(exc)} except Exception as exc: - return {"error": f"Element dump failed: {exc}"} + return { + "error": f"Element dump failed: {exc}", + "visual_grounding_hint": VISUAL_GROUNDING_HINT, + } # --------------------------------------------------------------------------- @@ -297,6 +234,9 @@ def _nerve_state_sync(device: str, track_changes: bool = False) -> Any: state["screenshot"] = path state.update(_core._screenshot_geometry(screenshot_bytes, state.get("screen_size", {}))) + if should_suggest_visual_grounding(state.get("elements"), state.get("elements_error")): + state["visual_grounding_hint"] = VISUAL_GROUNDING_HINT + return state @@ -391,3 +331,65 @@ def _nerve_diff_sync(device: str) -> Any: result["screenshot"] = path return result + + +@device_tool +def _nerve_ground_sync( + device: str, + prompt: str, + annotate: bool = False, +) -> dict[str, Any]: + """Locate UI controls visually by natural-language concept prompt using Meta SAM 3.1. + + USE FOR: grounding visual UI elements when nerve_elements / nerve_state + return an empty or unlabelled hierarchy (uiautomator dump killed, + custom Compose/Flutter/WebView canvas, or unlabelled icon buttons). + NOT FOR: standard native screens where nerve_elements already returns + labelled bounds (reach for nerve_elements / nerve_state first). + TIP: returns physical device coordinates ready for nerve_tap(x, y), or + pass prompt directly to nerve_tap(device, prompt=...). + + Args: + device: Device serial. + prompt: Natural-language description of the visual UI target to segment. + annotate: Save and return a green mask/centroid overlay PNG when True. + + Returns: + Dict with count, targets (bounds, center, confidence), and top_target. + """ + _core._ensure_initialized() + if not isinstance(prompt, str) or not prompt.strip(): + return {"error": "prompt must be a non-empty string", "validation_error": True} + + dev, platform = _core._resolve_device(device) + if dev is None: + return {"error": platform} + + guard = _core._check_guard(device) + if guard: + return guard + + ctrl = _core._get_controller(platform) + try: + png_bytes = ctrl.screenshot(device) + screen_size = ctrl.get_screen_size(device) if hasattr(ctrl, "get_screen_size") else None + targets = ground_visual_targets(png_bytes, prompt.strip(), screen_size=screen_size) + out: dict[str, Any] = { + "device": device, + "prompt": prompt.strip(), + "count": len(targets), + "targets": targets, + "top_target": targets[0] if targets else None, + } + if screen_size: + out["screen_size"] = screen_size + if annotate: + overlay_bytes = render_grounded_overlay(png_bytes, targets, screen_size=screen_size) + path = _core._next_output_path(device, "grounded") + with open(path, "wb") as f: + f.write(overlay_bytes) + out["screenshot"] = path + out[_core._INLINE_IMAGE_KEY] = overlay_bytes + return out + except Exception as exc: + return {"error": f"Visual grounding failed: {exc}"} diff --git a/tests/snapshots/tool_input_schemas.json b/tests/snapshots/tool_input_schemas.json index dd5a9ed..ea214b1 100644 --- a/tests/snapshots/tool_input_schemas.json +++ b/tests/snapshots/tool_input_schemas.json @@ -35,7 +35,8 @@ "path" ], "title": "_nerve_actionlog_start_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_actionlog_stop": { "properties": { @@ -48,7 +49,8 @@ "device" ], "title": "_nerve_actionlog_stop_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_apps": { "properties": { @@ -61,12 +63,14 @@ "device" ], "title": "_nerve_apps_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_capabilities": { "properties": {}, "title": "_nerve_capabilities_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_clear_app_data": { "properties": { @@ -89,7 +93,8 @@ "package" ], "title": "_nerve_clear_app_data_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_density": { "properties": { @@ -102,7 +107,8 @@ "device" ], "title": "_nerve_density_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_device_state": { "properties": { @@ -130,7 +136,8 @@ "action" ], "title": "_nerve_device_state_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_diff": { "properties": { @@ -143,12 +150,14 @@ "device" ], "title": "_nerve_diff_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_discover": { "properties": {}, "title": "_nerve_discover_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_elements": { "properties": { @@ -171,12 +180,14 @@ "device" ], "title": "_nerve_elements_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_emulator_list": { "properties": {}, "title": "_nerve_emulator_list_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_emulator_start": { "properties": { @@ -204,7 +215,8 @@ "name_or_id" ], "title": "_nerve_emulator_start_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_emulator_stop": { "properties": { @@ -217,11 +229,13 @@ "device" ], "title": "_nerve_emulator_stop_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_ensure_ready": { "properties": { "device": { + "default": "", "title": "Device", "type": "string" }, @@ -231,11 +245,9 @@ "type": "boolean" } }, - "required": [ - "device" - ], "title": "_nerve_ensure_ready_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_extract_frames": { "properties": { @@ -258,7 +270,8 @@ "timestamps" ], "title": "_nerve_extract_frames_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_grant_permission": { "properties": { @@ -286,7 +299,32 @@ "permission" ], "title": "_nerve_grant_permission_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false + }, + "nerve_ground": { + "properties": { + "device": { + "title": "Device", + "type": "string" + }, + "prompt": { + "title": "Prompt", + "type": "string" + }, + "annotate": { + "default": false, + "title": "Annotate", + "type": "boolean" + } + }, + "required": [ + "device", + "prompt" + ], + "title": "_nerve_ground_syncArguments", + "type": "object", + "additionalProperties": false }, "nerve_inject_sensor": { "properties": { @@ -319,7 +357,8 @@ "device" ], "title": "_nerve_inject_sensor_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_inspect": { "properties": { @@ -342,7 +381,8 @@ "package" ], "title": "_nerve_inspect_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_install": { "properties": { @@ -360,7 +400,8 @@ "path" ], "title": "_nerve_install_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_key": { "properties": { @@ -369,16 +410,22 @@ "type": "string" }, "key": { + "default": "", "title": "Key", "type": "string" + }, + "keycode": { + "default": "", + "title": "Keycode", + "type": "string" } }, "required": [ - "device", - "key" + "device" ], "title": "_nerve_key_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_kill": { "properties": { @@ -396,11 +443,13 @@ "package" ], "title": "_nerve_kill_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_launch": { "properties": { "device": { + "default": "", "title": "Device", "type": "string" }, @@ -428,11 +477,11 @@ } }, "required": [ - "device", "package" ], "title": "_nerve_launch_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_logcat": { "properties": { @@ -449,13 +498,24 @@ "default": "", "title": "Tag", "type": "string" + }, + "package": { + "default": "", + "title": "Package", + "type": "string" + }, + "grep": { + "default": "", + "title": "Grep", + "type": "string" } }, "required": [ "device" ], "title": "_nerve_logcat_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_long_press": { "properties": { @@ -483,7 +543,8 @@ "y" ], "title": "_nerve_long_press_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_metrics": { "properties": { @@ -509,7 +570,8 @@ } }, "title": "_nerve_metrics_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_navigate": { "properties": { @@ -532,7 +594,8 @@ "route_id" ], "title": "_nerve_navigate_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_navigate_screenshot": { "properties": { @@ -565,7 +628,8 @@ "route_id" ], "title": "_nerve_navigate_screenshot_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_network": { "properties": { @@ -583,12 +647,14 @@ "device" ], "title": "_nerve_network_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_pool": { "properties": {}, "title": "_nerve_pool_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_record_fixed": { "properties": { @@ -606,7 +672,8 @@ "device" ], "title": "_nerve_record_fixed_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_record_start": { "properties": { @@ -619,7 +686,8 @@ "device" ], "title": "_nerve_record_start_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_record_stop": { "properties": { @@ -632,11 +700,13 @@ "device" ], "title": "_nerve_record_stop_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_release": { "properties": { "device": { + "default": "", "title": "Device", "type": "string" }, @@ -646,11 +716,9 @@ "type": "string" } }, - "required": [ - "device" - ], "title": "_nerve_release_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_replay": { "properties": { @@ -678,7 +746,8 @@ "log_path" ], "title": "_nerve_replay_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_reserve": { "properties": { @@ -716,7 +785,8 @@ "device" ], "title": "_nerve_reserve_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_screenshot": { "properties": { @@ -744,12 +814,14 @@ "device" ], "title": "_nerve_screenshot_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_session_stats": { "properties": {}, "title": "_nerve_session_stats_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_state": { "properties": { @@ -767,7 +839,8 @@ "device" ], "title": "_nerve_state_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_stream_logs": { "properties": { @@ -785,7 +858,8 @@ "device" ], "title": "_nerve_stream_logs_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_stream_screenshot": { "properties": { @@ -803,7 +877,8 @@ "device" ], "title": "_nerve_stream_screenshot_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_stream_start": { "properties": { @@ -826,12 +901,14 @@ "device" ], "title": "_nerve_stream_start_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_stream_status": { "properties": {}, "title": "_nerve_stream_status_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_stream_stop": { "properties": { @@ -844,7 +921,8 @@ "device" ], "title": "_nerve_stream_stop_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_swipe": { "properties": { @@ -853,36 +931,113 @@ "type": "string" }, "start_x": { - "title": "Start X", - "type": "integer" + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Start X" }, "start_y": { - "title": "Start Y", - "type": "integer" + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Start Y" }, "end_x": { - "title": "End X", - "type": "integer" + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "End X" }, "end_y": { - "title": "End Y", - "type": "integer" + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "End Y" }, "duration_ms": { "default": 300, "title": "Duration Ms", "type": "integer" + }, + "x1": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "X1" + }, + "y1": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Y1" + }, + "x2": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "X2" + }, + "y2": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Y2" } }, "required": [ - "device", - "start_x", - "start_y", - "end_x", - "end_y" + "device" ], "title": "_nerve_swipe_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_tap": { "properties": { @@ -891,21 +1046,41 @@ "type": "string" }, "x": { - "title": "X", - "type": "integer" + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "X" }, "y": { - "title": "Y", - "type": "integer" + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Y" + }, + "prompt": { + "default": "", + "title": "Prompt", + "type": "string" } }, "required": [ - "device", - "x", - "y" + "device" ], "title": "_nerve_tap_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_type": { "properties": { @@ -928,7 +1103,8 @@ "text" ], "title": "_nerve_type_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_uninstall": { "properties": { @@ -951,7 +1127,8 @@ "package" ], "title": "_nerve_uninstall_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false }, "nerve_usage_stats": { "properties": { @@ -977,6 +1154,7 @@ } }, "title": "_nerve_usage_stats_syncArguments", - "type": "object" + "type": "object", + "additionalProperties": false } } diff --git a/tests/test_action_log.py b/tests/test_action_log.py index 3cee9c9..f608ac8 100644 --- a/tests/test_action_log.py +++ b/tests/test_action_log.py @@ -504,3 +504,279 @@ def test_resolve_log_path_accepts_the_root_itself_as_parent(tmp_path: Path) -> N assert action_log.resolve_log_path(str(tmp_path / "x.jsonl"), [tmp_path]) == ( Path(os.path.realpath(tmp_path)) / "x.jsonl" ) + + +# --------------------------------------------------------------------------- +# denied_label refusal hint + registry guard re-arm +# --------------------------------------------------------------------------- + + +class TestRefusalHints: + def test_denied_label_default_hint(self) -> None: + res = action_log.refusal( + "denied_label", + "checkout", + "guarded session: input on a 'checkout' label refused", + ) + assert res["error"] == "guarded session: input on a 'checkout' label refused" + assert res["guard"] == "denied_label" + assert res["detail"] == "checkout" + assert res["validation_error"] is True + assert res["hint"] == ( + "'checkout' is on the default deny list. If tapping it is intended in " + "this session, call nerve_actionlog_start again with the same path and " + "deny_words set to the full list you want (deny_words replaces the " + "defaults; pass 'delete account' to keep only that)." + ) + + def test_denied_label_custom_list_hint(self) -> None: + guard = action_log.build_guard("com.example.app", deny_words="launch missiles, nuke") + assert guard is not None + res = action_log.label_refusal(guard, [{"text": "Launch missiles", "content_desc": ""}]) + assert res is not None + assert res["guard"] == "denied_label" + assert res["detail"] == "launch missiles" + assert "default deny list" not in res["hint"] + assert "launch missiles, nuke" in res["hint"] + assert "nerve_actionlog_start" in res["hint"] + + @pytest.mark.parametrize( + "reason", ["foreground_not_allowed", "other_package", "target_unreadable"] + ) + def test_non_denied_label_refusal_has_no_hint(self, reason: str) -> None: + res = action_log.refusal(reason, "detail", "msg") + assert "hint" not in res + + +class TestRegistryRearm: + def test_rearm_same_path_and_target_swaps_guard_and_writes_event( + self, reg: ActionLogRegistry, tmp_path: Path, clock: FakeClock + ) -> None: + path = tmp_path / "run.jsonl" + g1 = action_log.build_guard("com.example.app") + assert g1 is not None + reg.start("S", path, {"target_package": "com.example.app"}, g1) + + clock.now = 103.25 + g2 = action_log.build_guard( + "com.example.app", allow_packages="com.login", deny_words="delete account" + ) + assert g2 is not None + info = reg.start("S", path, {"target_package": "com.example.app"}, g2) + + assert info["rearmed"] is True + assert info["path"] == str(path) + session = reg.active("S") + assert session is not None + assert session.guard is g2 + assert session.guard is not g1 + lines = _lines(path) + assert len(lines) == 2 + assert lines[0]["type"] == "header" + assert lines[1]["event"] == "guard_updated" + assert lines[1]["deny_words"] == ["delete account"] + assert "com.login" in lines[1]["allowed_packages"] + assert lines[1]["t_session_s"] == 3.25 + + def test_rearm_rejects_different_path_or_target_or_mode_flip( + self, reg: ActionLogRegistry, tmp_path: Path, clock: FakeClock + ) -> None: + path = tmp_path / "run.jsonl" + g1 = action_log.build_guard("com.example.app") + g_other = action_log.build_guard("com.other.app") + reg.start("S", path, {"target_package": "com.example.app"}, g1) + + # different path + with pytest.raises(ActionLogError, match="already active"): + reg.start("S", tmp_path / "other.jsonl", {"target_package": "com.example.app"}, g1) + # different target_package + with pytest.raises(ActionLogError, match="already active"): + reg.start("S", path, {"target_package": "com.other.app"}, g_other) + # guarded -> unguarded + with pytest.raises(ActionLogError, match="already active"): + reg.start("S", path, {}, None) + + reg.stop("S") + # unguarded -> guarded + reg.start("S", path, {}, None) + with pytest.raises(ActionLogError, match="already active"): + reg.start("S", path, {"target_package": "com.example.app"}, g1) + # unguarded -> unguarded (same path) + with pytest.raises(ActionLogError, match="already active"): + reg.start("S", path, {}, None) + + +# --------------------------------------------------------------------------- +# custom_deny flag, hint source selection, guard_updated line, re-arm result +# --------------------------------------------------------------------------- + + +def _session_list_hint(word: str, words: tuple[str, ...]) -> str: + joined = ", ".join(words) + return ( + f"'{word}' is on the session's deny_words list ({joined!r}). If tapping it is " + "intended in this session, call nerve_actionlog_start again with the same path " + "and deny_words set to the full list you want (deny_words replaces the " + "defaults; pass 'delete account' to keep only that)." + ) + + +_DEFAULT_LIST_HINT = ( + "'checkout' is on the default deny list. If tapping it is intended in this " + "session, call nerve_actionlog_start again with the same path and deny_words " + "set to the full list you want (deny_words replaces the defaults; pass " + "'delete account' to keep only that)." +) + +_CHECKOUT = [{"text": "Checkout", "content_desc": ""}] + + +class TestCustomDenyFlag: + def test_build_guard_without_deny_words_is_not_custom(self) -> None: + guard = action_log.build_guard("com.example.app") + assert guard is not None + assert guard.custom_deny is False + assert guard.deny_words == action_log.DEFAULT_DENY_WORDS + + def test_build_guard_blank_deny_words_is_not_custom(self) -> None: + guard = action_log.build_guard("com.example.app", deny_words=" , ") + assert guard is not None + assert guard.custom_deny is False + + def test_build_guard_with_deny_words_is_custom(self) -> None: + guard = action_log.build_guard("com.example.app", deny_words="nuke") + assert guard is not None + assert guard.custom_deny is True + assert guard.deny_words == ("nuke",) + + def test_explicit_default_list_is_still_custom(self) -> None: + words = ",".join(action_log.DEFAULT_DENY_WORDS) + guard = action_log.build_guard("com.example.app", deny_words=words) + assert guard is not None + assert guard.custom_deny is True + assert guard.deny_words == action_log.DEFAULT_DENY_WORDS + + +class TestHintSource: + def test_default_guard_hint_names_default_list(self) -> None: + guard = action_log.build_guard("com.example.app") + assert guard is not None + assert action_log._custom_deny_words(guard) is None + res = action_log.label_refusal(guard, _CHECKOUT) + assert res is not None + assert res["hint"] == _DEFAULT_LIST_HINT + + def test_explicit_default_list_hint_names_session_list(self) -> None: + # custom_deny=True while deny_words == DEFAULT_DENY_WORDS: the caller set + # the list, so the hint must name it rather than "the default deny list". + words = ",".join(action_log.DEFAULT_DENY_WORDS) + guard = action_log.build_guard("com.example.app", deny_words=words) + assert guard is not None + assert action_log._custom_deny_words(guard) == action_log.DEFAULT_DENY_WORDS + res = action_log.label_refusal(guard, _CHECKOUT) + assert res is not None + assert res["hint"] == _session_list_hint("checkout", action_log.DEFAULT_DENY_WORDS) + + def test_non_default_words_without_flag_hint_names_session_list(self) -> None: + # A GuardConfig built directly (custom_deny left False) with its own words + # still gets a hint naming those words, not the default list. + words = ("checkout", "nuke") + guard = action_log.GuardConfig( + "com.example.app", + frozenset({"com.example.app"}), + words, + action_log.deny_pattern(words), + ) + assert guard.custom_deny is False + assert action_log._custom_deny_words(guard) == words + res = action_log.label_refusal(guard, _CHECKOUT) + assert res is not None + assert res["hint"] == _session_list_hint("checkout", words) + + def test_custom_list_hint_is_exact(self) -> None: + guard = action_log.build_guard("com.example.app", deny_words="checkout, nuke") + assert guard is not None + res = action_log.label_refusal(guard, _CHECKOUT) + assert res == { + "error": "guarded session: input on a 'checkout' label refused", + "guard": "denied_label", + "detail": "checkout", + "validation_error": True, + "hint": _session_list_hint("checkout", ("checkout", "nuke")), + } + + +class TestGuardUpdatedLine: + def test_update_guard_writes_exact_line_with_rounded_t_session( + self, reg: ActionLogRegistry, tmp_path: Path, clock: FakeClock + ) -> None: + path = tmp_path / "run.jsonl" + g1 = action_log.build_guard("com.example.app") + assert g1 is not None + reg.start("S", path, {"target_package": "com.example.app"}, g1) + session = reg.active("S") + assert session is not None + + g2 = action_log.build_guard( + "com.example.app", allow_packages="com.login", deny_words="delete account" + ) + assert g2 is not None + record = session.update_guard(g2, 101.23456) + + expected = { + "type": "guard_updated", + "event": "guard_updated", + "deny_words": ["delete account"], + "allowed_packages": sorted( + {"com.example.app", "com.login"} | action_log.BUILTIN_ALLOWED_PACKAGES + ), + "t_session_s": 1.235, + } + assert record == expected + assert session.guard is g2 + lines = _lines(path) + assert len(lines) == 2 + assert lines[1] == expected + + +class TestRearmResult: + def test_rearm_returns_exact_info_and_logs_event( + self, + reg: ActionLogRegistry, + tmp_path: Path, + clock: FakeClock, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + from structlog.testing import capture_logs + + path = tmp_path / "run.jsonl" + g1 = action_log.build_guard("com.example.app") + assert g1 is not None + first = reg.start("S", path, {"target_package": "com.example.app"}, g1) + assert first["started_at"] == "2026-10-03T12:00:00+00:00" + + # A later wall clock proves started_at is the session's, not re-stamped. + monkeypatch.setattr( + action_log, "_wallclock", lambda: datetime(2026, 10, 4, 9, 30, 0, tzinfo=UTC) + ) + clock.now = 102.0 + g2 = action_log.build_guard("com.example.app", deny_words="delete account") + assert g2 is not None + with capture_logs() as logs: + info = reg.start("S", path, {"target_package": "com.example.app"}, g2) + + assert info == { + "serial": "S", + "path": str(path), + "started_at": "2026-10-03T12:00:00+00:00", + "rearmed": True, + "target_package": "com.example.app", + "allowed_packages": sorted({"com.example.app"} | action_log.BUILTIN_ALLOWED_PACKAGES), + "deny_words": ["delete account"], + } + rearmed = [e for e in logs if e["event"] == "action_log_rearmed"] + assert len(rearmed) == 1 + assert rearmed[0]["serial"] == "S" + assert rearmed[0]["path"] == str(path) + assert rearmed[0]["log_level"] == "info" + assert _lines(path)[1]["t_session_s"] == 2.0 diff --git a/tests/test_actionlog_tools.py b/tests/test_actionlog_tools.py index da58dce..f60ce25 100644 --- a/tests/test_actionlog_tools.py +++ b/tests/test_actionlog_tools.py @@ -364,7 +364,7 @@ def test_mcp_wrapper_logs_exactly_once(ctrl: FakeController, tmp_path: Path) -> def test_wrapper_preserves_name_and_signature() -> None: fn = input_tools._nerve_tap_sync assert fn.__name__ == "_nerve_tap_sync" - assert list(inspect.signature(fn).parameters) == ["device", "x", "y"] + assert list(inspect.signature(fn).parameters) == ["device", "x", "y", "prompt"] assert "Tap a single point" in (fn.__doc__ or "") @@ -534,3 +534,104 @@ def test_replay_relative_log_path_resolves_under_first_root( (a / "rel.jsonl").write_text('{"type": "header"}\n', encoding="utf-8") monkeypatch.setenv("NERVE_ACTIONLOG_ROOTS", f"{a}:{b}") assert actionlog_tools._load_log("rel.jsonl") == [(1, {"type": "header"})] + + +def test_start_already_active_different_path_includes_stop_first_hint( + ctrl: FakeController, tmp_path: Path +) -> None: + path_a = tmp_path / "a.jsonl" + path_b = tmp_path / "b.jsonl" + _nerve_actionlog_start_sync("S", str(path_a), target_package="com.example.app") + + second = _nerve_actionlog_start_sync("S", str(path_b), target_package="com.example.app") + assert "action log already active on S" in second["error"] + assert "nerve_actionlog_stop" in second["hint"] + + +def test_start_rearm_returns_rearmed_and_updated_guard_info( + ctrl: FakeController, tmp_path: Path +) -> None: + path = tmp_path / "a.jsonl" + first = _nerve_actionlog_start_sync("S", str(path), target_package="com.example.app") + assert first["success"] is True + assert "rearmed" not in first + + rearmed = _nerve_actionlog_start_sync( + "S", + str(path), + target_package="com.example.app", + allow_packages="com.example.auth", + deny_words="delete account", + ) + assert rearmed["success"] is True + assert rearmed["action"] == "actionlog_start" + assert rearmed["rearmed"] is True + assert rearmed["guarded"] is True + assert "com.example.auth" in rearmed["allowed_packages"] + assert rearmed["deny_words"] == ["delete account"] + + +def test_start_and_rearm_results_are_exact( + ctrl: FakeController, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from datetime import UTC, datetime + + monkeypatch.setattr( + action_log, "_wallclock", lambda: datetime(2026, 10, 3, 12, 0, 0, tzinfo=UTC) + ) + first = _nerve_actionlog_start_sync( + "S", str(tmp_path / "a.jsonl"), target_package="com.example.app" + ) + resolved = first["path"] + builtin = action_log.BUILTIN_ALLOWED_PACKAGES + assert first == { + "success": True, + "action": "actionlog_start", + "serial": "S", + "path": resolved, + "started_at": "2026-10-03T12:00:00+00:00", + "guarded": True, + "target_package": "com.example.app", + "allowed_packages": sorted({"com.example.app"} | builtin), + "deny_words": list(action_log.DEFAULT_DENY_WORDS), + } + + rearmed = _nerve_actionlog_start_sync( + "S", + resolved, + target_package="com.example.app", + allow_packages="com.example.auth", + deny_words="delete account", + ) + assert rearmed == { + "success": True, + "action": "actionlog_start", + "serial": "S", + "path": resolved, + "started_at": "2026-10-03T12:00:00+00:00", + "rearmed": True, + "guarded": True, + "target_package": "com.example.app", + "allowed_packages": sorted({"com.example.app", "com.example.auth"} | builtin), + "deny_words": ["delete account"], + } + + +def test_already_active_error_is_exact_with_stop_first_hint( + ctrl: FakeController, tmp_path: Path +) -> None: + assert action_log.ALREADY_ACTIVE_HINT == ( + "Call nerve_actionlog_stop first to start a new log, or call " + "nerve_actionlog_start with the active path and target_package to " + "re-arm the guard in place." + ) + first = _nerve_actionlog_start_sync( + "S", str(tmp_path / "a.jsonl"), target_package="com.example.app" + ) + second = _nerve_actionlog_start_sync( + "S", str(tmp_path / "b.jsonl"), target_package="com.example.app" + ) + assert second == { + "error": f"action log already active on S: {first['path']}", + "hint": action_log.ALREADY_ACTIVE_HINT, + } diff --git a/tests/test_android.py b/tests/test_android.py index c381d33..797563a 100644 --- a/tests/test_android.py +++ b/tests/test_android.py @@ -995,10 +995,10 @@ def test_launch_with_uri_ampersand_survives_shell(self) -> None: ] def test_launch_with_uri_space_and_semicolon_survives_shell(self) -> None: - """A URI with a space and a `;` must arrive at the device as one literal arg. + """A URI with a percent-encoded space and a `;` must arrive at the device + as one literal arg. - An unquoted space would split the URI into two argv words, and an - unquoted `;` would terminate the `am start` command and start a new + An unquoted `;` would terminate the `am start` command and start a new one. Fails pre-fix. """ ctrl = AndroidController() @@ -1007,7 +1007,7 @@ def test_launch_with_uri_space_and_semicolon_survives_shell(self) -> None: mock_client.device.return_value = mock_device ctrl._client = mock_client - uri = "app://navigate?screen=two words;rm -rf /tmp" + uri = "app://navigate?screen=two%20words;rm=-rf" ctrl.launch("ABC", "com.app", uri=uri) cmd = mock_device.shell.call_args.args[0] assert _sh_tokenize(cmd) == [ diff --git a/tests/test_auto_device.py b/tests/test_auto_device.py new file mode 100644 index 0000000..2f28a86 --- /dev/null +++ b/tests/test_auto_device.py @@ -0,0 +1,314 @@ +"""Tests for auto-selecting device from a single caller reservation (linc-nerve-6xem). + +Covers: +- Pure helper ``_pick_reserved_device(reservations, caller)`` (one, two, zero leases, + and ignoring other owners' leases). +- ``device_tool`` wrapper auto-selection for ``nerve_ensure_ready``, ``nerve_launch``, + and ``nerve_release`` only: + 1. One lease -> picked, ``_core._device_semaphores`` keyed on that serial, result + carries ``device_auto_selected: True`` and ``device: ``. + 2. Two leases -> error listing both held serials. + 3. Zero leases -> error ``"device is required: you hold no reservation; " + "pass device or nerve_reserve first"`` (even when a device is online or + reserved by another owner). + 4. Non-opted tool (``nerve_tap``) with empty ``device=""`` -> unchanged error; + missing ``device`` -> FastMCP validation error. + 5. Guarded session active on the picked serial still refuses a cross-app ``nerve_launch``. +""" + +from __future__ import annotations + +import json +from collections.abc import Iterator +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import anyio +import pytest +from mcp.server.fastmcp.exceptions import ToolError as FastMCPToolError + +from linc_nerve.cli.main import _registry_server +from linc_nerve.core import action_log +from linc_nerve.core.device import DeviceType, NerveDevice, Platform +from linc_nerve.core.device_manager import DeviceManager +from linc_nerve.mcp import _core +from linc_nerve.mcp.actionlog_tools import _nerve_actionlog_start_sync +from tests.test_guarded_session import FakeController + +TARGET = "com.example.app" + + +class ReadyFakeController(FakeController): + """FakeController extended with ensure_ready + get_network_info for state_tools.""" + + def ensure_ready(self, serial: str) -> dict[str, Any]: + self.calls.append(("ensure_ready", (serial,))) + return { + "awake": True, + "unlocked": True, + "battery_pct": 95, + "charging": True, + "ready": True, + "reason": "ok", + "actions_taken": [], + "platform": "android", + } + + +def _android_device(serial: str = "PIXEL8PRO") -> NerveDevice: + return NerveDevice( + serial=serial, + platform=Platform.ANDROID, + device_type=DeviceType.PHONE, + model="Pixel 8 Pro", + os_version="15", + ) + + +def _mcp_call(tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]: + srv = _registry_server() + blocks = anyio.run(srv.call_tool, tool_name, arguments) + if isinstance(blocks, tuple): + blocks = blocks[0] + return json.loads(blocks[0].text) + + +@pytest.fixture(name="env") +def _env( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> Iterator[tuple[DeviceManager, ReadyFakeController]]: + fake = ReadyFakeController() + mgr = DeviceManager(db_path=tmp_path / "devices.db") + mgr._heartbeat_stop.set() + mgr._heartbeat_thread.join(timeout=2) + action_log.REGISTRY.reset() + _core._device_semaphores.clear() + monkeypatch.setenv("NERVE_ACTIONLOG_ROOTS", str(tmp_path)) + + def _fake_resolve(serial: str) -> tuple[NerveDevice | None, str]: + if serial in {"PIXEL8PRO", "PIXEL9PRO"}: + return _android_device(serial), "android" + return None, f"Device '{serial}' not found. Run nerve_discover to list devices." + + with ( + patch.object(_core, "_device_mgr", mgr), + patch.object(_core, "_resolve_device", side_effect=_fake_resolve), + patch.object(_core, "_get_controller", return_value=fake), + ): + yield mgr, fake + + action_log.REGISTRY.reset() + _core._device_semaphores.clear() + mgr.close() + + +# --------------------------------------------------------------------------- +# (a) Pure helper _pick_reserved_device(reservations, caller) +# --------------------------------------------------------------------------- + + +def test_pick_reserved_device_one_lease() -> None: + reservations = [ + {"device_serial": "OTHER-DEV", "owner": "someone-else"}, + {"device_serial": "EXPIRED-DEV", "owner": "session-123", "expires_in_s": 0.0}, + {"device_serial": "DEAD-PID-DEV", "owner": "session-123", "owner_alive": False}, + {"device_serial": "PIXEL8PRO", "owner": "session-123", "expires_in_s": 120.0}, + ] + assert _core._pick_reserved_device(reservations, "session-123") == "PIXEL8PRO" + + +def test_pick_reserved_device_accepts_mapping_and_dataclass_entries() -> None: + from linc_nerve.core.device_manager import DeviceReservation + + lease = DeviceReservation( + device_serial="PIXEL8PRO", + session_id="session-123", + pid=1234, + reserved_at=100.0, + last_heartbeat=100.0, + timeout_seconds=300, + purpose="bvt", + owner="session-123", + ) + assert _core._pick_reserved_device({"PIXEL8PRO": lease}, "session-123") == "PIXEL8PRO" + assert _core._inject_device_arg(("", "pkg"), {}, "PIXEL8PRO") == (("PIXEL8PRO", "pkg"), {}) + + +def test_pick_reserved_device_two_leases_lists_both() -> None: + reservations = [ + {"device_serial": "PIXEL8PRO", "owner": "session-123"}, + {"device_serial": "PIXEL9PRO", "owner": "session-123"}, + ] + res = _core._pick_reserved_device(reservations, "session-123") + assert isinstance(res, dict) + assert res.get("validation_error") is True + assert "PIXEL8PRO" in res["error"] + assert "PIXEL9PRO" in res["error"] + + +def test_pick_reserved_device_zero_leases() -> None: + reservations = [ + {"device_serial": "PIXEL8PRO", "owner": "other-owner"}, + ] + res = _core._pick_reserved_device(reservations, "session-123") + assert isinstance(res, dict) + assert res.get("validation_error") is True + assert ( + res["error"] + == "device is required: you hold no reservation; pass device or nerve_reserve first" + ) + + +# --------------------------------------------------------------------------- +# (b)-(d) device_tool wrapper auto-selection on the three opted-in tools +# --------------------------------------------------------------------------- + + +def test_one_lease_auto_selects_and_keys_semaphore_on_serial( + env: tuple[DeviceManager, ReadyFakeController], +) -> None: + mgr, fake = env + mgr.reserve("PIXEL8PRO", purpose="bvt") + _core._device_semaphores.clear() + + ready_res = _mcp_call("nerve_ensure_ready", {}) + assert ready_res["ready"] is True + assert ready_res["device"] == "PIXEL8PRO" + assert ready_res["device_auto_selected"] is True + assert ("ensure_ready", ("PIXEL8PRO",)) in fake.calls + assert "PIXEL8PRO" in _core._device_semaphores + assert "" not in _core._device_semaphores + + _core._device_semaphores.clear() + launch_res = _mcp_call("nerve_launch", {"package": TARGET}) + assert launch_res == { + "success": True, + "action": "launch", + "package": TARGET, + "device": "PIXEL8PRO", + "device_auto_selected": True, + } + assert ("launch", ("PIXEL8PRO", TARGET)) in fake.calls + assert "PIXEL8PRO" in _core._device_semaphores + assert "" not in _core._device_semaphores + + _core._device_semaphores.clear() + release_res = _mcp_call("nerve_release", {}) + assert release_res == { + "success": True, + "device": "PIXEL8PRO", + "device_auto_selected": True, + } + assert "PIXEL8PRO" in _core._device_semaphores + assert "" not in _core._device_semaphores + assert mgr.check("PIXEL8PRO") is None + + +def test_release_uses_owner_arg_when_given( + env: tuple[DeviceManager, ReadyFakeController], +) -> None: + mgr, _ = env + mgr.reserve("PIXEL8PRO", owner="lane-a") + mgr.reserve("PIXEL9PRO") # owned by mgr.session_id + + release_res = _mcp_call("nerve_release", {"owner": "lane-a"}) + assert release_res == { + "success": True, + "device": "PIXEL8PRO", + "device_auto_selected": True, + } + assert mgr.check("PIXEL8PRO") is None + assert mgr.check("PIXEL9PRO") is not None + + +def test_two_leases_returns_error_listing_both( + env: tuple[DeviceManager, ReadyFakeController], +) -> None: + mgr, fake = env + mgr.reserve("PIXEL8PRO", purpose="bvt-1") + mgr.reserve("PIXEL9PRO", purpose="bvt-2") + _core._device_semaphores.clear() + + for tool, args in ( + ("nerve_ensure_ready", {}), + ("nerve_launch", {"package": TARGET}), + ("nerve_release", {}), + ): + res = _mcp_call(tool, args) + assert "error" in res + assert "PIXEL8PRO" in res["error"] + assert "PIXEL9PRO" in res["error"] + + assert fake.calls == [] + assert _core._device_semaphores == {} + + +def test_zero_leases_returns_required_error_never_picks_online_or_foreign_device( + env: tuple[DeviceManager, ReadyFakeController], +) -> None: + mgr, fake = env + # Another owner holds a lease on PIXEL8PRO, and PIXEL9PRO is online but unreserved. + mgr.reserve("PIXEL8PRO", owner="foreign-owner") + _core._device_semaphores.clear() + + for tool, args in ( + ("nerve_ensure_ready", {}), + ("nerve_launch", {"package": TARGET}), + ("nerve_release", {}), + ): + res = _mcp_call(tool, args) + assert ( + res["error"] + == "device is required: you hold no reservation; pass device or nerve_reserve first" + ) + + assert fake.calls == [] + assert _core._device_semaphores == {} + + +def test_explicit_device_does_not_set_device_auto_selected( + env: tuple[DeviceManager, ReadyFakeController], +) -> None: + mgr, _ = env + mgr.reserve("PIXEL8PRO") + + res = _mcp_call("nerve_launch", {"device": "PIXEL8PRO", "package": TARGET}) + assert res == {"success": True, "action": "launch", "package": TARGET} + assert "device_auto_selected" not in res + + +def test_non_opted_tool_empty_device_returns_unchanged_error( + env: tuple[DeviceManager, ReadyFakeController], +) -> None: + mgr, fake = env + mgr.reserve("PIXEL8PRO") + + res = _mcp_call("nerve_tap", {"device": "", "x": 100, "y": 200}) + assert res == {"error": "Device '' not found. Run nerve_discover to list devices."} + assert fake.calls == [] + + with pytest.raises(FastMCPToolError): + anyio.run(_registry_server().call_tool, "nerve_tap", {"x": 100, "y": 200}) + + +def test_guarded_session_on_picked_serial_still_refuses_cross_app_launch( + env: tuple[DeviceManager, ReadyFakeController], tmp_path: Path +) -> None: + mgr, fake = env + mgr.reserve("PIXEL8PRO") + log_path = tmp_path / "guarded.jsonl" + started = _nerve_actionlog_start_sync("PIXEL8PRO", str(log_path), target_package=TARGET) + assert started["guarded"] is True + + refused = _mcp_call("nerve_launch", {"package": "com.other.app"}) + assert refused["guard"] == "other_package" + assert refused["detail"] == "com.other.app" + assert refused["validation_error"] is True + assert ("launch", ("PIXEL8PRO", "com.other.app")) not in fake.calls + + allowed = _mcp_call("nerve_launch", {"package": TARGET}) + assert allowed["success"] is True + assert allowed["device"] == "PIXEL8PRO" + assert allowed["device_auto_selected"] is True + assert ("launch", ("PIXEL8PRO", TARGET)) in fake.calls diff --git a/tests/test_cli_parity.py b/tests/test_cli_parity.py index 5193ea0..043556b 100644 --- a/tests/test_cli_parity.py +++ b/tests/test_cli_parity.py @@ -2,8 +2,11 @@ from __future__ import annotations +import anyio import pytest +from linc_cli_kit import registry_of from linc_cli_kit.testing import assert_cli_parity +from mcp.server.fastmcp.exceptions import ToolError as FastMCPToolError from linc_nerve.cli.main import _registry_server, main @@ -25,5 +28,18 @@ def test_cli_parity() -> None: report = assert_cli_parity( main, _registry_server(), prefix="nerve_", samples=_OVERRIDE_SAMPLES ) - assert report.tools == 48 + assert report.tools == 49 assert report.skipped == 0 + + +def test_strict_tools_contract() -> None: + """[contract] Every linc-nerve MCP tool forbids extra properties and unknown arguments.""" + srv = _registry_server() + specs = registry_of(srv) + assert specs + for spec in specs: + assert spec.parameters.get("additionalProperties") is False, ( + f"{spec.name}: missing additionalProperties: false" + ) + with pytest.raises(FastMCPToolError, match="unknown_arg_xyz"): + anyio.run(srv.call_tool, "nerve_discover", {"unknown_arg_xyz": 1}) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 61b69a7..885d701 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -415,3 +415,242 @@ def test_fresh_host_cli_exits_zero_and_still_offers_fix(monkeypatch) -> None: home = linc_home() assert f"create directories: {home}, {home / 'nerve' / 'recordings'}" in result.output assert executed == [] + + +# --------------------------------------------------------------------------- +# Probe-started adb server cleanup + `nerve doctor --human` +# --------------------------------------------------------------------------- + + +class TestAdbProbeServerHygiene: + @staticmethod + def _stub_other_probes(monkeypatch) -> None: + from linc_nerve.cli import doctor as doctor_mod + + monkeypatch.setattr(doctor_mod, "ADB_PATH", "/sdk/adb") + monkeypatch.setattr(doctor_mod.shutil, "which", lambda _p: "/sdk/adb") + monkeypatch.setattr(doctor_mod, "_probe_daemon_health", lambda _port: None) + monkeypatch.setattr(doctor_mod, "_probe_xcode_clt", lambda: "/Library/Developer/CLT") + + def test_not_listening_issues_adb_kill_server_after_adb_devices(self, monkeypatch) -> None: + import subprocess as sp + + from linc_nerve.cli import doctor as doctor_mod + + self._stub_other_probes(monkeypatch) + seen_addresses: list[object] = [] + calls: list[tuple[list[str], dict[str, object]]] = [] + + def fake_listening(address: object) -> doctor_mod.AdbProbe: + seen_addresses.append(address) + assert calls == [], "_adb_server_probe must run BEFORE _probe_adb_devices" + return doctor_mod.AdbProbe.REFUSED + + def fake_run(cmd: list[str], **kwargs: object) -> sp.CompletedProcess[str]: + calls.append((list(cmd), dict(kwargs))) + return sp.CompletedProcess( + cmd, + 0, + stdout="List of devices attached\n", + stderr="* daemon started successfully\n", + ) + + monkeypatch.setattr(doctor_mod, "_adb_server_probe", fake_listening) + monkeypatch.setattr(doctor_mod.subprocess, "run", fake_run) + + probes = doctor_mod.gather_probes() + assert len(seen_addresses) == 1 + assert probes.adb_server_listening is False + assert [c[0] for c in calls] == [ + ["/sdk/adb", "devices"], + ["/sdk/adb", "kill-server"], + ] + assert calls[1][1].get("timeout") == 10 + assert calls[1][1].get("capture_output") is True + assert calls[1][1].get("text") is True + assert calls[1][1].get("check") is False + + adb_check = next(r for r in run_checks(probes) if r.name == "adb") + assert adb_check.status == "ok" + assert ( + adb_check.detail + == "adb responsive (/sdk/adb) (server started for the check and stopped)" + ) + + def test_already_listening_issues_no_kill_server(self, monkeypatch) -> None: + import subprocess as sp + + from linc_nerve.cli import doctor as doctor_mod + + self._stub_other_probes(monkeypatch) + calls: list[list[str]] = [] + + monkeypatch.setattr( + doctor_mod, "_adb_server_probe", lambda _addr: doctor_mod.AdbProbe.LISTENING + ) + + def fake_run(cmd: list[str], **_kwargs: object) -> sp.CompletedProcess[str]: + calls.append(list(cmd)) + return sp.CompletedProcess( + cmd, + 0, + stdout="List of devices attached\n", + stderr="* daemon started successfully\n", + ) + + monkeypatch.setattr(doctor_mod.subprocess, "run", fake_run) + + probes = doctor_mod.gather_probes() + assert probes.adb_server_listening is True + assert calls == [["/sdk/adb", "devices"]] + + adb_check = next(r for r in run_checks(probes) if r.name == "adb") + assert adb_check.status == "ok" + assert adb_check.detail == "adb responsive (/sdk/adb)" + assert ( + check_adb("/sdk/adb", True, "List of devices attached\n").detail + == "adb responsive (/sdk/adb)" + ) + + def test_kill_server_carries_android_adb_server_port_env(self, monkeypatch) -> None: + import subprocess as sp + + from linc_nerve.cli import doctor as doctor_mod + + self._stub_other_probes(monkeypatch) + monkeypatch.setenv("ANDROID_ADB_SERVER_PORT", "5199") + probed_ports: list[int] = [] + calls: list[tuple[list[str], dict[str, object]]] = [] + + def fake_listening(address: object) -> doctor_mod.AdbProbe: + probed_ports.append(getattr(address, "port", -1)) + return doctor_mod.AdbProbe.REFUSED + + def fake_run(cmd: list[str], **kwargs: object) -> sp.CompletedProcess[str]: + calls.append((list(cmd), dict(kwargs))) + return sp.CompletedProcess( + cmd, + 0, + stdout="List of devices attached\n", + stderr="* daemon started successfully\n", + ) + + monkeypatch.setattr(doctor_mod.subprocess, "run", fake_run) + + doctor_mod.gather_probes(listening_probe=fake_listening) + assert probed_ports == [5199] + assert [c[0] for c in calls] == [ + ["/sdk/adb", "devices"], + ["/sdk/adb", "kill-server"], + ] + kill_env = calls[1][1].get("env") + assert isinstance(kill_env, dict) + assert kill_env.get("ANDROID_ADB_SERVER_PORT") == "5199" + + def test_non_executable_adb_skips_devices_and_kill_server(self, monkeypatch) -> None: + from linc_nerve.cli import doctor as doctor_mod + + self._stub_other_probes(monkeypatch) + monkeypatch.setattr(doctor_mod, "ADB_PATH", "adb") + monkeypatch.setattr(doctor_mod.shutil, "which", lambda _p: None) + calls: list[list[str]] = [] + monkeypatch.setattr( + doctor_mod.subprocess, + "run", + lambda cmd, **_kw: calls.append(list(cmd)), + ) + + probes = doctor_mod.gather_probes( + listening_probe=lambda _addr: doctor_mod.AdbProbe.REFUSED + ) + assert probes.adb_is_executable is False + assert probes.adb_devices_output is None + assert probes.adb_server_listening is True + assert calls == [] + + def test_invalid_adb_server_env_reads_unknown_never_refused(self, monkeypatch) -> None: + """[contract] An ADB_SERVER_SOCKET nerve rejects is UNKNOWN, so doctor never kills.""" + from linc_nerve.cli import doctor as doctor_mod + + monkeypatch.setenv("ADB_SERVER_SOCKET", "invalid-spec") + result = doctor_mod._probe_adb_listening(lambda _a: doctor_mod.AdbProbe.REFUSED) + assert result is doctor_mod.AdbProbe.UNKNOWN + assert doctor_mod.should_stop(result, "* daemon started successfully") is False + + def test_stop_probe_started_server_swallows_errors(self, monkeypatch) -> None: + from linc_nerve.cli import doctor as doctor_mod + + def boom(*_a: object, **_kw: object) -> None: + raise OSError("kill-server failed") + + monkeypatch.setattr(doctor_mod.subprocess, "run", boom) + doctor_mod._stop_probe_started_server("/sdk/adb") + + def test_fix_adb_start_server_executes_after_gather_probes( + self, monkeypatch + ) -> None: + import subprocess as sp + + from linc_nerve.cli import doctor as doctor_mod + + calls: list[list[str]] = [] + + def fake_run(cmd: list[str], **_kw: object) -> sp.CompletedProcess[str]: + calls.append(list(cmd)) + return sp.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(doctor_mod, "ADB_PATH", "/sdk/adb") + monkeypatch.setattr(doctor_mod.subprocess, "run", fake_run) + monkeypatch.setattr( + doctor_mod, + "gather_probes", + lambda **_kw: _green_probes(adb_devices_output=None), + ) + result = CliRunner().invoke(doctor, ["--fix"]) + assert result.exit_code == 1 + assert calls == [["/sdk/adb", "start-server"]] + + +class TestDoctorHumanFlag: + def test_doctor_human_flag_via_group_runner( + self, monkeypatch, restore_signal_handlers + ) -> None: + from linc_nerve.cli.main import main + + monkeypatch.setattr( + "linc_nerve.cli.doctor.gather_probes", lambda **kw: _green_probes() + ) + plain = CliRunner().invoke(main, ["doctor"]) + human = CliRunner().invoke(main, ["doctor", "--human"]) + global_human = CliRunner().invoke(main, ["--human", "doctor"]) + assert plain.exit_code == 0 + assert human.exit_code == 0 + assert global_human.exit_code == 0 + assert human.output == plain.output + assert "all green" in human.output + + def test_doctor_human_flag_exits_one_on_failure( + self, monkeypatch, restore_signal_handlers + ) -> None: + from linc_nerve.cli.main import main + + monkeypatch.setattr( + "linc_nerve.cli.doctor.gather_probes", + lambda **kw: _green_probes(scrcpy_server_exists=False), + ) + human = CliRunner().invoke(main, ["doctor", "--human"]) + assert human.exit_code == 1 + assert "✗" in human.output + + def test_doctor_human_with_json_is_usage_error( + self, monkeypatch, restore_signal_handlers + ) -> None: + from linc_nerve.cli.main import main + + monkeypatch.setattr( + "linc_nerve.cli.doctor.gather_probes", lambda **kw: _green_probes() + ) + result = CliRunner().invoke(main, ["doctor", "--human", "--json"]) + assert result.exit_code == 2 + assert "--human" in result.output + assert "--json" in result.output diff --git a/tests/test_doctor_adb_stop_contract.py b/tests/test_doctor_adb_stop_contract.py new file mode 100644 index 0000000..e0cef71 --- /dev/null +++ b/tests/test_doctor_adb_stop_contract.py @@ -0,0 +1,213 @@ +"""[contract] `nerve doctor` only stops an adb server its own probe started. + +`adb kill-server` drops every attached device, so doctor may run it only when +both facts hold: the pre-probe connect was positively refused (no server was +there) AND the `adb devices` stderr says *this* call started the daemon. Every +other outcome — connect timeout, an unexpected exception, an invalid +``ADB_SERVER_SOCKET`` — must read as "a server may already be running, leave it +alone". Everything here is stubbed: no real adb, subprocess, or socket. +""" + +from __future__ import annotations + +import errno +import subprocess as sp + +import pytest + +from linc_nerve.cli import doctor as doctor_mod +from linc_nerve.cli.doctor import AdbProbe, classify_connect_error, should_stop +from linc_nerve.core.adb_server import AdbServerAddress + +_STARTED = "* daemon not running; starting now at tcp:5037\n* daemon started successfully\n" +_ADDR = AdbServerAddress("127.0.0.1", 5037, "tcp:5037") + + +@pytest.fixture +def stubbed(monkeypatch) -> list[list[str]]: + """Stub every impure probe except the adb path under test; record adb argv.""" + monkeypatch.setattr(doctor_mod, "ADB_PATH", "/sdk/adb") + monkeypatch.setattr(doctor_mod.shutil, "which", lambda _p: "/sdk/adb") + monkeypatch.setattr(doctor_mod, "_probe_daemon_health", lambda _port: None) + monkeypatch.setattr(doctor_mod, "_probe_xcode_clt", lambda: None) + return [] + + +def _fake_run(calls: list[list[str]], stderr: str): + def run(cmd: list[str], **_kw: object) -> sp.CompletedProcess[str]: + calls.append(list(cmd)) + return sp.CompletedProcess(cmd, 0, stdout="List of devices attached\n", stderr=stderr) + + return run + + +def _kills(calls: list[list[str]]) -> int: + return sum(1 for c in calls if c[1:] == ["kill-server"]) + + +# --------------------------------------------------------------------------- +# The four required end-to-end cases (gather_probes with injected seams) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "spec", ["10.0.0.5:5037", "tcp:evil.example:5037", "garbage", "tcp:99999", "tcp:-1", "tcp:"] +) +def test_invalid_adb_server_socket_does_not_kill(stubbed, monkeypatch, spec) -> None: + """[contract] An ADB_SERVER_SOCKET nerve rejects never leads to kill-server.""" + monkeypatch.setenv("ADB_SERVER_SOCKET", spec) + monkeypatch.setattr(doctor_mod.subprocess, "run", _fake_run(stubbed, _STARTED)) + monkeypatch.setattr( + doctor_mod.socket, + "create_connection", + lambda *_a, **_k: (_ for _ in ()).throw(ConnectionRefusedError()), + ) + probes = doctor_mod.gather_probes() + assert _kills(stubbed) == 0 + assert probes.adb_server_listening is True + + +def test_connect_timeout_does_not_kill(stubbed, monkeypatch) -> None: + """[contract] A wedged server that misses the connect timeout is left alone.""" + + def timeout(*_a: object, **_k: object) -> None: + raise TimeoutError("timed out") + + monkeypatch.delenv("ADB_SERVER_SOCKET", raising=False) + monkeypatch.setattr(doctor_mod.socket, "create_connection", timeout) + monkeypatch.setattr(doctor_mod.subprocess, "run", _fake_run(stubbed, _STARTED)) + doctor_mod.gather_probes() + assert _kills(stubbed) == 0 + + +@pytest.mark.parametrize("stderr", ["", "error: something else\n", None]) +def test_refused_without_daemon_started_does_not_kill(stubbed, monkeypatch, stderr) -> None: + """[contract] Refused, but adb devices did not start a daemon (race: someone else did).""" + monkeypatch.setattr(doctor_mod.subprocess, "run", _fake_run(stubbed, stderr)) + doctor_mod.gather_probes(listening_probe=lambda _a: AdbProbe.REFUSED) + assert _kills(stubbed) == 0 + + +def test_refused_and_daemon_started_kills_once(stubbed, monkeypatch) -> None: + """[contract] Refused + 'daemon started successfully' -> exactly one kill-server.""" + monkeypatch.setattr(doctor_mod.subprocess, "run", _fake_run(stubbed, _STARTED)) + probes = doctor_mod.gather_probes(listening_probe=lambda _a: AdbProbe.REFUSED) + assert stubbed == [["/sdk/adb", "devices"], ["/sdk/adb", "kill-server"]] + assert _kills(stubbed) == 1 + assert probes.adb_server_listening is False + + +# --------------------------------------------------------------------------- +# Pure helpers +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("probe", "stderr", "expected"), + [ + (AdbProbe.REFUSED, _STARTED, True), + (AdbProbe.REFUSED, "* DAEMON Started\tSuccessfully *", True), + (AdbProbe.REFUSED, "", False), + (AdbProbe.REFUSED, None, False), + (AdbProbe.REFUSED, "daemon started", False), + (AdbProbe.LISTENING, _STARTED, False), + (AdbProbe.UNKNOWN, _STARTED, False), + (AdbProbe.UNKNOWN, None, False), + ], +) +def test_should_stop_truth_table(probe, stderr, expected) -> None: + """[contract] should_stop requires REFUSED AND the daemon-started phrase.""" + assert should_stop(probe, stderr) is expected + + +@pytest.mark.parametrize("hostile", [True, False, 0, 1, "refused", object()]) +def test_should_stop_rejects_non_enum_probe(hostile) -> None: + """[contract] A non-AdbProbe value (e.g. a legacy bool) never authorises a kill.""" + assert should_stop(hostile, _STARTED) is False # type: ignore[arg-type] + + +def test_should_stop_rejects_non_str_stderr() -> None: + """[contract] Bytes / non-str stderr is not parsed as consent to kill.""" + assert should_stop(AdbProbe.REFUSED, b"daemon started successfully") is False # type: ignore[arg-type] + + +@pytest.mark.parametrize( + ("exc", "expected"), + [ + (ConnectionRefusedError(), AdbProbe.REFUSED), + (OSError(errno.ECONNREFUSED, "refused"), AdbProbe.REFUSED), + (TimeoutError(), AdbProbe.UNKNOWN), + (OSError(errno.ETIMEDOUT, "timed out"), AdbProbe.UNKNOWN), + (OSError(errno.EHOSTUNREACH, "unreachable"), AdbProbe.UNKNOWN), + (OSError("no errno"), AdbProbe.UNKNOWN), + (ValueError("bad"), AdbProbe.UNKNOWN), + ], +) +def test_classify_connect_error(exc, expected) -> None: + """[contract] Only a positive ECONNREFUSED classifies as REFUSED.""" + assert classify_connect_error(exc) is expected + + +def test_adb_server_probe_maps_connect_outcomes(monkeypatch) -> None: + """[contract] The socket probe: connect -> LISTENING, refused -> REFUSED, else UNKNOWN.""" + + class _Conn: + def __enter__(self) -> _Conn: + return self + + def __exit__(self, *_exc: object) -> None: + return None + + seen: list[tuple[object, object]] = [] + + def ok(target: object, timeout: object = None) -> _Conn: + seen.append((target, timeout)) + return _Conn() + + monkeypatch.setattr(doctor_mod.socket, "create_connection", ok) + assert doctor_mod._adb_server_probe(_ADDR) is AdbProbe.LISTENING + assert seen == [(("127.0.0.1", 5037), 0.5)] + + def refused(*_a: object, **_k: object) -> None: + raise ConnectionRefusedError() + + monkeypatch.setattr(doctor_mod.socket, "create_connection", refused) + assert doctor_mod._adb_server_probe(_ADDR) is AdbProbe.REFUSED + + def boom(*_a: object, **_k: object) -> None: + raise RuntimeError("unexpected") + + monkeypatch.setattr(doctor_mod.socket, "create_connection", boom) + assert doctor_mod._adb_server_probe(_ADDR) is AdbProbe.UNKNOWN + + +@pytest.mark.parametrize("bad", [True, False, None, "REFUSED"]) +def test_probe_adb_listening_coerces_non_enum_to_unknown(monkeypatch, bad) -> None: + """[contract] An injected probe returning a non-AdbProbe value reads as UNKNOWN.""" + monkeypatch.delenv("ADB_SERVER_SOCKET", raising=False) + assert doctor_mod._probe_adb_listening(lambda _a: bad) is AdbProbe.UNKNOWN + + +def test_probe_adb_listening_exception_is_unknown(monkeypatch) -> None: + """[contract] A raising probe reads as UNKNOWN (never REFUSED).""" + + def boom(_a: object) -> AdbProbe: + raise OSError(errno.ECONNREFUSED, "refused") + + monkeypatch.delenv("ADB_SERVER_SOCKET", raising=False) + assert doctor_mod._probe_adb_listening(boom) is AdbProbe.UNKNOWN + + +def test_devices_stderr_captured_on_nonzero_exit(monkeypatch) -> None: + """[contract] A probe-started daemon is still stopped when adb devices exits non-zero.""" + calls: list[list[str]] = [] + + def run(cmd: list[str], **_kw: object) -> sp.CompletedProcess[str]: + calls.append(list(cmd)) + return sp.CompletedProcess(cmd, 1, stdout="", stderr=_STARTED) + + monkeypatch.setattr(doctor_mod.subprocess, "run", run) + listening, out = doctor_mod._run_adb_probe("/sdk/adb", True, lambda _a: AdbProbe.REFUSED) + assert out is None + assert calls == [["/sdk/adb", "devices"], ["/sdk/adb", "kill-server"]] + assert listening is False diff --git a/tests/test_doctor_env_resolution.py b/tests/test_doctor_env_resolution.py index c398dc4..7af4e27 100644 --- a/tests/test_doctor_env_resolution.py +++ b/tests/test_doctor_env_resolution.py @@ -42,7 +42,8 @@ def _fake_health(port: int): return None monkeypatch.setattr(doctor_mod, "_probe_daemon_health", _fake_health) - monkeypatch.setattr(doctor_mod, "_probe_adb_devices", lambda _adb: None) + monkeypatch.setattr(doctor_mod, "_probe_adb_devices", lambda _adb: (None, None)) + monkeypatch.setattr(doctor_mod, "_adb_server_probe", lambda _addr: doctor_mod.AdbProbe.UNKNOWN) monkeypatch.setattr(doctor_mod, "_probe_xcode_clt", lambda: None) return ports diff --git a/tests/test_grounding.py b/tests/test_grounding.py new file mode 100644 index 0000000..bb850af --- /dev/null +++ b/tests/test_grounding.py @@ -0,0 +1,165 @@ +"""Unit and contract tests for SAM 3.1 visual UI grounding (`linc_nerve.core.grounding`).""" + +from __future__ import annotations + +import io +from typing import Any +from unittest.mock import MagicMock + +import pytest +from PIL import Image + +from linc_nerve.core import grounding +from linc_nerve.mcp import _core, input_tools, ui_tools + + +def _make_png(width: int = 496, height: int = 1080) -> bytes: + img = Image.new("RGB", (width, height), color=(24, 32, 48)) + buf = io.BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() + + +# Authentic SAM 3.1 wire output shape captured against `POST https://api.meta.ai/v1/responses` +LIVE_SAM31_WIRE = ( + "<0f>0<|box;x1=29;y1=534;x2=133;y2=593;w=496;h=1080;c=0.8439024686813354|>" + "<|mask;x=0;y=0;c=0.8439024686813354;data=60,105,!0|>" + ",1<|box;x1=200;y1=800;x2=300;y2=900;w=496;h=1080;c=0.9500|>" + "<|mask;x=0;y=0;c=0.9500;data=101,101,!0|>" +) + + +def test_contract_parse_sam31_records_scales_phone_and_watch_coordinates() -> None: + """SAM 3.1 source-pixel boxes/masks scale to phone (1080x2340) and watch (432x432) coords.""" + # 1. Phone: Stage-1 screenshot 496x1080 -> physical 1080x2340 + phone_targets = grounding.parse_sam31_records( + LIVE_SAM31_WIRE, + prompt="submit button", + img_size=(496, 1080), + screen_size={"width": 1080, "height": 2340}, + ) + assert len(phone_targets) == 2 + # Sorted descending by confidence: object 1 (0.95) before object 0 (0.8439) + assert phone_targets[0]["object_id"] == "1" + assert phone_targets[0]["confidence"] == pytest.approx(0.95, abs=1e-4) + assert phone_targets[0]["center"] == { + "x": round(250.0 * (1080 / 496)), + "y": round(850.0 * (2340 / 1080)), + } + assert phone_targets[1]["object_id"] == "0" + assert phone_targets[1]["confidence"] == pytest.approx(0.8439, abs=1e-4) + assert phone_targets[1]["source"] == "sam-3.1" + assert phone_targets[1]["clickable"] is True + + # 2. Wear OS Watch (432x432 -> 432x432, scale = 1.0x) + watch_wire = ( + "<0f>0<|box;x1=180;y1=320;x2=252;y2=380;w=432;h=432;c=0.91|>" + "<|mask;x=0;y=0;c=0.91;data=61,73,!0|>" + ) + watch_targets = grounding.parse_sam31_records( + watch_wire, + prompt="ping button", + img_size=(432, 432), + screen_size={"width": 432, "height": 432}, + ) + assert len(watch_targets) == 1 + assert watch_targets[0]["bounds"] == "[180,320][252,380]" + assert watch_targets[0]["center"] == {"x": 216, "y": 350} + + +def test_parse_sam31_records_handles_empty_and_hostile_inputs() -> None: + """Zero matches or empty wire responses return `[]` without raising.""" + assert grounding.parse_sam31_records("", "button", (496, 1080)) == [] + assert grounding.parse_sam31_records(" ", "button", (496, 1080)) == [] + # Guard zero/missing screen_size (e.g., iOS simulator with no active WDA session) + targets = grounding.parse_sam31_records( + "<0f>0<|box;x1=10;y1=20;x2=30;y2=40;w=400;h=800;c=0.75|><|mask;x=0;y=0;c=0.75;data=21,21,!0|>", + "icon", + (400, 800), + screen_size={"width": 0, "height": 0}, + ) + assert len(targets) == 1 + assert targets[0]["center"] == {"x": 20, "y": 30} + + +def test_ground_visual_targets_and_overlay_rendering(monkeypatch: pytest.MonkeyPatch) -> None: + """`ground_visual_targets` calls SAM 3.1 and `render_grounded_overlay` produces a valid PNG.""" + png_bytes = _make_png(496, 1080) + monkeypatch.setattr( + grounding, + "_call_sam31_api", + lambda png, prompt, key, **kw: ( + "<0f>0<|box;x1=100;y1=200;x2=200;y2=300;w=496;h=1080;c=0.88|>" + "<|mask;x=0;y=0;c=0.88;data=101,101,!0|>" + ), + ) + targets = grounding.ground_visual_targets( + png_bytes, + "play triangle", + screen_size={"width": 1080, "height": 2340}, + api_key="test-key", + ) + assert len(targets) == 1 + assert targets[0]["text"] == "play triangle" + + overlay_png = grounding.render_grounded_overlay( + png_bytes, targets, screen_size={"width": 1080, "height": 2340} + ) + with Image.open(io.BytesIO(overlay_png)) as out_img: + assert out_img.size == (496, 1080) + + +def test_should_suggest_visual_grounding() -> None: + """Empty trees, failed dumps, or unlabelled clickable Canvas nodes trigger the hint.""" + assert grounding.should_suggest_visual_grounding([]) is True + assert grounding.should_suggest_visual_grounding(None, elements_error="timeout") is True + # Seeded c9 Canvas button: clickable node with empty text/desc/id + assert ( + grounding.should_suggest_visual_grounding( + [{"class": "android.view.View", "clickable": True, "text": "", "desc": "", "id": ""}] + ) + is True + ) + # Normal labelled button -> False + assert ( + grounding.should_suggest_visual_grounding( + [{"class": "android.widget.Button", "clickable": True, "text": "Submit"}] + ) + is False + ) + + +def test_nerve_ground_and_tap_prompt_integration(monkeypatch: pytest.MonkeyPatch) -> None: + """`nerve_ground` and `nerve_tap(prompt=...)` resolve SAM 3.1 targets into physical taps.""" + png_bytes = _make_png(496, 1080) + fake_ctrl = MagicMock() + fake_ctrl.screenshot.return_value = png_bytes + fake_ctrl.get_screen_size.return_value = {"width": 1080, "height": 2340} + tapped: list[tuple[str, int, int]] = [] + fake_ctrl.tap.side_effect = lambda dev, x, y: tapped.append((dev, x, y)) + + monkeypatch.setattr(_core, "_ensure_initialized", lambda: (fake_ctrl, fake_ctrl)) + monkeypatch.setattr(_core, "_resolve_device", lambda dev: ({"serial": dev}, "android")) + monkeypatch.setattr(_core, "_check_guard", lambda dev: None) + monkeypatch.setattr(_core, "_get_controller", lambda platform: fake_ctrl) + monkeypatch.setattr( + grounding, + "_call_sam31_api", + lambda png, prompt, key, **kw: ( + "<0f>0<|box;x1=100;y1=200;x2=196;y2=300;w=496;h=1080;c=0.92|>" + "<|mask;x=0;y=0;c=0.92;data=101,97,!0|>" + ), + ) + monkeypatch.setenv("META_AI_API_KEY", "test-meta-key") + + ground_res = ui_tools._nerve_ground_sync(device="SER123", prompt="submit icon", annotate=False) + assert ground_res["count"] == 1 + expected_x = round(148.0 * (1080 / 496)) + expected_y = round(250.0 * (2340 / 1080)) + assert ground_res["top_target"]["center"] == {"x": expected_x, "y": expected_y} + + tap_res: dict[str, Any] = input_tools._nerve_tap_sync(device="SER123", prompt="submit icon") + assert tap_res["success"] is True + assert (tap_res["x"], tap_res["y"]) == (expected_x, expected_y) + assert tapped == [("SER123", expected_x, expected_y)] + assert tap_res["grounded"]["prompt"] == "submit icon" diff --git a/tests/test_guard_bypasses.py b/tests/test_guard_bypasses.py index 809d931..1af8f72 100644 --- a/tests/test_guard_bypasses.py +++ b/tests/test_guard_bypasses.py @@ -356,3 +356,59 @@ def test_word_boundary_still_holds(self) -> None: def test_returns_the_canonical_custom_phrase(self) -> None: pattern = action_log.deny_pattern(["Launch Missiles"]) assert denied_label("LAUNCH-missiles now", pattern) == "launch missiles" + + +# --------------------------------------------------------------------------- +# 6. Input aliases (keycode=, x1..y2) fold before guard, log, and replay +# --------------------------------------------------------------------------- + + +def test_aliased_key_and_swipe_refused_like_canonical( + ctrl: FakeController, tmp_path: Path +) -> None: + path = _start(tmp_path, target_package=TARGET) + ctrl.tree = _FOCUSED_SUBSCRIBE + ctrl.label = {"text": "Buy"} + + key_canon = input_tools._nerve_key_sync("S", "ENTER") + key_alias = input_tools._nerve_key_sync("S", keycode="ENTER") + swipe_canon = input_tools._nerve_swipe_sync("S", 150, 150, 150, 150) + swipe_alias = input_tools._nerve_swipe_sync("S", x1=150, y1=150, x2=150, y2=150) + + assert key_alias == key_canon + assert swipe_alias == swipe_canon + assert _tool_calls(ctrl, "press_key") == [] + assert _tool_calls(ctrl, "swipe") == [] + g1, g2, g3, g4 = _lines(path, "guard") + assert (g1["tool"], g1["reason"], g1["detail"]) == (g2["tool"], g2["reason"], g2["detail"]) + assert (g3["tool"], g3["reason"], g3["detail"]) == (g4["tool"], g4["reason"], g4["detail"]) + + +def test_aliased_key_and_swipe_log_canonical_args_and_replay( + ctrl: FakeController, tmp_path: Path +) -> None: + from linc_nerve.mcp.actionlog_tools import _nerve_actionlog_stop_sync, _nerve_replay_sync + + ctrl.tree = [{**_FOCUSED_SUBSCRIBE[1]}, {**_FOCUSED_SUBSCRIBE[2], "text": "Next"}] + log_path = _start(tmp_path, target_package=TARGET) + assert input_tools._nerve_key_sync("S", "ENTER") == input_tools._nerve_key_sync( + "S", keycode="ENTER" + ) + assert input_tools._nerve_swipe_sync("S", 150, 150, 150, 900) == input_tools._nerve_swipe_sync( + "S", x1=150, y1=150, x2=150, y2=900 + ) + _nerve_actionlog_stop_sync("S") + + k_canon, k_alias, s_canon, s_alias = _lines(log_path, "action") + assert k_canon["args"] == k_alias["args"] == {"key": "ENTER"} + assert ( + s_canon["args"] + == s_alias["args"] + == { + "start_x": 150, + "start_y": 150, + "end_x": 150, + "end_y": 900, + } + ) + assert _nerve_replay_sync("S", str(log_path))["outcome"] == "completed" diff --git a/tests/test_guarded_session.py b/tests/test_guarded_session.py index 8a39e6e..4360c90 100644 --- a/tests/test_guarded_session.py +++ b/tests/test_guarded_session.py @@ -780,8 +780,10 @@ def test_needs_label_check_for_any_swipe(self) -> None: args = {"start_x": 0, "start_y": 0, "end_x": 900, "end_y": 900} assert action_log.needs_label_check("nerve_swipe", args) is True - def test_malformed_swipe_needs_no_check(self) -> None: - assert action_log.needs_label_check("nerve_swipe", {"start_x": "x"}) is False + def test_malformed_swipe_still_needs_check(self) -> None: + """Fail closed: unparseable coordinates are refused, never waved through.""" + assert action_log.needs_label_check("nerve_swipe", {"start_x": "x"}) is True + assert action_log.coordinate_refusal("nerve_swipe", {"start_x": "x"}) is not None def test_click_point_is_the_start_inside_one_clickable(self) -> None: args = {"start_x": 40, "start_y": 1100, "end_x": 540, "end_y": 1100} @@ -808,3 +810,135 @@ def test_activation_key_with_nothing_focused_refused(ctrl: FakeController, tmp_p assert _tool_calls(ctrl, "press_key") == [] (line,) = _lines(path, "guard") assert line["detail"] == "no focused element" + + +# --------------------------------------------------------------------------- +# Remediation hint on denied_label + in-place guard re-arm +# --------------------------------------------------------------------------- + + +def test_denied_label_refusal_carries_default_list_hint( + ctrl: FakeController, tmp_path: Path +) -> None: + _start(tmp_path, target_package=TARGET) + ctrl.label = {"text": "Checkout"} + + result = input_tools._nerve_tap_sync("S", 150, 150) + + assert result == { + "error": "guarded session: input on a 'checkout' label refused", + "guard": "denied_label", + "detail": "checkout", + "validation_error": True, + "hint": ( + "'checkout' is on the default deny list. If tapping it is intended in " + "this session, call nerve_actionlog_start again with the same path and " + "deny_words set to the full list you want (deny_words replaces the " + "defaults; pass 'delete account' to keep only that)." + ), + } + + +def test_denied_label_refusal_hint_names_custom_list_when_set( + ctrl: FakeController, tmp_path: Path +) -> None: + _start(tmp_path, target_package=TARGET, deny_words="launch missiles, nuke") + ctrl.label = {"text": "Launch missiles"} + + result = input_tools._nerve_tap_sync("S", 150, 150) + + assert result["guard"] == "denied_label" + assert result["detail"] == "launch missiles" + assert "default deny list" not in result["hint"] + assert "launch missiles, nuke" in result["hint"] + assert "nerve_actionlog_start" in result["hint"] + + +def test_rearm_same_path_lifts_checkout_and_logs_in_same_file( + ctrl: FakeController, tmp_path: Path +) -> None: + path = _start(tmp_path, target_package=TARGET) + ctrl.label = {"text": "Checkout"} + + refused = input_tools._nerve_tap_sync("S", 150, 150) + assert refused["guard"] == "denied_label" + assert refused["detail"] == "checkout" + assert _tool_calls(ctrl, "tap") == [] + + rearmed = _nerve_actionlog_start_sync( + "S", str(path), target_package=TARGET, deny_words="delete account" + ) + assert rearmed.get("success") is True, rearmed + assert rearmed["action"] == "actionlog_start" + assert rearmed["rearmed"] is True + assert "'checkout' is on the default deny list." in refused["hint"] + + allowed = input_tools._nerve_tap_sync("S", 150, 150) + assert allowed == {"success": True, "action": "tap", "x": 150, "y": 150} + assert _tool_calls(ctrl, "tap") == [("S", 150, 150)] + + # "delete account" stays refused after narrowing deny_words to it + ctrl.label = {"text": "Delete account"} + still_refused = input_tools._nerve_tap_sync("S", 150, 150) + assert still_refused["guard"] == "denied_label" + assert still_refused["detail"] == "delete account" + assert "default deny list" not in still_refused["hint"] + assert "delete account" in still_refused["hint"] + + raw_lines = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] + assert [r.get("event") or r["type"] for r in raw_lines] == [ + "header", + "guard", + "guard_updated", + "action", + "guard", + ] + assert raw_lines[2]["event"] == "guard_updated" + assert raw_lines[2]["deny_words"] == ["delete account"] + assert TARGET in raw_lines[2]["allowed_packages"] + assert isinstance(raw_lines[2]["t_session_s"], float) + + +def test_rearm_different_path_or_target_or_mode_flip_errors( + ctrl: FakeController, tmp_path: Path +) -> None: + path = _start(tmp_path, target_package=TARGET) + + diff_path = _nerve_actionlog_start_sync( + "S", str(tmp_path / "other.jsonl"), target_package=TARGET, deny_words="delete account" + ) + assert "action log already active on S" in diff_path["error"] + assert "nerve_actionlog_stop" in diff_path["hint"] + + diff_target = _nerve_actionlog_start_sync( + "S", str(path), target_package="com.other.app", deny_words="delete account" + ) + assert "action log already active on S" in diff_target["error"] + assert "nerve_actionlog_stop" in diff_target["hint"] + + to_unguarded = _nerve_actionlog_start_sync("S", str(path), target_package="") + assert "action log already active on S" in to_unguarded["error"] + assert "nerve_actionlog_stop" in to_unguarded["hint"] + + _nerve_actionlog_stop_sync("S") + unguarded_path = tmp_path / "unguarded.jsonl" + _nerve_actionlog_start_sync("S", str(unguarded_path)) + to_guarded = _nerve_actionlog_start_sync("S", str(unguarded_path), target_package=TARGET) + assert "action log already active on S" in to_guarded["error"] + assert "nerve_actionlog_stop" in to_guarded["hint"] + + +def test_rearm_never_widens_allowed_packages_unless_asked( + ctrl: FakeController, tmp_path: Path +) -> None: + path = _start(tmp_path, target_package=TARGET) + rearmed = _nerve_actionlog_start_sync( + "S", str(path), target_package=TARGET, deny_words="delete account" + ) + assert rearmed["rearmed"] is True + + ctrl.foreground = "com.android.vending" + cross_app = input_tools._nerve_tap_sync("S", 150, 150) + assert cross_app["guard"] == "foreground_not_allowed" + assert "hint" not in cross_app + assert _tool_calls(ctrl, "tap") == [] diff --git a/tests/test_input_aliases.py b/tests/test_input_aliases.py new file mode 100644 index 0000000..b78fd9e --- /dev/null +++ b/tests/test_input_aliases.py @@ -0,0 +1,209 @@ +"""Tests for nerve_key/nerve_swipe aliases and nerve_navigate hint (linc-nerve-vwkl).""" + +from __future__ import annotations + +import json +from collections.abc import Iterator +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import anyio +import pytest + +from linc_nerve.cli.main import _registry_server +from linc_nerve.core import action_log +from linc_nerve.mcp import _core, input_tools, navigation_tools +from tests.test_guarded_session import FakeController + + +@pytest.fixture(name="ctrl") +def _ctrl(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[FakeController]: + fake = FakeController() + action_log.REGISTRY.reset() + monkeypatch.setenv("NERVE_ACTIONLOG_ROOTS", str(tmp_path)) + with ( + patch.object(_core, "_resolve_device", return_value=(object(), "android")), + patch.object(_core, "_get_controller", return_value=fake), + patch.object(_core, "_check_guard", return_value=None), + ): + yield fake + action_log.REGISTRY.reset() + + +def _mcp_call(tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]: + srv = _registry_server() + blocks = anyio.run(srv.call_tool, tool_name, arguments) + if isinstance(blocks, tuple): + blocks = blocks[0] + return json.loads(blocks[0].text) + + +# --------------------------------------------------------------------------- +# 1. MCP strict_tools accepts keycode= and x1..y2 (and stringified ints) +# --------------------------------------------------------------------------- + + +def test_mcp_nerve_key_accepts_keycode_alias(ctrl: FakeController) -> None: + res = _mcp_call("nerve_key", {"device": "S", "keycode": "ENTER"}) + assert res == {"success": True, "action": "key", "key": "ENTER"} + assert ctrl.calls == [("press_key", ("S", "ENTER"))] + + +def test_mcp_nerve_key_accepts_matching_key_and_keycode(ctrl: FakeController) -> None: + res = _mcp_call("nerve_key", {"device": "S", "key": "BACK", "keycode": "BACK"}) + assert res == {"success": True, "action": "key", "key": "BACK"} + + +def test_mcp_nerve_swipe_accepts_x1_y1_x2_y2_aliases(ctrl: FakeController) -> None: + res = _mcp_call("nerve_swipe", {"device": "S", "x1": 10, "y1": 20, "x2": 30, "y2": 400}) + assert res == {"success": True, "action": "swipe", "from": [10, 20], "to": [30, 400]} + assert ctrl.calls == [("swipe", ("S", 10, 20, 30, 400, 300))] + + +def test_mcp_nerve_swipe_coerces_stringified_ints(ctrl: FakeController) -> None: + res = _mcp_call( + "nerve_swipe", + {"device": "S", "x1": "540", "y1": "1600", "x2": "540", "y2": "400", "duration_ms": "450"}, + ) + assert res == {"success": True, "action": "swipe", "from": [540, 1600], "to": [540, 400]} + assert ctrl.calls == [("swipe", ("S", 540, 1600, 540, 400, 450))] + + +# --------------------------------------------------------------------------- +# 2. Validation errors for missing, conflicting, or mixed arguments +# --------------------------------------------------------------------------- + + +def test_nerve_key_missing_both_is_validation_error(ctrl: FakeController) -> None: + res = input_tools._nerve_key_sync("S") + assert res.get("validation_error") is True + assert "key" in res["error"] and "keycode" in res["error"] + assert ctrl.calls == [] + + +def test_nerve_key_conflicting_key_and_keycode_is_validation_error(ctrl: FakeController) -> None: + res = input_tools._nerve_key_sync("S", key="HOME", keycode="BACK") + assert res.get("validation_error") is True + assert "key" in res["error"] and "keycode" in res["error"] + assert ctrl.calls == [] + + +def test_nerve_swipe_missing_all_coordinates_is_validation_error(ctrl: FakeController) -> None: + res = input_tools._nerve_swipe_sync("S") + assert res.get("validation_error") is True + for name in ("start_x", "start_y", "end_x", "end_y", "x1", "y1", "x2", "y2"): + assert name in res["error"] + assert ctrl.calls == [] + + +def test_nerve_swipe_partial_canonical_names_missing(ctrl: FakeController) -> None: + res = input_tools._nerve_swipe_sync("S", start_x=10, start_y=20) + assert res.get("validation_error") is True + assert "end_x" in res["error"] and "end_y" in res["error"] + assert ctrl.calls == [] + + +def test_nerve_swipe_partial_alias_names_missing(ctrl: FakeController) -> None: + res = input_tools._nerve_swipe_sync("S", x1=10, y2=40) + assert res.get("validation_error") is True + assert "y1" in res["error"] and "x2" in res["error"] + assert ctrl.calls == [] + + +def test_nerve_swipe_mixed_canonical_and_alias_is_validation_error(ctrl: FakeController) -> None: + res = input_tools._nerve_swipe_sync("S", start_x=10, start_y=20, x2=30, y2=40) + assert res.get("validation_error") is True + assert "end_x" in res["error"] and "end_y" in res["error"] + assert "x1" in res["error"] and "y1" in res["error"] + assert ctrl.calls == [] + + +def test_nerve_swipe_both_complete_sets_is_validation_error(ctrl: FakeController) -> None: + res = input_tools._nerve_swipe_sync( + "S", start_x=1, start_y=2, end_x=3, end_y=4, x1=1, y1=2, x2=3, y2=4 + ) + assert res.get("validation_error") is True + assert ctrl.calls == [] + + +# --------------------------------------------------------------------------- +# 3. Pure helper _canonical_input_args +# --------------------------------------------------------------------------- + + +def test_canonical_input_args_passes_other_tools_unchanged() -> None: + raw = {"device": "S", "x": 1, "y": 2} + assert _core._canonical_input_args("nerve_tap", raw) == raw + + +# --------------------------------------------------------------------------- +# 4. nerve_navigate unknown-app error includes hint pointing at nerve_launch +# --------------------------------------------------------------------------- + + +def test_nerve_navigate_unknown_app_includes_launch_uri_hint() -> None: + res = navigation_tools._nerve_navigate_sync("S", "com.some.unregistered", "home") + assert res["validation_error"] is True + assert "Unknown app 'com.some.unregistered'" in res["error"] + assert res["hint"] == ( + "nerve_navigate only knows apps listed in its route registry " + "(routes.yaml / NERVE_ROUTES_FILE). For any other app use " + "nerve_launch(device, package, uri=)." + ) + + +# --------------------------------------------------------------------------- +# 5. Coordinate bounds 0..32767 via MCP call_tool (canonical and x1..y2 alias) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "bad_x", + [10**30, "1000000000000000000000000000000", -1, 32768], +) +def test_mcp_nerve_tap_out_of_range_is_validation_error(ctrl: FakeController, bad_x: Any) -> None: + res = _mcp_call("nerve_tap", {"device": "S", "x": bad_x, "y": 150}) + assert res == { + "error": f"Invalid coordinates (integers in 0..32767 required): x={int(bad_x)!r}", + "validation_error": True, + } + assert ctrl.calls == [] + + +@pytest.mark.parametrize( + ("args", "expected_shown"), + [ + ( + {"device": "S", "start_x": 10, "start_y": 20, "end_x": 32768, "end_y": 40}, + "end_x=32768", + ), + ( + {"device": "S", "x1": 10, "y1": 20, "x2": 40000, "y2": 40}, + "end_x=40000", + ), + ], +) +def test_mcp_nerve_swipe_out_of_range_canonical_and_alias_is_validation_error( + ctrl: FakeController, args: dict[str, Any], expected_shown: str +) -> None: + res = _mcp_call("nerve_swipe", args) + assert res == { + "error": f"Invalid coordinates (integers in 0..32767 required): {expected_shown}", + "validation_error": True, + } + assert ctrl.calls == [] + + +def test_mcp_nerve_tap_and_swipe_accept_0_and_32767_bounds(ctrl: FakeController) -> None: + assert _mcp_call("nerve_tap", {"device": "S", "x": 0, "y": 32767})["success"] is True + assert ( + _mcp_call("nerve_swipe", {"device": "S", "x1": 0, "y1": 0, "x2": 32767, "y2": 32767})[ + "success" + ] + is True + ) + assert ctrl.calls == [ + ("tap", ("S", 0, 32767)), + ("swipe", ("S", 0, 0, 32767, 32767, 300)), + ] diff --git a/tests/test_launch_uri.py b/tests/test_launch_uri.py new file mode 100644 index 0000000..fadd4ba --- /dev/null +++ b/tests/test_launch_uri.py @@ -0,0 +1,262 @@ +"""Tests for nerve launch --uri, validate_launch_uri, controller/router guards, and CLI parity. + +Acceptance contract: +- validate_launch_uri accepts valid RFC 3986 URIs with query params, percent-encoding, anchors. +- validate_launch_uri rejects empty, whitespace, newlines, control characters, leading-dash, + missing scheme, quotes, backticks, backslashes, length > 2048, NUL bytes. +- AndroidController.launch with uri executes am start with VIEW intent and shlex-quoted URI. +- _nerve_launch_sync rejects bad uri with "Launch failed: ..." without calling controller + (Android & iOS). +- Router _reject_bad_uri rejects bad uri with 400, passes valid / empty / multi-param URIs. +- CLI nerve launch sends uri in payload only when non-empty. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from linc_nerve.cli.main import main +from linc_nerve.core.android import AndroidController, validate_launch_uri +from linc_nerve.core.device import DeviceType, NerveDevice, Platform +from linc_nerve.daemon.http_router import _reject_bad_uri, route_request +from linc_nerve.mcp import server as mcp_mod +from linc_nerve.mcp.app_tools import _nerve_launch_sync + +pytestmark = pytest.mark.usefixtures("restore_signal_handlers") + + +def _reset_singletons() -> None: + if mcp_mod._device_mgr is not None: + mcp_mod._device_mgr.close() + mcp_mod._android = None + mcp_mod._ios = None + mcp_mod._state_mgr = None + mcp_mod._metrics = None + mcp_mod._device_mgr = None + mcp_mod._emulator = None + mcp_mod._cached_devices = [] + mcp_mod._cache_timestamp = 0.0 + mcp_mod._cleanup_done = False + + +@pytest.fixture(autouse=True) +def reset_singletons() -> Iterator[None]: + _reset_singletons() + yield + _reset_singletons() + + +# --------------------------------------------------------------------------- +# 1. validate_launch_uri accept / reject +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "valid_uri", + [ + "market://details?id=net.sourceforge.opencamera", + "market://details?id=org.x&referrer=utm_source%3Dbench", + "https://www.traeger.com/recipes?q=a%20b", + "precisionlevel://settings", + "sniperpulse://session/42#top", + ], +) +def test_validate_launch_uri_accepts_valid(valid_uri: str) -> None: + validate_launch_uri(valid_uri) + + +@pytest.mark.parametrize( + "invalid_uri", + [ + "", + "market://x y", + "market://x\n", + "market://x\nreboot", + "-d evil", + "//no-scheme", + "1abc://x", + "https://x/`id`", + 'https://x/"q"', + "https://x/\\y", + "https://example.com/" + "a" * (2049 - len("https://example.com/")), + "https://example.com/\x00evil", + ], +) +def test_validate_launch_uri_rejects_invalid(invalid_uri: str) -> None: + with pytest.raises(ValueError, match="RFC 3986"): + validate_launch_uri(invalid_uri) + + +# --------------------------------------------------------------------------- +# 2. AndroidController.launch with fake _shell +# --------------------------------------------------------------------------- + + +def test_android_controller_launch_valid_uri() -> None: + ctrl = AndroidController() + shell_calls: list[tuple[str, str]] = [] + ctrl._shell = lambda serial, cmd: shell_calls.append((serial, cmd)) or "" # type: ignore[assignment] + + ctrl.launch("S", "com.android.vending", uri="market://details?id=org.x") + + assert shell_calls == [ + ( + "S", + ( + "am start -a android.intent.action.VIEW " + "-d 'market://details?id=org.x' com.android.vending" + ), + ) + ] + + +def test_android_controller_launch_rejected_uri_raises_and_never_calls_shell() -> None: + ctrl = AndroidController() + shell_calls: list[tuple[str, str]] = [] + ctrl._shell = lambda serial, cmd: shell_calls.append((serial, cmd)) or "" # type: ignore[assignment] + + with pytest.raises(ValueError): + ctrl.launch("S", "com.android.vending", uri="market://x\nreboot") + + assert shell_calls == [] + + +# --------------------------------------------------------------------------- +# 3. _nerve_launch_sync bad uri returns error without calling controller +# --------------------------------------------------------------------------- + + +def test_nerve_launch_sync_bad_uri_android() -> None: + mock_android = MagicMock() + mock_android.discover.return_value = [ + NerveDevice( + serial="android-dev", + platform=Platform.ANDROID, + model="Pixel", + device_type=DeviceType.PHONE, + os_version="14", + ) + ] + mcp_mod._android = mock_android + mcp_mod._ios = MagicMock() + mcp_mod._ios.discover.return_value = [] + + res = _nerve_launch_sync("android-dev", "com.android.vending", uri="market://x\nreboot") + + assert "error" in res + assert res["error"].startswith("Launch failed") + mock_android.launch.assert_not_called() + + +def test_nerve_launch_sync_bad_uri_ios() -> None: + mock_ios = MagicMock() + mock_ios.discover.return_value = [ + NerveDevice( + serial="ios-dev", + platform=Platform.IOS, + model="iPhone", + device_type=DeviceType.PHONE, + os_version="17", + ) + ] + mcp_mod._ios = mock_ios + mcp_mod._android = MagicMock() + mcp_mod._android.discover.return_value = [] + + res = _nerve_launch_sync("ios-dev", "com.example.ios", uri="market://x\nreboot") + + assert "error" in res + assert res["error"].startswith("Launch failed") + mock_ios.launch.assert_not_called() + + +# --------------------------------------------------------------------------- +# 4. Router _reject_bad_uri +# --------------------------------------------------------------------------- + + +def test_router_reject_bad_uri() -> None: + rejection = _reject_bad_uri("nerve_launch", {"uri": "market://x\nreboot"}) + assert rejection is not None + assert rejection["status"] == 400 + assert rejection["body"] == {"error": "Request body contains a disallowed uri value"} + + # passes navigate shape + nav_shape = {"package": "", "uri": "precisionlevel://settings"} + assert _reject_bad_uri("nerve_launch", nav_shape) is None + + # passes multi-param & uri + assert ( + _reject_bad_uri( + "nerve_launch", + {"uri": "market://details?id=org.x&referrer=utm_source%3Dbench"}, + ) + is None + ) + + # ignores body with no uri + assert _reject_bad_uri("nerve_launch", {"device": "S", "package": "com.x"}) is None + + +def test_route_request_runs_uri_guard_before_dispatch() -> None: + """The guard is wired into the pre-dispatch chain: a bad uri never reaches the tool.""" + calls: list[dict[str, object]] = [] + + def fake_launch(device: str = "", package: str = "", uri: str = "") -> dict[str, object]: + calls.append({"device": device, "package": package, "uri": uri}) + return {"launched": package} + + registry = {"nerve_launch": fake_launch} + bad = route_request( + "POST", + "/tools/nerve_launch", + {"device": "S", "package": "com.x", "uri": "market://x\nreboot"}, + registry, + ) + assert bad["status"] == 400 + assert bad["body"] == {"error": "Request body contains a disallowed uri value"} + assert calls == [] + + ok = route_request( + "POST", + "/tools/nerve_launch", + {"device": "S", "package": "", "uri": "market://details?id=org.x&referrer=a%3Db"}, + registry, + ) + assert ok["status"] == 200 + assert calls == [ + {"device": "S", "package": "", "uri": "market://details?id=org.x&referrer=a%3Db"} + ] + + +# --------------------------------------------------------------------------- +# 5. CLI nerve launch sends uri in payload only when non-empty +# --------------------------------------------------------------------------- + + +def test_cli_launch_with_uri() -> None: + runner = CliRunner() + with patch("linc_nerve.cli.main._call") as mock_call: + mock_call.return_value = {"success": True} + result = runner.invoke( + main, + ["launch", "S", "com.android.vending", "--uri", "market://details?id=org.x"], + ) + assert result.exit_code == 0 + mock_call.assert_called_once_with( + "nerve_launch", + {"device": "S", "package": "com.android.vending", "uri": "market://details?id=org.x"}, + ) + + +def test_cli_launch_without_uri() -> None: + runner = CliRunner() + with patch("linc_nerve.cli.main._call") as mock_call: + mock_call.return_value = {"success": True} + result = runner.invoke(main, ["launch", "S", "com.x"]) + assert result.exit_code == 0 + mock_call.assert_called_once_with("nerve_launch", {"device": "S", "package": "com.x"}) diff --git a/tests/test_lease_ownership.py b/tests/test_lease_ownership.py new file mode 100644 index 0000000..8269baa --- /dev/null +++ b/tests/test_lease_ownership.py @@ -0,0 +1,398 @@ +"""Lease ownership per caller (linc-nerve-jtt8, epic linc-nerve-hts0). + +p0-05 (bench bug-hunt Phase 0, 2026-10-12): a case agent's plain reserve was +refused with "or pass takeover=True"; it passed --takeover with no --owner, the +lease went to the daemon's shared session id, the orchestrator's renew failed +and the agent's later plain release freed the device. These tests pin: + +(a) a takeover without an explicit identity is refused and the row is intact; +(b) the conflict refusal no longer coaches takeover; +(c) owner_pid without owner defaults the owner to ``pid:``; +(d) the CLI fills a default owner from ``NERVE_OWNER``; +(e) anonymous (session-id-owned) leases are flagged in the tool result; +(f) a same-owner re-reserve is reported as a renewal. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +from collections.abc import Iterator +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import pytest +from click.testing import CliRunner +from linc_cli_kit import HttpDaemonTransport, UsageError + +from linc_nerve.cli.main import _with_default_owner, main +from linc_nerve.core.device_manager import ( + DeviceManager, + DeviceReservation, + _default_owner, + _has_explicit_identity, +) +from linc_nerve.mcp import _core +from linc_nerve.mcp.reservation_tools import _nerve_reserve_sync + + +@pytest.fixture +def mgr(tmp_path: Path) -> Iterator[DeviceManager]: + manager = DeviceManager(db_path=tmp_path / "devices.db") + manager._heartbeat_stop.set() + manager._heartbeat_thread.join(timeout=2) + try: + yield manager + finally: + manager.close() + + +@pytest.fixture +def tool_mgr(mgr: DeviceManager, monkeypatch: pytest.MonkeyPatch) -> DeviceManager: + """Wire ``mgr`` as the tool layer's DeviceManager without touching controllers.""" + monkeypatch.setattr(_core, "_ensure_initialized", lambda: None) + monkeypatch.setattr(_core, "_device_mgr", mgr) + monkeypatch.setattr(_core, "_metrics", None) + return mgr + + +# --- (a) + (b): the p0-05 regression ------------------------------------------------ + + +class TestP005Regression: + def test_anonymous_takeover_refused_and_lease_survives(self, mgr: DeviceManager) -> None: + assert isinstance(mgr.reserve("D1", owner="orchestrator"), DeviceReservation) + + refused = mgr.reserve("D1", purpose="agent") + assert isinstance(refused, str) + assert " is held by orchestrator" in refused + assert "takeover=True" not in refused + + anonymous = mgr.reserve("D1", takeover=True, owner_pid=0) + assert isinstance(anonymous, str) + assert "takeover needs an explicit owner" in anonymous + assert " is held by " in anonymous + lease = mgr.check("D1") + assert lease is not None + assert lease.owner == "orchestrator" + + assert mgr.release("D1") is False + lease = mgr.check("D1") + assert lease is not None + assert lease.owner == "orchestrator" + + renewed = mgr.reserve("D1", owner="orchestrator") + assert isinstance(renewed, DeviceReservation) + assert renewed.owner == "orchestrator" + + def test_conflict_refusal_points_at_renew_not_takeover(self, mgr: DeviceManager) -> None: + mgr.reserve("D1", owner="orchestrator") + refused = mgr.reserve("D1", owner="agent") + assert isinstance(refused, str) + assert "If this is your own lease, re-run with the same owner to renew it." in refused + assert "Do not take over a lease you did not create." in refused + assert "takeover" not in refused.replace("take over", "") + + def test_explicit_owner_takeover_still_reowns(self, mgr: DeviceManager) -> None: + mgr.reserve("D1", owner="lane-a") + taken = mgr.reserve("D1", owner="lane-b", takeover=True) + assert isinstance(taken, DeviceReservation) + lease = mgr.check("D1") + assert lease is not None + assert lease.owner == "lane-b" + + def test_owner_pid_takeover_reowns_as_pid_owner(self, mgr: DeviceManager) -> None: + mgr.reserve("D1", owner="lane-a") + pid = os.getpid() + taken = mgr.reserve("D1", owner_pid=pid, takeover=True) + assert isinstance(taken, DeviceReservation) + lease = mgr.check("D1") + assert lease is not None + assert lease.owner == f"pid:{pid}" + + def test_anonymous_takeover_on_unreserved_device_is_a_normal_reserve( + self, mgr: DeviceManager + ) -> None: + result = mgr.reserve("D1", takeover=True) + assert isinstance(result, DeviceReservation) + assert result.owner == mgr.session_id + + +# --- (c): per-process default owner --------------------------------------------------- + + +class TestDefaultOwner: + def test_owner_pid_without_owner_stores_pid_owner(self, mgr: DeviceManager) -> None: + pid = os.getpid() + result = mgr.reserve("D2", owner_pid=pid) + assert isinstance(result, DeviceReservation) + lease = mgr.check("D2") + assert lease is not None + assert lease.owner == f"pid:{pid}" + assert mgr.release("D2") is False + assert mgr.release("D2", owner=f"pid:{pid}") is True + + def test_neither_owner_nor_pid_stores_session_id(self, mgr: DeviceManager) -> None: + result = mgr.reserve("D3") + assert isinstance(result, DeviceReservation) + lease = mgr.check("D3") + assert lease is not None + assert lease.owner == mgr.session_id + + @pytest.mark.parametrize( + ("owner", "owner_pid", "expected"), + [ + ("x", 0, "x"), + ("x", 42, "x"), + ("", 42, "pid:42"), + ("", 0, "sess"), + ("", -1, "sess"), + ], + ) + def test_default_owner_helper(self, owner: str, owner_pid: int, expected: str) -> None: + assert _default_owner(owner, owner_pid, "sess") == expected + + @pytest.mark.parametrize( + ("owner", "owner_pid", "expected"), + [("x", 0, True), ("", 7, True), ("x", 7, True), ("", 0, False)], + ) + def test_has_explicit_identity(self, owner: str, owner_pid: int, expected: bool) -> None: + assert _has_explicit_identity(owner, owner_pid) is expected + + +# --- (d): NERVE_OWNER default on the CLI ---------------------------------------------- + + +class TestWithDefaultOwner: + @pytest.mark.parametrize("tool", ["nerve_reserve", "nerve_release"]) + def test_env_fills_owner(self, tool: str) -> None: + payload = {"device": "D1"} + out = _with_default_owner(tool, payload, {"NERVE_OWNER": "lane-x"}) + assert out == {"device": "D1", "owner": "lane-x"} + assert payload == {"device": "D1"} # never mutated in place + + def test_explicit_owner_wins(self) -> None: + payload = {"device": "D1", "owner": "other"} + out = _with_default_owner("nerve_reserve", payload, {"NERVE_OWNER": "lane-x"}) + assert out["owner"] == "other" + + def test_other_tool_untouched(self) -> None: + payload = {"device": "D1"} + out = _with_default_owner("nerve_tap", payload, {"NERVE_OWNER": "lane-x"}) + assert out == {"device": "D1"} + + @pytest.mark.parametrize("env", [{}, {"NERVE_OWNER": ""}]) + def test_unset_or_empty_env_changes_nothing(self, env: dict[str, str]) -> None: + payload = {"device": "D1"} + assert _with_default_owner("nerve_reserve", payload, env) == {"device": "D1"} + + @pytest.mark.parametrize("bad", ["a b", "x\n", "-x", "x" * 129]) + def test_invalid_env_raises_usage_error(self, bad: str) -> None: + with pytest.raises(UsageError, match="NERVE_OWNER"): + _with_default_owner("nerve_reserve", {"device": "D1"}, {"NERVE_OWNER": bad}) + + def test_valid_grammar(self) -> None: + good = "a" + "b" * 127 + out = _with_default_owner("nerve_release", {}, {"NERVE_OWNER": good}) + assert out["owner"] == good + out = _with_default_owner("nerve_release", {}, {"NERVE_OWNER": "h@host:lane_1.a-b"}) + assert out["owner"] == "h@host:lane_1.a-b" + + +@pytest.mark.usefixtures("restore_signal_handlers") +class TestCliNerveOwner: + def _invoke(self, args: list[str], monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]: + sent: list[dict[str, Any]] = [] + + def fake_call(self: Any, spec: Any, payload: dict[str, Any], **_: Any) -> Any: + sent.append(dict(payload)) + return {"success": True, "device": payload.get("device"), "owner": payload["owner"]} + + monkeypatch.setenv("NERVE_OWNER", "lane-x") + with patch.object(HttpDaemonTransport, "call", fake_call): + result = CliRunner().invoke(main, args) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["success"] is True + return sent + + def test_env_owner_is_sent(self, monkeypatch: pytest.MonkeyPatch) -> None: + sent = self._invoke(["reserve", "--device", "D1"], monkeypatch) + assert sent[-1]["owner"] == "lane-x" + + def test_explicit_owner_flag_wins(self, monkeypatch: pytest.MonkeyPatch) -> None: + sent = self._invoke(["reserve", "--device", "D1", "--owner", "other"], monkeypatch) + assert sent[-1]["owner"] == "other" + + +# --- (e) + (f): the tool result -------------------------------------------------------- + + +class TestReserveToolResult: + def test_anonymous_lease_is_flagged(self, tool_mgr: DeviceManager) -> None: + result = _nerve_reserve_sync("D1") + assert result["success"] is True + assert result["shared_owner"] is True + assert result["warning"].startswith("anonymous lease:") + assert tool_mgr.session_id in result["warning"] + assert "NERVE_OWNER" in result["warning"] + # pre-existing keys keep their values + assert result["device"] == "D1" + assert result["session_id"] == tool_mgr.session_id + assert result["owner"] == tool_mgr.session_id + assert result["timeout_seconds"] == 300 + assert result["purpose"] == "" + + def test_owned_lease_is_not_flagged(self, tool_mgr: DeviceManager) -> None: + result = _nerve_reserve_sync("D1", owner="x") + assert result["success"] is True + assert result["shared_owner"] is False + assert "warning" not in result + assert result["owner"] == "x" + + def test_renewal_is_reported(self, tool_mgr: DeviceManager) -> None: + first = _nerve_reserve_sync("D1", owner="x", timeout_seconds=120) + assert first["renewed"] is False + assert first["expires_in_s"] == 120 + again = _nerve_reserve_sync("D1", owner="x", timeout_seconds=120) + assert again["renewed"] is True + assert again["expires_in_s"] == again["timeout_seconds"] == 120 + + def test_takeover_is_not_a_renewal(self, tool_mgr: DeviceManager) -> None: + _nerve_reserve_sync("D1", owner="a") + taken = _nerve_reserve_sync("D1", owner="b", takeover=True) + assert taken["renewed"] is False + + def test_anonymous_takeover_refused_through_tool(self, tool_mgr: DeviceManager) -> None: + _nerve_reserve_sync("D1", owner="orchestrator") + refused = _nerve_reserve_sync("D1", takeover=True) + assert "takeover needs an explicit owner" in refused["error"] + + +# --- (g): daemon mode & renew_anonymous (linc-nerve-l7pm) ----------------------------- + + +class TestRenewAnonymous: + @staticmethod + def _tick_and_reap(mgr: DeviceManager, serial: str, age_s: float = 61) -> float: + ts = time.time() - age_s + mgr._conn.execute( + "UPDATE reservations SET last_heartbeat = ? WHERE device_serial = ?", (ts, serial) + ) + mgr._conn.commit() + now = time.time() + with mgr._lock: + mgr._renew_live_leases(now) + mgr._reap_stale() + return now + + def _stopped_mgr(self, tmp_path: Path, renew_anonymous: bool) -> DeviceManager: + mgr = DeviceManager(db_path=tmp_path / "d.db", renew_anonymous=renew_anonymous) + mgr._heartbeat_stop.set() + mgr._heartbeat_thread.join(timeout=2) + return mgr + + def test_renew_anonymous_false_reaps_expired_anonymous_lease(self, tmp_path: Path) -> None: + mgr = self._stopped_mgr(tmp_path, renew_anonymous=False) + try: + assert isinstance(mgr.reserve("D1", timeout_seconds=60), DeviceReservation) + self._tick_and_reap(mgr, "D1") + assert mgr.check("D1") is None + finally: + mgr.close() + + def test_renew_anonymous_true_keeps_anonymous_lease(self, tmp_path: Path) -> None: + mgr = self._stopped_mgr(tmp_path, renew_anonymous=True) + try: + assert isinstance(mgr.reserve("D1", timeout_seconds=60), DeviceReservation) + now = self._tick_and_reap(mgr, "D1") + lease = mgr.check("D1") + assert lease is not None + assert lease.last_heartbeat == now + finally: + mgr.close() + + def test_renew_anonymous_false_with_live_owner_pid_keeps_lease(self, tmp_path: Path) -> None: + mgr = self._stopped_mgr(tmp_path, renew_anonymous=False) + try: + pid = os.getpid() + assert isinstance( + mgr.reserve("D1", timeout_seconds=60, owner_pid=pid), DeviceReservation + ) + now = self._tick_and_reap(mgr, "D1") + lease = mgr.check("D1") + assert lease is not None + assert lease.last_heartbeat == now + finally: + mgr.close() + + def test_renew_anonymous_false_with_dead_owner_pid_reaps(self, tmp_path: Path) -> None: + proc = subprocess.Popen([sys.executable, "-c", "pass"]) + proc.wait() + dead_pid = proc.pid + mgr = self._stopped_mgr(tmp_path, renew_anonymous=False) + try: + assert isinstance( + mgr.reserve("D1", timeout_seconds=60, owner_pid=dead_pid), DeviceReservation + ) + self._tick_and_reap(mgr, "D1") + assert mgr.check("D1") is None + finally: + mgr.close() + + +class TestDaemonModeInit: + def test_set_daemon_mode_affects_singleton_init( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("LINC_HOME", str(tmp_path)) + for daemon_flag, expected in [(True, False), (False, True)]: + monkeypatch.setattr(_core, "_device_mgr", None) + monkeypatch.setattr(_core, "_daemon_mode", False) + if daemon_flag: + _core.set_daemon_mode() + _core._init_singletons() + try: + assert _core._device_mgr is not None + assert _core._device_mgr.renew_anonymous is expected + finally: + if _core._device_mgr is not None: + _core._device_mgr.close() + _core._device_mgr = None + + +class TestRenewalReporting: + def test_nerve_reserve_sync_reports_renewal_mode( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + mgr_daemon = DeviceManager(db_path=tmp_path / "daemon.db", renew_anonymous=False) + mgr_stdio = DeviceManager(db_path=tmp_path / "stdio.db", renew_anonymous=True) + for m in (mgr_daemon, mgr_stdio): + m._heartbeat_stop.set() + m._heartbeat_thread.join(timeout=2) + + monkeypatch.setattr(_core, "_ensure_initialized", lambda: None) + monkeypatch.setattr(_core, "_metrics", None) + + live_pid = os.getpid() + + try: + monkeypatch.setattr(_core, "_device_mgr", mgr_daemon) + res_anon_daemon = _nerve_reserve_sync("D1") + assert res_anon_daemon["renewal"] == "caller" + + res_live_pid_daemon = _nerve_reserve_sync("D2", owner_pid=live_pid) + assert res_live_pid_daemon["renewal"] == "daemon" + + monkeypatch.setattr(_core, "_device_mgr", mgr_stdio) + res_anon_stdio = _nerve_reserve_sync("D3") + assert res_anon_stdio["renewal"] == "daemon" + + res_live_pid_stdio = _nerve_reserve_sync("D4", owner_pid=live_pid) + assert res_live_pid_stdio["renewal"] == "daemon" + finally: + mgr_daemon.close() + mgr_stdio.close() diff --git a/tests/test_logcat_filters.py b/tests/test_logcat_filters.py new file mode 100644 index 0000000..c89c8f7 --- /dev/null +++ b/tests/test_logcat_filters.py @@ -0,0 +1,569 @@ +"""Tests for nerve_logcat package=, grep=, and zero-line tag fallback (linc-nerve-jdt9).""" + +from __future__ import annotations + +import time +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from linc_nerve.core.android import AndroidController +from linc_nerve.core.device import DeviceType, NerveDevice, Platform +from linc_nerve.core.ios import IOSController +from linc_nerve.mcp import server as mcp_mod + + +class _RecordingDevice: + """Fake adbutils device that records every shell command and returns scripted output.""" + + def __init__(self, handler: Any) -> None: + self.commands: list[str] = [] + self._handler = handler + + def shell(self, cmd: str, timeout: int = 15) -> str: + self.commands.append(cmd) + if callable(self._handler): + return str(self._handler(cmd)) + return str(self._handler.get(cmd, "")) + + +class _RecordingClient: + def __init__(self, device: _RecordingDevice) -> None: + self._device = device + + def device(self, serial: str) -> _RecordingDevice: + # Every device command must target the requested serial (a None/other serial fails). + if serial != "PIXEL8PRO": + raise AssertionError(f"adb command sent to unexpected serial {serial!r}") + return self._device + + +def _reset_singletons() -> None: + if mcp_mod._device_mgr is not None: + mcp_mod._device_mgr.close() + mock_android = MagicMock(spec=AndroidController) + mock_android.discover.return_value = [] + mock_ios = MagicMock(spec=IOSController) + mock_ios.discover.return_value = [] + mcp_mod._android = mock_android + mcp_mod._ios = mock_ios + mcp_mod._state_mgr = None + mcp_mod._metrics = None + mcp_mod._device_mgr = None + mcp_mod._emulator = None + mcp_mod._cached_devices = [] + mcp_mod._cache_timestamp = 0.0 + + +@pytest.fixture(autouse=True) +def _cleanup_singletons() -> Any: + _reset_singletons() + yield + _reset_singletons() + + +def _setup_fake_android(handler: Any) -> list[str]: + """Wire a real AndroidController backed by a command-recording fake adb shell.""" + _reset_singletons() + + rec_dev = _RecordingDevice(handler) + ctrl = AndroidController() + ctrl._scrcpy_available = False + ctrl._client = _RecordingClient(rec_dev) + + mock_ios = MagicMock(spec=IOSController) + mock_ios.discover.return_value = [] + + mcp_mod._android = ctrl + mcp_mod._ios = mock_ios + mcp_mod._cached_devices = [ + NerveDevice( + serial="PIXEL8PRO", + platform=Platform.ANDROID, + device_type=DeviceType.PHONE, + model="Pixel 8 Pro", + os_version="14", + ) + ] + mcp_mod._cache_timestamp = time.monotonic() + return rec_dev.commands + + +def _setup_fake_ios() -> MagicMock: + """Wire a mock IOSController with a cached simulator.""" + mcp_mod._android = MagicMock(spec=AndroidController) + mcp_mod._android.discover.return_value = [] + mock_ios = MagicMock(spec=IOSController) + mock_ios.discover.return_value = [] + mcp_mod._ios = mock_ios + mcp_mod._cached_devices = [ + NerveDevice( + serial="UUID-123", + platform=Platform.IOS, + device_type=DeviceType.SIMULATOR, + model="iPhone 15 Pro", + os_version="17.5", + ) + ] + mcp_mod._cache_timestamp = time.monotonic() + return mock_ios + + +def test_package_resolves_pid_and_passes_pid_flag() -> None: + commands = _setup_fake_android( + { + "pidof -s dev.example.app": "4321\n", + "logcat -d -t 50 --pid=4321": ( + "10-14 07:00:01.000 4321 4321 I ExampleApp: started\n" + "10-14 07:00:02.000 4321 4321 D ExampleApp: ready\n" + ), + } + ) + + result = mcp_mod._nerve_logcat_sync("PIXEL8PRO", package="dev.example.app") + assert result["count"] == 2 + assert result["matched_via"] == "package" + assert "hint" not in result + assert result["lines"] == [ + "10-14 07:00:01.000 4321 4321 I ExampleApp: started", + "10-14 07:00:02.000 4321 4321 D ExampleApp: ready", + ] + assert commands == [ + "pidof -s dev.example.app", + "logcat -d -t 50 --pid=4321", + ] + + +def test_package_not_running_returns_validation_error() -> None: + commands = _setup_fake_android( + { + "pidof -s dev.example.app": "", + "ps -A -o PID,NAME": " 1 init\n", + } + ) + + result = mcp_mod._nerve_logcat_sync("PIXEL8PRO", package="dev.example.app") + assert result == { + "error": ( + "package 'dev.example.app' is not running (no pid); launch it first or omit package" + ), + "validation_error": True, + } + assert not any("logcat" in cmd for cmd in commands) + + +def test_package_with_tag_filters_pid_then_exact_tag() -> None: + commands = _setup_fake_android( + { + "pidof -s dev.example.app": "4321\n", + "logcat -d -t 2000 --pid=4321": ( + "10-14 07:00:01.000 4321 4321 I OtherTag: ignore me\n" + "10-14 07:00:02.000 4321 4321 I TargetTag: keep me\n" + ), + } + ) + + result = mcp_mod._nerve_logcat_sync("PIXEL8PRO", package="dev.example.app", tag="TargetTag") + assert result["count"] == 1 + assert result["matched_via"] == "package" + assert result["lines"] == ["10-14 07:00:02.000 4321 4321 I TargetTag: keep me"] + assert commands == [ + "pidof -s dev.example.app", + "logcat -d -t 2000 --pid=4321", + ] + + +@pytest.mark.parametrize("bad_pkg", ["bad;pkg", "-n com.evil", "com.app\n"]) +def test_package_invalid_identifier_rejected_before_shell(bad_pkg: str) -> None: + commands = _setup_fake_android({}) + result = mcp_mod._nerve_logcat_sync("PIXEL8PRO", package=bad_pkg) + assert result.get("validation_error") is True + assert "error" in result + assert commands == [] + + +def test_package_with_invalid_tag_rejected_before_shell() -> None: + commands = _setup_fake_android({}) + result = mcp_mod._nerve_logcat_sync("PIXEL8PRO", package="dev.example.app", tag="bad;tag") + assert result.get("validation_error") is True + assert "error" in result + assert commands == [] + + +def test_ios_package_returns_validation_error() -> None: + mock_ios = _setup_fake_ios() + result = mcp_mod._nerve_logcat_sync("UUID-123", package="dev.example.app") + assert result.get("validation_error") is True + assert "android" in result["error"].lower() + mock_ios.get_logs.assert_not_called() + + +@pytest.mark.parametrize( + ("lines", "expected_window"), + [ + (50, 2000), + (150, 3000), + (1500, 20000), + ], +) +def test_grep_case_insensitive_and_window_math(lines: int, expected_window: int) -> None: + log_body = "\n".join( + [ + "10-14 07:00:01.000 100 100 I Noise: nothing here", + "10-14 07:00:02.000 100 100 I App: Token=Alpha-1", + "10-14 07:00:03.000 100 100 I App: TOKEN=ALPHA-2 [literal]", + "10-14 07:00:04.000 100 100 I App: token=alpha-3", + ] + ) + commands = _setup_fake_android(lambda cmd: log_body) + + result = mcp_mod._nerve_logcat_sync("PIXEL8PRO", lines=lines, grep="token=alpha") + assert commands == [f"logcat -d -t {expected_window}"] + assert result["matched_via"] == "grep" + assert result["count"] == 3 + assert result["lines"] == [ + "10-14 07:00:02.000 100 100 I App: Token=Alpha-1", + "10-14 07:00:03.000 100 100 I App: TOKEN=ALPHA-2 [literal]", + "10-14 07:00:04.000 100 100 I App: token=alpha-3", + ] + + +def test_grep_keeps_last_n_matches_and_is_literal_substring() -> None: + log_body = "\n".join(f"line {i} [a-z]+ match" for i in range(10)) + _setup_fake_android(lambda cmd: log_body) + + result = mcp_mod._nerve_logcat_sync("PIXEL8PRO", lines=3, grep="[A-Z]+") + assert result["matched_via"] == "grep" + assert result["count"] == 3 + assert result["lines"] == [ + "line 7 [a-z]+ match", + "line 8 [a-z]+ match", + "line 9 [a-z]+ match", + ] + + +def test_grep_never_appears_in_device_commands() -> None: + needle = "SuperSecretNeedle_42" + commands = _setup_fake_android( + { + "pidof -s dev.example.app": "4321\n", + "logcat -d -t 2000": "10-14 07:00:01.000 4321 4321 I App: supersecretneedle_42\n", + "logcat -d -t 2000 --pid=4321": ( + "10-14 07:00:01.000 4321 4321 I App: supersecretneedle_42\n" + ), + "logcat -d -s MyTag": "10-14 07:00:01.000 4321 4321 I MyTag: supersecretneedle_42\n", + } + ) + + r1 = mcp_mod._nerve_logcat_sync("PIXEL8PRO", grep=needle) + r2 = mcp_mod._nerve_logcat_sync("PIXEL8PRO", package="dev.example.app", grep=needle) + r3 = mcp_mod._nerve_logcat_sync("PIXEL8PRO", tag="MyTag", grep=needle) + + assert r1["count"] == 1 + assert r2["count"] == 1 + assert r3["count"] == 1 + assert commands + for cmd in commands: + assert needle.lower() not in cmd.lower(), f"grep leaked into command: {cmd!r}" + + +def test_ios_grep_filters_in_python_with_wider_window() -> None: + mock_ios = _setup_fake_ios() + mock_ios.get_logs.return_value = "ios noise\nios BenchTarget hit\n" + + result = mcp_mod._nerve_logcat_sync("UUID-123", lines=25, grep="benchtarget") + mock_ios.get_logs.assert_called_once_with("UUID-123", lines=2000, filter="") + assert result["matched_via"] == "grep" + assert result["lines"] == ["ios BenchTarget hit"] + + +@pytest.mark.parametrize("bad_grep", ["has\nnewline", "has\x00nul", "has\ttab", "x" * 201]) +def test_grep_rejects_control_chars_and_overlong_input(bad_grep: str) -> None: + commands = _setup_fake_android({}) + result = mcp_mod._nerve_logcat_sync("PIXEL8PRO", grep=bad_grep) + assert result.get("validation_error") is True + assert "error" in result + assert commands == [] + + +@pytest.mark.parametrize("pkg_tag", ["dev.example.app", "dev.lincforge.benchtarget"]) +def test_tag_zero_exact_lines_falls_back_to_package_pid(pkg_tag: str) -> None: + commands = _setup_fake_android( + { + f"logcat -d -s {pkg_tag}": "", + f"pidof -s {pkg_tag}": "4321\n", + "logcat -d -t 50 --pid=4321": ( + "10-14 07:00:01.000 4321 4321 I BenchTarget: token=alpha-42\n" + ), + } + ) + + result = mcp_mod._nerve_logcat_sync("PIXEL8PRO", tag=pkg_tag) + assert result["matched_via"] == "package_fallback" + assert result["count"] == 1 + assert result["lines"] == ["10-14 07:00:01.000 4321 4321 I BenchTarget: token=alpha-42"] + assert "hint" in result + assert "package_fallback" in result["hint"] or pkg_tag in result["hint"] + assert commands == [ + f"logcat -d -s {pkg_tag}", + f"pidof -s {pkg_tag}", + "logcat -d -t 50 --pid=4321", + ] + + +def test_tag_zero_exact_lines_falls_back_to_substring_match() -> None: + commands = _setup_fake_android( + { + "logcat -d -s benchtarget": "", + "logcat -d -t 5000": ( + "10-14 07:00:00.500 1000 1000 I ActivityManager: proc start\n" + "10-14 07:00:01.000 4321 4321 I BenchTarget: token=alpha-42\n" + ), + } + ) + + result = mcp_mod._nerve_logcat_sync("PIXEL8PRO", tag="benchtarget") + assert result["matched_via"] == "substring_fallback" + assert result["count"] == 1 + assert result["lines"] == ["10-14 07:00:01.000 4321 4321 I BenchTarget: token=alpha-42"] + assert "hint" in result + assert "substring_fallback" in result["hint"] or "substring" in result["hint"].lower() + assert commands == [ + "logcat -d -s benchtarget", + "logcat -d -t 5000", + ] + + +def test_exact_tag_hit_issues_no_fallback_command() -> None: + commands = _setup_fake_android( + { + "logcat -d -s BenchTarget": ( + "10-14 07:00:01.000 4321 4321 I BenchTarget: exact hit\n" + ), + } + ) + + result = mcp_mod._nerve_logcat_sync("PIXEL8PRO", tag="BenchTarget") + assert result["matched_via"] == "tag" + assert result["count"] == 1 + assert "hint" not in result + assert commands == ["logcat -d -s BenchTarget"] + + +def test_unfiltered_call_omits_matched_via() -> None: + _setup_fake_android({"logcat -d -t 50": "line1\nline2\n"}) + result = mcp_mod._nerve_logcat_sync("PIXEL8PRO") + assert result["count"] == 2 + assert "matched_via" not in result + assert "hint" not in result + + +def test_large_fallback_output_preserves_matched_via_and_hint() -> None: + many_lines = "\n".join( + f"10-14 07:00:01.{i:03d} 4321 4321 I BenchTarget: line {i}" for i in range(260) + ) + _setup_fake_android( + { + "logcat -d -s benchtarget": "", + "logcat -d -t 5000": many_lines, + } + ) + + result = mcp_mod._nerve_logcat_sync("PIXEL8PRO", lines=260, tag="benchtarget") + assert result["count"] == 260 + assert result["matched_via"] == "substring_fallback" + assert "logcat" in result + assert "Full output (260 lines) saved to" in result["hint"] + assert "benchtarget" in result["hint"] + + +def test_line_has_tag_formats() -> None: + from linc_nerve.mcp.diagnostic_tools import _line_has_tag + + assert _line_has_tag("10-14 07:00:01.000 4321 4321 I BenchTarget: msg", "BenchTarget") + assert _line_has_tag("I/BenchTarget( 4321): msg", "BenchTarget") + assert _line_has_tag("E BenchTarget: msg", "BenchTarget") + assert _line_has_tag("BenchTarget: msg", "BenchTarget") + assert not _line_has_tag("--------- beginning of main", "BenchTarget") + assert not _line_has_tag( + "10-14 07:00:01.000 4321 4321 I Other: BenchTarget: msg", "BenchTarget" + ) + + +def test_package_shaped_tag_not_running_falls_through_to_substring() -> None: + """A package-shaped tag whose app is not running must not report a package error.""" + commands = _setup_fake_android( + { + "logcat -d -s dev.gone.app": "", + "pidof -s dev.gone.app": "", + "ps -A -o PID,NAME": " 1 init\n", + "logcat -d -t 5000": "10-14 07:00:01.000 4321 4321 I Probe: dev.gone.app crashed\n", + } + ) + + result = mcp_mod._nerve_logcat_sync("PIXEL8PRO", tag="dev.gone.app") + assert "error" not in result + assert result["matched_via"] == "substring_fallback" + assert result["lines"] == ["10-14 07:00:01.000 4321 4321 I Probe: dev.gone.app crashed"] + assert commands[0] == "logcat -d -s dev.gone.app" + assert commands[-1] == "logcat -d -t 5000" + assert not any("--pid=" in cmd for cmd in commands) + + +@pytest.mark.parametrize("bad_lines", [0, -5]) +def test_lines_below_one_rejected_before_shell(bad_lines: int) -> None: + commands = _setup_fake_android({}) + + result = mcp_mod._nerve_logcat_sync("PIXEL8PRO", lines=bad_lines, grep="x") + assert result == {"error": "lines must be >= 1", "validation_error": True} + assert commands == [] + + +_PID_LINE = "10-14 07:00:01.000 4321 4321 I BenchTarget: token=alpha-42" + + +@pytest.mark.parametrize( + ("kwargs", "handler", "matched_via"), + [ + ( + {"package": "dev.example.app"}, + {"pidof -s dev.example.app": "4321\n", "logcat -d -t 50 --pid=4321": _PID_LINE}, + "package", + ), + ( + {"tag": "dev.example.app"}, + { + "logcat -d -s dev.example.app": "", + "pidof -s dev.example.app": "4321\n", + "logcat -d -t 50 --pid=4321": _PID_LINE, + }, + "package_fallback", + ), + ( + {"tag": "benchtarget"}, + {"logcat -d -s benchtarget": "", "logcat -d -t 5000": _PID_LINE}, + "substring_fallback", + ), + ({"tag": "BenchTarget"}, {"logcat -d -s BenchTarget": _PID_LINE}, "tag"), + ({"grep": "alpha-42"}, {"logcat -d -t 2000": _PID_LINE}, "grep"), + ], +) +def test_android_results_name_device_platform_and_match( + kwargs: dict[str, str], handler: dict[str, str], matched_via: str +) -> None: + _setup_fake_android(handler) + + result = mcp_mod._nerve_logcat_sync("PIXEL8PRO", **kwargs) + assert result["device"] == "PIXEL8PRO" + assert result["platform"] == "android" + assert result["matched_via"] == matched_via + assert result["lines"] == [_PID_LINE] + assert ("hint" in result) is matched_via.endswith("_fallback") + + +def test_ios_tag_result_names_device_platform_and_tag_match() -> None: + mock_ios = _setup_fake_ios() + mock_ios.get_logs.return_value = "ios BenchTarget hit\n" + + result = mcp_mod._nerve_logcat_sync("UUID-123", lines=5, tag="BenchTarget") + mock_ios.get_logs.assert_called_once_with("UUID-123", lines=5, filter="BenchTarget") + assert result == { + "lines": ["ios BenchTarget hit"], + "count": 1, + "device": "UUID-123", + "platform": "ios", + "matched_via": "tag", + } + + +def test_ios_package_error_is_exact() -> None: + _setup_fake_ios() + result = mcp_mod._nerve_logcat_sync("UUID-123", package="dev.example.app") + assert result == { + "error": "package filter is Android-only; on iOS pass the bundle ID via tag=", + "validation_error": True, + } + + +@pytest.mark.parametrize( + ("exc", "expected"), + [ + (ValueError("bad filter"), {"error": "bad filter"}), + (RuntimeError("boom"), {"error": "iOS log retrieval failed: boom"}), + ], +) +def test_ios_log_errors_are_reported(exc: Exception, expected: dict[str, str]) -> None: + mock_ios = _setup_fake_ios() + mock_ios.get_logs.side_effect = exc + assert mcp_mod._nerve_logcat_sync("UUID-123", tag="X") == expected + + +def test_invalid_package_error_names_the_package_field() -> None: + _setup_fake_android({}) + result = mcp_mod._nerve_logcat_sync("PIXEL8PRO", package="bad;pkg") + assert result["validation_error"] is True + assert "package" in result["error"] + assert "bad;pkg" in result["error"] + + +def test_read_pid_logcat_targets_serial_with_timeout() -> None: + from linc_nerve.mcp.diagnostic_tools import _read_pid_logcat + + android = MagicMock() + android._shell.return_value = "a\nb\n" + assert _read_pid_logcat(android, "S1", 42, 7) == ["a", "b"] + android._shell.assert_called_once_with("S1", "logcat -d -t 7 --pid=42", timeout=10) + + +def test_get_android_logs_pid_and_plain_paths() -> None: + from linc_nerve.mcp.diagnostic_tools import _get_android_logs + + android = MagicMock() + android._shell.return_value = "1\n2\n3\n" + assert _get_android_logs(android, "S1", 42, 2) == ["2", "3"] + android._shell.assert_called_once_with("S1", "logcat -d -t 2 --pid=42", timeout=10) + + android = MagicMock() + android.logcat.return_value = "x\ny\nz\n" + assert _get_android_logs(android, "S1", None, 2) == ["y", "z"] + android.logcat.assert_called_once_with("S1", lines=2) + + android = MagicMock() + android.logcat.side_effect = RuntimeError("adb gone") + assert _get_android_logs(android, "S1", None, 2) == [] + + +def test_filter_constants_drive_limits_and_windows() -> None: + from linc_nerve.mcp import diagnostic_tools as dt + + assert dt._validate_grep("x" * dt._GREP_MAX_LEN) is None + assert dt._validate_grep("x" * (dt._GREP_MAX_LEN + 1)) == { + "error": ( + f"Invalid grep filter: must be printable characters and <= {dt._GREP_MAX_LEN} chars" + ), + "validation_error": True, + } + assert dt._read_window(10, "g") == dt._GREP_WINDOW_MIN + assert dt._read_window(500, "g") == 500 * dt._GREP_WINDOW_FACTOR + assert dt._read_window(10**6, "g") == dt._GREP_WINDOW_MAX + assert dt._read_window(7, "") == 7 + assert dt._SUBSTRING_FALLBACK_WINDOW == 5000 + + +@pytest.mark.parametrize( + ("tag", "is_package"), + [("dev.example.app", True), ("a.b_c.D9", True), ("benchtarget", False), ("1dev.app", False)], +) +def test_package_like_pattern(tag: str, is_package: bool) -> None: + from linc_nerve.mcp.diagnostic_tools import _PACKAGE_LIKE_RE + + assert bool(_PACKAGE_LIKE_RE.fullmatch(tag)) is is_package + + +def test_line_tag_pattern_tolerates_leading_whitespace() -> None: + from linc_nerve.mcp.diagnostic_tools import _LINE_TAG_RE, _line_has_tag + + assert _line_has_tag(" I/BenchTarget( 4321): ready", "BenchTarget") is True + assert _line_has_tag(" I/BenchTarget( 4321): ready", "Other") is False + assert _LINE_TAG_RE.match("I/BenchTarget( 4321): ready") is not None diff --git a/tests/test_logcat_spill.py b/tests/test_logcat_spill.py new file mode 100644 index 0000000..018c22b --- /dev/null +++ b/tests/test_logcat_spill.py @@ -0,0 +1,225 @@ +"""Tests for nerve_logcat inline limits (250 lines / 24 KB) and tail_lines (linc-nerve-g92x).""" + +from __future__ import annotations + +import time +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from linc_nerve.core.android import AndroidController +from linc_nerve.core.device import DeviceType, NerveDevice, Platform +from linc_nerve.core.ios import IOSController +from linc_nerve.mcp import _core +from linc_nerve.mcp import diagnostic_tools as dt +from linc_nerve.mcp import server as mcp_mod + + +def _reset_singletons() -> None: + if mcp_mod._device_mgr is not None: + mcp_mod._device_mgr.close() + mock_android = MagicMock(spec=AndroidController) + mock_android.discover.return_value = [] + mock_ios = MagicMock(spec=IOSController) + mock_ios.discover.return_value = [] + mcp_mod._android = mock_android + mcp_mod._ios = mock_ios + mcp_mod._state_mgr = None + mcp_mod._metrics = None + mcp_mod._device_mgr = None + mcp_mod._emulator = None + mcp_mod._cached_devices = [] + mcp_mod._cache_timestamp = 0.0 + + +@pytest.fixture(autouse=True) +def _cleanup_singletons() -> Any: + _reset_singletons() + yield + _reset_singletons() + + +def _setup_mock_android(logcat_output: str) -> MagicMock: + mock_android = MagicMock(spec=AndroidController) + mock_android.discover.return_value = [] + mock_android.logcat.return_value = logcat_output + mock_ios = MagicMock(spec=IOSController) + mock_ios.discover.return_value = [] + mcp_mod._android = mock_android + mcp_mod._ios = mock_ios + mcp_mod._cached_devices = [ + NerveDevice( + serial="PIXEL8PRO", + platform=Platform.ANDROID, + device_type=DeviceType.PHONE, + model="Pixel 8 Pro", + os_version="14", + ) + ] + mcp_mod._cache_timestamp = time.monotonic() + return mock_android + + +def test_200_short_lines_returned_inline_without_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + out_file = tmp_path / "should_not_exist.txt" + monkeypatch.setattr(_core, "_next_output_path", lambda *a, **kw: str(out_file)) + + lines = [f"10-14 08:00:00.{i:03d} 1000 1000 I TestTag: short line {i}" for i in range(200)] + _setup_mock_android("\n".join(lines)) + + result = mcp_mod._nerve_logcat_sync("PIXEL8PRO", lines=200) + assert result == { + "lines": lines, + "count": 200, + "device": "PIXEL8PRO", + "platform": "android", + } + assert not out_file.exists() + + +def test_250_short_lines_boundary_returned_inline( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + out_file = tmp_path / "should_not_exist_250.txt" + monkeypatch.setattr(_core, "_next_output_path", lambda *a, **kw: str(out_file)) + + lines = [f"line-{i}" for i in range(250)] + _setup_mock_android("\n".join(lines)) + + result = mcp_mod._nerve_logcat_sync("PIXEL8PRO", lines=250) + assert result["lines"] == lines + assert result["count"] == 250 + assert "logcat" not in result + assert "tail_lines" not in result + assert not out_file.exists() + + +def test_251_lines_spills_to_file_with_last_50_tail_lines( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + out_file = tmp_path / "logcat_251.txt" + monkeypatch.setattr(_core, "_next_output_path", lambda *a, **kw: str(out_file)) + + lines = [f"10-14 08:00:00.{i:03d} 1000 1000 I TestTag: line {i}" for i in range(251)] + _setup_mock_android("\n".join(lines)) + + result = mcp_mod._nerve_logcat_sync("PIXEL8PRO", lines=251) + assert out_file.is_file() + file_lines = out_file.read_text(encoding="utf-8").splitlines() + assert file_lines == lines + assert out_file.read_bytes() == "\n".join(lines).encode("utf-8") + + assert result == { + "logcat": str(out_file), + "count": 251, + "tail_lines": file_lines[-50:], + "tail_count": 50, + "device": "PIXEL8PRO", + "platform": "android", + "hint": ( + f"Full output (251 lines) saved to {out_file}; " + "tail_lines holds the last 50. " + "Read the file only if you need earlier lines." + ), + } + + +def test_120_lines_of_400_chars_exceeding_24kb_spills_to_file_with_tail( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + out_file = tmp_path / "logcat_over_24kb.txt" + monkeypatch.setattr(_core, "_next_output_path", lambda *a, **kw: str(out_file)) + + lines = [f"{i:03d}-" + ("x" * 396) for i in range(120)] + assert all(len(line) == 400 for line in lines) + assert sum(len(line.encode("utf-8")) for line in lines) == 48_000 > dt._LOGCAT_INLINE_MAX_BYTES + + _setup_mock_android("\n".join(lines)) + + result = mcp_mod._nerve_logcat_sync("PIXEL8PRO", lines=120) + assert out_file.is_file() + assert out_file.read_bytes() == "\n".join(lines).encode("utf-8") + assert "lines" not in result + assert result["logcat"] == str(out_file) + assert result["count"] == 120 + assert result["tail_count"] == 50 + assert result["tail_lines"] == lines[-50:] + assert result["hint"] == ( + f"Full output (120 lines) saved to {out_file}; " + "tail_lines holds the last 50. " + "Read the file only if you need earlier lines." + ) + + +def test_2000_char_line_in_tail_is_truncated_while_file_stays_intact( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + out_file = tmp_path / "logcat_long_tail_line.txt" + monkeypatch.setattr(_core, "_next_output_path", lambda *a, **kw: str(out_file)) + + exact_500 = "E" * 500 + over_501 = "O" * 501 + long_line = "L" * 2000 + lines = [f"short-line-{i}" for i in range(248)] + [exact_500, over_501, long_line] + assert len(lines) == 251 + + _setup_mock_android("\n".join(lines)) + + result = mcp_mod._nerve_logcat_sync("PIXEL8PRO", lines=251) + # File content is byte-identical to today's format (untruncated lines joined by \n). + assert out_file.read_bytes() == "\n".join(lines).encode("utf-8") + + tail = result["tail_lines"] + assert len(tail) == 50 + assert result["tail_count"] == 50 + # 500-char line is NOT cut; 501-char and 2000-char lines ARE cut at 500 chars + "…". + assert tail[-3] == exact_500 + assert not tail[-3].endswith("…") + assert tail[-2] == ("O" * 500) + "…" + assert tail[-1] == ("L" * 500) + "…" + + +def test_byte_limit_exact_boundary_and_utf8_multibyte( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + out_file = tmp_path / "logcat_utf8.txt" + called_args: list[tuple[Any, ...]] = [] + + def fake_next_path(device: str, prefix: str, ext: str = "png") -> str: + called_args.append((device, prefix, ext)) + return str(out_file) + + monkeypatch.setattr(_core, "_next_output_path", fake_next_path) + + assert dt._LOGCAT_INLINE_MAX == 250 + assert dt._LOGCAT_INLINE_MAX_BYTES == 24 * 1024 + assert dt._LOGCAT_TAIL_LINES == 50 + assert dt._LOGCAT_TAIL_LINE_MAX_CHARS == 500 + + # Exact 24 KB (24 lines * 1024 ASCII bytes = 24576 bytes) stays inline. + exact_24kb = ["a" * 1024 for _ in range(24)] + r_inline = dt._process_log_lines("PIXEL8PRO", "android", exact_24kb) + assert r_inline["lines"] == exact_24kb + assert not out_file.exists() + + # 24 KB + 1 byte spills. + over_by_one = [*exact_24kb, "b"] + r_spill = dt._process_log_lines("PIXEL8PRO", "android", over_by_one) + assert r_spill["logcat"] == str(out_file) + assert r_spill["tail_count"] == 25 + assert len(r_spill["tail_lines"]) == 25 + assert called_args == [("PIXEL8PRO", "logcat", "txt")] + + # Multi-byte UTF-8: 100 lines of 100 "é" chars (10,000 chars, 20,000 UTF-8 bytes) -> inline; + # 130 lines of 100 "é" chars (13,000 chars <= 24576, but 26,000 UTF-8 bytes > 24576) -> spills. + utf8_inline = ["é" * 100 for _ in range(100)] + assert "lines" in dt._process_log_lines("PIXEL8PRO", "android", utf8_inline) + + utf8_spill = ["é" * 100 for _ in range(130)] + r_utf8_spill = dt._process_log_lines("PIXEL8PRO", "android", utf8_spill) + assert r_utf8_spill["logcat"] == str(out_file) + assert r_utf8_spill["tail_count"] == 50 diff --git a/tests/test_mcp_contracts.py b/tests/test_mcp_contracts.py index 6f5fb5a..5fd1b44 100644 --- a/tests/test_mcp_contracts.py +++ b/tests/test_mcp_contracts.py @@ -101,6 +101,13 @@ def test_success_response_shape(self, tmp_path: Any) -> None: "timeout_seconds", "purpose", "owner", + # linc-nerve-jtt8: anonymous-lease flag + renewal report + "shared_owner", + "renewed", + "expires_in_s", + "warning", + # linc-nerve-l7pm: lease renewal authority + "renewal", } assert result["success"] is True assert isinstance(result["device"], str) @@ -113,6 +120,10 @@ def test_success_response_shape(self, tmp_path: Any) -> None: assert result["timeout_seconds"] == 60 # linc-nerve-enz.3: no owner supplied -> defaults to this session's id assert result["owner"] == result["session_id"] + assert result["shared_owner"] is True + assert result["renewed"] is False + assert result["expires_in_s"] == 60 + assert result["renewal"] == "daemon" finally: mgr.close() diff --git a/tests/test_output_retention.py b/tests/test_output_retention.py new file mode 100644 index 0000000..a31caf3 --- /dev/null +++ b/tests/test_output_retention.py @@ -0,0 +1,541 @@ +"""Tests for nerve-output retention cap, stale-dir sweep, and `nerve cleanup` (linc-nerve-si3y).""" + +from __future__ import annotations + +import json +import os +import shutil +import stat +import tempfile +import time +from pathlib import Path +from typing import BinaryIO + +import pytest +import structlog +import structlog.testing +from click.testing import CliRunner + +from linc_nerve.cli.main import main +from linc_nerve.core import output_retention as retention +from linc_nerve.mcp import _core + +pytestmark = pytest.mark.usefixtures("restore_signal_handlers") + + +def _set_mtime_tree(path: Path, mtime: float) -> None: + """Set atime/mtime on *path* and every non-symlink child inside it.""" + if path.is_dir() and not path.is_symlink(): + for child in path.iterdir(): + if not child.is_symlink(): + os.utime(child, (mtime, mtime)) + os.utime(path, (mtime, mtime), follow_symlinks=False) + + +def test_retention_cap_205_outputs_leaves_200_files( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """[test] 205 outputs with NERVE_OUTPUT_KEEP=200 leave exactly 200 files.""" + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + monkeypatch.setattr(_core, "_OUTPUT_DIR", None) + monkeypatch.setattr(_core, "_file_counter", 0) + monkeypatch.setenv("NERVE_OUTPUT_KEEP", "200") + + paths: list[Path] = [] + for i in range(205): + p = Path(_core._next_output_path("emu-1", "screenshot")) + p.write_bytes(f"frame-{i}".encode()) + paths.append(p) + + out_dir = Path(_core._output_dir()) + remaining = sorted(p.name for p in out_dir.iterdir() if p.name != retention.LOCK_NAME) + assert len(remaining) == 200 + assert paths[-1].exists() + assert paths[-1].name == "emu-1_0205_screenshot.png" + assert not paths[0].exists() + assert not paths[4].exists() + assert paths[5].exists() + + +def test_retention_cap_preserves_non_matching_files_and_symlinks( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Only regular files matching __. are pruned.""" + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + monkeypatch.setattr(_core, "_OUTPUT_DIR", None) + monkeypatch.setattr(_core, "_file_counter", 0) + monkeypatch.setenv("NERVE_OUTPUT_KEEP", "3") + + out_dir = Path(_core._output_dir()) + note = out_dir / "README.txt" + note.write_text("keep") + victim = tmp_path / "outside.png" + victim.write_bytes(b"keep-outside") + sym = out_dir / "emu-1_0000_screenshot.png" + sym.symlink_to(victim) + + for _ in range(5): + p = Path(_core._next_output_path("emu-1", "screenshot")) + p.write_bytes(b"png") + + matching = sorted( + p.name + for p in out_dir.iterdir() + if p.is_file() and not p.is_symlink() and p != note and p.name != retention.LOCK_NAME + ) + assert len(matching) == 3 + assert note.exists() + assert sym.is_symlink() + assert victim.read_bytes() == b"keep-outside" + + +def test_prune_output_dir_tiebreaks_by_mtime_and_normalizes_invalid_keep( + tmp_path: Path, +) -> None: + """When two files have the same counter, the older mtime is pruned first; keep<=0 -> 200.""" + f_newer = tmp_path / "alpha_0001_screenshot.png" + f_older = tmp_path / "zeta_0001_screenshot.png" + f_newer.write_bytes(b"newer") + f_older.write_bytes(b"older") + now = time.time() + os.utime(f_older, (now - 100, now - 100)) + os.utime(f_newer, (now, now)) + + assert _core._prune_output_dir(tmp_path, keep=0) == [] + removed = _core._prune_output_dir(tmp_path, keep=2) + assert removed == [str(f_older)] + assert not f_older.exists() + assert f_newer.exists() + + +@pytest.mark.parametrize("raw_val", ["0", "-10", "not-an-int", ""]) +def test_retention_cap_invalid_or_zero_env_uses_default( + monkeypatch: pytest.MonkeyPatch, raw_val: str +) -> None: + """0 or invalid NERVE_OUTPUT_KEEP / NERVE_OUTPUT_MAX_AGE_S falls back to defaults.""" + monkeypatch.setenv("NERVE_OUTPUT_KEEP", raw_val) + monkeypatch.setenv("NERVE_OUTPUT_MAX_AGE_S", raw_val) + assert _core._output_keep() == 200 + assert _core._output_max_age_s() == 86400 + + +def test_stale_sweep_removes_old_dir_and_preserves_fresh_and_symlink( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """[test] Stale-dir sweep removes a 2-day-old nerve-output-* dir on first _output_dir() + call and leaves a fresh dir, a symlinked dir and its target untouched.""" + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + monkeypatch.setattr(_core, "_OUTPUT_DIR", None) + monkeypatch.setenv("NERVE_OUTPUT_SWEEP", "1") + monkeypatch.delenv("NERVE_OUTPUT_MAX_AGE_S", raising=False) + + now = time.time() + two_days_ago = now - 2 * 86400 + + stale_dir = tmp_path / "nerve-output-stale" + stale_dir.mkdir(mode=0o700) + (stale_dir / "emu_0001_screenshot.png").write_bytes(b"old-shot") + _set_mtime_tree(stale_dir, two_days_ago) + + # Dir with old dir mtime but a fresh file inside must be preserved: + fresh_entry_dir = tmp_path / "nerve-output-fresh-child" + fresh_entry_dir.mkdir(mode=0o700) + fresh_child = fresh_entry_dir / "emu_0001_screenshot.png" + fresh_child.write_bytes(b"new-shot") + os.utime(fresh_entry_dir, (two_days_ago, two_days_ago)) + os.utime(fresh_child, (now, now)) + + victim_dir = tmp_path / "victim-dir" + victim_dir.mkdir() + victim_file = victim_dir / "precious.txt" + victim_file.write_text("do not delete") + _set_mtime_tree(victim_dir, two_days_ago) + + symlink_dir = tmp_path / "nerve-output-x" + symlink_dir.symlink_to(victim_dir) + + other_old = tmp_path / "unrelated-old" + other_old.mkdir() + _set_mtime_tree(other_old, two_days_ago) + + created = Path(_core._output_dir()) + + assert created.exists() + assert not stale_dir.exists() + assert fresh_entry_dir.exists() + assert fresh_child.exists() + assert symlink_dir.is_symlink() + assert victim_dir.exists() + assert victim_file.read_text() == "do not delete" + assert other_old.exists() + + # Even if own _OUTPUT_DIR has an old mtime, _sweep_stale_output_dirs never deletes it: + _set_mtime_tree(created, two_days_ago) + assert _core._sweep_stale_output_dirs(tmp_path) == [] + assert created.exists() + + # Subsequent _output_dir() calls in the same process do not re-sweep: + late_stale = tmp_path / "nerve-output-late" + late_stale.mkdir(mode=0o700) + _set_mtime_tree(late_stale, two_days_ago) + assert Path(_core._output_dir()) == created + assert late_stale.exists() + + +def test_stale_sweep_skips_dir_owned_by_another_uid( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A nerve-output-* dir owned by another uid is left untouched.""" + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + monkeypatch.setattr(_core, "_OUTPUT_DIR", None) + monkeypatch.setenv("NERVE_OUTPUT_SWEEP", "1") + + two_days_ago = time.time() - 2 * 86400 + foreign_dir = tmp_path / "nerve-output-foreign" + foreign_dir.mkdir(mode=0o700) + (foreign_dir / "emu_0001_screenshot.png").write_bytes(b"foreign") + _set_mtime_tree(foreign_dir, two_days_ago) + + monkeypatch.setattr(os, "getuid", lambda: os.stat(foreign_dir).st_uid + 1) + + _core._output_dir() + assert foreign_dir.exists() + + +def test_stale_sweep_swallows_and_logs_errors_once( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Sweep errors are swallowed and logged once (one structlog event), never on stdout.""" + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + monkeypatch.setattr(_core, "_OUTPUT_DIR", None) + monkeypatch.setenv("NERVE_OUTPUT_SWEEP", "1") + + two_days_ago = time.time() - 2 * 86400 + for name in ("nerve-output-err1", "nerve-output-err2"): + d = tmp_path / name + d.mkdir(mode=0o700) + _set_mtime_tree(d, two_days_ago) + + def _boom(_path: str) -> None: + raise PermissionError("simulated rmtree failure") + + monkeypatch.setattr(shutil, "rmtree", _boom) + + with structlog.testing.capture_logs() as logs: + out = _core._output_dir() + assert Path(out).exists() + events = [e for e in logs if e["event"] == "stale_output_dir_sweep_error"] + assert len(events) == 1 + assert events[0]["count"] == 2 + assert capsys.readouterr().out == "" + + +def test_nerve_cleanup_removes_stale_and_idle_dirs_and_prints_json( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """[test] `nerve cleanup` removes stale + idle dirs and prints the removed list.""" + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + + now = time.time() + two_days_ago = now - 2 * 86400 + two_mins_ago = now - 120 + + stale_dir = tmp_path / "nerve-output-stale" + stale_dir.mkdir(mode=0o700) + (stale_dir / "emu_0001_screenshot.png").write_bytes(b"123456789012") + (stale_dir / "subdir").mkdir() + _set_mtime_tree(stale_dir, two_days_ago) + + idle_dir = tmp_path / "nerve-output-idle" + idle_dir.mkdir(mode=0o700) + (idle_dir / "emu_0002_screenshot.png").write_bytes(b"12345678") + _set_mtime_tree(idle_dir, two_mins_ago) + + active_dir = tmp_path / "nerve-output-active" + active_dir.mkdir(mode=0o700) + (active_dir / "emu_0003_screenshot.png").write_bytes(b"fresh") + + victim_dir = tmp_path / "victim" + victim_dir.mkdir() + (victim_dir / "data.txt").write_text("safe") + _set_mtime_tree(victim_dir, two_days_ago) + + symlink_dir = tmp_path / "nerve-output-symlink" + symlink_dir.symlink_to(victim_dir) + + unrelated_dir = tmp_path / "other-temp-dir" + unrelated_dir.mkdir() + _set_mtime_tree(unrelated_dir, two_days_ago) + + result = CliRunner().invoke(main, ["cleanup"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.stdout) + + assert payload["removed"] == sorted([str(stale_dir), str(idle_dir)]) + assert payload["skipped"] == sorted([str(active_dir), str(symlink_dir)]) + assert payload["bytes_freed"] == 20 + + assert not stale_dir.exists() + assert not idle_dir.exists() + assert active_dir.exists() + assert symlink_dir.is_symlink() + assert victim_dir.exists() + assert (victim_dir / "data.txt").read_text() == "safe" + assert unrelated_dir.exists() + + +def test_nerve_cleanup_all_and_human_mode(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """`--all` also removes <60s active dirs; `--human` renders readable text.""" + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + + active_dir = tmp_path / "nerve-output-active" + active_dir.mkdir(mode=0o700) + (active_dir / "emu_0001_screenshot.png").write_bytes(b"abcd") + sym_dir = tmp_path / "nerve-output-sym" + sym_dir.symlink_to(tmp_path) + + result = CliRunner().invoke(main, ["cleanup", "--all", "--human"]) + assert result.exit_code == 0, result.output + assert not active_dir.exists() + assert f"removed: {active_dir}" in result.stdout + assert f"skipped: {sym_dir}" in result.stdout + assert "freed 4 bytes" in result.stdout + + +def test_nerve_cleanup_skips_foreign_uid_and_rmtree_errors( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """`nerve cleanup` skips directories owned by another uid or failing rmtree.""" + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + + two_days_ago = time.time() - 2 * 86400 + foreign_dir = tmp_path / "nerve-output-foreign" + foreign_dir.mkdir(mode=0o700) + _set_mtime_tree(foreign_dir, two_days_ago) + + assert retention.is_owned_output_dir(str(tmp_path / "nerve-output-missing"), 0) is None + + monkeypatch.setattr(os, "getuid", lambda: os.stat(foreign_dir).st_uid + 1) + result = CliRunner().invoke(main, ["cleanup", "--all"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.stdout) + assert payload == {"removed": [], "skipped": [str(foreign_dir)], "bytes_freed": 0} + assert foreign_dir.exists() + + # Now restore uid and simulate rmtree OSError: + monkeypatch.undo() + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + monkeypatch.setattr( + shutil, "rmtree", lambda _p: (_ for _ in ()).throw(OSError("rmtree denied")) + ) + result2 = CliRunner().invoke(main, ["cleanup", "--all"]) + assert result2.exit_code == 0, result2.output + assert json.loads(result2.stdout) == { + "removed": [], + "skipped": [str(foreign_dir)], + "bytes_freed": 0, + } + + +# --------------------------------------------------------------------------- +# Liveness: a dir whose owner process is alive is never removed (review of PR #240) +# --------------------------------------------------------------------------- + + +def _old_output_dir(root: Path, name: str, age_s: float = 3 * 86400) -> Path: + """A nerve-output-* dir whose dir mtime and entries are *age_s* old.""" + d = root / name + d.mkdir(mode=0o700) + (d / "emu_0001_screenshot.png").write_bytes(b"shot") + _set_mtime_tree(d, time.time() - age_s) + return d + + +def _hold_lock(d: Path) -> BinaryIO: + """Hold the owner lock on *d* exactly as a live nerve process does.""" + handle = retention.acquire_dir_lock(str(d)) + assert handle is not None + _set_mtime_tree(d, time.time() - 3 * 86400) + return handle + + +@pytest.fixture +def isolated_tmp(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + """tmp_path as the temp root, a fresh _core output dir state, sweep opted in.""" + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + monkeypatch.setattr(_core, "_OUTPUT_DIR", None) + monkeypatch.setenv("NERVE_OUTPUT_SWEEP", "1") + return tmp_path + + +def test_contract_live_locked_dir_survives_nerve_cleanup_all(isolated_tmp: Path) -> None: + """[contract] A lock-held dir older than both thresholds survives `nerve cleanup --all`.""" + live = _old_output_dir(isolated_tmp, "nerve-output-live") + lock = _hold_lock(live) + dead = _old_output_dir(isolated_tmp, "nerve-output-dead") + try: + result = CliRunner().invoke(main, ["cleanup", "--all"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.stdout) + assert payload["removed"] == [str(dead)] + assert str(live) in payload["skipped"] + assert (live / "emu_0001_screenshot.png").exists() + finally: + lock.close() + + +def test_contract_live_locked_dir_survives_first_output_sweep(isolated_tmp: Path) -> None: + """[contract] The first-output sweep keeps a live-locked dir older than max-age.""" + live = _old_output_dir(isolated_tmp, "nerve-output-live") + lock = _hold_lock(live) + dead = _old_output_dir(isolated_tmp, "nerve-output-dead") + try: + _core._output_dir() + assert live.exists() + assert (live / "emu_0001_screenshot.png").exists() + assert not dead.exists() + finally: + lock.close() + + +def test_contract_output_dir_holds_its_own_lock(isolated_tmp: Path) -> None: + """[contract] The process's own output dir reads as live to every other process.""" + own = _core._output_dir() + assert retention.dir_is_live(own) is True + assert (Path(own) / retention.LOCK_NAME).read_text().strip() == str(os.getpid()) + + +def test_contract_output_dir_recreated_after_deletion(isolated_tmp: Path) -> None: + """[contract] _output_dir() recreates a deleted dir; the next spill path is writable.""" + first = _core._output_dir() + shutil.rmtree(first) + second = _core._output_dir() + assert Path(second).is_dir() + assert Path(second).parent == isolated_tmp + assert stat.S_IMODE(os.stat(second).st_mode) == 0o700 + assert retention.dir_is_live(second) is True + path = Path(_core._next_output_path("emu-1", "screenshot")) + path.write_bytes(b"png") + assert path.parent == Path(second) + + +def test_contract_output_dir_replaced_when_swapped_for_symlink(isolated_tmp: Path) -> None: + """[contract] A deleted own dir re-created as a symlink is never reused (hostile).""" + first = _core._output_dir() + victim = isolated_tmp / "victim" + victim.mkdir() + shutil.rmtree(first) + Path(first).symlink_to(victim) + second = _core._output_dir() + assert second != first + assert not Path(second).is_symlink() + assert list(victim.iterdir()) == [] + + +@pytest.mark.parametrize("content", [b"", b"not-a-pid\n", b"\x00\xff garbage", b"999999999\n"]) +def test_contract_unlocked_dir_with_garbage_or_dead_pid_is_stale( + isolated_tmp: Path, content: bytes +) -> None: + """[contract] An unheld lock file (garbage or dead-pid contents) does not keep a dir.""" + d = _old_output_dir(isolated_tmp, "nerve-output-orphan") + (d / retention.LOCK_NAME).write_bytes(content) + _set_mtime_tree(d, time.time() - 3 * 86400) + assert retention.dir_is_live(str(d)) is False + _core._output_dir() + assert not d.exists() + + +def test_contract_legacy_dir_without_lock_file_is_not_live(tmp_path: Path) -> None: + """[contract] A dir from an older nerve (no lock file) falls back to the age rules.""" + d = _old_output_dir(tmp_path, "nerve-output-legacy") + assert retention.dir_is_live(str(d)) is False + + +def test_contract_symlinked_lock_file_fails_safe_as_live(tmp_path: Path) -> None: + """[contract] A symlinked lock file is unknown state: the dir is treated as live (hostile).""" + d = _old_output_dir(tmp_path, "nerve-output-symlock") + target = tmp_path / "elsewhere.lock" + target.write_text("1") + (d / retention.LOCK_NAME).symlink_to(target) + assert retention.dir_is_live(str(d)) is True + out = retention.cleanup_output_dirs(tmp_path, remove_all=True) + assert out["removed"] == [] + assert d.exists() + assert target.exists() + + +def test_contract_lock_file_that_is_a_directory_fails_safe_as_live(tmp_path: Path) -> None: + """[contract] A lock path that cannot be opened as a file reads as live.""" + d = _old_output_dir(tmp_path, "nerve-output-lockdir") + (d / retention.LOCK_NAME).mkdir() + assert retention.dir_is_live(str(d)) is True + + +def test_contract_flock_error_fails_safe_as_live( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """[contract] An unexpected flock error is unknown state: treated as live, never removed.""" + d = _old_output_dir(tmp_path, "nerve-output-enolck") + (d / retention.LOCK_NAME).write_text("1") + + def _enolck(_fd: int, _op: int) -> None: + raise OSError(37, "No locks available") + + monkeypatch.setattr(retention.fcntl, "flock", _enolck) + assert retention.dir_is_live(str(d)) is True + assert retention.sweep_stale_output_dirs(tmp_path, None, 60, time.time()) == [] + assert d.exists() + + +def test_contract_acquire_dir_lock_failure_returns_none( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """[contract] A failed lock acquisition never raises: the output dir still works.""" + + def _enolck(_fd: int, _op: int) -> None: + raise OSError(37, "No locks available") + + monkeypatch.setattr(retention.fcntl, "flock", _enolck) + assert retention.acquire_dir_lock(str(tmp_path)) is None + assert retention.acquire_dir_lock(str(tmp_path / "missing")) is None + + +@pytest.mark.parametrize( + ("env", "expected"), + [ + ({}, True), + ({"NERVE_OUTPUT_SWEEP": "0"}, False), + ({"PYTEST_CURRENT_TEST": "t"}, False), + ({"PYTEST_CURRENT_TEST": "t", "NERVE_OUTPUT_SWEEP": "1"}, True), + ({"NERVE_OUTPUT_SWEEP": "garbage"}, True), + ], +) +def test_contract_sweep_enabled(env: dict[str, str], expected: bool) -> None: + """[contract] Sweep is off under pytest unless opted in; NERVE_OUTPUT_SWEEP=0 disables it.""" + assert retention.sweep_enabled(env) is expected + + +def test_first_output_sweep_disabled_under_pytest_by_default( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Without the opt-in, a test run never sweeps the temp root (no real $TMPDIR deletions).""" + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + monkeypatch.setattr(_core, "_OUTPUT_DIR", None) + monkeypatch.delenv("NERVE_OUTPUT_SWEEP", raising=False) + stale = _old_output_dir(tmp_path, "nerve-output-stale") + _core._output_dir() + assert stale.exists() + + +def test_release_output_lock_closes_handle_and_tolerates_none( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The atexit hook closes the owner-lock handle; with no lock it is a no-op.""" + handle = retention.acquire_dir_lock(str(tmp_path)) + assert handle is not None + monkeypatch.setattr(_core, "_OUTPUT_LOCK", handle) + _core._release_output_lock() + assert handle.closed + assert retention.dir_is_live(str(tmp_path)) is False + monkeypatch.setattr(_core, "_OUTPUT_LOCK", None) + _core._release_output_lock() diff --git a/tests/test_public_docs.py b/tests/test_public_docs.py index f44c386..d123d62 100644 --- a/tests/test_public_docs.py +++ b/tests/test_public_docs.py @@ -161,6 +161,26 @@ def test_security_replaces_the_review_stub_with_review_summaries_and_hashes() -> assert len(sha256_hashes) >= 2 +def test_security_release_candidate_verification_names_public_tag_and_commit() -> None: + security = (REPO / "SECURITY.md").read_text() + assert "to be exported" not in security + assert "attached to the v0.1.0 release" not in security + assert "### Release candidate verification" in security + rc_section = security.split("### Release candidate verification", 1)[1] + assert "LincForge/nerve" in rc_section + assert "v0.1.0" in rc_section + commit_match = re.search(r"commit `([0-9a-f]{12,40})`", rc_section) + assert commit_match is not None + # Published v0.1.0 identifiers (tag object on LincForge/nerve, PyPI wheel digest). + assert "547786321703b7c8640e3f8a610e34f561fe7c9e" in rc_section + assert "321e50e2aba0e668107b364a94852ee7796b42c1" in rc_section + assert "747798cf8c869e4e0f6d49426faada97221cfd55e70fef540afa3fe2d949ca19" in rc_section + # The pre-publication placeholder sentence is gone, the timestamp explanation stays. + assert "After v0.1.0 is published" not in rc_section + assert "records each file's timestamp" in rc_section + assert "pip download linc-nerve==0.1.0" in rc_section + + def test_changelog_has_first_public_release_section() -> None: assert re.search(r"^## \[0\.1\.0\]", (REPO / "CHANGELOG.md").read_text(), re.M) @@ -204,3 +224,10 @@ def test_pyproject_project_urls_point_at_the_public_repo() -> None: assert urls["Homepage"] == PUBLIC_REPO assert urls["Source"] == PUBLIC_REPO assert urls["Issues"] == f"{PUBLIC_REPO}/issues" + + +def test_readme_cross_links_sibling_public_repos() -> None: + targets = set(_readme_link_targets()) + assert "https://github.com/LincForge/crucible" in targets + assert "https://github.com/LincForge/spectra" in targets + assert "https://github.com/LincForge/mobile-agent-bench" in targets diff --git a/tests/test_replay.py b/tests/test_replay.py index 8a2d13e..cff3466 100644 --- a/tests/test_replay.py +++ b/tests/test_replay.py @@ -955,3 +955,23 @@ def test_registered_device_tool_ignores_global_and_unknown_tools() -> None: def test_element_centre_none_for_unparseable_bounds() -> None: assert replay.element_centre({"bounds": "junk"}) is None assert replay.element_centre({"bounds": "[0,0][10,20]"}) == (5, 10) + + +def test_replay_tool_skips_guard_updated_lines(tool_ctrl: Any, tmp_path: Path) -> None: + entries = [ + FIXTURE_LOG[0], + {"type": "guard", "tool": "nerve_tap", "reason": "denied_label", "detail": "checkout"}, + { + "event": "guard_updated", + "deny_words": ["delete account"], + "allowed_packages": ["com.example.app"], + "t_session_s": 1.2, + }, + FIXTURE_LOG[2], + ] + log_path = _write_log(tmp_path / "rearmed.jsonl", entries) + result = _nerve_replay_sync("S", str(log_path)) + assert result["outcome"] == "completed" + assert result["steps_total"] == 1 + assert result["steps_done"] == 1 + assert tool_ctrl.calls == [("tap", (200, 150))] diff --git a/tests/test_schema_help.py b/tests/test_schema_help.py index ce184d8..01fb1ca 100644 --- a/tests/test_schema_help.py +++ b/tests/test_schema_help.py @@ -70,7 +70,7 @@ def test_every_schema_option_has_help() -> None: def test_schema_covers_all_commands_and_options() -> None: commands = _schema()["commands"] options = sum(len(command["options"]) for command in commands.values()) - assert (len(commands), options) == (52, 113) + assert (len(commands), options) == (54, 127) # --------------------------------------------------------------------------- diff --git a/tests/test_scrcpy.py b/tests/test_scrcpy.py index deaa573..99222bb 100644 --- a/tests/test_scrcpy.py +++ b/tests/test_scrcpy.py @@ -25,6 +25,29 @@ ) +@pytest.fixture(autouse=True) +def _no_leaked_scrcpy_threads(): + """Fail any test that leaves a new ``scrcpy*`` thread alive on exit.""" + before = set(threading.enumerate()) + yield + + def _leaked() -> list[threading.Thread]: + return [ + t + for t in threading.enumerate() + if t not in before and t.name.startswith("scrcpy") and t.is_alive() + ] + + deadline = time.monotonic() + 3.0 + while _leaked() and time.monotonic() < deadline: + time.sleep(0.02) + + leaked = _leaked() + if leaked: + names = ", ".join(t.name for t in leaked) + pytest.fail(f"leaked thread {names}") + + class TestRecvExact: def test_reads_exact_bytes(self) -> None: server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -1035,9 +1058,8 @@ def fake_connect() -> None: "real read loop never produced a frame after start()" ) finally: - stream._running = False - if stream._thread: - stream._thread.join(timeout=2) + with patch.object(stream, "_teardown_transport"): + stream.stop() def test_screenshot_pil_advances_last_access_via_real_code_path( self, monkeypatch @@ -1388,7 +1410,8 @@ def slow_teardown() -> None: # `_running is False` and call start() before this reap finishes # tearing down — the exact window round-2 finding 2 describes. release_reap.wait(timeout=2.0) - real_teardown() + with patch("linc_nerve.core.scrcpy.subprocess.run"): + real_teardown() new_sock_holder: dict[str, object] = {} @@ -1404,43 +1427,74 @@ def fake_start() -> None: ) stream._thread.start() - with ( - patch.object(stream, "_teardown_transport", side_effect=slow_teardown), - patch.object(stream, "start", side_effect=fake_start), - ): - stream._running = True - stream._sock = _InfiniteFrameSocket() - stream._last_access = time.monotonic() + try: + with ( + patch.object(stream, "_teardown_transport", side_effect=slow_teardown), + patch.object(stream, "start", side_effect=fake_start), + ): + stream._running = True + stream._sock = _InfiniteFrameSocket() + stream._last_access = time.monotonic() - reap_thread = threading.Thread( - target=stream._reap, args=(999.0,), daemon=True + reap_thread = threading.Thread( + target=stream._reap, args=(999.0,), daemon=True + ) + reap_thread.start() + assert reap_entered.wait(timeout=2.0), "reap did not start teardown" + + # screenshot_pil() races the in-flight reap. It must not observe + # `_running is False` until the reap has FULLY finished, so the + # transport it starts survives. + def call_screenshot() -> None: + stream.screenshot_pil() + + screenshot_thread = threading.Thread(target=call_screenshot, daemon=True) + screenshot_thread.start() + + # Let the reap's teardown proceed now that both threads are racing. + release_reap.set() + reap_thread.join(timeout=3.0) + screenshot_thread.join(timeout=3.0) + + assert "sock" in new_sock_holder, "screenshot_pil() never restarted the stream" + assert stream._sock is new_sock_holder["sock"], ( + "the reap must not close a transport started AFTER it finished " + "tearing down — the stale reap tore down the freshly-started " + "transport instead of leaving it alone" ) - reap_thread.start() - assert reap_entered.wait(timeout=2.0), "reap did not start teardown" - - # screenshot_pil() races the in-flight reap. It must not observe - # `_running is False` until the reap has FULLY finished, so the - # transport it starts survives. - def call_screenshot() -> None: - stream.screenshot_pil() - - screenshot_thread = threading.Thread(target=call_screenshot, daemon=True) - screenshot_thread.start() - - # Let the reap's teardown proceed now that both threads are racing. + assert stream.is_running, ( + "the stream must end up running with a live transport, not " + "flagged running with a transport the stale reap already killed" + ) + finally: release_reap.set() - reap_thread.join(timeout=3.0) - screenshot_thread.join(timeout=3.0) - - assert "sock" in new_sock_holder, "screenshot_pil() never restarted the stream" - assert stream._sock is new_sock_holder["sock"], ( - "the reap must not close a transport started AFTER it finished " - "tearing down — the stale reap tore down the freshly-started " - "transport instead of leaving it alone" - ) - assert stream.is_running, ( - "the stream must end up running with a live transport, not " - "flagged running with a transport the stale reap already killed" + with patch.object(stream, "_teardown_transport"): + stream.stop() + + def test_reap_restart_race_stops_thread_without_calling_subprocess_run( + self, monkeypatch + ) -> None: + """Regression (linc-nerve-twyb / reactor 29e39f9d): the restarted + ``scrcpy-race-restart`` read thread must not outlive the race test or + reach ``ScrcpyStream._teardown_transport``'s real ``subprocess.run``.""" + subprocess_calls: list[object] = [] + + def record_run(*args, **kwargs): + subprocess_calls.append((args, kwargs)) + return MagicMock(returncode=0) + + monkeypatch.setattr("linc_nerve.core.scrcpy.subprocess.run", record_run) + self.test_screenshot_pil_during_reap_keeps_the_new_transport_alive(monkeypatch) + + assert _wait_until( + lambda: not any( + t.name.startswith("scrcpy") and t.is_alive() for t in threading.enumerate() + ), + timeout=0.5, + ), "scrcpy thread still alive within 0.5s of race test finishing" + assert subprocess_calls == [], ( + "restarted stream teardown must be stubbed so subprocess.run is " + f"never called, got: {subprocess_calls!r}" ) diff --git a/tests/test_screenshot_unsupported.py b/tests/test_screenshot_unsupported.py index 239b1a2..7e5b7d3 100644 --- a/tests/test_screenshot_unsupported.py +++ b/tests/test_screenshot_unsupported.py @@ -11,8 +11,12 @@ import glob import os +import tempfile +from pathlib import Path from unittest.mock import MagicMock +import pytest + from linc_nerve.core.android import AndroidController from linc_nerve.core.device import DeviceType, NerveDevice, Platform from linc_nerve.core.ios import IOSController @@ -46,8 +50,13 @@ def _watch() -> NerveDevice: ) -def test_screenshot_not_implemented_returns_unsupported_shape() -> None: +def test_screenshot_not_implemented_returns_unsupported_shape( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: """NotImplementedError -> unsupported dict, no screenshot key, no file written.""" + # Isolated temp root: _output_dir() must never create in (or sweep) the real $TMPDIR. + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + monkeypatch.setattr(_core, "_OUTPUT_DIR", None) _reset_singletons() mock_android = MagicMock(spec=AndroidController) mock_android.discover.return_value = [] diff --git a/tests/test_security_post_review_surface.py b/tests/test_security_post_review_surface.py index 49380f3..1a6210d 100644 --- a/tests/test_security_post_review_surface.py +++ b/tests/test_security_post_review_surface.py @@ -458,14 +458,15 @@ def test_c3_hostile_launch_package_and_uri(root: Path) -> None: bad_pkg, cmds_pkg = _replay_lines( root, _action(1, "nerve_launch", {"package": "com.a;reboot", "uri": ""}) ) - _, cmds_uri = _replay_lines( + bad_uri, cmds_uri = _replay_lines( root, _action(1, "nerve_launch", {"package": TARGET, "uri": "x://$(reboot) -n a/.B"}) ) assert bad_pkg["outcome"] == "diverged" and cmds_pkg == [] - assert cmds_uri == [ - f"am start -a android.intent.action.VIEW -d 'x://$(reboot) -n a/.B' {TARGET}" - ] + # linc-nerve-o2ro: a uri outside the RFC 3986 grammar (space here) is now + # refused before the shell string is built, instead of only being quoted. + assert bad_uri["outcome"] == "diverged" and "Invalid uri" in bad_uri["reason"] + assert cmds_uri == [] @pytest.mark.parametrize("timeout", [float("nan"), float("inf"), 0.0, -1.0, 120.5]) diff --git a/tests/test_swipe_coordinate_guard.py b/tests/test_swipe_coordinate_guard.py new file mode 100644 index 0000000..60344f4 --- /dev/null +++ b/tests/test_swipe_coordinate_guard.py @@ -0,0 +1,465 @@ +"""Non-integer coordinates never slip past the guard (linc-nerve-vwkl review fix). + +Swipe coordinates became ``int | None`` when the x1..y2 aliases landed. The +daemon's coercer only knew plain ``int``, and the guard's label check skipped a +swipe whose coordinates did not parse — so ``start_x="150.5"`` swiped a "Buy" +button in a guarded session. These tests pin every layer closed: + +1. the pure guard helper refuses non-integer coordinates (fail closed); +2. a guarded session refuses them with a guard line, even past the daemon; +3. the daemon coerces / rejects ``X | None`` params like plain ``X``; +4. the tool body refuses them with no session at all; +5. the generated CLI types --start-x/--x1.. as INT and rejects 150.5. +""" + +from __future__ import annotations + +import inspect +from collections.abc import Iterator +from pathlib import Path +from typing import Any, Optional +from unittest.mock import patch + +import pytest +from click.testing import CliRunner + +from linc_nerve.core import action_log +from linc_nerve.daemon import http_router +from linc_nerve.mcp import _core, input_tools +from tests.test_guarded_session import TARGET, FakeController, _lines, _start, _tool_calls + +_BAD = ("150.5", "1.5e2", 150.5, "abc", True) +_CANON = ("start_x", "start_y", "end_x", "end_y") +_ALIAS = ("x1", "y1", "x2", "y2") + + +@pytest.fixture(name="ctrl") +def _ctrl(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[FakeController]: + fake = FakeController() + action_log.REGISTRY.reset() + monkeypatch.setenv("NERVE_ACTIONLOG_ROOTS", str(tmp_path)) + with ( + patch.object(_core, "_resolve_device", return_value=(object(), "android")), + patch.object(_core, "_get_controller", return_value=fake), + patch.object(_core, "_check_guard", return_value=None), + ): + yield fake + action_log.REGISTRY.reset() + + +def _swipe_body(names: tuple[str, ...], first: Any) -> dict[str, Any]: + return {"device": "S", **dict(zip(names, (first, 150, 150, 150), strict=True))} + + +# --------------------------------------------------------------------------- +# 1. Pure helper — fail closed +# --------------------------------------------------------------------------- + + +class TestCoordinateRefusal: + @pytest.mark.parametrize("bad", _BAD) + def test_swipe_non_integer_coordinate_refused(self, bad: Any) -> None: + args = {"start_x": bad, "start_y": 150, "end_x": 150, "end_y": 150} + refusal = action_log.coordinate_refusal("nerve_swipe", args) + assert refusal is not None + assert refusal["validation_error"] is True + assert refusal["guard"] == "target_unreadable" + assert "start_x" in refusal["detail"] + + @pytest.mark.parametrize("tool", ["nerve_tap", "nerve_long_press"]) + def test_point_tools_non_integer_refused(self, tool: str) -> None: + refusal = action_log.coordinate_refusal(tool, {"x": "1.5e2", "y": 10}) + assert refusal is not None and "x" in refusal["detail"] + + def test_missing_coordinate_refused(self) -> None: + refusal = action_log.coordinate_refusal("nerve_swipe", {"start_x": 1, "start_y": 2}) + assert refusal is not None + assert "end_x" in refusal["detail"] and "end_y" in refusal["detail"] + + @pytest.mark.parametrize("good", [0, "0", 150, "150", 150.0, 32767, "32767"]) + def test_integer_coordinates_pass(self, good: Any) -> None: + args = {"start_x": good, "start_y": 1, "end_x": 2, "end_y": 3} + assert action_log.coordinate_refusal("nerve_swipe", args) is None + assert action_log.coordinate_refusal("nerve_tap", {"x": good, "y": 1}) is None + + def test_is_integer_coordinate_still_parses_signed_integers(self) -> None: + assert action_log.is_integer_coordinate("-7") is True + assert action_log.is_integer_coordinate(-1) is True + assert action_log.is_integer_coordinate(10**30) is True + assert action_log.is_integer_coordinate(1e30) is True + + @pytest.mark.parametrize( + "out_of_range", + [-1, "-1", "-7", -1.0, 32768, "32768", 32768.0, 40000, 10**30, "1" + "0" * 30, 1e30], + ) + def test_coordinate_in_range_and_refusal_reject_out_of_range(self, out_of_range: Any) -> None: + assert action_log.COORDINATE_MAX == 32767 + assert action_log.coordinate_in_range(out_of_range) is False + refusal = action_log.coordinate_refusal("nerve_tap", {"x": out_of_range, "y": 150}) + assert refusal == { + "error": ( + "guarded session: input refused, " + "invalid coordinates (integers in 0..32767 required): x" + ), + "guard": "target_unreadable", + "detail": "invalid coordinates (integers in 0..32767 required): x", + "validation_error": True, + } + + @pytest.mark.parametrize("bad", _BAD) + def test_coordinate_in_range_rejects_non_integers(self, bad: Any) -> None: + assert action_log.coordinate_in_range(bad) is False + + @pytest.mark.parametrize("good", [0, "0", 0.0, 150, 32767, "32767", 32767.0]) + def test_coordinate_in_range_accepts_bounds(self, good: Any) -> None: + assert action_log.coordinate_in_range(good) is True + + def test_other_tools_never_refused(self) -> None: + assert action_log.coordinate_refusal("nerve_type", {"text": "x"}) is None + assert action_log.coordinate_refusal("nerve_key", {"key": "ENTER"}) is None + + def test_non_integer_names_lists_every_bad_key(self) -> None: + args = {"start_x": "1.5", "start_y": 1, "end_x": None, "end_y": "x"} + assert action_log.non_integer_coordinates("nerve_swipe", args) == [ + "start_x", + "end_x", + "end_y", + ] + assert action_log.non_integer_coordinates( + "nerve_swipe", {"start_x": -1, "start_y": 0, "end_x": 32768, "end_y": 32767} + ) == ["start_x", "end_x"] + + +# --------------------------------------------------------------------------- +# 2. Guarded session — refused with a guard line, never swiped +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("names", [_CANON, _ALIAS], ids=["canonical", "alias"]) +@pytest.mark.parametrize("bad", ["150.5", "1.5e2"]) +def test_daemon_dispatch_guarded_buy_swipe_refused( + ctrl: FakeController, tmp_path: Path, names: tuple[str, ...], bad: str +) -> None: + """The reviewer's probe: daemon _dispatch_tool + guarded session + a Buy button.""" + _start(tmp_path, target_package=TARGET) + ctrl.label = {"text": "Buy"} + registry = {"nerve_swipe": input_tools._nerve_swipe_sync} + + result = http_router._dispatch_tool("nerve_swipe", _swipe_body(names, bad), registry) + + assert result["status"] == 400 + assert "Invalid parameters" in result["body"]["error"] + assert names[0] in result["body"]["error"] + assert _tool_calls(ctrl, "swipe") == [] + + +@pytest.mark.parametrize("bad", ["150.5", "1.5e2", 150.5]) +def test_guarded_session_refuses_non_integer_swipe_past_the_coercer( + ctrl: FakeController, tmp_path: Path, bad: Any +) -> None: + """Even if a caller skips the daemon coercer, the guard fails closed (guard line).""" + path = _start(tmp_path, target_package=TARGET) + ctrl.label = {"text": "Buy"} + session = action_log.REGISTRY.active("S") + assert session is not None + raw = inspect.unwrap(input_tools._nerve_swipe_sync) + kwargs = dict(zip(_CANON, (bad, 150, 150, 150), strict=True)) + + result = _core._run_in_session(raw, "nerve_swipe", session, ("S",), kwargs) + + assert result["validation_error"] is True + assert result["guard"] == "target_unreadable" + assert _tool_calls(ctrl, "swipe") == [] + (guard,) = _lines(path, "guard") + assert guard["tool"] == "nerve_swipe" + assert "start_x" in guard["detail"] + + +@pytest.mark.parametrize("names", [_CANON, _ALIAS], ids=["canonical", "alias"]) +def test_guarded_direct_call_non_integer_swipe_refused( + ctrl: FakeController, tmp_path: Path, names: tuple[str, ...] +) -> None: + path = _start(tmp_path, target_package=TARGET) + ctrl.label = {"text": "Buy"} + + result = input_tools._nerve_swipe_sync(**_swipe_body(names, "150.5")) + + assert result["validation_error"] is True + assert _tool_calls(ctrl, "swipe") == [] + assert len(_lines(path, "guard")) == 1 + + +# --------------------------------------------------------------------------- +# 3. Daemon coercer — X | None and Optional[X] behave like X +# --------------------------------------------------------------------------- + + +def _optional_tool( + device: str, + a: int | None = None, + b: Optional[int] = None, # noqa: UP045 + c: float | None = None, +) -> dict[str, Any]: + return {"a": a, "b": b, "c": c} + + +class TestOptionalCoercion: + @pytest.mark.parametrize("field", ["a", "b"]) + @pytest.mark.parametrize("bad", ["150.5", "1.5e2", 1.5, "x", True, [1]]) + def test_optional_int_rejects_bad_value_with_400(self, field: str, bad: Any) -> None: + result = http_router._coerce_typed_params(_optional_tool, {"device": "S", field: bad}) + assert result is not None + assert result["status"] == 400 + assert result["body"]["error"].startswith(f"Invalid parameters: {field} must be int") + + @pytest.mark.parametrize("field", ["a", "b"]) + def test_optional_int_coerces_integer_string(self, field: str) -> None: + body: dict[str, Any] = {"device": "S", field: "150"} + assert http_router._coerce_typed_params(_optional_tool, body) is None + assert body[field] == 150 and type(body[field]) is int + + def test_optional_accepts_none(self) -> None: + body: dict[str, Any] = {"device": "S", "a": None, "b": None, "c": None} + assert http_router._coerce_typed_params(_optional_tool, body) is None + assert body == {"device": "S", "a": None, "b": None, "c": None} + + def test_optional_float_coerces_and_rejects(self) -> None: + body: dict[str, Any] = {"device": "S", "c": "1.5"} + assert http_router._coerce_typed_params(_optional_tool, body) is None + assert body["c"] == 1.5 + bad = http_router._coerce_typed_params(_optional_tool, {"device": "S", "c": "1.5x"}) + assert bad is not None and "c must be float" in bad["body"]["error"] + + def test_wider_union_is_left_alone(self) -> None: + def tool(device: str, v: int | str | None = None) -> dict[str, Any]: + return {} + + body: dict[str, Any] = {"device": "S", "v": "1.5"} + assert http_router._coerce_typed_params(tool, body) is None + assert body["v"] == "1.5" + + +# --------------------------------------------------------------------------- +# 4. Tool body — refused with no session at all +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("names", [_CANON, _ALIAS], ids=["canonical", "alias"]) +@pytest.mark.parametrize("bad", ["150.5", "1.5e2", 150.5]) +def test_unsessioned_swipe_non_integer_is_validation_error( + ctrl: FakeController, names: tuple[str, ...], bad: Any +) -> None: + result = input_tools._nerve_swipe_sync(**_swipe_body(names, bad)) + assert result["validation_error"] is True + assert "start_x" in result["error"] + assert _tool_calls(ctrl, "swipe") == [] + + +@pytest.mark.parametrize( + ("call", "name"), + [ + (lambda: input_tools._nerve_tap_sync("S", "1.5e2", 10), "tap"), + (lambda: input_tools._nerve_long_press_sync("S", 10, "150.5"), "long_press"), + ], +) +def test_unsessioned_point_tools_non_integer_is_validation_error( + ctrl: FakeController, call: Any, name: str +) -> None: + result = call() + assert result["validation_error"] is True + assert _tool_calls(ctrl, name) == [] + + +def test_unsessioned_integer_string_swipe_still_runs(ctrl: FakeController) -> None: + result = input_tools._nerve_swipe_sync("S", "150", 150, 150, 900) + assert result["success"] is True + + +# --------------------------------------------------------------------------- +# 5. CLI — swipe coordinates are INT again +# --------------------------------------------------------------------------- + + +def test_cli_schema_types_swipe_coordinates_as_int() -> None: + import json + + from linc_nerve.cli.main import main + + result = CliRunner().invoke(main, ["schema", "--json"]) + assert result.exit_code == 0, result.output + options = json.loads(result.output)["commands"]["swipe"]["options"] + for name in (*_CANON, *_ALIAS): + assert options[name]["type"] == "INT", name + + +@pytest.mark.parametrize("flag", ["--start-x", "--x1"]) +def test_cli_rejects_non_integer_swipe_coordinate(flag: str) -> None: + from linc_nerve.cli.main import main + + rest = ( + ["--start-y", "1", "--end-x", "1", "--end-y", "1"] + if flag == "--start-x" + else ["--y1", "1", "--x2", "1", "--y2", "1"] + ) + result = CliRunner().invoke(main, ["swipe", "--device", "S", flag, "150.5", *rest]) + assert result.exit_code == 2 + assert flag in result.output + + +# --------------------------------------------------------------------------- +# 6. Coordinate bounds 0..32767 — tool body, guarded session, daemon +# --------------------------------------------------------------------------- + +_OUT_OF_RANGE_TAP_X: tuple[Any, ...] = ( + 10**30, + "1000000000000000000000000000000", + 1e30, + -1, + 32768, +) + + +@pytest.mark.parametrize("bad_x", _OUT_OF_RANGE_TAP_X) +def test_unsessioned_tap_out_of_range_returns_validation_error( + ctrl: FakeController, bad_x: Any +) -> None: + result = input_tools._nerve_tap_sync("S", bad_x, 150) + assert result == { + "error": f"Invalid coordinates (integers in 0..32767 required): x={bad_x!r}", + "validation_error": True, + } + assert _tool_calls(ctrl, "tap") == [] + + +@pytest.mark.parametrize("bad_y", [-1, 32768, 10**30]) +def test_unsessioned_long_press_out_of_range_returns_validation_error( + ctrl: FakeController, bad_y: Any +) -> None: + result = input_tools._nerve_long_press_sync("S", 150, bad_y) + assert result == { + "error": f"Invalid coordinates (integers in 0..32767 required): y={bad_y!r}", + "validation_error": True, + } + assert _tool_calls(ctrl, "long_press") == [] + + +@pytest.mark.parametrize( + ("kwargs", "expected_shown"), + [ + ( + {"start_x": 100, "start_y": 150, "end_x": 32768, "end_y": 150}, + "end_x=32768", + ), + ( + {"x1": 100, "y1": 150, "x2": 40000, "y2": 150}, + "end_x=40000", + ), + ], +) +def test_unsessioned_swipe_out_of_range_returns_validation_error( + ctrl: FakeController, kwargs: dict[str, Any], expected_shown: str +) -> None: + result = input_tools._nerve_swipe_sync("S", **kwargs) + assert result == { + "error": f"Invalid coordinates (integers in 0..32767 required): {expected_shown}", + "validation_error": True, + } + assert _tool_calls(ctrl, "swipe") == [] + + +@pytest.mark.parametrize("bound", [0, 32767]) +def test_boundary_coordinates_0_and_32767_accepted(ctrl: FakeController, bound: int) -> None: + tap_res = input_tools._nerve_tap_sync("S", bound, bound) + assert tap_res["success"] is True + lp_res = input_tools._nerve_long_press_sync("S", bound, bound) + assert lp_res["success"] is True + swipe_res = input_tools._nerve_swipe_sync("S", bound, 0, 32767, bound) + assert swipe_res["success"] is True + assert len(_tool_calls(ctrl, "tap")) == 1 + assert len(_tool_calls(ctrl, "long_press")) == 1 + assert len(_tool_calls(ctrl, "swipe")) == 1 + + +@pytest.mark.parametrize("bad_x", _OUT_OF_RANGE_TAP_X) +def test_guarded_session_tap_out_of_range_refused_and_logged_once( + ctrl: FakeController, tmp_path: Path, bad_x: Any +) -> None: + path = _start(tmp_path, target_package=TARGET) + ctrl.label = {"text": "OK"} + + result = input_tools._nerve_tap_sync("S", bad_x, 150) + + assert result == { + "error": ( + "guarded session: input refused, " + "invalid coordinates (integers in 0..32767 required): x" + ), + "guard": "target_unreadable", + "detail": "invalid coordinates (integers in 0..32767 required): x", + "validation_error": True, + } + assert _tool_calls(ctrl, "tap") == [] + guards = _lines(path, "guard") + assert len(guards) == 1 + assert guards[0]["tool"] == "nerve_tap" + assert guards[0]["detail"] == "invalid coordinates (integers in 0..32767 required): x" + + +@pytest.mark.parametrize( + "kwargs", + [ + {"start_x": 100, "start_y": 150, "end_x": 32768, "end_y": 150}, + {"x1": 100, "y1": 150, "x2": 40000, "y2": 150}, + ], + ids=["end_x_32768", "alias_x2_40000"], +) +def test_guarded_session_swipe_out_of_range_refused_and_logged_once( + ctrl: FakeController, tmp_path: Path, kwargs: dict[str, Any] +) -> None: + path = _start(tmp_path, target_package=TARGET) + ctrl.label = {"text": "OK"} + + result = input_tools._nerve_swipe_sync("S", **kwargs) + + assert result == { + "error": ( + "guarded session: input refused, " + "invalid coordinates (integers in 0..32767 required): end_x" + ), + "guard": "target_unreadable", + "detail": "invalid coordinates (integers in 0..32767 required): end_x", + "validation_error": True, + } + assert _tool_calls(ctrl, "swipe") == [] + guards = _lines(path, "guard") + assert len(guards) == 1 + assert guards[0]["tool"] == "nerve_swipe" + assert guards[0]["detail"] == "invalid coordinates (integers in 0..32767 required): end_x" + + +@pytest.mark.parametrize( + "bad_x", + [10**30, "1000000000000000000000000000000", 1e30, -1, 32768], +) +def test_daemon_dispatch_tap_out_of_range_returns_validation_error( + ctrl: FakeController, bad_x: Any +) -> None: + registry = {"nerve_tap": input_tools._nerve_tap_sync} + result = http_router._dispatch_tool( + "nerve_tap", {"device": "S", "x": bad_x, "y": 150}, registry + ) + assert result["status"] == 200 + inner = result["body"]["result"] + assert inner["validation_error"] is True + assert inner["error"].startswith("Invalid coordinates (integers in 0..32767 required): x=") + assert _tool_calls(ctrl, "tap") == [] + + +def test_daemon_dispatch_tap_scientific_string_rejected_400(ctrl: FakeController) -> None: + registry = {"nerve_tap": input_tools._nerve_tap_sync} + result = http_router._dispatch_tool( + "nerve_tap", {"device": "S", "x": "1e30", "y": 150}, registry + ) + assert result["status"] == 400 + assert "Invalid parameters: x must be int" in result["body"]["error"] + assert _tool_calls(ctrl, "tap") == []