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") == []