Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<dir>/.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=<deep link>)` for apps outside the route registry.
- CLI: `nerve launch --uri <uri>` (linc-nerve-o2ro) opens a deep link (e.g.
`market://details?id=<pkg>` 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": <serial>` 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:<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.
Expand Down
28 changes: 24 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <!-- NERVE:TOOL-COUNT -->48<!-- /NERVE:TOOL-COUNT --> tools — screenshots, the UI
nerve exposes <!-- NERVE:TOOL-COUNT -->49<!-- /NERVE:TOOL-COUNT --> tools — screenshots, the UI
element tree, tap/swipe/type, app install/launch, logs, recording — to any MCP client, and the same
tools as `nerve <tool>` commands that print JSON.

Expand Down Expand Up @@ -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:<port>` or `tcp:<host>:<port>` 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 `<temp dir>/nerve-output-<random>/<serial>_<NNNN>_<tool>.<ext>` (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`) `<serial>_<NNNN>_<tool>.<ext>` files, pruning the oldest (by counter, then mtime) before writing a new one.
- **Owner lock:** Each process holds an exclusive `flock` on `<dir>/.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 `<temp dir>/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)

Expand All @@ -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.

<!-- NERVE:TOOLS-AUTO-START — generated by `python -m linc_nerve.docs_gen`, do not edit -->
## 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.

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
52 changes: 52 additions & 0 deletions src/linc_nerve/cli/cleanup.py
Original file line number Diff line number Diff line change
@@ -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))
Loading
Loading