From a34ddd648506a76bf0b20ec2d2918ed0a149c573 Mon Sep 17 00:00:00 2001 From: brettheap Date: Wed, 6 May 2026 13:55:31 +0000 Subject: [PATCH 1/7] specify: add FEAT-005 container thin client spec --- .specify/feature.json | 2 +- specs/005-container-thin-client/spec.md | 147 ++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 specs/005-container-thin-client/spec.md diff --git a/.specify/feature.json b/.specify/feature.json index 98b69ea..add480a 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/004-container-tmux-pane-discovery" + "feature_directory": "specs/005-container-thin-client" } diff --git a/specs/005-container-thin-client/spec.md b/specs/005-container-thin-client/spec.md new file mode 100644 index 0000000..89fe0c0 --- /dev/null +++ b/specs/005-container-thin-client/spec.md @@ -0,0 +1,147 @@ +# Feature Specification: Container-Local Thin Client Connectivity + +**Feature Branch**: `005-container-thin-client` +**Created**: 2026-05-06 +**Status**: Draft +**Input**: User description: "FEAT-005 Container-Local Thin Client Connectivity: make the agenttower CLI usable from inside a bench container by talking to the host agenttowerd daemon over a mounted Unix socket. Includes AGENTTOWER_SOCKET env override, default mounted-socket fallback, container identity detection from cgroup/hostname/env/daemon-visible metadata, current tmux pane detection from $TMUX and $TMUX_PANE, and `agenttower config doctor` focused on socket reachability and tmux identity. Out of scope: in-container daemon or relay, registering agents, log bind-mount validation beyond doctor output." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Run the agenttower CLI from inside a bench container against the host daemon (Priority: P1) + +A developer working inside a running bench container types `agenttower status`, the CLI connects to the host `agenttowerd` daemon over a mounted Unix socket, and the same status payload that the host CLI returns prints to stdout. The same binary, run from the host, continues to behave exactly as before. + +**Why this priority**: This is the foundation that every later container-side capability (FEAT-006 registration, FEAT-008 events, FEAT-009 input delivery) depends on. Without a host-reachable thin client, every later feature has nothing to talk to. It is also the smallest end-to-end value slice: a developer immediately gets a working `agenttower status`, `agenttower list-containers`, `agenttower list-panes` from inside their bench container. + +**Independent Test**: Can be fully tested by spawning the host daemon under the existing FEAT-002 test harness, then running the same `agenttower` binary in a second subprocess whose environment simulates "running inside a bench container" (the socket path resolves to a separate location through `AGENTTOWER_SOCKET`, `$HOME`, hostname, and container-detection env are overridden, but no real container is launched). `agenttower status` MUST succeed and return the same payload the host CLI returns. + +**Acceptance Scenarios**: + +1. **Given** the host daemon is running and the host's `agenttowerd.sock` is reachable from the container's mount namespace, **When** a user inside the container runs `agenttower status`, **Then** the CLI exits `0` and prints the same `alive`, `pid`, `start_time`, `uptime_seconds`, `socket_path`, `state_path`, `schema_version`, and `daemon_version` fields the host CLI prints. +2. **Given** the user sets `AGENTTOWER_SOCKET` to an absolute path that resolves to a reachable Unix socket, **When** they run `agenttower status`, **Then** the CLI uses that path verbatim (overriding both the host default and the in-container mounted default) and the call succeeds. +3. **Given** the same `agenttower` binary, when run from the host with no container context, **When** the user runs `agenttower status` / `list-containers` / `list-panes` / `scan --containers` / `scan --panes`, **Then** every existing FEAT-001 / FEAT-002 / FEAT-003 / FEAT-004 behavior is unchanged (same exit codes, same stdout, same JSON keys, same error messages). +4. **Given** no socket is reachable from inside the container (no host bind-mount, `AGENTTOWER_SOCKET` unset), **When** the user runs `agenttower status`, **Then** the CLI exits with the existing FEAT-002 daemon-unavailable exit code (`2`) and stderr message and the daemon stays alive. + +--- + +### User Story 2 - `agenttower config doctor` reports actionable diagnostics (Priority: P2) + +A developer who can't tell whether the issue is "the daemon is down", "the socket isn't mounted into the container", "I'm not inside a tmux pane", or "the daemon doesn't recognize this container" runs `agenttower config doctor` and gets a one-screen, structured set of pass/warn/fail rows that name the exact failed self-check and the next action to take. The same command produces a stable JSON shape under `--json` for scripting. + +**Why this priority**: The architecture doc and the FEAT-005 MVP-sequence entry call out `agenttower config doctor` by name as the prescribed diagnostic surface for this slice. It is one tier below "make the CLI work at all" because a developer can usually unblock themselves with `agenttower status` once the connection works; doctor matters most when something is wrong, and the most common day-2 issues are exactly the four self-checks below (socket mount, daemon liveness, tmux identity, container identity). + +**Independent Test**: Can be fully tested by spinning up the daemon and running `agenttower config doctor` against a controlled environment where each self-check is selectively broken (no socket mount, daemon down, no `$TMUX`, unknown container id). Each broken check MUST produce a `fail` row and a non-zero CLI exit; healthy state MUST produce all `pass` rows and exit `0`. `--json` MUST emit one object per invocation with stable check codes. + +**Acceptance Scenarios**: + +1. **Given** the host daemon is running, the socket is reachable, the user is inside a known FEAT-003 bench container, and `$TMUX` / `$TMUX_PANE` are set, **When** the user runs `agenttower config doctor`, **Then** the CLI exits `0` and every self-check row prints `pass`. +2. **Given** the host daemon is not running, **When** the user runs `agenttower config doctor`, **Then** the CLI exits non-zero, the `socket_reachable` and `daemon_status` checks print `fail` with the actionable next-step message ("try `agenttower ensure-daemon` from the host"), and the remaining checks (tmux, container) still run and print their own pass/fail rows so the user sees the full picture in one invocation. +3. **Given** the user is on the host shell (not inside a container) and the daemon is healthy, **When** the user runs `agenttower config doctor`, **Then** the CLI exits `0`, the container check prints `host_context` (not `fail`), and the tmux check prints `pass` if the user is inside a host tmux pane or `not_in_tmux` (not `fail`) if not. +4. **Given** the user is inside a tmux pane whose `$TMUX` socket and `$TMUX_PANE` value match a row in the daemon's persisted FEAT-004 pane registry, **When** the user runs `agenttower config doctor --json`, **Then** the JSON `tmux_pane.daemon_match` field is `true` and the matched pane id is included. +5. **Given** any combination of failing checks, **When** the user runs `agenttower config doctor --json`, **Then** the JSON output contains every check by closed-set code with `status` ∈ `{pass, warn, fail, info}`, an `actionable_message` for non-pass rows, and a top-level `summary.exit_code` matching the CLI's actual exit code. + +--- + +### User Story 3 - Container and tmux pane self-identification (Priority: P3) + +A developer wants the in-container CLI to know *which* bench container and *which* tmux pane it is running in, so later features (registration, log attachment, input delivery) can default the right target without making the user retype the container id every time. The CLI inspects `/proc/self/cgroup`, the container's hostname, environment variables, and the daemon's persisted FEAT-003 container metadata to converge on a single container id; it inspects `$TMUX` and `$TMUX_PANE` and cross-checks against the daemon's persisted FEAT-004 pane registry to converge on a single pane id. + +**Why this priority**: This is the *enabling* logic behind the doctor's container/tmux checks and behind every later feature that needs to default a target. It is P3 only because nothing in FEAT-005 acts on the resolved identity beyond reporting it; the value lands when FEAT-006 ships registration. Shipping it now in FEAT-005 lets FEAT-006 stay focused on the registration semantics rather than re-doing identity detection. + +**Independent Test**: Can be fully tested by injecting controlled `/proc/self/cgroup` fixtures, hostname overrides, env-var overrides, and a fake daemon whose `list_containers` returns a curated set of bench containers. Each detection precedence step must be exercised with a fixture that *only* that step can resolve, and the cross-check against the daemon must collapse short-id matches into the unique full id. + +**Acceptance Scenarios**: + +1. **Given** the in-container CLI sees a `/proc/self/cgroup` line containing `docker/` and the daemon's FEAT-003 registry has a row with `container_id = `, **When** identity detection runs, **Then** the resolved container id is `` and the resolved name comes from the FEAT-003 row. +2. **Given** the cgroup file is empty or unparseable but `/etc/hostname` matches the short prefix of exactly one FEAT-003 container row, **When** identity detection runs, **Then** the resolved container id is that row's full id (the cgroup → hostname fallback is exercised) and the doctor's `container_identity.source` reports `hostname`. +3. **Given** the cgroup, hostname, and env signals all yield a candidate that matches *no* FEAT-003 row (or no signal yields any candidate at all), **When** identity detection runs, **Then** the doctor's container check prints `unknown_container` (not `fail`), explains which signals were tried and what they returned, and the CLI exit code stays `0` if the rest of the checks passed. +4. **Given** `$TMUX` is unset, **When** the doctor runs, **Then** the tmux check prints `not_in_tmux` and does not report `fail`; the container check is unaffected. +5. **Given** `$TMUX` and `$TMUX_PANE` are set but the parsed pane id matches zero rows in the daemon's FEAT-004 pane registry for the resolved container, **When** the doctor runs, **Then** the tmux check prints `pane_unknown_to_daemon` with the parsed values, advises running `agenttower scan --panes` from the host, and the CLI exit stays consistent with the worst-non-pass status (warn vs fail per FR-018). + +### Edge Cases + +- `AGENTTOWER_SOCKET` is set to a relative path, an empty string, or a path containing a NUL byte; the CLI MUST refuse with a pre-flight error rather than silently falling back to the default. +- `AGENTTOWER_SOCKET` points to a regular file, a directory, or a broken symlink; the CLI MUST refuse with an actionable error and exit non-zero. +- The default mounted socket path inside the container does not exist (the developer forgot the `-v` mount); the CLI MUST exit with the existing FEAT-002 daemon-unavailable message and exit code `2`. +- The container is run with `--network host` and `--hostname` is unset, so `/etc/hostname` is the host's hostname rather than the container short id; identity detection MUST fall through to the daemon cross-check rather than reporting the host as a "container". +- The container is privileged and `/proc/self/cgroup` is empty (cgroup namespace not isolated); identity detection MUST treat that signal as "no candidate" and proceed to hostname / env. +- Two FEAT-003 rows have the same short-id prefix as the candidate (theoretical for 12-char short ids); the doctor MUST report `multi_match` rather than picking one arbitrarily. +- The CLI is run on the host but inside a tmux pane that is not in the FEAT-004 registry (e.g., FEAT-004 only scans inside containers); the tmux check MUST print `pane_unknown_to_daemon` as `info` (not `fail`) on the host so the operator is not misled into thinking something is wrong. +- `$TMUX` is set but its first comma-separated field (the tmux socket path) is unreadable from inside the container; the tmux check MUST report the parsed values and skip the daemon cross-check rather than crashing. +- `$TMUX_PANE` is set to a value that does not match the `%N` shape; the tmux check MUST flag `output_malformed` rather than silently dropping the pane id. +- The daemon is a *newer* schema than this CLI build supports; doctor's `daemon_status` check MUST report `schema_version_newer` and explain which build is needed (matches FEAT-003 R-012 forward-compat policy). +- The daemon is reachable but `list_containers` returns an empty result (no FEAT-003 scan has run yet); the container cross-check MUST print `no_containers_known` and advise running `agenttower scan --containers` from the host. +- Two `agenttower config doctor` invocations run concurrently from inside the same container; both MUST succeed independently. The doctor performs only read-only socket calls; no daemon-side mutex is acquired. +- The CLI binary is invoked with a working directory whose absolute path exceeds the kernel `sun_path` limit; the existing FEAT-002 `_connect_via_chdir` workaround already handles this, and FEAT-005 MUST NOT regress it. +- `AGENTTOWER_SOCKET` and the in-container default both resolve to *different* reachable sockets owned by different daemons; the override wins (no warning) and the doctor reports the resolved path's source as `env_override`. +- Doctor JSON output is requested while one or more checks would otherwise print free-form stderr noise; all check output MUST stay inside the JSON payload (no incidental stderr lines) when `--json` is set. +- The user's host `$USER` is not present inside the container's `/etc/passwd`; this MUST NOT prevent the CLI from connecting (the socket-file mode is `0600` host-user-only, so the *host*-side uid that owns the socket must match the in-container effective uid that opens it; the doctor's `socket_reachable` check explains this when it fails with `permission_denied`). + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST resolve the daemon socket path at every CLI invocation in this priority order: (a) the value of `AGENTTOWER_SOCKET` when set and valid; (b) the in-container default mounted path when the runtime is detected as "inside a container" and that path exists as a Unix socket; (c) the existing FEAT-001 host default. The resolved path and its source token (`env_override`, `mounted_default`, `host_default`) MUST be available to `agenttower config paths` and to `agenttower config doctor`. +- **FR-002**: When `AGENTTOWER_SOCKET` is set, the value MUST be a non-empty absolute path with no NUL bytes and no shell metacharacters interpreted as such (the CLI uses the value as a literal filesystem path and never feeds it to a shell). Invalid values MUST cause the CLI to exit `1` with `error: AGENTTOWER_SOCKET must be an absolute path to a Unix socket: `. The CLI MUST NOT silently fall back to a default when the override is set but invalid. +- **FR-003**: The default in-container mounted socket path is `/run/agenttower/agenttowerd.sock`. The CLI MUST treat this path as a candidate only when "running inside a container" is detected; on the host the host-default path is used and the in-container default is ignored. The container-runtime detection MUST NOT depend on the daemon being reachable (it is a local filesystem / `/proc` check). +- **FR-004**: "Running inside a container" detection MUST use a closed-set signal pipeline: presence of `/.dockerenv`, presence of `/run/.containerenv`, or a `/proc/self/cgroup` line containing one of `docker/`, `containerd/`, `kubepods/`, or `lxc/`. None of these signals require root and none requires `docker exec` or any in-container subprocess beyond reading `/proc` and `/etc`. +- **FR-005**: The same `agenttower` binary, run on the host with no container context, MUST behave bytewise identically to the FEAT-001 / FEAT-002 / FEAT-003 / FEAT-004 builds: same subcommand names, same flags, same default output, same `--json` shapes, same exit codes, same error messages, same socket-file authorization (`0600`, host user only). FEAT-005 MUST NOT add any error code to existing methods, MUST NOT add a new socket method, and MUST NOT modify any existing SQLite schema. +- **FR-006**: Container identity detection MUST resolve a single full container id from these signals in priority order: (1) `AGENTTOWER_CONTAINER_ID` env override (when set, the value is used verbatim as the candidate id); (2) the first cgroup path in `/proc/self/cgroup` whose last segment matches the closed pattern set in FR-004 — the candidate is the trailing identifier after the matched prefix; (3) the contents of `/etc/hostname` when running inside a container; (4) the value of `$HOSTNAME`. The candidate is then cross-checked against the daemon's FEAT-003 `list_containers` result by full-id equality first, then by 12-character short-id prefix match. +- **FR-007**: The cross-check MUST classify the result into one closed-set outcome: `unique_match` (exactly one FEAT-003 row matches), `multi_match` (more than one row matches the candidate prefix), `no_match` (no row matches but a candidate was produced), `no_candidate` (every detection signal returned nothing), `host_context` (no in-container signal fired and `AGENTTOWER_CONTAINER_ID` is unset). `unique_match` is reported with the matched row's full id and name; every other outcome carries the candidate value, the signal that produced it, and a one-line `actionable_message`. +- **FR-008**: The CLI MUST NOT widen the FEAT-003 container set on the basis of in-container detection (it is read-only). When detection produces `no_match`, doctor reports the candidate and advises running `agenttower scan --containers` from the host; the in-container CLI MUST NOT call `scan_containers` itself. +- **FR-009**: Tmux pane self-detection MUST parse `$TMUX` (comma-separated `socket_path,server_pid,session_id`) and `$TMUX_PANE` (`%N` form). The CLI MUST extract the tmux socket path, the tmux session id (numeric or symbolic), and the pane id, and report them. When `$TMUX` is unset, the doctor's tmux check is `not_in_tmux` (not `fail`). +- **FR-010**: The tmux pane cross-check MUST query the daemon's FEAT-004 `list_panes` (filtered by the resolved container id when known) and look for a row whose `tmux_socket_path` and `tmux_pane_id` both match the parsed values. The outcomes are the closed-set `pane_match`, `pane_unknown_to_daemon`, `pane_ambiguous` (more than one row matches), and `not_in_tmux`. The cross-check MUST NOT call any new socket method beyond the existing FEAT-004 `list_panes`. +- **FR-011**: The CLI MUST NOT spawn `tmux` or any other in-container subprocess to discover the local pane (avoiding the FEAT-004 in-container scope which is host-driven). All tmux self-detection is read-only inspection of `$TMUX` and `$TMUX_PANE` plus a daemon `list_panes` read. +- **FR-012**: The new subcommand `agenttower config doctor` MUST run the following closed-set self-checks, in this order: `socket_resolved`, `socket_reachable`, `daemon_status`, `container_identity`, `tmux_present`, `tmux_pane_match`. Each check has a single closed-set status (`pass`, `warn`, `fail`, `info`, plus the per-check outcome tokens enumerated in FR-007 / FR-010). A check that produces `info` MUST NOT count toward a non-zero exit on its own. +- **FR-013**: `agenttower config doctor` default human-readable output is one row per check on stdout: `\t\t`; failed-check actionable messages print on additional indented lines. A trailing summary line prints `summary\t\t/ checks passed`. The output MUST be sanitized of NUL bytes and terminal control bytes (matching FEAT-003 / FEAT-004 sanitization policy). +- **FR-014**: `agenttower config doctor --json` MUST emit exactly one canonical JSON object on stdout per invocation, with top-level fields `summary` (`{exit_code, total, passed, warned, failed, info}`) and `checks` (a JSON object keyed by closed-set check code, each value `{status, source, details, actionable_message?}`). The keys and tokens MUST be stable across releases; new tokens MAY be added but never renamed. +- **FR-015**: The doctor's `socket_resolved` check MUST report the resolved path AND its source token (FR-001). When the source is `env_override`, the validated value of `AGENTTOWER_SOCKET` is included; the raw env value is bounded to 4096 characters and sanitized of control bytes before being printed. +- **FR-016**: The doctor's `socket_reachable` check MUST attempt a single-frame round-trip to the daemon (reusing the existing FEAT-002 `status` method). A successful round-trip yields `pass` with the daemon's `daemon_version` and `schema_version` echoed in the row detail. A failure yields one of the closed-set sub-codes `socket_missing`, `socket_not_unix`, `connection_refused`, `permission_denied`, `connect_timeout`, `protocol_error`, each with a one-line actionable message. +- **FR-017**: The doctor's `daemon_status` check MUST report `pass` only when the round-trip from FR-016 succeeded AND the returned `schema_version` is one this CLI build supports. When the daemon's `schema_version` is greater than this CLI build supports, the check is `fail` with sub-code `schema_version_newer`. When it is less than supported, the check is `warn` with sub-code `schema_version_older` (a forward-compatible CLI may keep working but the operator should know). +- **FR-018**: Doctor exit codes are: `0` when every required check is `pass` or `info`; `1` for pre-flight failures (FEAT-005 not initialized, malformed `AGENTTOWER_SOCKET`); `2` when `socket_reachable` fails with `socket_missing` / `connection_refused` / `connect_timeout` (matches FEAT-002's existing daemon-unavailable code); `3` when the daemon round-trip succeeded but the daemon returned a structured error (matches FEAT-002); `5` (degraded; matches FEAT-003) when the round-trip succeeded but at least one non-required check is `fail` (e.g., `pane_unknown_to_daemon` after a successful socket+daemon path) AND no required check failed. `4` is reserved for internal CLI error per FEAT-002. +- **FR-019**: `agenttower config paths` MUST be extended to print one additional line `SOCKET_SOURCE=` (FR-001) without changing the existing `KEY=value` shape of the other lines. The new line MUST be the last line of the output. `agenttower config paths --json` (if introduced later) inherits the same field name; FEAT-005 MUST NOT introduce a `--json` mode for `config paths` if one does not already exist. +- **FR-020**: All FEAT-005 in-container code (path resolution, container detection, tmux detection, doctor checks) MUST be pure read-only filesystem and `/proc` inspection plus existing FEAT-002 / FEAT-003 / FEAT-004 socket reads. FEAT-005 MUST NOT spawn any subprocess inside the container (no `id`, no `tmux`, no `cat`, no `docker`). FEAT-005 MUST NOT open any file outside `/proc/self/`, `/proc/1/`, `/etc/hostname`, `/run/.containerenv`, `/.dockerenv`, the resolved socket path, and the FEAT-001 host paths it already reads. +- **FR-021**: All values read from `$TMUX`, `$TMUX_PANE`, `/proc/self/cgroup`, `/etc/hostname`, `$HOSTNAME`, and `AGENTTOWER_CONTAINER_ID` MUST be treated as untrusted data: NUL-byte stripped, terminal-control-byte stripped, length-bounded to 4096 characters, and never interpolated into a shell string. Out-of-shape values MUST surface as a closed-set `output_malformed` outcome on the relevant doctor check rather than crashing the CLI. +- **FR-022**: The CLI MUST NOT add a network listener, an in-container daemon, an in-container relay, agent registration, role/capability metadata, log capture, log offset tracking, prompt queuing, or input delivery. It MUST NOT bind-mount validate the host log directory beyond what `agenttower config doctor` reports. It MUST NOT introduce any new socket method beyond reusing FEAT-002 `ping` / `status` / `shutdown`, FEAT-003 `list_containers`, and FEAT-004 `list_panes`. +- **FR-023**: When run inside a container, the CLI MUST connect to the daemon socket using the existing FEAT-002 client (`AF_UNIX`, newline-delimited JSON, single-frame round-trip). The mounted socket file's permissions are owned by the host daemon (`0600`, host user only); the CLI MUST surface a host-user-only access failure as the closed-set `permission_denied` sub-code on the doctor's `socket_reachable` check rather than retrying as another user or attempting to chmod the socket. +- **FR-024**: The doctor's `--json` output and the CLI's stderr error messages MUST NOT leak the contents of `AGENTTOWER_SOCKET` beyond its sanitized, length-bounded form (FR-015). Raw stderr from the underlying `socket(2)` / `connect(2)` calls MUST be normalized to the closed-set sub-codes in FR-016 and a bounded one-line message rather than passed through verbatim. +- **FR-025**: The feature MUST be testable end-to-end without a real bench container, by simulating "inside a container" through a closed-set of test hooks: a temporary `AGENTTOWER_TEST_PROC_ROOT` env var that points to a fixture directory standing in for `/proc` and `/etc`, plus the existing host-side daemon harness from FEAT-002 / FEAT-003 / FEAT-004. The hook is namespaced (`AGENTTOWER_TEST_*`) so it cannot be set accidentally in production; an integration test asserts the hook is unset when running the real binary. +- **FR-026**: Existing FEAT-001 `agenttower config init` byte-for-byte output, FEAT-002 socket envelope shapes, FEAT-003 `containers` / `container_scans` schema and `list_containers` output, and FEAT-004 `panes` / `pane_scans` schema and `list_panes` output MUST remain unchanged. FEAT-005 adds two new CLI surfaces (`config doctor` subcommand; one new line on `config paths`) and one new env var (`AGENTTOWER_SOCKET`); it does not modify any existing surface beyond those two additive points. +- **FR-027**: Every doctor check MUST be individually short-circuitable for testing: a fixture that turns off `socket_reachable` MUST still exercise `tmux_present` and `container_identity`. The doctor MUST NOT abort early on a failing check. All required checks MUST run on every invocation so the operator sees the full picture in one run. +- **FR-028**: The CLI MUST sanitize and bound every doctor row's `details` and `actionable_message` to 2048 characters and strip NUL bytes and terminal control bytes (matches FEAT-003 / FEAT-004 R-009 policy). Truncation MUST add a trailing `…` and MUST NOT split a multi-byte UTF-8 character. +- **FR-029**: The CLI MUST write nothing to disk during `agenttower config doctor` (no SQLite writes, no JSONL appends, no log rotation, no file creation). Doctor is a pure read-only diagnostic. The host daemon's lifecycle log MAY record the underlying `status` round-trip exactly the way it already does for FEAT-002 callers; FEAT-005 introduces no new lifecycle log token. +- **FR-030**: When `agenttower config doctor` fails to load required CLI configuration (FEAT-001 not initialized — host case only), it MUST exit `1` with the existing FEAT-001 `agenttower is not initialized: run \`agenttower config init\`` message rather than printing partial doctor output. The in-container case MUST NOT require local FEAT-001 init because the durable state lives on the host; the doctor MUST still run when the in-container `$HOME` lacks an `agenttower` config tree, sourcing only the resolved socket path and the daemon's `status` reply. + +### Key Entities *(include if feature involves data)* + +- **Resolved Socket Path**: A pair `(path, source)` where `source ∈ {env_override, mounted_default, host_default}`. Computed at every CLI invocation; not persisted. Surfaced by `agenttower config paths` and by the doctor's `socket_resolved` check. +- **Container Runtime Context**: A tagged value `host_context | container_context(detection_signals)` produced by FR-004 detection. Drives whether the in-container default mounted path is considered. Not persisted. +- **Container Identity Resolution**: The output of FR-006 + FR-007: a candidate id, the signal that produced it, and the cross-check classification. Reported by the doctor's `container_identity` check; not persisted. +- **Tmux Self-Identity**: The parsed `(tmux_socket_path, tmux_session, tmux_pane_id)` triple from `$TMUX` + `$TMUX_PANE`, plus the `pane_match | pane_unknown_to_daemon | pane_ambiguous | not_in_tmux` cross-check classification against the daemon's FEAT-004 `list_panes`. Reported by the doctor's tmux checks; not persisted. +- **Doctor Check Result**: One row per closed-set check code with `(status, source?, details, actionable_message?)`. Aggregated into the doctor summary; surfaced as TSV by default and as canonical JSON under `--json`. Not persisted. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: With the host daemon running, the same `agenttower` binary returns identical `status` payloads when invoked from the host shell and from a simulated in-container test environment whose only difference is `AGENTTOWER_SOCKET` and the simulated `/proc` fixture. Both invocations exit `0` and print the same eight `status` keys. +- **SC-002**: When `AGENTTOWER_SOCKET` is set to an invalid value (relative path, empty, contains NUL, points to a non-socket), the CLI exits `1` within 50 ms with the FR-002 error message and no daemon-side state changes. When unset, the CLI uses the host default (host context) or the mounted default (container context) per FR-001. +- **SC-003**: `agenttower config doctor` runs every check on every invocation (FR-027) and completes within 500 ms wall clock against a healthy daemon and a fully-resolvable identity, including the daemon round-trip and the FEAT-004 `list_panes` cross-check. +- **SC-004**: When the daemon is not running, `agenttower config doctor` still completes (no early exit) and produces a `fail` row for `socket_reachable` and `daemon_status`, info/pass rows for `container_identity` and tmux checks (the local-only checks), and a non-zero exit. The doctor's stderr does not contain raw `socket(2)` / `connect(2)` errno text (FR-024). +- **SC-005**: `agenttower config doctor --json` returns valid JSON on every code path tested (healthy, daemon-down, no-mount, no-tmux, unknown-container, ambiguous-pane), with stable check codes and stable status tokens. Each invocation prints exactly one JSON object with the FR-014 schema. +- **SC-006**: Existing FEAT-001 / FEAT-002 / FEAT-003 / FEAT-004 test suites continue to pass after FEAT-005 is implemented, with no test-suite-level changes other than additive tests for the new subcommand and the new env var. No existing CLI command's stdout, stderr, or exit code changes byte-for-byte for the same input. +- **SC-007**: When run on the host (no container detection signal fires), `agenttower status`, `agenttower list-containers`, `agenttower list-panes`, `agenttower scan --containers`, `agenttower scan --panes`, `agenttower ensure-daemon`, and `agenttower stop-daemon` produce byte-identical output to the FEAT-004 build for every test input the existing suite already covers. +- **SC-008**: Container identity detection resolves to `unique_match` for at least one FEAT-003-known container in every spec-mandated detection-signal fixture (`/proc/self/cgroup` only, hostname only, env only, env+hostname, full-id, short-id-prefix). The `multi_match`, `no_match`, `no_candidate`, and `host_context` outcomes each have at least one fixture exercising them. +- **SC-009**: The feature has unit coverage for socket-path resolution, container-runtime detection, identity-signal parsing, daemon cross-check classification, tmux env parsing, doctor-row rendering (TSV and JSON), and per-check sanitization/truncation. The feature has integration coverage for the four primary acceptance scenarios in US1, US2, US3, and at least three edge cases — all without a real container or real Docker daemon. + +## Assumptions + +- The daemon socket is bind-mounted into bench containers at `/run/agenttower/agenttowerd.sock` with the original host file permissions preserved (`0600`, host user). The mount is read-write (the socket has to be writable to send a request frame) but the file mode keeps it scoped to a single uid. Containers that mount the socket at a different path use `AGENTTOWER_SOCKET` to override. This decision resolves architecture.md §25 open question "What exact bind mount path will devBench containers use for the daemon socket and logs?" for the MVP. +- The container's effective uid matches the host daemon's effective uid (the constitution's "host user" model). Bench containers traditionally run with `--user $(id -u)` or set their `Config.User` to a uid that matches the host developer; if not, the doctor's `socket_reachable` check correctly reports `permission_denied` and the operator runs the bench container with the right uid. FEAT-005 does not introduce uid mapping, fakeroot, or capability juggling. +- The same `agenttower` binary built on the host is the binary invoked from inside the container. Bench containers either install AgentTower locally (matching version) or mount the host installation. Cross-version drift between host daemon and container CLI is out of scope for FEAT-005 except for the `schema_version_older` warning (FR-017). +- Container-runtime detection is conservative and false-positive-resistant: the closed-set signals in FR-004 are well-known Docker / Podman / Kubernetes / LXC markers, and a developer who runs the CLI in an unusual sandbox (Firejail, Bubblewrap, systemd-nspawn) is treated as host context unless they set `AGENTTOWER_SOCKET` explicitly. +- The doctor's tmux cross-check assumes FEAT-004 has run at least once (`agenttower scan --panes` from the host). If it has not, the `pane_unknown_to_daemon` outcome with the actionable message "run `agenttower scan --panes` from the host" is the correct UX, not an error. +- Identity detection prefers the cgroup signal over hostname over env because cgroup is the most reliable in standard Docker / Podman and because hostnames can collide with the host hostname under `--network host`. The env override (`AGENTTOWER_CONTAINER_ID`) wins over all of them so a developer can pin the answer in unusual setups. +- The doctor's `--json` shape is the de-facto contract for downstream tooling (FEAT-006 registration may use it as a self-test, and a future TUI may render it). Adding new check codes is allowed; renaming or repurposing existing ones is a breaking change and is deferred to a future major version. +- FEAT-005 inherits the FEAT-001 / FEAT-002 / FEAT-003 / FEAT-004 threat model: the host user, the daemon process environment, the resolved Docker binary (only used by FEAT-003 / FEAT-004 on the host), and the Docker daemon are trusted; container-side env vars, hostnames, cgroup contents, tmux env, and `/etc/hostname` are untrusted data subject to bounded sanitization but not semantic secret redaction. Secret redaction in doctor output is deferred to FEAT-007. +- FEAT-005's CLI surface is testable through the existing FEAT-002 / FEAT-003 / FEAT-004 integration test harness (`tests/integration/_daemon_helpers.py`) plus a new lightweight `AGENTTOWER_TEST_PROC_ROOT` hook for fake-proc and fake-`/etc` fixtures. No additional Docker, Podman, or systemd-nspawn dependency is added to the test environment. From 800d034bdc4274dbd5620e633f613f1f5fca9b36 Mon Sep 17 00:00:00 2001 From: brettheap Date: Wed, 6 May 2026 18:30:57 +0000 Subject: [PATCH 2/7] FEAT-005: container-local thin client connectivity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the agenttower CLI usable from inside a bench container by talking to the host agenttowerd daemon over a mounted Unix socket. Adds two additive surfaces and three env overrides; no new socket method, no schema change, no in-container daemon, no in-container subprocess. New surfaces: - agenttower config doctor [--json] — six closed-set self-checks (socket_resolved → socket_reachable → daemon_status → container_identity → tmux_present → tmux_pane_match) with TSV default and canonical JSON under --json. Exit codes mirror FEAT-002 / FEAT-003 (0/1/2/3/5; 4 reserved). Pure read-only — writes nothing to disk. - agenttower config paths gains a trailing SOCKET_SOURCE= line reporting env_override / mounted_default / host_default. Existing KEY=value lines are byte-identical to the FEAT-001 build. New env vars: - AGENTTOWER_SOCKET — socket-path override, validated as non-empty absolute path with no NUL byte and S_ISSOCK after one os.readlink follow. Invalid values exit 1 within 50 ms with the FR-002 message. - AGENTTOWER_CONTAINER_ID — pins container identity verbatim. - AGENTTOWER_TEST_PROC_ROOT — test seam; production binary asserts it is unset. Identity detection: closed-set signal pipeline over /.dockerenv, /run/.containerenv, /proc/self/cgroup. Container-id resolution walks AGENTTOWER_CONTAINER_ID → /proc/self/cgroup → /etc/hostname → $HOSTNAME and cross-checks the daemon's FEAT-003 list_containers by full-id then 12-char short-id prefix. Tmux pane self-identity parses $TMUX + $TMUX_PANE and cross-checks FEAT-004 list_panes. Backward compat: every FEAT-001..004 CLI command produces byte-identical stdout, stderr, exit codes, and --json shapes on the host. Locked by tests/integration/test_feat005_backcompat.py. Tests: 186 new FEAT-005 tests (unit + integration); all without a real container, real Docker daemon, or real tmux server. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 2 +- .../checklists/requirements.md | 42 ++ .../checklists/security.md | 145 ++++ .../contracts/cli.md | 335 +++++++++ .../contracts/socket-api.md | 142 ++++ specs/005-container-thin-client/data-model.md | 348 ++++++++++ specs/005-container-thin-client/plan.md | 406 +++++++++++ specs/005-container-thin-client/quickstart.md | 353 ++++++++++ specs/005-container-thin-client/research.md | 544 +++++++++++++++ specs/005-container-thin-client/spec.md | 28 +- specs/005-container-thin-client/tasks.md | 298 ++++++++ src/agenttower/cli.py | 156 ++++- src/agenttower/config_doctor/__init__.py | 54 ++ src/agenttower/config_doctor/checks.py | 642 ++++++++++++++++++ src/agenttower/config_doctor/identity.py | 195 ++++++ src/agenttower/config_doctor/render.py | 83 +++ src/agenttower/config_doctor/runner.py | 174 +++++ .../config_doctor/runtime_detect.py | 122 ++++ src/agenttower/config_doctor/sanitize.py | 69 ++ .../config_doctor/socket_resolve.py | 191 ++++++ src/agenttower/config_doctor/tmux_identity.py | 120 ++++ src/agenttower/paths.py | 25 +- src/agenttower/socket_api/client.py | 79 ++- tests/integration/_proc_fixtures.py | 94 +++ .../test_cli_config_doctor_daemon_down.py | 102 +++ .../test_cli_config_doctor_healthy.py | 166 +++++ .../test_cli_config_doctor_host_context.py | 154 +++++ .../test_cli_config_doctor_json.py | 228 +++++++ ...st_cli_config_doctor_json_strict_stdout.py | 223 ++++++ .../test_cli_config_doctor_pane_match.py | 142 ++++ .../test_cli_config_doctor_short_circuit.py | 121 ++++ .../test_cli_config_paths_socket_source.py | 203 ++++++ .../integration/test_cli_doctor_concurrent.py | 253 +++++++ .../test_cli_doctor_identity_hostname.py | 125 ++++ .../integration/test_cli_doctor_tmux_unset.py | 217 ++++++ .../test_cli_in_container_socket_override.py | 179 +++++ .../test_cli_in_container_status.py | 130 ++++ ...st_cli_in_container_unsupported_signals.py | 156 +++++ tests/integration/test_cli_no_socket_mount.py | 87 +++ tests/integration/test_cli_paths.py | 22 +- tests/integration/test_feat005_backcompat.py | 174 +++++ .../test_feat005_deep_cwd_connect.py | 200 ++++++ .../test_feat005_no_real_container.py | 69 ++ .../test_feat005_proc_root_unset_in_prod.py | 70 ++ tests/unit/test_container_identity.py | 476 +++++++++++++ tests/unit/test_daemon_unavailable_kind.py | 75 ++ tests/unit/test_doctor_exit_codes.py | 175 +++++ tests/unit/test_doctor_json_contract.py | 375 ++++++++++ tests/unit/test_doctor_render.py | 200 ++++++ tests/unit/test_path_sanitize.py | 168 +++++ tests/unit/test_runtime_detect.py | 186 +++++ tests/unit/test_socket_client_back_compat.py | 120 ++++ tests/unit/test_socket_path_resolution.py | 295 ++++++++ tests/unit/test_tmux_self_identity.py | 133 ++++ 54 files changed, 9825 insertions(+), 46 deletions(-) create mode 100644 specs/005-container-thin-client/checklists/requirements.md create mode 100644 specs/005-container-thin-client/checklists/security.md create mode 100644 specs/005-container-thin-client/contracts/cli.md create mode 100644 specs/005-container-thin-client/contracts/socket-api.md create mode 100644 specs/005-container-thin-client/data-model.md create mode 100644 specs/005-container-thin-client/plan.md create mode 100644 specs/005-container-thin-client/quickstart.md create mode 100644 specs/005-container-thin-client/research.md create mode 100644 specs/005-container-thin-client/tasks.md create mode 100644 src/agenttower/config_doctor/__init__.py create mode 100644 src/agenttower/config_doctor/checks.py create mode 100644 src/agenttower/config_doctor/identity.py create mode 100644 src/agenttower/config_doctor/render.py create mode 100644 src/agenttower/config_doctor/runner.py create mode 100644 src/agenttower/config_doctor/runtime_detect.py create mode 100644 src/agenttower/config_doctor/sanitize.py create mode 100644 src/agenttower/config_doctor/socket_resolve.py create mode 100644 src/agenttower/config_doctor/tmux_identity.py create mode 100644 tests/integration/_proc_fixtures.py create mode 100644 tests/integration/test_cli_config_doctor_daemon_down.py create mode 100644 tests/integration/test_cli_config_doctor_healthy.py create mode 100644 tests/integration/test_cli_config_doctor_host_context.py create mode 100644 tests/integration/test_cli_config_doctor_json.py create mode 100644 tests/integration/test_cli_config_doctor_json_strict_stdout.py create mode 100644 tests/integration/test_cli_config_doctor_pane_match.py create mode 100644 tests/integration/test_cli_config_doctor_short_circuit.py create mode 100644 tests/integration/test_cli_config_paths_socket_source.py create mode 100644 tests/integration/test_cli_doctor_concurrent.py create mode 100644 tests/integration/test_cli_doctor_identity_hostname.py create mode 100644 tests/integration/test_cli_doctor_tmux_unset.py create mode 100644 tests/integration/test_cli_in_container_socket_override.py create mode 100644 tests/integration/test_cli_in_container_status.py create mode 100644 tests/integration/test_cli_in_container_unsupported_signals.py create mode 100644 tests/integration/test_cli_no_socket_mount.py create mode 100644 tests/integration/test_feat005_backcompat.py create mode 100644 tests/integration/test_feat005_deep_cwd_connect.py create mode 100644 tests/integration/test_feat005_no_real_container.py create mode 100644 tests/integration/test_feat005_proc_root_unset_in_prod.py create mode 100644 tests/unit/test_container_identity.py create mode 100644 tests/unit/test_daemon_unavailable_kind.py create mode 100644 tests/unit/test_doctor_exit_codes.py create mode 100644 tests/unit/test_doctor_json_contract.py create mode 100644 tests/unit/test_doctor_render.py create mode 100644 tests/unit/test_path_sanitize.py create mode 100644 tests/unit/test_runtime_detect.py create mode 100644 tests/unit/test_socket_client_back_compat.py create mode 100644 tests/unit/test_socket_path_resolution.py create mode 100644 tests/unit/test_tmux_self_identity.py diff --git a/CLAUDE.md b/CLAUDE.md index f41a2d9..b582293 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ For additional context about technologies to be used, project structure, shell commands, and other important information, read the current plan: -`specs/003-bench-container-discovery/plan.md`. +`specs/005-container-thin-client/plan.md`. # AgentTower Agent Context diff --git a/specs/005-container-thin-client/checklists/requirements.md b/specs/005-container-thin-client/checklists/requirements.md new file mode 100644 index 0000000..e51f292 --- /dev/null +++ b/specs/005-container-thin-client/checklists/requirements.md @@ -0,0 +1,42 @@ +# Specification Quality Checklist: Container-Local Thin Client Connectivity + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-06 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +Validation pass against spec.md (one iteration): + +- **Implementation details**: The spec references concrete artifacts the feature *touches* (e.g., `/proc/self/cgroup`, `$TMUX`, `/run/agenttower/agenttowerd.sock`, `AF_UNIX`). These are part of the user-facing contract for this feature (the bench-container interaction surface), not framework choices, so they remain in the spec rather than being deferred to plan.md. Spec stays language- and framework-neutral (no Python references, no class/function names, no SQLite mentions beyond reusing existing FEAT-001..004 schemas). +- **No `[NEEDS CLARIFICATION]` markers**: Three potential clarifications (default mounted socket path, identity-detection precedence, doctor exit-code convention) were resolved in-line with documented assumptions backed by the architecture doc and the FEAT-003 / FEAT-004 precedents. The most consequential — the default mounted socket path — explicitly resolves architecture.md §25's "open question" rather than perpetuating it. +- **Success criteria**: Every SC has a measurable threshold (time, count, byte-identical output, exit-code values, or an enumeration of fixture cases). No SC mentions implementation specifics like "Python", "argparse", or "SQLite". +- **Scope bounded**: FR-022 enumerates the closed set of out-of-scope items; the spec narrative repeats them in the User Scenarios and the Assumptions section. +- **Out-of-iteration risks**: None. The spec is internally consistent on closed-set tokens (status, sub-codes, signal sources) and mirrors FEAT-003 / FEAT-004's policies on sanitization (FR-021, FR-028) and audit (FR-029). + +- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`. diff --git a/specs/005-container-thin-client/checklists/security.md b/specs/005-container-thin-client/checklists/security.md new file mode 100644 index 0000000..3db29d5 --- /dev/null +++ b/specs/005-container-thin-client/checklists/security.md @@ -0,0 +1,145 @@ +# Security Checklist: Container-Local Thin Client Connectivity (FEAT-005) + +**Purpose**: Unit-test the *requirements writing* in spec.md, plan.md, research.md, data-model.md, contracts/cli.md, and contracts/socket-api.md for FEAT-005's security-critical surface area before tasks are generated. This is the first feature whose code path consumes container-side untrusted strings (`AGENTTOWER_SOCKET`, `AGENTTOWER_CONTAINER_ID`, `$TMUX`, `$TMUX_PANE`, `/proc/self/cgroup`, `/etc/hostname`, `$HOSTNAME`) and exposes them through a stable JSON contract; verifying the FR-002 absolute-path validator, the FR-021 sanitization bounds, the FR-024 stderr-leak guard, the FR-018 closed-set exit-code mapping, the FR-014 stable-key invariant, and the FR-005 / SC-007 host-context byte-parity guarantee directly determines implementation correctness. +**Created**: 2026-05-06 +**Walked end-to-end**: 2026-05-06 (T058 / post-implementation audit). All 99 items resolved. + +## Socket Path Resolution & Override Validation + +- [x] CHK001 Is the priority order for socket-path resolution (`env_override` → `mounted_default` → `host_default`) stated as a closed enumeration with no implicit fall-throughs? [Clarity, Spec FR-001, Research R-001] **Verified**: FR-001 lists (a)/(b)/(c) closed; R-001 numbers 1–3; plan summary mirrors. No fall-through wording. +- [x] CHK002 Are the four `AGENTTOWER_SOCKET` validity gates (non-empty after `.strip()`, absolute path, no NUL byte, points at an `S_ISSOCK` target after exactly one `os.readlink` follow) enumerated as a closed checklist rather than left to implementation discretion? [Completeness, Spec FR-002, Research R-001, Contracts §C-CLI-501] **Verified**: research R-001 + contracts/cli.md `AGENTTOWER_SOCKET validation` enumerate all four gates as a bulleted closed list; plan §Constraints echoes. Spec FR-002 narrative covers gates 1–3 explicitly and gate 4 via edge case 2 + the closed-set `` token `value is not a Unix socket`. Locked by T009 + T024. +- [x] CHK003 Is the literal stderr error message format `error: AGENTTOWER_SOCKET must be an absolute path to a Unix socket: ` specified verbatim with the closed-set `` tokens (`value is empty`, `value is not absolute`, `value contains NUL byte`, `value does not exist`, `value is not a Unix socket`) stated identically in spec FR-002, research R-001, and contracts/cli.md? [Closed-Set Token, Consistency, Spec FR-002, Research R-001, Contracts §C-CLI-501] **Verified**: contracts/cli.md `AGENTTOWER_SOCKET validation` enumerates all 5 tokens; spec FR-002 + research R-001 reproduce the literal message; locked by T009 + T024 with exact-spelling assertions. +- [x] CHK004 Is the requirement that the CLI MUST NOT silently fall back to a default when `AGENTTOWER_SOCKET` is set but invalid stated as a "MUST NOT" in spec FR-002 *and* echoed in plan §Constraints, research R-001, and contracts/cli.md, including the case where the in-container mounted default would otherwise resolve? [Default-Fallback Rejection, Consistency, Spec FR-002, Plan §Constraints, Research R-001, Contracts §C-CLI-501, Spec §Edge Cases item 1] **Verified**: "MUST NOT silently fall back" appears verbatim in FR-002, plan §Constraints, R-001, contracts/cli.md, and edge case 1. +- [x] CHK005 Is the symlink-follow depth bounded to exactly one `os.readlink` follow (no recursive `os.path.realpath`), and is the rationale stated so a future maintainer cannot widen it? [Edge Case, Research R-001] **Verified**: R-001 specifies "**exactly one** `os.readlink` follow" and rejects recursive realpath as YAGNI; plan §Constraints + contracts/cli.md echo. +- [x] CHK006 Are requirements consistent between Spec FR-002, Plan §Constraints, Research R-001, and Contracts §C-CLI-501 on whether broken symlinks, regular files, directories, and non-`S_ISSOCK` targets all fail the same way with the same `` token? [Consistency, Spec §Edge Cases item 2] **Verified**: contracts/cli.md `` mapping (`value does not exist` for missing/broken; `value is not a Unix socket` for regular file / dir / non-socket) is consistent with spec edge case 2 and plan §Constraints. +- [x] CHK007 Is the SC-002 50 ms pre-flight rejection budget for invalid `AGENTTOWER_SOCKET` stated as a numeric requirement (in milliseconds) rather than a "fast" hand-wave, and is the same threshold echoed in plan §Performance Goals with the requirement that no socket syscall fires before validation? [Measurability, Consistency, Spec SC-002, Plan §Performance Goals, Research R-001] **Verified**: SC-002, plan §Performance Goals, and R-001 all cite "50 ms" as the budget; "no socket syscall fires before validation" stated in plan §Performance Goals + R-001. Locked by T009. +- [x] CHK008 Is the `(path, source)` pair shape pinned consistently between Data-Model §3.1, FR-001, FR-019, and the doctor's `socket_resolved` row source token? [Consistency, Data-Model §3.1, Spec FR-019] **Verified**: data-model §3.1 `ResolvedSocket(path, source)` matches FR-001 + FR-019 + contracts/cli.md `socket_resolved` source-token table. +- [x] CHK009 Is the closed-set source-token enumeration `{env_override, mounted_default, host_default}` stated identically (same three tokens, same spelling, same order) in spec FR-001, research R-001, data-model §3.1, contracts/cli.md C-CLI-501 + C-CLI-502, and plan summary? [Closed-Set Token, Consistency, Spec FR-001, Research R-001, Data-Model §3.1, Contracts §C-CLI-501, §C-CLI-502] **Verified**: identical 3-token spelling and order across all 6 cited artifacts; locked by T009 + T018 + T032. +- [x] CHK010 Are requirements defined for the case where `AGENTTOWER_SOCKET` and the in-container mounted default both resolve to *different* reachable sockets owned by different daemons (override wins, no warning, source reports `env_override`)? [Edge Case, Spec §Edge Cases item 14] **Verified**: spec edge case 14 specifies override wins, no warning, source = `env_override`. Locked by T022. +- [x] CHK011 Are requirements explicit that the FEAT-002 `_connect_via_chdir` deep-cwd workaround for the 108-byte `sun_path` limit MUST NOT regress under FEAT-005's new resolver? [Edge Case, Plan §Constraints, Spec §Edge Cases item 12] **Verified**: spec edge case 12 + plan §Constraints state "MUST NOT regress"; locked by T060 (`test_feat005_deep_cwd_connect.py`, now exists; constructs > 108-byte socket path and verifies round-trip succeeds). +- [x] CHK012 Is the 4096-character cap on the raw `AGENTTOWER_SOCKET` value stated as a numeric requirement (not "bounded") in spec FR-015 / FR-021, research R-008, and plan §Constraints, with the same number `4096` in every artifact? [Measurability, Consistency, Spec FR-015, Spec FR-021, Research R-008, Plan §Constraints] **Verified**: "4096" appears in FR-015, FR-021, R-008 per-field-cap table, and plan §Constraints. Locked by T005 (`ENV_VALUE_CAP = 4096`). + +## Container-Runtime Detection + +- [x] CHK013 Is the closed-set detection signal pipeline (`/.dockerenv` ∨ `/run/.containerenv` ∨ `/proc/self/cgroup` line whose final segment matches `docker/` | `containerd/` | `kubepods/` | `lxc/`) enumerated rather than left to implementation discretion? [Completeness, Spec FR-004, Research R-003] **Verified**: FR-004 + R-003 enumerate the closed-set OR-pipeline. Locked by T012. +- [x] CHK014 Is the requirement that runtime detection MUST NOT depend on the daemon being reachable, and MUST NOT spawn a subprocess, stated explicitly? [Clarity, Spec FR-003, Spec FR-020] **Verified**: FR-003 ("MUST NOT depend on the daemon being reachable") + FR-020 (no subprocess inside the container). +- [x] CHK015 Is the closed-set cgroup-prefix enumeration `{docker/, containerd/, kubepods/, lxc/}` stated identically (same four tokens, same trailing slash) across spec FR-004, research R-003, and plan §Constraints? [Closed-Set Token, Consistency, Spec FR-004, Research R-003, Plan §Constraints] **Verified**: identical 4-token spelling with trailing slash across FR-004, R-003 regex `(docker|containerd|kubepods|lxc)/`, plan §Constraints. Locked by T012. +- [x] CHK016 Is the closed-set runtime-detection signal enumeration (`/.dockerenv`, `/run/.containerenv`, `/proc/self/cgroup`) stated identically across spec FR-004, research R-003, data-model §1, and plan §Constraints, including the order in which each is probed? [Closed-Set Token, Consistency, Spec FR-004, Research R-003, Data-Model §1, Plan §Constraints] **Verified**: same 3 paths in same order across FR-004, R-003 numbered 1–3, data-model §1 read table, plan §Constraints. Locked by T012. +- [x] CHK017 Are requirements consistent between FR-004, R-003, and Plan §Constraints about which sandboxes (Firejail, Bubblewrap, systemd-nspawn) fall to `host_context` rather than `container_context`? [Consistency, Plan §Constraints] **Verified**: R-003 ("treated as host context unless they set AGENTTOWER_SOCKET explicitly") + plan §Constraints + spec assumptions all consistent that unusual sandboxes fall to host_context. +- [x] CHK018 Is the requirement that the in-container default mounted path is *only* considered when runtime detection fires (not whenever the path happens to exist on the host) stated explicitly? [Clarity, Spec FR-003, Research R-002] **Verified**: FR-003 + R-002 ("the path is ignored even if it happens to exist") explicitly close this loophole. +- [x] CHK019 Are requirements defined for what happens when `/proc/self/cgroup` is unparseable (binary garbage, permission denied, empty) — treated as `no_signal` with the `IOError` swallowed, not propagated? [Edge Case, Research R-003, Spec §Edge Cases item 5] **Verified**: R-003 ("`IOError` is swallowed, never propagated") + spec edge case 5. Locked by T012 / T013. +- [x] CHK020 Is the file-open allowlist (`/proc/self/`, `/proc/1/`, `/etc/hostname`, `/run/.containerenv`, `/.dockerenv`, the resolved socket path, FEAT-001 host paths) stated identically in spec FR-020, plan §Constraints, and data-model §1, with no path appearing in one artifact but missing from another? [Closed-Set Token, Consistency, Spec FR-020, Plan §Constraints, Data-Model §1] **Verified**: same allowlist (with the `AGENTTOWER_TEST_PROC_ROOT` test-fixture root added in plan §Constraints) across FR-020, plan §Constraints, data-model §1. +- [x] CHK021 Is `os.environ.get("AGENTTOWER_TEST_PROC_ROOT", "/")` rooting required to apply uniformly to every detection read path *and to no other path* (no leakage into socket / FEAT-001 host paths)? [Clarity, Research R-011, Data-Model §1] **Verified**: R-011 ("All other reads (the resolved socket path, FEAT-001 host paths) ignore the override") + data-model §1 explicit rooting note. Locked by T012 + T055. +- [x] CHK022 Is the `RuntimeContext = HostContext | ContainerContext` tagged union shape pinned consistently between Data-Model §3.2 and the runtime_detect.py contract? [Consistency, Data-Model §3.2] **Verified**: data-model §3.2 defines the tagged union; T013 pins `detect(proc_root) -> RuntimeContext` returning `ContainerContext(detection_signals=...)` or `HostContext()`. +- [x] CHK023 Are requirements defined for the case where `/.dockerenv` exists as a directory or symlink rather than a regular file, and whether existence-only is the closed-set predicate? [Edge Case, Gap] **Verified**: R-003 specifies `os.path.exists("/.dockerenv")` — existence-only is the canonical predicate. A directory or symlink at that path triggers detection identically to the conventional regular-file marker. The R-003 rationale explicitly chooses existence-only over regular-file-only. + +## Container Identity Detection & Cross-Check + +- [x] CHK024 Is the four-step identity precedence chain (`AGENTTOWER_CONTAINER_ID` → `/proc/self/cgroup` → `/etc/hostname` → `$HOSTNAME`) stated as a total order with first-non-empty-wins semantics? [Clarity, Spec FR-006, Research R-004] **Verified**: FR-006 numbers (1)–(4); R-004 table with "first non-empty wins"; data-model §4.2 state-machine arrows. +- [x] CHK025 Is the closed-set classification enumeration `{unique_match, multi_match, no_match, no_candidate, host_context}` stated identically (same five tokens, same spelling) in Spec FR-007, Research R-004, Data-Model §3.3, and Contracts §C-CLI-501? [Closed-Set Token, Consistency, Spec FR-007, Research R-004, Data-Model §3.3, Contracts §C-CLI-501] **Verified**: same 5 tokens identical across all 4 artifacts. Locked by T030 + T044 + T053. +- [x] CHK026 Is the closed-set identity-signal token enumeration `{env, cgroup, hostname, hostname_env}` stated identically across spec FR-006, research R-004, and data-model §3.3 (including whether the env-var fallback signal is named `hostname_env` everywhere)? [Closed-Set Token, Consistency, Spec FR-006, Research R-004, Data-Model §3.3] **Verified**: R-004 + data-model §3.3 use `hostname_env` literal; FR-006 narrative maps cleanly to the same 4 signals. Locked by T014. +- [x] CHK027 Is the requirement that full-id equality is checked *before* 12-character short-id prefix match stated explicitly, and is the prefix-match length pinned at exactly 12? [Clarity, Spec FR-006, Research R-004] **Verified**: FR-006, R-004, plan §Constraints all state "full-id equality first, then 12-character short-id prefix match" with literal "12". +- [x] CHK028 Is the requirement that `multi_match` MUST NOT be auto-resolved (the CLI never picks one arbitrarily) stated as a "MUST NOT" in spec FR-007 and re-stated in research R-004 and plan §Constraints? [Default-Fallback Rejection, Consistency, Spec FR-007, Research R-004, Plan §Constraints, Spec §Edge Cases item 6] **Verified**: spec Clarifications 2026-05-06 ("the doctor MUST NOT pick one arbitrarily"), R-004 ("the CLI MUST NOT auto-pick"), plan §Constraints, edge case 6 all explicit. Locked by T044. +- [x] CHK029 Is the requirement that the in-container CLI MUST NOT widen the FEAT-003 container set (no `scan_containers` invocation from inside the container) stated as a "MUST NOT" in spec FR-008 and echoed verbatim in plan §Constraints and contracts/socket-api.md? [Default-Fallback Rejection, Consistency, Spec FR-008, Plan §Constraints, Contracts §C-API-501] **Verified**: FR-008 ("MUST NOT widen"), plan §Constraints, contracts/socket-api.md C-API-501 ("FEAT-005 reuses the existing socket methods exclusively"). Locked by T048 + T054. +- [x] CHK030 Are requirements defined for the `--network host` case where `/etc/hostname` equals the host hostname, ensuring identity falls through to `no_match` rather than mis-classifying the host as a container? [Edge Case, Research R-004, Spec §Edge Cases item 4] **Verified**: spec edge case 4 + R-004 ("cross-check returns `no_match` (no FEAT-003 row), not a false positive"). Locked by T024. +- [x] CHK031 Is the precondition for `host_context` classification (RuntimeContext is `HostContext` *AND* `AGENTTOWER_CONTAINER_ID` is unset) stated identically in Data-Model §3.3 and Research R-004? [Consistency, Data-Model §3.3] **Verified**: data-model §3.3, R-004, contracts/cli.md `container_identity` table all state the conjunction explicitly. Locked by T034 + T048. +- [x] CHK032 Are requirements explicit that `AGENTTOWER_CONTAINER_ID` is used *verbatim* as the candidate id (no parsing, no munging) and is subject to FR-021 sanitization before any output? [Clarity, Spec FR-006, Spec FR-021] **Verified**: FR-006 ("the value is used verbatim as the candidate id") + FR-021 untrusted-input list includes `AGENTTOWER_CONTAINER_ID`. Locked by T014 + T015. +- [x] CHK033 Is the `no_containers_known` outcome (round-trip succeeded but `list_containers` empty) specified consistently between Spec §Edge Cases item 11, Contracts §C-CLI-501 sub-codes, and the `actionable_message` contract? [Consistency, Spec §Edge Cases item 11] **Resolved by Clarifications 2026-05-06**: `no_containers_known` is NOT a sub-code; the empty-`list_containers` case surfaces as `details.daemon_container_set_empty=true` on the existing `no_candidate` / `no_match` outcome. FR-007 5-token closed set is not extended. Locked by T036 + T044 + T050. +- [x] CHK034 Is the contracts/cli.md emission of both `host_context` and `not_in_container` as sub-codes (with a "synonym; only one is emitted" note) reconciled with the spec FR-007 closed set, which lists only `host_context`? [Conflict, Spec FR-007, Contracts §container_identity] **Resolved by Clarifications 2026-05-06**: `host_context` is canonical; `not_in_container` is dead. The `not_in_container` synonym row was removed from `contracts/cli.md` §container_identity in the post-clarify refresh. Negative-locked by T030 + T053 (assert `not_in_container` is NEVER emitted) and by T048 (treats it as DEAD code). + +## Tmux Self-Identity Parsing + +- [x] CHK035 Is the parse rule for `$TMUX` (split on the first two commas into `(socket_path, server_pid, session_id)`) specified, including which fields are used in matching and which are advisory only? [Clarity, Spec FR-009, Research R-005] **Verified**: R-005 ("split on the first two commas... Only socket_path is used in the daemon cross-check") + FR-009 + data-model §3.4. Locked by T016. +- [x] CHK036 Is the `$TMUX_PANE` regex `^%[0-9]+$` stated as a literal regex (not "the `%N` shape") in spec FR-009 / FR-021, research R-005, and at least one contract? [Measurability, Consistency, Spec FR-009, Spec FR-021, Research R-005, Contracts §tmux_present] **Verified**: literal regex `^%[0-9]+$` appears in R-005, contracts/cli.md `tmux_present`, data-model §3.4 (`pane_id_valid: True iff tmux_pane_id matches ^%[0-9]+$`). FR-009 narrative uses `%N` form which maps cleanly to the literal regex. Locked by T016 + T017. +- [x] CHK037 Is the closed-set tmux classification enumeration `{pane_match, pane_unknown_to_daemon, pane_ambiguous, not_in_tmux, output_malformed}` stated identically across spec FR-010, research R-005, data-model §3.4, and contracts/cli.md (noting that `output_malformed` appears in research/data-model but spec FR-010 lists only four)? [Closed-Set Token, Consistency, Conflict, Spec FR-010, Research R-005, Data-Model §3.4, Contracts §tmux_pane_match] **Resolved by spec amendment 2026-05-06**: FR-010 was amended to enumerate the closed-set 5-token classification (`pane_match`, `pane_unknown_to_daemon`, `pane_ambiguous`, `not_in_tmux`, `output_malformed`) explicitly, with a note that `output_malformed` propagates from FR-009 / FR-021 parsing failure and is surfaced on `tmux_present` and `tmux_pane_match` without performing the daemon round-trip. The 5-token spelling is now identical across spec FR-010, research R-005, data-model §3.4, and contracts/cli.md. Locked by T016 (test asserts all 5 spellings). +- [x] CHK038 Are requirements defined for the case where `$TMUX` is set but its first comma-separated field (the tmux socket path) is unreadable from inside the container — report parsed values, skip cross-check, do not crash? [Edge Case, Research R-005, Spec §Edge Cases item 7] **Verified**: spec edge case 7 + R-005 (skip cross-check, classify as `pane_unknown_to_daemon` with "tmux socket not visible from container" actionable message). Locked by T035 (parametrize id `socket_path_unreadable_in_container`). +- [x] CHK039 Is the requirement that tmux self-detection MUST NOT spawn `tmux`, `id`, `cat`, or any other in-container subprocess stated explicitly across FR-011, FR-020, and Plan §Constraints? [Consistency, Spec FR-011, Spec FR-020] **Verified**: FR-011 ("MUST NOT spawn `tmux` or any other in-container subprocess"), FR-020 ("no `id`, no `tmux`, no `cat`, no `docker`"), plan §Constraints. Locked by T054. +- [x] CHK040 Is the composite-key shape for the cross-check (`tmux_socket_path` *AND* `tmux_pane_id`, optionally filtered by resolved container id) pinned, including why pane-id-only would be insufficient? [Clarity, Research R-005] **Verified**: R-005 alternatives section ("pane id only is insufficient (`%N` reuses across server restarts; FEAT-004 R-008)") + FR-010 ("look for a row whose `tmux_socket_path` and `tmux_pane_id` both match"). Locked by T045. +- [x] CHK041 Are requirements explicit that the cross-check MUST NOT call any new socket method beyond the existing FEAT-004 `list_panes`, and that the optional `params.container_id` filter shape is already supported by the FEAT-004 contract? [Clarity, Spec FR-010, Contracts §C-API-501] **Verified**: FR-010 ("MUST NOT call any new socket method beyond the existing FEAT-004 `list_panes`") + contracts/socket-api.md C-API-501 (`list_panes` with `params.container_id` filter is already supported). Locked by T054. +- [x] CHK042 Are requirements defined for the case where `$TMUX_PANE` is set to a value containing whitespace, NUL bytes, or other characters that would fail the `%N` regex (flag `output_malformed`, do not silently drop)? [Edge Case, Spec §Edge Cases item 9, Spec FR-021] **Verified**: spec edge case 9 ("MUST flag `output_malformed` rather than silently dropping the pane id") + FR-021. Locked by T016. + +## Untrusted Input Sanitization & Truncation + +- [x] CHK043 Are the byte classes that MUST be stripped from every untrusted input (NUL `\x00`, C0 `\x01`–`\x08`, `\x0b`–`\x1f`, `\x7f`) enumerated rather than referred to as "control bytes"? [Clarity, Research R-008] **Verified**: R-008 enumerates the byte ranges explicitly. Locked by T004 + T005 (test asserts each range). +- [x] CHK044 Is the substitution rule for embedded `\t` and `\n` in doctor row text (replace with single space so TSV stays one row per check) stated, and is it consistent between R-008, FR-013, and Contracts §C-CLI-501? [Consistency, Research R-008] **Verified**: R-008 ("Replaces every `\t` and `\n`... with a single space") + contracts/cli.md ("embedded `\t` and `\n` are replaced with single spaces"). FR-013 narrative covers via "sanitized of NUL bytes and terminal control bytes". Locked by T004 + T005. +- [x] CHK045 Are the per-field caps (4096 for untrusted env values and untrusted file contents, 2048 for doctor row `details` and `actionable_message`) stated as numeric requirements identically across spec FR-021, FR-028, research R-008, plan §Constraints, contracts/cli.md, and contracts/socket-api.md? [Measurability, Consistency, Spec FR-021, Spec FR-028, Research R-008, Plan §Constraints, Contracts §C-CLI-501, Contracts §C-API-503] **Verified**: 4096 / 2048 numerics appear identically across all 6 artifacts. Locked by T004 + T005 (`ENV_VALUE_CAP=4096`, `DETAILS_CAP=2048`, etc.). +- [x] CHK046 Is the multi-byte-UTF-8 truncation invariant ("truncation MUST NOT split a multi-byte UTF-8 character") stated as a requirement in spec FR-028, research R-008, and plan §Constraints, rather than left to implementation discretion? [Measurability, Spec FR-028, Research R-008, Plan §Constraints] **Verified**: FR-028 ("MUST NOT split a multi-byte UTF-8 character"), R-008 (character-aware Python str slicing), plan §Constraints ("multi-byte-safe `…` truncation"). Locked by T005. +- [x] CHK047 Is the appended truncation marker (`…`, U+2026) specified, and is it required to be a single character not a three-dot ASCII run? [Clarity, Spec FR-028, Research R-008] **Verified**: R-008 ("appends a single `…` (U+2026)") + T004 ("U+2026, single character — not three ASCII dots") + T005 lock. +- [x] CHK048 Is the closed list of untrusted inputs (`AGENTTOWER_SOCKET`, `AGENTTOWER_CONTAINER_ID`, `$TMUX`, `$TMUX_PANE`, `/proc/self/cgroup`, `/etc/hostname`, `$HOSTNAME`) enumerated exhaustively, and is each tagged with the cap that applies? [Completeness, Spec FR-021, Research R-008] **Verified**: FR-021 enumerates 6 inputs (env + files); FR-015 covers `AGENTTOWER_SOCKET`; R-008 per-field-cap table tags each with 4096 (env / file) or 2048 (doctor row). +- [x] CHK049 Are requirements explicit that no untrusted value is interpolated into a shell string anywhere in FEAT-005 (the value is a literal filesystem path / argv data only)? [Clarity, Spec FR-002, Spec FR-021] **Verified**: FR-002 ("the CLI uses the value as a literal filesystem path and never feeds it to a shell") + FR-021 ("never interpolated into a shell string") + plan §Constraints. +- [x] CHK050 Is the requirement that out-of-shape values surface as the closed-set `output_malformed` outcome on the relevant doctor check (rather than crashing the CLI) stated explicitly? [Clarity, Spec FR-021] **Verified**: FR-021 ("MUST surface as a closed-set `output_malformed` outcome on the relevant doctor check rather than crashing the CLI"). Locked by T016. + +## Doctor Output Hygiene (no-leak, JSON purity, stderr discipline) + +- [x] CHK051 Is the closed-set `socket_reachable` sub-code enumeration `{socket_missing, socket_not_unix, connection_refused, permission_denied, connect_timeout, protocol_error}` stated identically (same six tokens, same spelling) across spec FR-016, research R-009, plan §Constraints, contracts/cli.md, and contracts/socket-api.md? [Closed-Set Token, Consistency, Spec FR-016, Research R-009, Plan §Constraints, Contracts §C-CLI-501, Contracts §C-API-502] **Verified**: identical 6-token spelling across all 5 artifacts. Locked by T031 + T033. +- [x] CHK052 Is the no-leak invariant (raw `socket(2)` / `connect(2)` errno text MUST NOT appear in stderr, JSON, or log rows; `__cause__` chain not formatted into output; `OSError.strerror` and errno number not surfaced) stated explicitly? [Clarity, Spec FR-024, Contracts §C-API-503] **Verified**: FR-024 + R-009 No-leak policy + contracts/socket-api.md C-API-503 all explicit; `__cause__` non-formatting stated in R-009 + C-API-503. Locked by T033 + T056 (asserts no `Errno`, `strerror`, `Connection refused`, `ENOENT`, `EACCES` substrings leak). +- [x] CHK053 Is the "no incidental stderr lines when `--json` is set" invariant stated as a "MUST NOT" requirement (with the documented FR-002 pre-flight exception) in spec edge cases, plan §Constraints, and contracts/cli.md, rather than as a default behavior? [Default-Fallback Rejection, Consistency, Spec §Edge Cases item 15, Plan §Constraints, Contracts §`--json` and stderr discipline] **Verified**: spec edge case 15 + plan §Constraints + contracts/cli.md `--json and stderr discipline` all enforce it as MUST NOT with the FR-002 pre-flight exception explicit. Locked by T056 (now exists; asserts stderr empty under `--json` across healthy/down/no-mount paths). +- [x] CHK054 Is the JSON envelope shape (`summary` with closed fields `{exit_code, total, passed, warned, failed, info}` + `checks` keyed by closed-set check code) pinned consistently between Spec FR-014, Research R-007, Data-Model §3.6, and Contracts §C-CLI-501? [Consistency, Spec FR-014] **Verified**: identical envelope shape across all 4 artifacts (FR-014, R-007 worked example, data-model §3.6 dataclasses, contracts/cli.md JSON schema). Locked by T030 + T036. +- [x] CHK055 Is the requirement that the doctor writes nothing to disk (no SQLite writes, no JSONL appends, no log rotation, no file creation) stated as a hard invariant covering every code path? [Clarity, Spec FR-029, Plan §Constraints] **Verified**: FR-029 ("MUST write nothing to disk"), plan §Constraints. Locked by T037 (state_dir snapshot diff before/after). +- [x] CHK056 Are requirements explicit that the raw `AGENTTOWER_SOCKET` value is sanitized + bounded to 4096 chars before ever appearing in stderr or in the doctor row's `details`? [Clarity, Spec FR-015, Spec FR-024] **Verified**: FR-015 + FR-024 + contracts/cli.md `AGENTTOWER_SOCKET validation` ("sanitized of control bytes and bounded to 4096 chars before ever being printed"). Locked by T029. +- [x] CHK057 Is the requirement that every check's `details` and `actionable_message` are sanitized + bounded to 2048 chars at every output boundary (TSV stdout, JSON stdout, no other channel) stated explicitly? [Completeness, Spec FR-013, Spec FR-028] **Verified**: FR-013 + FR-028 + contracts/cli.md ("lines are bounded to 2048 chars"). Locked by T029 + T040. +- [x] CHK058 Are the closed-set `daemon_status` sub-codes `{schema_version_older, schema_version_newer, daemon_unavailable, daemon_error}` stated identically across spec FR-017, research R-010, and contracts/cli.md? [Closed-Set Token, Consistency, Spec FR-017, Research R-010, Contracts §daemon_status] **Verified**: identical 4-token spelling across FR-017 + R-010 table + contracts/cli.md `daemon_status` table. Locked by T031. + +## Doctor Exit-Code Mapping & Required-vs-Non-Required Discipline + +- [x] CHK059 Is the closed-set exit-code mapping `{0, 1, 2, 3, 4 (reserved), 5}` stated identically (same six tokens, same per-pattern attribution) across Spec FR-018, Research R-006, Plan §Constraints, and Contracts §C-CLI-501? [Closed-Set Token, Consistency, Spec FR-018, Research R-006, Plan §Constraints, Contracts §C-CLI-501] **Verified**: identical mapping across all 4 artifacts; per-pattern attribution matches. Locked by T031. +- [x] CHK060 Is the required-vs-non-required check partition (required: `socket_resolved`, `socket_reachable`, `daemon_status`; non-required: `container_identity`, `tmux_present`, `tmux_pane_match`) stated identically in research R-006, plan §Constraints, and contracts/cli.md, given the spec only describes the partition implicitly via FR-018? [Consistency, Gap, Spec FR-018, Research R-006, Contracts §C-CLI-501] **Verified**: R-006 + contracts/cli.md state the partition explicitly with the same 3+3 split; spec FR-018 implies it via the exit-class mapping. Locked by T031. +- [x] CHK061 Is the rule "an `info` outcome MUST NOT push the exit code above `0` on its own" stated explicitly as a requirement? [Clarity, Spec FR-012, Research R-006] **Verified**: FR-012 ("A check that produces `info` MUST NOT count toward a non-zero exit on its own") + R-006 ("info outcomes never push the exit code above 0 on their own"). Locked by T031. +- [x] CHK062 Are requirements explicit that `4` is reserved for internal CLI error per FEAT-002 and is *never produced deliberately* by FEAT-005 code? [Clarity, Spec FR-018, Contracts §C-CLI-501] **Verified**: FR-018 + R-006 + contracts/cli.md all state `4` is reserved per FEAT-002. Negative-locked by T031 (asserts `4` is never produced under any FEAT-005 control flow). +- [x] CHK063 Is the FR-027 invariant "every required check runs every invocation" reconciled with the data-model §6 statement that `list_containers` and `list_panes` round-trips are "skipped when prior checks made them moot"? Specifically, is it explicit whether "every check runs" means every check produces a `CheckResult` row (data-model §6 reading) or every check actually attempts its round-trip (literal FR-027 reading)? [Conflict, Ambiguity, Spec FR-027, Data-Model §6] **Resolved by Clarifications 2026-05-06**: "every check runs" = "every check produces a `CheckResult` row in the output." When an upstream gate has already failed, the dependent check skips its round-trip and emits `status=info` with sub-code `daemon_unavailable`. FR-027 was rewritten to make this explicit. Locked by T037, T039, T051. +- [x] CHK064 Are requirements defined for the case where `socket_reachable` fails: downstream checks (`daemon_status`, `container_identity`, `tmux_pane_match`) MUST still emit a `CheckResult` with `info` status and `daemon_unavailable` sub-code (not silently omitted)? [Edge Case, Data-Model §6, Contracts §C-CLI-501] **Resolved by Clarifications 2026-05-06**: yes — `daemon_unavailable` is the documented `info` sub-code on every dependent check (`daemon_status`, `container_identity`, `tmux_pane_match`) when an upstream gate fails. Locked by T033 + T037. +- [x] CHK065 Is the schema-version comparison policy (`==` → `pass`; `<` → `warn` `schema_version_older` exit 0; `>` → `fail` `schema_version_newer` exit 3) pinned consistently between Spec FR-017, Research R-010, and Contracts §C-CLI-501? [Consistency, Spec FR-017] **Verified**: identical comparison policy across FR-017 + R-010 table + contracts/cli.md `daemon_status` table. Locked by T031. +- [x] CHK066 Is the `MAX_SUPPORTED_SCHEMA_VERSION` value (currently `3`) stated as a numeric requirement in research R-010 and data-model §7, with an explicit "bumped each schema migration" rule rather than a "supported" hand-wave? [Measurability, Research R-010, Data-Model §7] **Verified**: R-010 ("currently `3`; bumped each schema migration") + data-model §7 + T003 (`MAX_SUPPORTED_SCHEMA_VERSION = 3` as single source of truth in `__init__.py`). +- [x] CHK067 Are requirements defined for the FEAT-001-not-initialized pre-flight on host context vs the in-container case where local FEAT-001 init MUST NOT be required (durable state lives on the host)? [Clarity, Spec FR-030] **Verified**: FR-030 ("host case only" pre-flight; "in-container case MUST NOT require local FEAT-001 init because the durable state lives on the host"). Locked by T041. + +## Backward Compatibility Surface (host parity, no schema, no socket method) + +- [x] CHK068 Is the requirement that the same `agenttower` binary on the host produces *byte-identical* stdout, stderr, exit codes, JSON shapes, and error messages to the FEAT-001..004 build for every existing subcommand stated as a hard invariant? [Clarity, Spec FR-005, SC-006, SC-007] **Verified**: FR-005 ("MUST behave bytewise identically"), SC-006, SC-007. Locked by T053 (reference-snapshot diff for every covered subcommand). +- [x] CHK069 Is the FR-022 "no new socket method" rule stated as a forward-compat requirement (with an explicit "deferred to a future feature" clause) in spec FR-022, plan summary, plan §Constraints, and contracts/socket-api.md C-API-501? [Forward-Compat Invariant, Consistency, Spec FR-022, Plan §Constraints, Contracts §C-API-501] **Verified**: FR-022 + plan summary + plan §Constraints + contracts/socket-api.md C-API-501 + R-012 ("Deferred to a future feature if the cost ever matters"). Locked by T054 (dispatch-table cardinality assertion). +- [x] CHK070 Is the FR-026 "no schema change, no new tables, no SQLite migration" rule stated as a forward-compat requirement (with an explicit "FEAT-006 concern" or equivalent deferred clause) in spec FR-026, plan §Storage, data-model §2, and data-model §7? [Forward-Compat Invariant, Consistency, Spec FR-026, Plan §Storage, Data-Model §2, Data-Model §7] **Verified**: FR-026 + plan §Storage + data-model §2 ("No change") + data-model §7 ("bumping it is a FEAT-006 concern and not a FEAT-005 concern"). +- [x] CHK071 Is the additive surface enumerated as a closed set: exactly two CLI surfaces (`config doctor` subcommand; one extra trailing line on `config paths`), exactly one new env override (`AGENTTOWER_SOCKET`), exactly one new identity env override (`AGENTTOWER_CONTAINER_ID`)? [Completeness, Spec FR-026, Plan §Scale/Scope] **Verified**: FR-026 + plan §Scale/Scope enumerate exactly: 2 CLI surfaces, 1 socket env override, 1 identity env override, 1 test seam. +- [x] CHK072 Are requirements explicit that the new `SOCKET_SOURCE=` line is *the last* line of `config paths` output, and that no preceding `KEY=value` line is altered byte-for-byte? [Clarity, Spec FR-019, Contracts §C-CLI-502] **Verified**: FR-019 ("MUST be the last line") + contracts/cli.md C-CLI-502 ("**exactly one new line** at the **end**" + "always the last line"). Locked by T018 + T053. +- [x] CHK073 Is the requirement that `agenttower config paths` MUST NOT introduce a `--json` mode in FEAT-005 stated explicitly to prevent scope creep? [Clarity, Spec FR-019] **Verified**: FR-019 ("MUST NOT introduce a `--json` mode for `config paths`") + contracts/cli.md C-CLI-502. Locked by T018. +- [x] CHK074 Is the requirement that `socket_api/client.py`'s existing `DaemonUnavailable` message text and repr remain byte-for-byte unchanged (so FEAT-002/003/004 callers' stderr does not regress) stated as a "MUST" in research R-009, contracts/socket-api.md C-API-502, and a SC-006/SC-007-traced test? [Default-Fallback Rejection, Consistency, Research R-009, Contracts §C-API-502] **Verified**: R-009 + contracts/socket-api.md C-API-502 ("`str(DaemonUnavailable(...))` returns the same text as before. The exception's repr is unchanged"). Locked by T007 + T062. +- [x] CHK075 Is the requirement that no FEAT-001..004 socket method is extended (no new error code, no new param, no new response field) stated explicitly across Contracts §C-API-501 and FR-022? [Consistency, Spec FR-022] **Verified**: contracts/socket-api.md C-API-501 ("No new method. No new error code. No new request shape. No new response shape.") + FR-022. +- [x] CHK076 Are requirements defined for the case where the daemon is *newer* schema than the CLI build supports (`schema_version_newer` `fail` with exit 3, not silent-fall-back)? [Edge Case, Spec §Edge Cases item 10, Spec FR-017] **Verified**: spec edge case 10 + FR-017 + FR-018 (exit 3 mapping) + R-010. Locked by T031 + T036 (parametrize id `schema_version_newer`). +- [x] CHK077 Is the requirement that FEAT-005 MUST NOT bind-mount-validate the host log directory beyond what `agenttower config doctor` reports stated explicitly to bound the diagnostic surface? [Clarity, Spec FR-022] **Verified**: FR-022 ("MUST NOT bind-mount validate the host log directory beyond what `agenttower config doctor` reports"). + +## Test Seam Discipline + +- [x] CHK078 Is the `AGENTTOWER_TEST_PROC_ROOT` env var documented as a test seam, with the requirement that the production binary's behavior is unaffected (or explicitly rejects the var) when not under the test harness? [Clarity, Spec FR-025, Research R-011] **Verified**: FR-025 + R-011. Locked by T055 (`test_feat005_proc_root_unset_in_prod.py`). +- [x] CHK079 Are requirements explicit that `AGENTTOWER_TEST_PROC_ROOT` MUST NOT be exposed via a CLI flag (no production surface contamination)? [Clarity, Research R-011] **Verified**: R-011 alternatives section ("CLI flag (`agenttower --test-proc-root `): rejected — leaks a test surface into the production CLI"). +- [x] CHK080 Is the closed list of paths the test seam covers (`/.dockerenv`, `/run/.containerenv`, `/proc/self/cgroup`, `/proc/1/cgroup`, `/etc/hostname`) enumerated, and is the rule that no other read path honors the override stated? [Completeness, Research R-011, Data-Model §1] **Verified**: R-011 enumerates the 5 paths + states "All other reads (the resolved socket path, FEAT-001 host paths) ignore the override"; data-model §1 reproduces. +- [x] CHK081 Is the requirement that the existing `AGENTTOWER_TEST_DOCKER_FAKE` (FEAT-003) and `AGENTTOWER_TEST_TMUX_FAKE` (FEAT-004) seams remain unchanged and are honored verbatim by the daemon process FEAT-005's tests spawn stated explicitly? [Clarity, Contracts §C-CLI-501] **Verified**: contracts/cli.md `CLI environment variables` section ("The existing `AGENTTOWER_TEST_DOCKER_FAKE` (FEAT-003) and `AGENTTOWER_TEST_TMUX_FAKE` (FEAT-004) seams are unchanged and honored verbatim by the daemon process FEAT-005's tests spawn"). +- [x] CHK082 Is "no real container, no real Docker daemon, no real tmux server invoked in the FEAT-005 test session" stated as a hard requirement with a positive-assertion test, not just a goal? [Measurability, Spec SC-009, Plan §Testing] **Verified**: SC-009 + plan §Testing. Locked by T054 (`test_feat005_no_real_container.py` — monkeypatches `subprocess.run` / `shutil.which` and asserts no `docker`/`tmux`/`runc`/`podman`/`id`/`cat` is called). +- [x] CHK083 Are requirements explicit that the FEAT-005 test session asserts no AF_INET / AF_INET6 socket is opened (parallel to FEAT-004's `test_feat004_no_network.py` invariant)? [Measurability, Plan §Testing, Spec FR-022] **Verified**: plan §Testing ("no AF_INET/AF_INET6 socket is opened by the daemon during any FEAT-005 dispatch path"). Locked by T054. +- [x] CHK084 Is the test-seam namespacing rule (`AGENTTOWER_TEST_*` prefix) stated as a requirement so future seams cannot leak into production env unintentionally? [Clarity, Spec FR-025, Research R-011] **Verified**: FR-025 ("namespaced (`AGENTTOWER_TEST_*`)") + R-011 ("`AGENTTOWER_TEST_*`-namespaced so a production environment cannot enable it accidentally"). +- [x] CHK085 Is the requirement that `AGENTTOWER_TEST_PROC_ROOT` be unset (or refused) in production binaries stated as a testable assertion (the named `test_feat005_proc_root_unset_in_prod.py`) in spec FR-025, plan §Testing / §Project Structure, and research R-011? [Measurability, Consistency, Spec FR-025, Plan §Testing, Research R-011] **Verified**: FR-025 + plan §Testing + plan §Project Structure + R-011 all name the test. Locked by T055. + +## Closed-Set Token Stability (FR-014 "added but never renamed") + +- [x] CHK086 Is the FR-014 "JSON keys MUST be added but never renamed" rule stated as a forward-compat *requirement* (with a "deferred to a future major version" clause) in spec FR-014, research R-007, plan §Constraints, and contracts/cli.md, rather than as a best-effort wish? [Forward-Compat Invariant, Consistency, Spec FR-014, Research R-007, Plan §Constraints, Contracts §`--json` output] **Verified**: FR-014 ("MUST be stable across releases; new tokens MAY be added but never renamed") + R-007 ("breaking-change rule") + plan §Constraints + contracts/cli.md ("renaming or repurposing existing ones is a breaking change deferred to a future major version"). +- [x] CHK087 Is the FR-014 "added but never renamed" rule applied consistently to *every* closed enumeration enumerated in this spec — `{env_override, mounted_default, host_default}`, `{unique_match, multi_match, no_match, no_candidate, host_context}`, `{pane_match, pane_unknown_to_daemon, pane_ambiguous, not_in_tmux, output_malformed}`, `{pass, warn, fail, info}`, `{socket_missing, socket_not_unix, connection_refused, permission_denied, connect_timeout, protocol_error}`, `{schema_version_older, schema_version_newer, daemon_unavailable, daemon_error}`, the exit-code set, and the six check codes — or only to top-level JSON keys? [Forward-Compat Invariant, Gap, Spec FR-014, Research R-007] **Verified**: FR-014's literal scope is JSON keys/tokens; research R-007 + contracts/cli.md extend the rule to "check codes or sub-codes"; the test-locking pattern (CHK088) operationalizes it across every closed enumeration. The discipline is maintained operationally even though FR-014 narrative is JSON-keys-scoped. +- [x] CHK088 For each closed-set token enumeration above, does at least one named test from plan §Project Structure (e.g., `test_doctor_json_contract.py`, `test_doctor_exit_codes.py`, `test_runtime_detect.py`, `test_container_identity.py`, `test_tmux_self_identity.py`) explicitly lock the token spelling so a typo cannot ship undetected? [Measurability, Plan §Project Structure, Spec FR-014] **Verified**: source-token set → T009/T018/T032; identity classification → T030/T044/T053; tmux classification → T030/T044; status tokens → T030/T031; socket_reachable sub-codes → T031/T033; daemon_status sub-codes → T031/T036; exit codes → T031; check codes → T030. +- [x] CHK089 Are the six doctor check codes (`socket_resolved`, `socket_reachable`, `daemon_status`, `container_identity`, `tmux_present`, `tmux_pane_match`) stated identically (same six tokens, same spelling, same order) in spec FR-012, research R-006, data-model §3.5, and contracts/cli.md? [Closed-Set Token, Consistency, Spec FR-012, Research R-006, Data-Model §3.5, Contracts §C-CLI-501] **Verified**: identical 6-token spelling and order across all 4 artifacts. Locked by T030 + T037 (asserts the exact set in the JSON envelope `checks` keys). +- [x] CHK090 Are the four doctor status tokens (`pass`, `warn`, `fail`, `info`) stated identically across spec FR-012, research R-007, data-model §3.5, and every contract that mentions them, with the rule that no fifth token may be silently introduced? [Closed-Set Token, Consistency, Spec FR-012, Research R-007, Data-Model §3.5, Contracts §C-CLI-501] **Verified**: identical 4-token spelling across FR-012 + FR-014 + R-007 + data-model §3.5 + contracts/cli.md. Locked by T030. + +## Success-Criterion Traceability + +- [x] CHK091 Does SC-001 (host vs in-container `status` parity, eight keys) trace to at least one named test in plan §Project Structure (`test_cli_in_container_status.py`), and is the eight-key enumeration listed durably in either spec US1 AS1 or a contract? [Traceability, Spec SC-001, Plan §Project Structure] **Verified**: T021 (`test_cli_in_container_status.py`) covers SC-001 + spec US1 AS1 lists the 8 keys (`alive`, `pid`, `start_time`, `uptime_seconds`, `socket_path`, `state_path`, `schema_version`, `daemon_version`). +- [x] CHK092 Does SC-002 (50 ms pre-flight rejection of invalid `AGENTTOWER_SOCKET`) trace to at least one named test (`test_socket_path_resolution.py` or equivalent) and is the 50 ms threshold asserted as a measured wall-clock check, not a comment? [Traceability, Measurability, Spec SC-002, Plan §Project Structure] **Verified**: T009 ("SC-002 50 ms wall-clock budget enforced via `time.perf_counter()` and `assert elapsed < 0.050` for each invalid case"). +- [x] CHK093 Does SC-003 (500 ms wall-clock for `agenttower config doctor` against a healthy daemon, all six checks) trace to `test_cli_config_doctor_short_circuit.py` (or equivalent) with the 500 ms threshold asserted, not implied? [Traceability, Measurability, Spec SC-003, Plan §Project Structure, Plan §Performance Goals] **Verified with traceability adjustment**: SC-003's 500 ms wall-clock budget is asserted in T032 (`test_cli_config_doctor_healthy.py`) via `time.perf_counter()` + `assert elapsed < 0.500`, NOT in T037 (`test_cli_config_doctor_short_circuit.py`) which is FR-027/FR-029-only. T037's docstring explicitly notes the relocation. Operationally locked; the CHK093 wording is a stale reference to the original T037 scope. +- [x] CHK094 Does SC-004 (daemon-down doctor still completes, no raw `socket(2)`/`connect(2)` errno text) trace to `test_cli_config_doctor_daemon_down.py` with both the "no early exit" and the "no raw errno" invariants asserted? [Traceability, Spec SC-004, Plan §Project Structure, Contracts §C-API-503] **Verified**: T033 (parametrized over `socket_missing`/`connection_refused`/`connect_timeout`/`permission_denied`; asserts no raw `[Errno N]` text) + T056 (asserts no `Errno`, `strerror`, `Connection refused`, `ENOENT`, `EACCES` substrings leak under `--json`). +- [x] CHK095 Does SC-005 (valid `--json` on every code path) trace to `test_cli_config_doctor_json.py` *and* explicitly enumerate the code paths covered (healthy, daemon-down, no-mount, no-tmux, unknown-container, ambiguous-pane)? [Traceability, Spec SC-005, Plan §Project Structure] **Verified**: T036 parametrizes 8 ids (`healthy`, `daemon_down`, `no_mount`, `no_tmux`, `unknown_container`, `ambiguous_pane`, `schema_version_newer`, `daemon_container_set_empty`) — superset of the 6 named in CHK095. +- [x] CHK096 Does SC-006 (existing FEAT-001..004 suites pass) trace to `test_feat005_backcompat.py` and is the "byte-identical stdout, stderr, exit code" assertion stated as a requirement, not a hope? [Traceability, Spec SC-006, Plan §Project Structure] **Verified**: T053 ("Re-run each command on the host and assert byte-identical stdout, stderr, exit codes, and `--json` shapes vs the FEAT-004 build"); T059 confirms FEAT-001..004 suites still pass on this branch. +- [x] CHK097 Does SC-007 (host-context byte-identical output for every existing subcommand) trace to `test_feat005_backcompat.py` with the explicit subcommand list (`status`, `list-containers`, `list-panes`, `scan --containers`, `scan --panes`, `ensure-daemon`, `stop-daemon`) named in plan §Project Structure or a contract? [Traceability, Spec SC-007, Plan §Project Structure] **Verified**: T053 names all 7 subcommands plus `agenttower config init` and `agenttower config paths`. +- [x] CHK098 Does SC-008 (every detection-signal fixture produces a documented classification outcome) trace to `test_container_identity.py` with at least one fixture per outcome `{unique_match, multi_match, no_match, no_candidate, host_context}` plus per-signal coverage (`/proc/self/cgroup` only, hostname only, env only, env+hostname, full-id, short-id-prefix)? [Traceability, Spec SC-008, Plan §Project Structure] **Verified**: T044 parametrizes the SC-008 30-cell matrix (5 outcomes × 6 signal-shapes); T046 covers the hostname-source fixture. T063 audits matrix completeness as a final gate. +- [x] CHK099 Does SC-009 (unit + integration coverage for every enumerated area) trace to a named test for each area (socket-path resolution, container-runtime detection, identity-signal parsing, daemon cross-check classification, tmux env parsing, doctor TSV/JSON rendering, per-check sanitization/truncation), and are at least three named edge-case integration tests cited from plan §Project Structure? [Traceability, Spec SC-009, Plan §Project Structure] **Verified**: unit coverage — T009 (resolution), T012 (detection), T014 (identity), T016 (tmux), T029 (TSV), T030 (JSON), T005 (sanitization). Edge-case integration tests — T024 (unsupported signals), T034 (host context), T035 (pane match edge cases), T037 (short-circuit / no-disk-write), T060 (deep cwd), T061 (concurrent doctor; pending). ≥ 3 cited. + +## Notes + +- Each item tests the *requirements writing*, not the implementation. Findings should be encoded as spec/plan/research/contract amendments before `/speckit.tasks` runs. +- Markers used: `[Gap]` = not currently specified; `[Ambiguity]`, `[Conflict]`, `[Edge Case]`, `[Coverage]`, `[Clarity]`, `[Completeness]`, `[Consistency]`, `[Measurability]`, `[Closed-Set Token]`, `[Forward-Compat Invariant]`, `[Default-Fallback Rejection]`, `[Traceability]`, `[Assumption]`. +- Spec section references use FR-### / SC-### / R-### / contract section numbers as they appear in the artifacts on this branch. +- This checklist intentionally excludes implementation-level checks (test runner, code review patterns, CI wiring) — those belong in a separate `quality.md` checklist if desired. +- After this checklist runs, the lead's judgment call is whether a second topic-specific checklist (e.g., `cli-contract`) is warranted before tasks are generated. Current recommendation in plan.md: **No** — FR-014 already pins the JSON contract; further checklist runs would be churn. diff --git a/specs/005-container-thin-client/contracts/cli.md b/specs/005-container-thin-client/contracts/cli.md new file mode 100644 index 0000000..55f3aef --- /dev/null +++ b/specs/005-container-thin-client/contracts/cli.md @@ -0,0 +1,335 @@ +# CLI Contracts: Container-Local Thin Client Connectivity + +**Branch**: `005-container-thin-client` | **Date**: 2026-05-06 + +This document is the authoritative contract for the two additive +CLI surfaces FEAT-005 introduces. It supplements `spec.md` +FR-013, FR-014, FR-015, FR-016, FR-017, FR-018, FR-019, and +FR-027. Anything here overrides informal CLI descriptions in +spec.md. + +FEAT-001 / FEAT-002 / FEAT-003 / FEAT-004 CLI surfaces are +unchanged byte-for-byte (FR-005, SC-006, SC-007). + +--- + +## C-CLI-501 — `agenttower config doctor` + +### Synopsis + +```text +agenttower config doctor [--json] +``` + +### Behavior + +Runs the closed-set checks +`socket_resolved → socket_reachable → daemon_status → +container_identity → tmux_present → tmux_pane_match` in fixed order +(FR-012). Every check emits exactly one `CheckResult` row on every +invocation (FR-027) — the doctor never aborts early and never omits +a check from the output. When an upstream gate fails (e.g., +`socket_reachable` fails before `daemon_status`), the dependent +check still emits its row but skips the actual socket round-trip; +the row carries `status=info` with sub-code `daemon_unavailable` +(see the `Per-check sub-codes` table below and Clarifications +2026-05-06 in spec.md). Writes nothing to disk (FR-029). + +Doctor itself is a pure read-only diagnostic; the only side effect +is the existing FEAT-002 `status` round-trip the host daemon +already records in its lifecycle log when the round-trip succeeds +(no new lifecycle log token is introduced). + +### Exit codes (FR-018) + +| Pattern | Exit code | +| ------- | --------- | +| Every required check is `pass` or `info` | `0` | +| Pre-flight failure (FEAT-001 not initialized on host context; malformed `AGENTTOWER_SOCKET`) | `1` | +| `socket_reachable` is `fail` with sub-code `socket_missing` / `connection_refused` / `connect_timeout` | `2` | +| `socket_reachable` is `pass` but `daemon_status` is `fail` (sub-codes `daemon_error` — FEAT-002 `DaemonError` envelope — or `schema_version_newer` — daemon ahead of CLI per R-010) | `3` | +| Internal CLI error (uncaught exception in CLI dispatch) | `4` (reserved per FEAT-002, never produced deliberately) | +| Round-trip ok and required checks pass, but at least one non-required check is `fail` | `5` | + +Required-for-non-degraded checks: `socket_resolved`, +`socket_reachable`, `daemon_status`. Non-required: +`container_identity`, `tmux_present`, `tmux_pane_match`. + +### Default output (FR-013) + +One TSV row per check, written to stdout, followed by a single +`summary` line: + +```text +\t\t +[indented actionable_message line(s) for non-pass rows] +... +summary\t\t/ checks passed +``` + +Every line is sanitized of NUL bytes and C0 control bytes; embedded +`\t` and `\n` are replaced with single spaces; lines are bounded to +2048 chars and truncated with a trailing `…` when needed (FR-013, +FR-021, FR-028). No incidental stderr lines when running in default +mode either; all CLI output is on stdout, except for the standard +FEAT-002 daemon-unavailable stderr line that the doctor itself does +not produce. + +`status ∈ {pass, warn, fail, info}`. The same canonical token set +appears in `--json` output (FR-014). + +#### Worked example — healthy + +```text +$ agenttower config doctor +socket_resolved pass /run/agenttower/agenttowerd.sock (env_override) +socket_reachable pass daemon_version=0.5.0 schema_version=3 +daemon_status pass schema_version=3 (cli supports 3) +container_identity pass unique_match: 1234abcd5678... (py-bench) +tmux_present pass $TMUX=/tmp/tmux-1000/default,12345,$0 +tmux_pane_match pass pane_match: %0 in py-bench:default:main:0.0 +summary 0 6/6 checks passed +``` + +#### Worked example — daemon down + +```text +$ agenttower config doctor +socket_resolved pass /run/agenttower/agenttowerd.sock (mounted_default) +socket_reachable fail socket_missing: /run/agenttower/agenttowerd.sock + try `agenttower ensure-daemon` from the host +daemon_status info daemon_unavailable +container_identity info daemon_unavailable: candidate=1234abcd5678... (cgroup) + run `agenttower scan --containers` from the host once the daemon is up +tmux_present pass $TMUX=/tmp/tmux-1000/default,12345,$0 +tmux_pane_match info daemon_unavailable +summary 2 1/6 checks passed +``` + +### `--json` output (FR-014) + +Exactly one canonical JSON object on stdout per invocation. No +incidental stderr lines (FR-014, edge case re. JSON purity). Schema: + +```json +{ + "summary": { + "exit_code": , + "total": , + "passed": , + "warned": , + "failed": , + "info": + }, + "checks": { + "": { + "status": "", + "source": "", + "details": "", + "sub_code": "", + "actionable_message": "" + } + } +} +``` + +`checks` is keyed by closed-set check code (`socket_resolved`, +`socket_reachable`, `daemon_status`, `container_identity`, +`tmux_present`, `tmux_pane_match`); the keys and tokens are stable +across releases. New check codes or sub-codes MAY be added; renaming +or repurposing existing ones is a breaking change deferred to a +future major version. + +### Per-check sub-codes + +#### `socket_resolved` + +| Source token | Status | Sub-code | When | +| ------------ | ------ | -------- | ---- | +| `env_override` | `pass` | (none) | `AGENTTOWER_SOCKET` set, valid, points at a Unix socket | +| `mounted_default` | `pass` | (none) | runtime context is container AND mounted-default exists as a Unix socket | +| `host_default` | `pass` | (none) | runtime context is host (or mounted-default missing) | +| (any) | `fail` | `path_invalid` | `AGENTTOWER_SOCKET` set but malformed (relative, NUL, empty); pre-flight exit `1` | +| (any) | `fail` | `not_a_socket` | resolved path exists but is not `S_ISSOCK` (regular file, dir, broken symlink) | + +#### `socket_reachable` (FR-016) + +| Sub-code | Underlying signal | +| -------- | ----------------- | +| `socket_missing` | `FileNotFoundError` from `_connect_via_chdir` | +| `socket_not_unix` | Pre-flight `S_ISSOCK` check fails | +| `connection_refused` | `ConnectionRefusedError` from `_connect_via_chdir` | +| `permission_denied` | `OSError(EACCES)` | +| `connect_timeout` | `TimeoutError` / `socket.timeout` | +| `protocol_error` | `UnicodeDecodeError`, `json.JSONDecodeError`, malformed envelope, non-dict result | + +Raw `socket(2)` / `connect(2)` errno text MUST NOT leak to stderr +or `--json` (FR-024). The CLI maps the underlying exception to a +sub-code and a one-line bounded actionable message; the wrapped +exception's `__cause__` is not formatted into output. + +#### `daemon_status` (FR-017) + +| Sub-code | Status | When | +| -------- | ------ | ---- | +| (none) | `pass` | `daemon.schema_version == cli.MAX_SUPPORTED_SCHEMA_VERSION` | +| `schema_version_older` | `warn` | `daemon.schema_version < cli.MAX_SUPPORTED_SCHEMA_VERSION` (forward-compatible CLI keeps working) | +| `schema_version_newer` | `fail` | `daemon.schema_version > cli.MAX_SUPPORTED_SCHEMA_VERSION` (CLI cannot serve safely; update CLI build) | +| `daemon_unavailable` | `info` | `socket_reachable` failed (round-trip never happened) | +| `daemon_error` | `fail` | round-trip succeeded but daemon returned a structured error (FEAT-002 `DaemonError`) | + +#### `container_identity` (FR-006, FR-007) + +The closed sub-code set is exactly **five** classification outcomes +plus two transversal states (`output_malformed`, +`daemon_unavailable`). The synonym `not_in_container` is **not** a +sub-code; only `host_context` is emitted (resolved by Clarifications +2026-05-06 in spec.md). The empty-`list_containers` case from +spec edge case 11 is **not** its own sub-code; it surfaces as a +structured `details.daemon_container_set_empty = true` qualifier +attached to the existing `no_candidate` / `no_match` outcome. + +| Sub-code | Status | When | +| -------- | ------ | ---- | +| `unique_match` | `pass` | exactly one `list_containers` row matches the candidate | +| `host_context` | `info` | runtime context is host AND `AGENTTOWER_CONTAINER_ID` is unset | +| `multi_match` | `fail` | more than one `list_containers` row matches the candidate prefix; OR `/proc/self/cgroup` has multiple matching lines yielding *distinct* container ids (per FR-006 cgroup multi-line rule); when the latter, the observed candidate ids surface in `details.cgroup_candidates` (array of strings) | +| `no_match` | `fail` | candidate produced but no row matches; actionable message advises running `agenttower scan --containers` from the host. When `list_containers` returned an empty result, also set `details.daemon_container_set_empty = true` (spec edge case 11) | +| `no_candidate` | `fail` | every detection signal returned empty inside a container context; actionable message lists tried signals. When `list_containers` returned an empty result, also set `details.daemon_container_set_empty = true` (spec edge case 11) | +| `output_malformed` | `fail` | candidate value contains data that fails sanitization shape (e.g., NUL byte in `AGENTTOWER_CONTAINER_ID`) | +| `daemon_unavailable` | `info` | `socket_reachable` failed; round-trip skipped (FR-027 + Clarifications 2026-05-06) | + +#### `tmux_present` (FR-009) + +| Sub-code | Status | When | +| -------- | ------ | ---- | +| (none) | `pass` | `$TMUX` parsed cleanly into `(socket_path, server_pid, session_id)` and `$TMUX_PANE` matches `^%[0-9]+$` | +| `not_in_tmux` | `info` | `$TMUX` is unset | +| `output_malformed` | `fail` | `$TMUX` set but not parseable, OR `$TMUX_PANE` fails the `%N` regex | + +#### `tmux_pane_match` (FR-010) + +| Sub-code | Status | When | +| -------- | ------ | ---- | +| `pane_match` | `pass` | exactly one `list_panes` row has matching `(tmux_socket_path, tmux_pane_id)` | +| `pane_unknown_to_daemon` | `fail` | `$TMUX`/`$TMUX_PANE` parse cleanly but no `list_panes` row matches; actionable message advises `agenttower scan --panes` from the host | +| `pane_ambiguous` | `fail` | more than one `list_panes` row matches | +| `not_in_tmux` | `info` | propagated from `tmux_present` | +| `daemon_unavailable` | `info` | `socket_reachable` failed; cross-check skipped | + +### `AGENTTOWER_SOCKET` validation (FR-002) + +When set, the value MUST be: + +- non-empty (after `.strip()`), +- absolute (`os.path.isabs(value)` is true), +- free of NUL bytes, +- pointing at a path whose target (after **exactly one** + `os.readlink` follow) satisfies `stat.S_ISSOCK(st_mode)`. + +Invalid values cause the CLI to exit `1` with the literal stderr +message: + +```text +error: AGENTTOWER_SOCKET must be an absolute path to a Unix socket: +``` + +`` is one of: `value is empty`, `value is not absolute`, +`value contains NUL byte`, `value does not exist`, +`value is not a Unix socket`. The CLI MUST NOT silently fall back +to a default when the override is set but invalid (FR-002). + +The raw `AGENTTOWER_SOCKET` value is sanitized of control bytes and +bounded to 4096 chars before ever being printed in stderr or in the +doctor row's `details` field (FR-015, FR-021, FR-024). + +### `--json` and stderr discipline + +When `--json` is set, every check's output MUST stay inside the +JSON payload (FR-014, edge case re. JSON purity). The CLI MUST NOT +emit incidental stderr lines (e.g., warnings, deprecation notices). +Pre-flight failures (FR-002) before JSON parsing still print the +plaintext `error: ...` line on stderr and exit `1`; that pre-flight +predates `--json` parsing and is the single documented exception. + +--- + +## C-CLI-502 — `agenttower config paths` (extended) + +### Synopsis + +```text +agenttower config paths +``` + +(Unchanged from FEAT-001.) + +### Behavior + +The existing `KEY=value` line shape is unchanged byte-for-byte +(FR-019, FR-026, SC-007). FEAT-005 adds **exactly one new line** at +the **end** of the output: + +```text +SOCKET_SOURCE= +``` + +The token comes from the `ResolvedSocket.source` of the current CLI +invocation (R-001). FEAT-005 MUST NOT alter any preceding line and +MUST NOT introduce a `--json` mode for `config paths` (FR-019). + +#### Worked example — host context + +```text +$ agenttower config paths +CONFIG_FILE=/home/brett/.config/opensoft/agenttower/config.toml +STATE_DB=/home/brett/.local/state/opensoft/agenttower/agenttower.sqlite3 +EVENTS_FILE=/home/brett/.local/state/opensoft/agenttower/events.jsonl +LOGS_DIR=/home/brett/.local/state/opensoft/agenttower/logs +SOCKET=/home/brett/.local/state/opensoft/agenttower/agenttowerd.sock +CACHE_DIR=/home/brett/.cache/opensoft/agenttower +SOCKET_SOURCE=host_default +``` + +(The first six lines are byte-identical to the FEAT-001 build's +output for the same `$HOME` — same key order, same casing, same +trailing-newline behavior — only `SOCKET_SOURCE=...` is new and it +is always the last line.) + +#### Worked example — container context with override + +```text +$ AGENTTOWER_SOCKET=/run/agenttower/agenttowerd.sock agenttower config paths +CONFIG_FILE=/home/brett/.config/opensoft/agenttower/config.toml +STATE_DB=/home/brett/.local/state/opensoft/agenttower/agenttower.sqlite3 +EVENTS_FILE=/home/brett/.local/state/opensoft/agenttower/events.jsonl +LOGS_DIR=/home/brett/.local/state/opensoft/agenttower/logs +SOCKET=/run/agenttower/agenttowerd.sock +CACHE_DIR=/home/brett/.cache/opensoft/agenttower +SOCKET_SOURCE=env_override +``` + +The `SOCKET=` line reflects the resolved value from R-001 (the +existing FEAT-001 line shape carries the `ResolvedSocket.path`); +the new `SOCKET_SOURCE=` line carries the `ResolvedSocket.source` +token. Both are produced by the same resolver invocation. The +six existing `KEY=value` lines are emitted in the order +`Paths` dataclass fields are declared in `src/agenttower/paths.py` +(`config_file`, `state_db`, `events_file`, `logs_dir`, `socket`, +`cache_dir`); FEAT-005 MUST NOT change this ordering or the +dataclass field count. + +--- + +## CLI environment variables introduced or extended by FEAT-005 + +| Variable | When honored | Effect | +| -------- | ------------ | ------ | +| `AGENTTOWER_SOCKET` | every CLI invocation | overrides the resolved socket path; validated per FR-002 (R-001) | +| `AGENTTOWER_CONTAINER_ID` | container-context CLI invocations | overrides container identity detection; used verbatim as the candidate (R-004) | +| `AGENTTOWER_TEST_PROC_ROOT` | tests only; production binary asserts unset | substitutes a fake root for `/.dockerenv`, `/run/.containerenv`, `/proc/self/cgroup`, `/proc/1/cgroup`, `/etc/hostname` (R-011, FR-025) | + +FEAT-005 introduces no other env var. The existing +`AGENTTOWER_TEST_DOCKER_FAKE` (FEAT-003) and +`AGENTTOWER_TEST_TMUX_FAKE` (FEAT-004) seams are unchanged and +honored verbatim by the daemon process FEAT-005's tests spawn. diff --git a/specs/005-container-thin-client/contracts/socket-api.md b/specs/005-container-thin-client/contracts/socket-api.md new file mode 100644 index 0000000..50d98f1 --- /dev/null +++ b/specs/005-container-thin-client/contracts/socket-api.md @@ -0,0 +1,142 @@ +# Socket API Contract: Container-Local Thin Client Connectivity + +**Branch**: `005-container-thin-client` | **Date**: 2026-05-06 + +This document is short by design. **FEAT-005 introduces no new +socket method, no new error code, no new request envelope, no new +response envelope, and no new dispatch entry.** It is recorded +explicitly so a future reviewer cannot reopen the question without +amending the spec. + +--- + +## C-API-501 — Reused FEAT-002 / FEAT-003 / FEAT-004 methods (no extension) + +### What FEAT-005 calls + +The doctor's three round-trips reuse existing methods exactly as +defined by FEAT-002 / FEAT-003 / FEAT-004: + +| # | Method | Defined by | Used by FEAT-005 for | +| - | ------ | ---------- | -------------------- | +| 1 | `status` | FEAT-002 | `socket_reachable` (proves the round-trip works) AND `daemon_status` (echoes `daemon_version` + `schema_version`) | +| 2 | `list_containers` | FEAT-003 | `container_identity` cross-check (full-id + 12-char short-id prefix match) | +| 3 | `list_panes` | FEAT-004 | `tmux_pane_match` cross-check (filter by resolved container id when known) | + +`list_panes` is called with `params.container_id` set to the full +id from `IdentityResolution.matched_id` when the cross-check +classified as `unique_match`; otherwise it is called with no +filter. Both shapes are already supported by the FEAT-004 method +contract. + +### What FEAT-005 does NOT add + +- **No new method.** `socket_api/methods.py` is unchanged. The + daemon dispatch table gains no entry. +- **No new error code.** `socket_api/errors.py` is unchanged. The + doctor maps existing FEAT-002 client exceptions + (`DaemonUnavailable`, `DaemonError`) to its own closed-set + per-check sub-codes (FR-016, R-009); the daemon-side error code + set is untouched. +- **No new request shape.** Every doctor call uses the existing + newline-delimited JSON request envelope: + ```json + {"method": "", "params": {...}?} + ``` +- **No new response shape.** Every doctor call consumes the existing + newline-delimited JSON response envelope: + ```json + {"ok": true, "result": {...}} + {"ok": false, "error": {"code": "...", "message": "..."}} + ``` +- **No new authorization tier.** The mounted-default socket file + inherits the FEAT-002 socket-file authorization (`0600`, host + user only) verbatim from the host file the bind-mount targets. +- **No new mutex.** None of the three methods FEAT-005 calls + acquires a scan mutex on the daemon side; the existing FEAT-003 / + FEAT-004 read methods are read-only. + +### Pinned FRs + +- **FR-022**: forbids any new socket method, in-container daemon, + or relay. +- **FR-026**: forbids any change to FEAT-001 / FEAT-002 / FEAT-003 + / FEAT-004 socket envelopes or schemas. +- **FR-029**: forbids any disk write during `agenttower config + doctor`; the three round-trips above are read-only and produce + no SQLite or JSONL writes on either side. + +--- + +## C-API-502 — Client extension (additive only) + +### What FEAT-005 changes in `socket_api/client.py` + +`socket_api/client.py` is extended **additively only** with one +attribute on the existing `DaemonUnavailable` exception: + +```python +class DaemonUnavailable(RuntimeError): + kind: Literal[ + "socket_missing", + "socket_not_unix", + "connection_refused", + "permission_denied", + "connect_timeout", + "protocol_error", + ] + # existing init signature unchanged +``` + +The doctor's `socket_reachable` check catches `DaemonUnavailable` +and dispatches on `.kind` to map to the FR-016 closed-set sub-code +without parsing the exception's message string. + +**Backward-compat invariants** (FR-026): + +- `str(DaemonUnavailable(...))` returns the same text as before. +- The exception's repr is unchanged. +- Every existing FEAT-002 / FEAT-003 / FEAT-004 caller of + `send_request(...)` continues to work without modification; the + new attribute is set on the exception before it is raised but is + ignored by callers that do not look at it. +- No new exception class is introduced. `DaemonError` is unchanged. + +The mapping from underlying syscall / parsing failure to `.kind`: + +| Underlying signal in `_connect_via_chdir` / `_recv_line` / decode | `.kind` | +| ------------------------------------------------------------------ | ------- | +| `FileNotFoundError` | `socket_missing` | +| (pre-flight `S_ISSOCK` check fails, raised by `socket_resolve.py` before `send_request`) | `socket_not_unix` | +| `ConnectionRefusedError` | `connection_refused` | +| `OSError` with `errno.EACCES` | `permission_denied` | +| `TimeoutError` / `socket.timeout` | `connect_timeout` | +| `OSError` (other) | `connect_timeout` (only when wrapping a generic connect/read I/O failure; the doctor's actionable_message echoes the bounded message) | +| `UnicodeDecodeError` / `json.JSONDecodeError` / malformed envelope / non-dict result | `protocol_error` | + +`socket_not_unix` is set by FEAT-005's pre-flight (in +`socket_resolve.py`) before any connect attempt; the existing +`client.py` connect path never produces it. + +--- + +## C-API-503 — No-leak invariant (FR-024) + +Raw `socket(2)` / `connect(2)` errno text MUST NOT appear in any +FEAT-005 stderr line, JSON payload, or log row. The CLI catches +`DaemonUnavailable` once, reads `.kind`, looks up the closed-set +sub-code, and emits exactly: + +- a sub-code token (closed set), +- a one-line bounded `actionable_message` (sanitized + ≤ 2048 + chars; R-008), +- the `details` field (sanitized + ≤ 2048 chars; R-008). + +The wrapped exception's `__cause__` chain is not formatted into +output. The bounded message MAY include classification context +(e.g., "socket file does not exist") but MUST NOT include the raw +errno number, the raw `OSError.strerror`, or any path beyond the +already-sanitized resolved socket path. + +This invariant is verified by `tests/integration/test_cli_config_doctor_daemon_down.py` +and `tests/unit/test_doctor_render.py` (FR-024, SC-004). diff --git a/specs/005-container-thin-client/data-model.md b/specs/005-container-thin-client/data-model.md new file mode 100644 index 0000000..19f00ea --- /dev/null +++ b/specs/005-container-thin-client/data-model.md @@ -0,0 +1,348 @@ +# Phase 1 Data Model: Container-Local Thin Client Connectivity + +**Branch**: `005-container-thin-client` | **Date**: 2026-05-06 + +This document is the canonical reference for FEAT-005 entities and +data flow. Anything here overrides the informal entity descriptions +in spec.md. FEAT-005 introduces **no SQLite schema change**, **no +new tables**, **no new files on disk**, and **no new socket method**; +every entity below is in-memory only and lives for the duration of +one CLI invocation. + +--- + +## 1. Filesystem footprint + +FEAT-005 adds **no new files**. Three existing FEAT-001 / FEAT-002 / +FEAT-003 / FEAT-004 paths gain new *read* behavior; nothing is +written. + +| Path | Read by FEAT-005 | Written by FEAT-005 | +| --------------------------------------------------- | ---------------- | ------------------- | +| `/.dockerenv` | yes (existence) | no | +| `/run/.containerenv` | yes (existence) | no | +| `/proc/self/cgroup` | yes (line scan) | no | +| `/proc/1/cgroup` | yes (defensive line scan) | no | +| `/etc/hostname` | yes (single read) | no | +| `` (env / mounted-default / host-default) | yes (`AF_UNIX` connect; FR-016 round-trip) | no | +| `.config_file` / `.state_db` etc. | yes (FEAT-001 paths surface only; through existing `agenttower config paths`) | no | + +In-container reads are rooted at +`os.environ.get("AGENTTOWER_TEST_PROC_ROOT", "/")` so test fixtures +substitute a fake root without touching the real filesystem +(R-011). + +No SQLite reads at all in FEAT-005 code. The daemon's SQLite reads +for `list_containers` / `list_panes` happen on the daemon side and +are reused as-is (FR-026). + +--- + +## 2. SQLite schema + +**No change.** `CURRENT_SCHEMA_VERSION` stays at `3` (the value +FEAT-004 set). FEAT-005 introduces no migration; the daemon does +not write any new row in any table on a `config doctor` invocation +(FR-029). + +The `daemon_status` doctor row reports `schema_version` from the +existing FEAT-002 `status` payload (R-010); no FEAT-005 code opens +the SQLite database directly. + +--- + +## 3. Domain entities + +All entities below are in-memory dataclasses; no persistence. + +### 3.1 `ResolvedSocket` (output of R-001) + +```python +@dataclass(frozen=True) +class ResolvedSocket: + path: Path # absolute Unix-socket path + source: Literal["env_override", "mounted_default", "host_default"] +``` + +Every CLI command that opens the socket calls +`resolve_socket_path(env, host_paths) -> ResolvedSocket` once at +startup. The dataclass is consumed by `socket_api/client.send_request` +(via the `socket_path` parameter), by `agenttower config paths` (the +new `SOCKET_SOURCE=` line; FR-019), and by the doctor's +`socket_resolved` check (FR-015). + +### 3.2 `RuntimeContext` (output of R-003) + +```python +RuntimeContext = HostContext | ContainerContext + +@dataclass(frozen=True) +class HostContext: + pass + +@dataclass(frozen=True) +class ContainerContext: + detection_signals: tuple[str, ...] # subset of {"dockerenv", "containerenv", "cgroup"} +``` + +Produced by `runtime_detect.detect(proc_root) -> RuntimeContext`. Drives +whether the in-container default mounted path is considered (FR-003) +and whether the doctor's container check classifies as `host_context` +when no candidate fires. + +### 3.3 `IdentityResolution` (output of R-004) + +```python +@dataclass(frozen=True) +class IdentityCandidate: + candidate: str # raw candidate id (sanitized) + signal: Literal["env", "cgroup", "hostname", "hostname_env"] + +@dataclass(frozen=True) +class IdentityResolution: + candidate: IdentityCandidate | None # None when every signal returned empty + classification: Literal["unique_match", "multi_match", "no_match", "no_candidate", "host_context"] + matched_id: str | None # full container id from list_containers when classification == "unique_match" + matched_name: str | None # container name from list_containers when classification == "unique_match" + multi_match_ids: tuple[str, ...] # both/all matching ids when classification == "multi_match" + cgroup_candidates: tuple[str, ...] # populated only when multi_match was caused by /proc/self/cgroup yielding distinct trailing identifiers across matching lines (FR-006 multi-line rule, Clarifications 2026-05-06); empty tuple otherwise. Surfaced in JSON as details.cgroup_candidates. + daemon_container_set_empty: bool # True when the daemon's list_containers reply was empty (spec edge case 11). Surfaced in JSON as details.daemon_container_set_empty on no_candidate / no_match rows; False when classification == "unique_match" or "multi_match". +``` + +`classification == "host_context"` only when both: +- `RuntimeContext` is `HostContext`, AND +- `AGENTTOWER_CONTAINER_ID` is unset. + +Otherwise `host_context` is impossible — the resolution falls +through to `no_match` or `no_candidate`. + +The `no_containers_known` token referenced in spec edge case 11 is +**not** a sub-code; the empty-`list_containers` case is signalled +by `daemon_container_set_empty=True` plus the existing +`no_candidate` / `no_match` classification (per Clarifications +2026-05-06 in spec.md). The closed FR-007 5-token set is not +extended. + +### 3.4 `TmuxIdentity` (output of R-005) + +```python +@dataclass(frozen=True) +class TmuxIdentity: + in_tmux: bool # False when $TMUX is unset + tmux_socket_path: str | None # parsed first comma field of $TMUX + server_pid: str | None # parsed second comma field (raw, not used in match) + session_id: str | None # parsed third comma field (raw, not used in match) + tmux_pane_id: str | None # raw $TMUX_PANE + pane_id_valid: bool # True iff tmux_pane_id matches ^%[0-9]+$ + classification: Literal["pane_match", "pane_unknown_to_daemon", "pane_ambiguous", "not_in_tmux", "output_malformed"] + matched_pane: MatchedPane | None # populated only on "pane_match" + ambiguous_panes: tuple[MatchedPane, ...] # populated only on "pane_ambiguous" + +@dataclass(frozen=True) +class MatchedPane: + container_id: str + tmux_socket_path: str + tmux_session_name: str + tmux_window_index: int + tmux_pane_index: int + tmux_pane_id: str +``` + +`output_malformed` fires when `$TMUX` is set but unparseable, or +when `$TMUX_PANE` fails the `^%[0-9]+$` regex (FR-021). + +### 3.5 `CheckResult` and `DoctorReport` + +```python +CheckCode = Literal[ + "socket_resolved", + "socket_reachable", + "daemon_status", + "container_identity", + "tmux_present", + "tmux_pane_match", +] + +CheckStatus = Literal["pass", "warn", "fail", "info"] + +@dataclass(frozen=True) +class CheckResult: + code: CheckCode + status: CheckStatus + source: str | None # closed-set per check (e.g., "env_override", "round_trip", "schema_check", "cgroup", "hostname", "list_panes") + details: str # sanitized + bounded to 2048 chars + actionable_message: str | None # sanitized + bounded to 2048 chars; only populated when status != "pass" + sub_code: str | None # closed-set per check (e.g., FR-016 socket_reachable sub-codes; FR-007 identity outcomes; FR-010 tmux outcomes; FR-017 schema sub-codes) + +@dataclass(frozen=True) +class DoctorReport: + checks: tuple[CheckResult, ...] # always exactly 6 entries, in FR-012 order + exit_code: Literal[0, 1, 2, 3, 4, 5] # computed from checks per R-006 +``` + +`source` is required for `pass` rows (it documents *how* the check +passed) and optional for non-pass rows. `sub_code` is `None` on +`pass`/`info` and is one of the closed-set sub-codes from R-006 / +R-009 / R-010 / FR-007 / FR-010 on `warn`/`fail`. + +### 3.6 `DoctorJSONEnvelope` (output of `--json`) + +```python +@dataclass(frozen=True) +class DoctorJSONSummary: + exit_code: int + total: int + passed: int + warned: int + failed: int + info: int + +@dataclass(frozen=True) +class DoctorJSONCheck: + status: CheckStatus + source: str | None + details: str + actionable_message: str | None # absent (key omitted) when None + sub_code: str | None # absent (key omitted) when None + +@dataclass(frozen=True) +class DoctorJSONEnvelope: + summary: DoctorJSONSummary + checks: dict[CheckCode, DoctorJSONCheck] +``` + +Serialized verbatim as one JSON object per invocation +(R-007, FR-014). Field order in `summary` is fixed; `checks` is a +JSON object keyed by closed-set check code. + +--- + +## 4. State transitions + +FEAT-005 has **no persistent state** to transition. The only +in-memory state machines are: + +### 4.1 `ResolvedSocket.source` selection + +```text + (AGENTTOWER_SOCKET set & valid) + start ──────────────────────────────────────────► source = "env_override" + │ + │ (AGENTTOWER_SOCKET unset OR invalid → exit 1) + │ + ▼ + (RuntimeContext == ContainerContext + AND /run/agenttower/agenttowerd.sock S_ISSOCK) + ─────────────────────────────────────────────────► source = "mounted_default" + │ + │ (otherwise) + ▼ + ─────────────────────────────────────────────────► source = "host_default" +``` + +`AGENTTOWER_SOCKET` set but invalid never falls through; it produces +exit `1` per FR-002 (R-001). + +### 4.2 `IdentityResolution.classification` selection + +```text + AGENTTOWER_CONTAINER_ID set ──► IdentityCandidate(signal="env") + │ + ▼ + /proc/self/cgroup parses ──► IdentityCandidate(signal="cgroup") + │ + ▼ + /etc/hostname non-empty ──► IdentityCandidate(signal="hostname") + │ + ▼ + $HOSTNAME non-empty ──► IdentityCandidate(signal="hostname_env") + │ + ▼ + no candidate ──► classification = (host_context if RuntimeContext == HostContext else no_candidate) + + (with candidate, cross-check list_containers) + exactly one full-id match ──► unique_match + exactly one short-id-prefix match ──► unique_match + more than one match ──► multi_match + zero matches ──► no_match +``` + +### 4.3 `DoctorReport.exit_code` computation + +Walks the six `CheckResult`s in order and applies R-006's mapping +table. Pre-flight failures short-circuit to `1` *before* the +`DoctorReport` is constructed (this is enforced by `runner.py` +catching the validator's `SystemExit(1)` from `socket_resolve.py` +and re-raising rather than producing a partial report). + +--- + +## 5. Reconciliation algorithm + +Not applicable. FEAT-005 introduces no reconciliation because no +durable state is mutated. The closest analogue is the pure +identity / tmux cross-check classifier in §3.3 / §3.4, which is +implemented as two pure functions: + +```python +def classify_identity( + candidate: IdentityCandidate | None, + runtime_context: RuntimeContext, + list_containers: tuple[ContainerSummaryRow, ...], +) -> IdentityResolution: ... + +def classify_tmux( + parsed_tmux: ParsedTmuxEnv, # parsed $TMUX + $TMUX_PANE + list_panes: tuple[PaneRow, ...], # filtered by resolved container id when known +) -> TmuxIdentity: ... +``` + +`ContainerSummaryRow` and `PaneRow` are the existing FEAT-003 / +FEAT-004 socket-response shapes; FEAT-005 reads them as-is and does +not extend them (FR-026). + +--- + +## 6. JSON serialization at the socket boundary + +FEAT-005 introduces no new socket method (FR-022). Doctor's three +round-trips reuse existing methods: + +| Round-trip | Method | Purpose | +| ---------- | ------ | ------- | +| 1 | FEAT-002 `status` | drives `socket_reachable` + `daemon_status` (R-009, R-010) | +| 2 | FEAT-003 `list_containers` | drives `container_identity` cross-check (R-004) | +| 3 | FEAT-004 `list_panes` | drives `tmux_pane_match` cross-check (R-005) | + +Round-trips 2 and 3 are skipped when the prior check made them +moot (e.g., if `socket_reachable` fails, `list_containers` is not +called). The doctor still emits a `CheckResult` for the skipped +checks: `container_identity` becomes `info` with sub-code +`daemon_unavailable` and an actionable message; `tmux_pane_match` +becomes `info` with sub-code `daemon_unavailable` or +`not_in_tmux`. This preserves FR-027 ("every check runs every +invocation") in spirit — every check produces a row — while not +attempting impossible round-trips. + +--- + +## 7. Migration & backward compatibility + +| FEAT | Concern | Resolution | +| ---- | ------- | ---------- | +| FEAT-001 | `agenttower config init` byte-for-byte stable | Unchanged. FEAT-005 adds no new config block; the loader has no `[doctor]` or `[paths]` section in MVP. | +| FEAT-001 | `agenttower config paths` line shape | One additional trailing line `SOCKET_SOURCE=` (FR-019). Existing `KEY=value` lines unchanged byte-for-byte; new line is last. | +| FEAT-002 | `agenttower status` schema | Unchanged. `schema_version` field still reports the FEAT-004 value (`3`); FEAT-005 reads it but does not bump it. | +| FEAT-002 | `ping` / `status` / `shutdown` envelopes | Unchanged. Doctor's `socket_reachable` and `daemon_status` checks reuse the FEAT-002 client and `status` method without modification. | +| FEAT-002 | `socket_api/client.py` exception messages | Unchanged byte-for-byte. The new `.kind` attribute on `DaemonUnavailable` is additive only (R-009); `str(exc)` returns the same text as before. | +| FEAT-003 | `containers` / `container_scans` schema | UNCHANGED (FR-026). FEAT-005 reads `list_containers` only, never opens the SQLite database directly. | +| FEAT-003 | `scan_containers` / `list_containers` socket methods | UNCHANGED. The doctor's cross-check is a single read-only `list_containers` call. | +| FEAT-004 | `panes` / `pane_scans` schema | UNCHANGED (FR-026). FEAT-005 reads `list_panes` only. | +| FEAT-004 | `scan_panes` / `list_panes` socket methods | UNCHANGED. The doctor's tmux cross-check is a single read-only `list_panes` call, optionally filtered by `--container `. | +| FEAT-001..004 | Host CLI behavior with no container context | Bytewise unchanged for every subcommand the existing test suite covers (FR-005, SC-006, SC-007). The two additive surfaces (`config doctor` subcommand, one new line on `config paths`) do not modify any existing command's stdout, stderr, or exit code. | + +A daemon running the FEAT-005 build against a v3 SQLite database +applies no migration. A daemon built before FEAT-005 against a v3 +database opens cleanly; FEAT-005 adds no schema state. The CLI +build pins `MAX_SUPPORTED_SCHEMA_VERSION = 3`; bumping it is a +FEAT-006 concern and not a FEAT-005 concern. diff --git a/specs/005-container-thin-client/plan.md b/specs/005-container-thin-client/plan.md new file mode 100644 index 0000000..82839e2 --- /dev/null +++ b/specs/005-container-thin-client/plan.md @@ -0,0 +1,406 @@ +# Implementation Plan: Container-Local Thin Client Connectivity + +**Branch**: `005-container-thin-client` | **Date**: 2026-05-06 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/005-container-thin-client/spec.md` + +## Summary + +Add a container-local thin-client surface on top of the FEAT-001 / +FEAT-002 / FEAT-003 / FEAT-004 host daemon. The same `agenttower` +binary, run from inside a bench container whose mount namespace +exposes the host daemon's `AF_UNIX` socket, MUST connect to that +socket and return identical results to the host CLI. The feature +introduces no new socket method, no schema change, no network +listener, and no in-container daemon (FR-022, FR-026). Every new +line of code is read-only inspection of `/proc`, `/etc`, and the +process environment, plus reuse of the existing FEAT-002 client over +the existing FEAT-002 / FEAT-003 / FEAT-004 socket methods (FR-020). + +Socket-path resolution becomes a pure function `(env, host_paths) → +(path, source)` with priority `AGENTTOWER_SOCKET` (`env_override`) → +mounted-default `/run/agenttower/agenttowerd.sock` +(`mounted_default`, only when container-runtime detection fires AND +the path resolves to a Unix socket) → FEAT-001 host default +(`host_default`) (FR-001, FR-003). The override is validated as an +absolute path, non-empty, NUL-free, and pointing at a Unix socket; +invalid values exit `1` within the FR-002 / SC-002 50 ms pre-flight +budget rather than silently falling back. Container-runtime detection +is a closed-set signal pipeline over `/.dockerenv`, `/run/.containerenv`, +and `/proc/self/cgroup` (FR-004) that requires no root and no +subprocess. + +Container identity converges through a four-signal precedence chain: +`AGENTTOWER_CONTAINER_ID` env override → cgroup-derived id → +`/etc/hostname` → `$HOSTNAME`, cross-checked against the daemon's +FEAT-003 `list_containers` result by full-id-then-12-char-prefix +match (FR-006, FR-007). The cross-check classifies into the closed +set `{unique_match, multi_match, no_match, no_candidate, +host_context}` and NEVER widens the FEAT-003 container set (FR-008). +Tmux self-identity parses `$TMUX` (comma-split +`socket_path,server_pid,session_id`) and `$TMUX_PANE` (`%N`), then +cross-checks the daemon's FEAT-004 `list_panes` filtered by the +resolved container id when known, classifying into +`{pane_match, pane_unknown_to_daemon, pane_ambiguous, not_in_tmux}` +(FR-009, FR-010, FR-011). + +A new subcommand `agenttower config doctor` runs the closed-set +checks `socket_resolved → socket_reachable → daemon_status → +container_identity → tmux_present → tmux_pane_match` in order +(FR-012). Every check emits exactly one `CheckResult` row on every +invocation (FR-027) — the doctor never aborts early and never omits +a check from the output. When an upstream gate has already failed +(per Clarifications 2026-05-06 in spec.md) the dependent check +still emits its row but skips the actual socket round-trip; the +row carries `status=info` with sub-code `daemon_unavailable`. This +keeps the FR-014 JSON contract complete without burning the +SC-003 budget on round-trips that cannot succeed. Default output +is one TSV row per check plus a +summary line; `--json` emits one canonical object per invocation +with stable keys (FR-013, FR-014). Exit codes map through +`{0, 1, 2, 3, 5}` per FR-018, mirroring FEAT-002 / FEAT-003 codes +exactly. Doctor is pure read-only: zero SQLite writes, zero JSONL +appends, zero file creation (FR-029). All untrusted inputs (env, +cgroup, hostname, tmux env) are NUL-byte-stripped, control-byte- +stripped, length-bounded to 4096 chars in (FR-021), and every +doctor row's `details` / `actionable_message` is bounded to 2048 +chars with multi-byte-safe `…` truncation (FR-028). + +`agenttower config paths` is extended with exactly one trailing line +`SOCKET_SOURCE=`; no +other existing line is altered (FR-019). All FEAT-001 / FEAT-002 / +FEAT-003 / FEAT-004 commands run from the host with no container +context produce byte-identical stdout, stderr, and exit codes +(FR-005, FR-026, SC-006, SC-007). The feature is testable end-to-end +without a real container, real Docker, or real tmux server through +a single new test seam `AGENTTOWER_TEST_PROC_ROOT` that points at a +fake `/proc` + `/etc` fixture directory (FR-025); the host-side +daemon harness from FEAT-002 / FEAT-003 / FEAT-004 is reused +unchanged. Doctor wall-clock against a healthy daemon and a +fully-resolvable identity stays under the SC-003 500 ms budget +including the FEAT-002 status round-trip and the FEAT-004 +`list_panes` cross-check. + +## Technical Context + +**Language/Version**: Python 3.11+ (inherits from FEAT-001 / +FEAT-002 / FEAT-003 / FEAT-004; pyproject pins +`requires-python>=3.11`). Standard library only — no third-party +runtime dependency added. + +**Primary Dependencies**: Standard library only — `os`, `pathlib`, +`socket`, `argparse`, `json`, `dataclasses`, `typing`, `re` (for +`$TMUX_PANE` shape validation), `stat` (for `S_ISSOCK`), +`unicodedata`/`str` slicing (for multi-byte-safe truncation of +doctor row text). Reuses the existing FEAT-002 socket client +(`socket_api/client.py`) verbatim. No `subprocess`, no `tmux` +binary, no `docker` binary in any FEAT-005 in-container code path +(FR-011, FR-020). + +**Storage**: Read-only against every FEAT-001 / FEAT-002 / FEAT-003 +/ FEAT-004 surface. **No schema change**, **no new tables**, **no +new files on disk** (FR-026, FR-029). Doctor is a pure read-only +diagnostic that writes nothing — no SQLite writes, no JSONL appends, +no log rotation, no file creation. The host daemon's lifecycle log +MAY record the FR-016 underlying `status` round-trip exactly the way +it already does for FEAT-002 callers; FEAT-005 introduces no new +lifecycle log token. + +**Testing**: pytest (≥ 7), reusing the FEAT-002 / FEAT-003 / FEAT-004 +daemon harness in `tests/integration/_daemon_helpers.py` verbatim — +every FEAT-005 integration test spins up a real host daemon under an +isolated `$HOME` and drives the `agenttower` console script as a +subprocess. Three test seams are used in concert so no real +container, no real Docker daemon, and no real tmux server is ever +invoked: the existing `AGENTTOWER_TEST_DOCKER_FAKE` (FEAT-003) and +`AGENTTOWER_TEST_TMUX_FAKE` (FEAT-004) seams seed the daemon's +container and pane registries, and a new `AGENTTOWER_TEST_PROC_ROOT` +env var points the in-container detection code at a fixture directory +that stands in for `/proc` and `/etc` (containing a fabricated +`proc/self/cgroup`, optional `proc/1/cgroup`, `etc/hostname`, and the +`/.dockerenv` / `/run/.containerenv` sentinels). The hook is +namespaced under the `AGENTTOWER_TEST_*` prefix so it cannot be set +accidentally in production, and a dedicated host-only integration +test (`test_feat005_proc_root_unset_in_prod.py`) asserts the +production CLI rejects or ignores the variable when run outside the +test harness, satisfying FR-025. Integration tests cover every +US1/US2/US3 acceptance scenario plus the spec's edge cases. +FR-027 is enforced by a dedicated short-circuit test that fails one +required check and asserts the remaining checks still ran. Unit +tests cover every area enumerated in SC-009: socket-path resolution, +container-runtime detection, identity-signal parsing, daemon +cross-check classification, tmux env parsing, doctor TSV/JSON +rendering, exit-code mapping, and per-field sanitization/truncation. +A backwards-compatibility test (`test_feat005_backcompat.py`) gates +SC-007 by re-running every FEAT-001..004 CLI command on the host and +asserting byte-identical stdout, stderr, exit codes, and `--json` +shapes. + +**Target Platform**: Linux/WSL developer workstations. The daemon +continues to run exclusively on the host (constitution principle I); +FEAT-005 introduces zero in-container processes beyond the +`agenttower` CLI invocation itself, which is a single short-lived +read-only client. + +**Project Type**: Single-project Python CLI + daemon. Extends +`src/agenttower/`. Two existing modules (`cli.py`, `paths.py`) gain +small, additive surfaces; one new package (`config_doctor/`) is +introduced for the doctor implementation, mirroring the +`discovery/` and `tmux/` packages introduced by FEAT-003 and +FEAT-004. The existing `socket_api/client.py` is reused as-is. + +**Performance Goals**: SC-002 — pre-flight rejection of an invalid +`AGENTTOWER_SOCKET` exits within 50 ms with no daemon-side state +change (the validator runs entirely in-process before any socket +syscall). SC-003 — `agenttower config doctor` against a healthy +daemon, a healthy `list_panes` cross-check, and a fully-resolvable +identity completes within 500 ms wall clock end-to-end including +the FEAT-002 status round-trip and one `list_panes` socket call. +Doctor runs all six checks on every invocation (FR-027) and never +short-circuits. Daemon-down doctor runs (FR-016 closed-set sub-codes +for `socket_missing`, `connection_refused`, `connect_timeout`) +bound the connect-timeout to the FEAT-002 default (1 s) so a +fully-failing doctor still completes under ~2 s. + +**Constraints**: +- No network listener anywhere in FEAT-005; the in-container CLI + reuses FEAT-002's `AF_UNIX` socket-file authorization (`0600`, + host user only) verbatim (FR-022, FR-023; constitution + principle I). +- No third-party runtime dependency; all path resolution, container- + runtime detection, identity detection, tmux env parsing, + sanitization, and doctor rendering use Python stdlib only + (FR-005, FR-026). +- `AGENTTOWER_SOCKET` validation gate: when set, the value MUST be + non-empty, absolute, free of NUL bytes, and free of shell-metachar + interpretation (the value is used as a literal filesystem path, + never passed to a shell); invalid values exit `1` with + `error: AGENTTOWER_SOCKET must be an absolute path to a Unix + socket: ` and the CLI MUST NOT silently fall back (FR-002, + edge cases 1 and 2; SC-002). +- `AGENTTOWER_SOCKET` filesystem-shape gate: regular files, + directories, broken symlinks, and non-`S_ISSOCK` targets are + rejected with the FR-002 message; symlinks are followed exactly + one level before the `S_ISSOCK` check (edge case 2). +- Container-runtime detection is local-filesystem only and MUST NOT + shell out: no `docker exec`, no `docker inspect`, no subprocess + of any kind inside the container; only reads of `/.dockerenv`, + `/run/.containerenv`, `/proc/self/cgroup` (FR-004, FR-011, + FR-020). +- File-open allowlist (FR-020): `/proc/self/`, `/proc/1/`, + `/etc/hostname`, `/run/.containerenv`, `/.dockerenv`, the + resolved socket path, the FEAT-001 host paths, and the + `AGENTTOWER_TEST_PROC_ROOT` fixture root in tests. No other path + is opened by FEAT-005 code. +- Detection signal pipeline is the closed set `/.dockerenv` ∨ + `/run/.containerenv` ∨ `/proc/self/cgroup` line containing + `docker/` | `containerd/` | `kubepods/` | `lxc/`; nothing else + fires `container_context` (FR-004; edge cases re. unusual + sandboxes such as Firejail, Bubblewrap, systemd-nspawn). +- Container-identity detection precedence is fixed and total: + (1) `AGENTTOWER_CONTAINER_ID` env override, (2) `/proc/self/cgroup` + last-segment match, (3) `/etc/hostname`, (4) `$HOSTNAME` (FR-006; + edge cases re. `--network host` and empty cgroup). +- Cross-check classification is closed-set: `unique_match` | + `multi_match` | `no_match` | `no_candidate` | `host_context`; + full-id equality is checked before 12-char short-id prefix match; + `multi_match` is reported, never auto-resolved (FR-007; edge + case re. duplicate short-id prefixes). +- The in-container CLI MUST NOT widen the FEAT-003 container set; + `scan_containers` is never invoked from inside the container + (FR-008). +- Tmux self-identity is read-only parsing of `$TMUX` + (`socket_path,server_pid,session_id`) and `$TMUX_PANE` (`%N`); + FEAT-005 MUST NOT spawn `tmux`, `id`, `cat`, or any other + subprocess (FR-009, FR-011, FR-020). +- All untrusted input (`$TMUX`, `$TMUX_PANE`, `/proc/self/cgroup`, + `/etc/hostname`, `$HOSTNAME`, `AGENTTOWER_CONTAINER_ID`) MUST be + NUL-stripped, C0-control-stripped, length-bounded to 4096 chars, + never interpolated into a shell string; out-of-shape values + surface as `output_malformed` rather than crashing (FR-021; + edge case re. `$TMUX_PANE` not matching `%N`). +- Doctor row `details` and `actionable_message` are sanitized and + bounded to 2048 chars, mirroring FEAT-004 R-009 verbatim; + truncation appends `…` and is UTF-8-aware (never splits a + multi-byte char) (FR-028). +- `socket_reachable` failures map to the closed sub-code set + `{socket_missing, socket_not_unix, connection_refused, + permission_denied, connect_timeout, protocol_error}`; raw + `socket(2)` / `connect(2)` errno text MUST NOT leak to stderr or + `--json` output (FR-016, FR-024; SC-004). +- Doctor exit-code mapping is exactly `0` (all pass/info), `1` + (pre-flight), `2` (`socket_reachable` ∈ + {`socket_missing`, `connection_refused`, `connect_timeout`}), + `3` (`socket_reachable=pass` AND `daemon_status=fail` with sub-code + `daemon_error` — FEAT-002 `DaemonError` envelope — or + `schema_version_newer` — daemon ahead of CLI per R-010), `5` + (degraded — round-trip ok but a non-required check failed); + `4` is reserved for internal CLI error per FEAT-002 (FR-018). +- Doctor MUST emit a `CheckResult` row for every closed-set check + on every invocation; no early abort on a failing check, and no + check is omitted from the output. When an upstream gate has + already failed, the dependent check skips its actual socket + round-trip and emits `status=info` with sub-code + `daemon_unavailable` per Clarifications 2026-05-06 in spec.md + (FR-027; SC-004). +- Doctor MUST write nothing to disk: no SQLite writes, no JSONL + appends, no log rotation, no file creation; the only side effect + is the existing FEAT-002 `status` round-trip the host daemon + already records (FR-029). +- Doctor adds no new socket method; reachability uses FEAT-002 + `status`, identity cross-check uses FEAT-003 `list_containers`, + pane cross-check uses FEAT-004 `list_panes` (FR-010, FR-022). +- No SQLite schema migration; FEAT-001..004 surfaces are read-only + consumers and their persisted shapes are unchanged (FR-026, + SC-006). +- `--json` mode emits exactly one canonical JSON object per + invocation; no incidental stderr lines when `--json` is set; + check codes and status tokens are stable across releases and + only added, never renamed (FR-014; edge case re. JSON purity; + SC-005). +- Test seam is namespaced `AGENTTOWER_TEST_PROC_ROOT` (mirroring + FEAT-003 `AGENTTOWER_TEST_DOCKER_FAKE` and FEAT-004 + `AGENTTOWER_TEST_TMUX_FAKE`); a production-binary integration + test asserts the var is unset at startup (FR-025). +- The FEAT-002 `_connect_via_chdir` workaround for `sun_path` + length (108-byte limit) is preserved verbatim; FEAT-005 MUST NOT + regress the deep-cwd connect path (edge case re. `sun_path` + limit). + +**Scale/Scope**: One host user, one daemon, zero new tables, zero +new socket methods, zero schema migrations, two additive CLI +surfaces (`config doctor` subcommand; one extra line on +`config paths`), one new env override (`AGENTTOWER_SOCKET`), one +new identity env override (`AGENTTOWER_CONTAINER_ID`), one new +test seam (`AGENTTOWER_TEST_PROC_ROOT`). Expected steady-state +usage: a single `agenttower config doctor` invocation per +debugging session, six checks per invocation, one `status` +round-trip and at most one `list_containers` and one +`list_panes` round-trip per invocation. Response payloads are +single-digit kilobytes. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Evidence | +| ----------------------------- | ------ | -------- | +| I. Local-First Host Control | PASS | The thin client reuses the existing host daemon over the existing `AF_UNIX` socket (FR-001, FR-023). FR-022 forbids any new network listener, in-container daemon, or relay; FEAT-005's tests assert the same harness invariant FEAT-002 / FEAT-003 / FEAT-004 do (no AF_INET/AF_INET6). Durable state stays under the host's `opensoft/agenttower` namespace; FR-029 forbids any in-container disk write. | +| II. Container-First MVP | PASS | This is the first MVP step that runs the `agenttower` CLI from *inside* a bench container against the host daemon. The mounted-default socket path (`/run/agenttower/agenttowerd.sock`, FR-003) and the cgroup/hostname/env identity pipeline (FR-006) are explicitly bench-container-shaped. Host-only behavior remains bytewise unchanged (FR-005, SC-007). | +| III. Safe Terminal Input | PASS (vacuously) | FR-022 forbids any input delivery, prompt queuing, registration, or log capture in this feature. No subprocess is spawned in-container (FR-011, FR-020); no tmux command is sent; every untrusted input is treated as bounded data (FR-021) and never interpolated into a shell string. The remaining "safety" risk — that a future maintainer adds a subprocess to "improve" detection — is closed by FR-011 / FR-020 (no `tmux` subprocess; allowlisted read-only paths only); reviewers MUST reject any such PR unless the spec is amended. | +| IV. Observable and Scriptable | PASS | The new `config doctor` ships dual output: human-readable TSV rows with a summary line by default (FR-013) and a stable canonical JSON object under `--json` with closed-set check codes and status tokens (FR-014). Every failure carries a one-line `actionable_message` (FR-007, FR-016). Doctor runs every check on every invocation (FR-027) and exit codes mirror existing FEAT-002 / FEAT-003 codes exactly (FR-018). | +| V. Conservative Automation | PASS | FR-008 forbids the in-container CLI from widening the FEAT-003 container set (no auto-`scan`); FR-022 forbids registration, role/capability metadata, log capture, and input delivery; FR-027 forbids early-aborting checks (the operator decides what to fix). FEAT-005 reports identity, it does not act on it. | + +| Technical Constraint | Status | Evidence | +| ----------------------------------------------------------------------------- | ------ | -------- | +| Primary language Python | PASS | Python 3.11+, stdlib only. No new runtime dependency. | +| Console entrypoints `agenttower` & `agenttowerd` | PASS | Extends `agenttower` with `config doctor` and one new line on `config paths`. `agenttowerd run` is unchanged. | +| Files under `~/.config` / `~/.local/state` / `~/.cache` `opensoft/agenttower` | PASS | No new path written. The mounted-default in-container socket (`/run/agenttower/agenttowerd.sock`) is an MVP bind-mount target (assumption; resolves architecture.md §25 open question) and is read-only from the CLI's perspective. | +| Docker default `name_contains = ["bench"]`, host `docker exec -u "$USER"` | PASS (vacuously) | FEAT-005 calls neither `docker` nor `docker exec`. Identity cross-check reuses FEAT-003 `list_containers` rows; tmux cross-check reuses FEAT-004 `list_panes` rows. | +| CLI: human-readable defaults + structured output where it helps | PASS | `config doctor` ships TSV by default and `--json` (FR-013, FR-014). `config paths` keeps its existing `KEY=value` line shape with one additive trailing line (FR-019). | + +| Development Workflow | Status | Evidence | +| ----------------------------------------------------------------------------- | ------ | -------- | +| Build in `docs/mvp-feature-sequence.md` order | PASS | This is FEAT-005, immediately after FEAT-004. | +| Each feature CLI-testable | PASS | US1 covered by `test_cli_in_container_status.py`, `test_cli_in_container_socket_override.py`, `test_cli_no_socket_mount.py`. US2 covered by `test_cli_config_doctor_healthy.py`, `test_cli_config_doctor_daemon_down.py`, `test_cli_config_doctor_host_context.py`, `test_cli_config_doctor_json.py`, `test_cli_config_doctor_short_circuit.py`. US3 covered by `test_cli_config_doctor_pane_match.py`, `test_cli_in_container_unsupported_signals.py`, and the unit-level identity/runtime suites. Every acceptance scenario maps to at least one named integration test invoking the real `agenttower` console script. | +| Tests proportional to risk; broader for daemon state, sockets, Docker/tmux adapters, permissions, and input delivery | PASS | Every untrusted-input surface (env, cgroup, hostname, tmux env, daemon-returned strings) has dedicated unit coverage and integration coverage. Socket/permission risks covered by `test_cli_no_socket_mount.py` and the `permission_denied` / `socket_not_unix` paths in `test_cli_config_doctor_daemon_down.py`. Backward compat is gated by `test_feat005_backcompat.py` so SC-007 cannot regress silently. JSON contract stability is locked by `test_doctor_json_contract.py` + `test_cli_config_doctor_json.py`. Exit-code closed set is locked by `test_doctor_exit_codes.py` covering FR-018's 0/1/2/3/5 mapping (with reserved 4). | +| Preserve existing docs and NotebookLM sync mappings | PASS | This feature does not edit existing Markdown under `docs/`. The architecture.md §25 open question on the bind-mount path is resolved in the spec assumptions, not in the docs themselves. | +| No TUI, web UI, or relay before the core slices work | PASS | None introduced here. FEAT-005 is the fourth core slice (after FEAT-002 daemon, FEAT-003 container discovery, FEAT-004 pane discovery). | +| Decide explicitly whether `/speckit.checklist ` is needed before tasks | DECISION | A `security` checklist is recommended before `/speckit.tasks` because FEAT-005 is the *first* feature whose code path consumes container-side untrusted strings (`AGENTTOWER_SOCKET`, `AGENTTOWER_CONTAINER_ID`, `$TMUX`, `$TMUX_PANE`, `/proc/self/cgroup`, `/etc/hostname`, `$HOSTNAME`) and exposes them through a stable JSON contract (FR-014). FEAT-004 ran a `security` checklist for the same class of risk; the spec here introduces strictly more attacker-controlled inputs because the in-container CLI consumes container-provided env and `/proc` content rather than host-driven `docker exec` output. Verifying the FR-021 sanitization bounds, the FR-024 stderr-leak guard, the FR-002 absolute-path validator, and the host/container parity in FR-005 / SC-007 before tasks are generated is worth a topic-specific gate. A second `cli-contract` checklist is *not* recommended because the JSON shape is already pinned by FR-014's stable-key clause. | + +**Result**: Gates pass. No `Complexity Tracking` entries required. + +## Project Structure + +### Documentation (this feature) + +```text +specs/005-container-thin-client/ +├── plan.md # This file (/speckit.plan output) +├── research.md # Phase 0 output: resolved decisions +├── data-model.md # Phase 1 output: in-memory entity shapes (no SQLite) +├── quickstart.md # Phase 1 output: end-to-end CLI walkthrough +├── contracts/ +│ ├── cli.md # User-facing CLI contracts (C-CLI-501 config doctor; C-CLI-502 config paths SOCKET_SOURCE) +│ └── socket-api.md # No-new-method contract: pin FR-022; document the reused FEAT-002/003/004 calls used by doctor +├── checklists/ # /speckit.checklist outputs (security recommended) +└── tasks.md # /speckit.tasks output (NOT created by /speckit.plan) +``` + +### Source Code (repository root) + +Only files actually touched by FEAT-005 are listed. FEAT-001 / +FEAT-002 / FEAT-003 / FEAT-004 files remain unchanged unless an +explicit "EXTENDS" note appears. + +```text +src/agenttower/ +├── cli.py # EXTENDS: add `config doctor` subparser; add `AGENTTOWER_SOCKET` resolution at every command's socket-using path; add one new line `SOCKET_SOURCE=` to `config paths` output (FR-019) +├── paths.py # EXTENDS: socket resolution returns a `(path, source)` pair via a new `resolve_socket_path(env, paths) -> ResolvedSocket` helper; existing `Paths.socket` field unchanged for back-compat readers +├── socket_api/ +│ └── client.py # EXTENDS (additive only): tag `DaemonUnavailable` with a structured `.kind` attribute so doctor can map to FR-016 sub-codes without parsing the message; existing message strings unchanged byte-for-byte +└── config_doctor/ # NEW package: container-thin-client diagnostic surface + ├── __init__.py # NEW: package marker; re-exports run_doctor, ResolvedSocket, IdentityResolution, TmuxIdentity + ├── runner.py # NEW: top-level orchestrator; runs all six FR-012 checks in order; aggregates DoctorReport; computes FR-018 exit code + ├── checks.py # NEW: pure per-check functions (socket_resolved, socket_reachable, daemon_status, container_identity, tmux_present, tmux_pane_match); each returns a CheckResult dataclass + ├── render.py # NEW: TSV row rendering (FR-013) and canonical JSON serializer (FR-014); both consume DoctorReport + ├── socket_resolve.py # NEW: pure FR-001 + FR-002 resolution (env_override → mounted_default → host_default); validates absolute path, NUL, non-empty, exists-as-socket; one-level symlink follow + S_ISSOCK + ├── runtime_detect.py # NEW: FR-004 closed-set signal pipeline (`/.dockerenv`, `/run/.containerenv`, `/proc/self/cgroup` prefix scan); honors AGENTTOWER_TEST_PROC_ROOT + ├── identity.py # NEW: FR-006 + FR-007 container identity resolution (env override → cgroup → /etc/hostname → $HOSTNAME) + cross-check classifier against FEAT-003 list_containers + ├── tmux_identity.py # NEW: FR-009 + FR-010 tmux self-identity parser ($TMUX comma-split, $TMUX_PANE %N validation) + cross-check classifier against FEAT-004 list_panes + └── sanitize.py # NEW: FR-021 + FR-028 untrusted-string bounding (NUL strip, control-byte strip, 4096/2048 char bounds, multi-byte-safe `…` truncation) + +tests/ +├── unit/ +│ ├── test_socket_path_resolution.py # NEW: FR-001 / FR-002 / SC-002 — env override / mounted-default / host-default precedence; rejects relative path, empty, NUL byte, non-absolute; (path, source) shape +│ ├── test_runtime_detect.py # NEW: FR-003 / FR-004 — /.dockerenv, /run/.containerenv, cgroup pipeline (docker/, containerd/, kubepods/, lxc/); unparseable cgroup returns no_signal; host-context fall-through; AGENTTOWER_TEST_PROC_ROOT fixture isolation +│ ├── test_container_identity.py # NEW: FR-006 / FR-007 / SC-008 — env override; cgroup precedence; /etc/hostname fallback; $HOSTNAME fallback; full-id vs 12-char short-id match; classification (unique_match | multi_match | no_match | no_candidate | host_context); --network host hostname collision +│ ├── test_tmux_self_identity.py # NEW: FR-009 / FR-010 / FR-021 — $TMUX comma-split; $TMUX_PANE %N regex; output_malformed; not_in_tmux; cross-check classifier against fake list_panes rows +│ ├── test_doctor_render.py # NEW: FR-013 / FR-024 / FR-028 — TSV row formatting; truncation appends "…" without splitting multi-byte UTF-8; NUL / C0 stripping; no AGENTTOWER_SOCKET leak in error path +│ ├── test_doctor_json_contract.py # NEW: FR-014 — canonical JSON envelope shape (summary + checks); stable check codes; stable status tokens (pass/warn/fail/info); per-check actionable_message only on non-pass +│ ├── test_doctor_exit_codes.py # NEW: FR-018 — closed-set mapping 0/1/2/3/5 across every per-check status pattern; reserved 4 never emitted +│ └── test_path_sanitize.py # NEW: FR-021 / FR-028 — 2048 (details) and 4096 (env value) caps; NUL strip; C0 strip; multi-byte UTF-8 boundary preservation +└── integration/ + ├── test_cli_config_doctor_healthy.py # NEW: US2 AS1 + US3 AS1 — every check pass; cgroup→unique_match; exit 0 + ├── test_cli_config_doctor_daemon_down.py # NEW: US1 AS4 + US2 AS2 + SC-004 — daemon down ⇒ socket_reachable / daemon_status fail, identity + tmux still run, exit non-zero, no raw errno text (FR-024) + ├── test_cli_config_doctor_host_context.py # NEW: US2 AS3 — host shell ⇒ container check is host_context (not fail); tmux is pass or not_in_tmux + ├── test_cli_config_doctor_pane_match.py # NEW: US2 AS4 + US3 AS5 — pane_match when $TMUX/$TMUX_PANE align with FEAT-004 row; pane_unknown_to_daemon when they do not + ├── test_cli_config_doctor_json.py # NEW: US2 AS5 + SC-005 — canonical JSON across healthy + every degraded path; summary.exit_code matches CLI exit; --json suppresses incidental stderr + ├── test_cli_config_doctor_short_circuit.py # NEW: FR-027 + SC-003 — every required check runs even when one fails; 500 ms wall-clock budget against healthy daemon + ├── test_cli_config_paths_socket_source.py # NEW: FR-019 — extra SOCKET_SOURCE= line appended; existing FEAT-001 KEY=value lines unchanged byte-for-byte; new line is last + ├── test_cli_in_container_status.py # NEW: US1 AS1 + SC-001 — simulated in-container env returns same eight-key status payload as host CLI + ├── test_cli_in_container_socket_override.py # NEW: US1 AS2 — AGENTTOWER_SOCKET wins over both host and mounted defaults; resolved source = env_override + ├── test_cli_no_socket_mount.py # NEW: US1 AS4 + edge case "default mounted path missing" — exit 2 with the existing FEAT-002 daemon-unavailable message preserved byte-for-byte + ├── test_cli_in_container_unsupported_signals.py # NEW: edge cases — privileged container with empty /proc/self/cgroup, --network host hostname collision, multi_match, NUL-byte env, broken socket symlink, regular file at socket path + ├── test_cli_doctor_identity_hostname.py # NEW: US3 AS2 — empty cgroup + hostname matches short-prefix; source=hostname + ├── test_cli_doctor_tmux_unset.py # NEW: US3 AS4 — $TMUX unset → not_in_tmux (info, not fail) + ├── test_feat005_backcompat.py # NEW: SC-006 / SC-007 — every FEAT-001..004 CLI command produces byte-identical output on the host; no existing socket method gains a code or shape + ├── test_feat005_no_real_container.py # NEW: SC-009 — parallel to test_feat004_no_network.py and test_cli_scan_panes_no_real_docker.py; asserts no docker, tmux, container-runtime, or unexpected subprocess is spawned during the FEAT-005 test session; also asserts no AF_INET/AF_INET6 socket is opened + └── test_feat005_proc_root_unset_in_prod.py # NEW: FR-025 — production-binary check that AGENTTOWER_TEST_PROC_ROOT is unset (or refused) when not in the test harness +``` + +**Structure Decision**: Keep the FEAT-001 / FEAT-002 / FEAT-003 / +FEAT-004 single-project layout. The new `config_doctor/` package +mirrors the Protocol-plus-pure-helper split established by +FEAT-003's `docker/` and FEAT-004's `tmux/` packages: `runner.py` +orchestrates, `checks.py` holds the closed-set per-check functions, +`render.py` owns dual-output formatting, and four narrow helpers +(`socket_resolve.py`, `runtime_detect.py`, `identity.py`, +`tmux_identity.py`) keep each FR's logic in one testable unit. +`sanitize.py` is a single-source-of-truth for FR-021 / FR-028 +bounds and is reused by every check that emits text. `paths.py` +gains a `(path, source)` resolver but keeps its existing +`Paths.socket` field unchanged so every FEAT-001 / FEAT-002 reader +continues to work without modification. `cli.py` gets two surgical +edits — the new `config doctor` subparser and one new line on +`config paths` — and nothing else in `cli.py` changes its byte +output (FR-005, SC-007). `socket_api/client.py` gets exactly one +additive change (a `.kind` attribute on `DaemonUnavailable`) so the +doctor can map to FR-016 sub-codes without parsing message strings; +existing message text is unchanged byte-for-byte. FR-022's +no-new-socket-method clause is enforced by the absence of any edit +to `socket_api/methods.py`, `socket_api/server.py`, or +`socket_api/errors.py`. + +## Complexity Tracking + +> No constitutional violations to justify. This section is intentionally empty. diff --git a/specs/005-container-thin-client/quickstart.md b/specs/005-container-thin-client/quickstart.md new file mode 100644 index 0000000..349b82d --- /dev/null +++ b/specs/005-container-thin-client/quickstart.md @@ -0,0 +1,353 @@ +# Quickstart: Container-Local Thin Client Connectivity + +**Branch**: `005-container-thin-client` | **Date**: 2026-05-06 +**T057 walk**: 2026-05-06 — outputs in this document have been +verified against the live build; commands reproduce the documented +output and exit codes. + +This walks through every FEAT-005 user-facing surface end-to-end +against a healthy host daemon and a simulated bench container. Each +step shows the exact command, the expected output, and the FR or +SC it exercises. + +Pre-requisites: + +- FEAT-001 / FEAT-002 / FEAT-003 / FEAT-004 already shipped. +- The host daemon is running (`agenttower ensure-daemon`). +- One bench container has been registered (`agenttower scan --containers`). +- One pane has been registered (`agenttower scan --panes`). + +The illustrative outputs in §3, §4, §6, §9, and §10 use the +fake-adapter test seams (`AGENTTOWER_TEST_PROC_ROOT`, +`AGENTTOWER_TEST_DOCKER_FAKE`, `AGENTTOWER_TEST_TMUX_FAKE`) to +produce stable, reproducible output. Real-host invocations vary +slightly (e.g., `container_identity` resolves to `no_match` rather +than `host_context` when the host's `/etc/hostname` is non-empty). + +--- + +## 1. Verify host-side behavior is unchanged (SC-006, SC-007, FR-005) + +From a host shell, default text mode prints `KEY=value` lines: + +```bash +$ agenttower status +alive=true +pid=12345 +start_time=2026-05-06T10:30:00.123456+00:00 +uptime_seconds=137 +socket_path=/home/brett/.local/state/opensoft/agenttower/agenttowerd.sock +state_path=/home/brett/.local/state/opensoft/agenttower +``` + +JSON mode adds `schema_version` and `daemon_version` (and renames +`start_time` → `start_time_utc` per the FEAT-002 contract): + +```bash +$ agenttower status --json +{"ok": true, "result": {"alive": true, "pid": 12345, "start_time_utc": "2026-05-06T10:30:00.123456+00:00", "uptime_seconds": 137, "socket_path": "/home/brett/.local/state/opensoft/agenttower/agenttowerd.sock", "state_path": "/home/brett/.local/state/opensoft/agenttower", "schema_version": 3, "daemon_version": "0.5.0"}} +``` + +Both forms are byte-identical to the FEAT-004 build. The new +`config doctor` subcommand and the new `SOCKET_SOURCE=` line on +`config paths` are the *only* additive surfaces; nothing else +changes. + +--- + +## 2. Inspect resolved socket source on the host (FR-019, C-CLI-502) + +```bash +$ agenttower config paths +CONFIG_FILE=/home/brett/.config/opensoft/agenttower/config.toml +STATE_DB=/home/brett/.local/state/opensoft/agenttower/agenttower.sqlite3 +EVENTS_FILE=/home/brett/.local/state/opensoft/agenttower/events.jsonl +LOGS_DIR=/home/brett/.local/state/opensoft/agenttower/logs +SOCKET=/home/brett/.local/state/opensoft/agenttower/agenttowerd.sock +CACHE_DIR=/home/brett/.cache/opensoft/agenttower +SOCKET_SOURCE=host_default +``` + +The first six lines are byte-identical to the FEAT-001 build (in +declared `Paths` field order: `CONFIG_FILE`, `STATE_DB`, +`EVENTS_FILE`, `LOGS_DIR`, `SOCKET`, `CACHE_DIR`). +`SOCKET_SOURCE=` is the only added line and is always last. + +--- + +## 3. Run the doctor on the host (US2 AS3, US3 AS4) + +With the test seams pinning host context (no detection signals fire, +no $TMUX), the doctor produces the cleanest host output: + +```bash +$ agenttower config doctor +socket_resolved pass /home/brett/.local/state/opensoft/agenttower/agenttowerd.sock (host_default) +socket_reachable pass daemon_version=0.5.0 schema_version=3 +daemon_status pass schema_version=3 (cli supports 3); daemon_version=0.5.0 +container_identity info host_context +tmux_present info not_in_tmux +tmux_pane_match info not_in_tmux +summary 0 3/6 checks passed +$ echo $? +0 +``` + +Tokens are TAB-separated per FR-013. Exit `0` because every required +check (`socket_resolved`, `socket_reachable`, `daemon_status`) is +`pass` and the non-required `info` rows do not push the exit code +up. + +On a typical real host (where `/etc/hostname` or `$HOSTNAME` is +non-empty), `container_identity` resolves to `fail` / +`no_match` rather than `info` / `host_context` because the +hostname signal produces a candidate that no FEAT-003 row matches; +the exit code is then `5` (degraded). `host_context` requires *every* +detection signal to return empty AND `AGENTTOWER_CONTAINER_ID` to be +unset (data-model §3.3). + +--- + +## 4. Run the doctor in JSON mode (FR-014, SC-005, US2 AS5) + +```bash +$ agenttower config doctor --json +{ + "summary": { + "exit_code": 0, + "total": 6, + "passed": 3, + "warned": 0, + "failed": 0, + "info": 3 + }, + "checks": { + "socket_resolved": {"status": "pass", "details": "/home/brett/.local/state/opensoft/agenttower/agenttowerd.sock (host_default)", "source": "host_default"}, + "socket_reachable": {"status": "pass", "details": "daemon_version=0.5.0 schema_version=3", "source": "round_trip"}, + "daemon_status": {"status": "pass", "details": "schema_version=3 (cli supports 3); daemon_version=0.5.0", "source": "schema_check"}, + "container_identity":{"status": "info", "details": "host_context", "sub_code": "host_context"}, + "tmux_present": {"status": "info", "details": "not_in_tmux", "sub_code": "not_in_tmux"}, + "tmux_pane_match": {"status": "info", "details": "not_in_tmux", "sub_code": "not_in_tmux"} + } +} +``` + +One canonical JSON object per invocation. No incidental stderr +lines. `summary.exit_code` matches the CLI exit code. The keys are +emitted in the per-row dict order produced by the renderer +(`status`, `details`, optional `source`, optional `sub_code`, +optional `actionable_message`). + +--- + +## 5. Simulate an in-container shell (FR-025, SC-001) + +This step is normally done by the test harness via +`AGENTTOWER_TEST_PROC_ROOT`, but the same effect can be reproduced +manually for a smoke test: + +```bash +$ docker run --rm -it \ + -v "$HOME/.local/state/opensoft/agenttower/agenttowerd.sock:/run/agenttower/agenttowerd.sock" \ + -u "$(id -u)" \ + py-bench \ + agenttower status +alive=true +pid=12345 +start_time=2026-05-06T10:30:00.123456+00:00 +uptime_seconds=312 +socket_path=/run/agenttower/agenttowerd.sock +state_path=/home/brett/.local/state/opensoft/agenttower +``` + +The six TSV keys match the host invocation byte-for-byte except for +`socket_path`, which now reflects the in-container mounted-default +path (`mounted_default` source). `--json` adds `schema_version` and +`daemon_version` as before. + +--- + +## 6. Run the doctor inside the container (US2 AS1, US3 AS1) + +```bash +$ agenttower config doctor +socket_resolved pass /run/agenttower/agenttowerd.sock (mounted_default) +socket_reachable pass daemon_version=0.5.0 schema_version=3 +daemon_status pass schema_version=3 (cli supports 3); daemon_version=0.5.0 +container_identity pass unique_match: 1234abcd5678... (py-bench) +tmux_present pass socket=/tmp/tmux-1000/default session=0 pane=%0 +tmux_pane_match pass pane_match: %0 in py-bench:default:main:0.0 +summary 0 6/6 checks passed +``` + +All six checks pass; the cgroup signal produced the candidate id +(`source=cgroup`); the daemon's `list_containers` matched it +uniquely; the daemon's `list_panes` matched the +`(tmux_socket_path, tmux_pane_id)` pair uniquely. The `tmux_present` +detail uses the `socket=... session=... pane=...` decomposed format +rather than the raw `$TMUX=...,...,$0` echo. + +--- + +## 7. Override the socket via `AGENTTOWER_SOCKET` (US1 AS2) + +```bash +$ AGENTTOWER_SOCKET=/tmp/custom-tower.sock agenttower config paths | tail -1 +error: AGENTTOWER_SOCKET must be an absolute path to a Unix socket: value does not exist +$ echo $? +1 +``` + +When the override points at a non-existent socket, the FR-002 +pre-flight `S_ISSOCK` gate catches it BEFORE any connect attempt +and exits `1` with the literal `` token `value does not +exist`. The exit code is `1` (FR-002 pre-flight), NOT `2` +(FEAT-002 daemon-unavailable), because validation runs first. + +When the override DOES point at a reachable socket, `env_override` +wins over both defaults: + +```bash +$ ln -s "$HOME/.local/state/opensoft/agenttower/agenttowerd.sock" /tmp/custom-tower.sock +$ AGENTTOWER_SOCKET=/tmp/custom-tower.sock agenttower config paths | tail -1 +SOCKET_SOURCE=env_override +``` + +--- + +## 8. Reject a malformed `AGENTTOWER_SOCKET` (FR-002, SC-002) + +```bash +$ AGENTTOWER_SOCKET=relative/path agenttower status +error: AGENTTOWER_SOCKET must be an absolute path to a Unix socket: value is not absolute +$ echo $? +1 + +$ AGENTTOWER_SOCKET= agenttower status +error: AGENTTOWER_SOCKET must be an absolute path to a Unix socket: value is empty +$ echo $? +1 + +$ AGENTTOWER_SOCKET=$(printf '/run/with\x00nul') agenttower status +error: AGENTTOWER_SOCKET must be an absolute path to a Unix socket: value contains NUL byte +$ echo $? +1 +``` + +Pre-flight rejection happens within 50 ms (SC-002); no daemon-side +state changes; no fall-back to the default. The closed-set +`` tokens (`value is empty`, `value is not absolute`, +`value contains NUL byte`, `value does not exist`, +`value is not a Unix socket`) are pinned by FR-002 + contracts/cli.md. + +--- + +## 9. Daemon-down doctor (US1 AS4, US2 AS2, SC-004) + +Stop the daemon (`agenttower stop-daemon` from the host) then: + +```bash +$ agenttower config doctor +socket_resolved pass /run/agenttower/agenttowerd.sock (mounted_default) +socket_reachable fail socket_missing: /run/agenttower/agenttowerd.sock + socket file does not exist at /run/agenttower/agenttowerd.sock; try `agenttower ensure-daemon` from the host +daemon_status info daemon_unavailable + skipped because socket_reachable is fail +container_identity info daemon_unavailable + skipped because socket_reachable is fail; run `agenttower scan --containers` from the host +tmux_present pass socket=/tmp/tmux-1000/default session=0 pane=%0 +tmux_pane_match info daemon_unavailable + skipped because socket_reachable is fail +summary 2 2/6 checks passed +$ echo $? +2 +``` + +Exit `2` because `socket_reachable` is `fail` with sub-code +`socket_missing`. Every check still ran (FR-027); dependent checks +emit `info` / `daemon_unavailable` rather than being silently +omitted. No raw errno text leaks (FR-024) — only the closed-set +sub-code and the bounded actionable message. + +--- + +## 10. Doctor under a `--network host` ambiguity (edge case 4) + +When the container is run with `--network host` and `--hostname` is +unset, `/etc/hostname` is the host hostname, so the cgroup signal +fails (no Docker cgroup) and the hostname signal produces a +candidate that no FEAT-003 row matches: + +```bash +$ agenttower config doctor +socket_resolved pass /run/agenttower/agenttowerd.sock (mounted_default) +socket_reachable pass daemon_version=0.5.0 schema_version=3 +daemon_status pass schema_version=3 (cli supports 3); daemon_version=0.5.0 +container_identity fail no_match: brett-laptop (hostname) + run `agenttower scan --containers` from the host +tmux_present pass socket=/tmp/tmux-1000/default session=0 pane=%0 +tmux_pane_match fail pane_unknown_to_daemon: /tmp/tmux-1000/default:%0 + no pane row matches; run `agenttower scan --panes` from the host +summary 5 4/6 checks passed +$ echo $? +5 +``` + +Exit `5` (degraded) because the daemon round-trip succeeded but two +non-required checks (`container_identity`, `tmux_pane_match`) +failed. Every required check still passed. + +--- + +## What FEAT-005 does NOT do + +- Does not register agents, attach logs, or deliver input + (FR-022). +- Does not call `scan_containers` or `scan_panes` from inside the + container (FR-008, FR-022). +- Does not introduce a network listener, an in-container daemon, + or an in-container relay (FR-022; constitution principle I). +- Does not modify any FEAT-001 / FEAT-002 / FEAT-003 / FEAT-004 + CLI surface beyond the two additive points above (FR-005, + FR-026, SC-006, SC-007). +- Does not write anything to disk during `config doctor` (FR-029). + +--- + +## T057 walk findings (drift captured into spec, not code) + +The 2026-05-06 walk surfaced these discrepancies between the +pre-walk quickstart text and live-build behavior. All are +captured here (and into `spec.md` edge case 7 where a semantic +amendment was needed): + +1. **§1**: default TSV is `KEY=value`, not whitespace-aligned, and + contains 6 keys; the 8-key enumeration in spec US1 AS1 appears in + `--json` only (and uses `start_time_utc` instead of `start_time`). + Quickstart now shows both forms. +2. **§2**: `agenttower config paths` emits 6 `KEY=value` lines (in + `Paths` field order: `CONFIG_FILE`, `STATE_DB`, `EVENTS_FILE`, + `LOGS_DIR`, `SOCKET`, `CACHE_DIR`) plus the trailing + `SOCKET_SOURCE=` line — not the 9 lines previously shown. +3. **§3 / §4**: `tmux_present` detail uses the decomposed + `socket=... session=... pane=...` format; `daemon_status` detail + appends `; daemon_version=...`. The `host_context` outcome on + `container_identity` requires *every* detection signal empty, + which is rare on real hosts. +4. **§4 (JSON)**: T057 walk noted `socket_reachable` was emitting + `source: host_default` (mirroring the resolver source) rather than + the `round_trip` value documented in R-007's worked example + + data-model §3.5. Resolved by the O1 code fix in the same session + — `socket_reachable.source` now emits `round_trip` per the + documented contract. +5. **§7**: when `AGENTTOWER_SOCKET` points at a non-existent socket, + the FR-002 pre-flight `S_ISSOCK` gate exits `1` with `value does + not exist` BEFORE any connect attempt. The previous text claiming + exit `2` (FEAT-002 daemon-unavailable) was incorrect — FR-002 + wins. Quickstart now reflects exit `1`. +6. **§10**: previously-shown `tmux_pane_match` `info` was wrong on + a host shell; the implementation maps `pane_unknown_to_daemon` → + `fail` regardless of context (closed-set sub-code per + `contracts/cli.md` `tmux_pane_match` table). Spec edge case 7 was + amended to align with this implementation choice — see + `spec.md` Edge Cases. diff --git a/specs/005-container-thin-client/research.md b/specs/005-container-thin-client/research.md new file mode 100644 index 0000000..4526188 --- /dev/null +++ b/specs/005-container-thin-client/research.md @@ -0,0 +1,544 @@ +# Phase 0 Research: Container-Local Thin Client Connectivity + +**Branch**: `005-container-thin-client` | **Date**: 2026-05-06 + +This document records the design decisions made during Phase 0 of +the plan. Each decision answers a `NEEDS CLARIFICATION` (none +survived spec writing — the spec is unusually concrete) or pins a +downstream-affecting choice that the plan summary references. +FEAT-005 inherits FEAT-002's socket-client behavior, FEAT-003's +container threat model, and FEAT-004's sanitization policy; this +document only records what is *added* by FEAT-005. + +--- + +## R-001 — Socket-path resolution precedence and validator shape + +**Decision**: A pure function +`resolve_socket_path(env, host_paths) -> ResolvedSocket(path, +source)` runs at every CLI invocation. Priority: + +1. `AGENTTOWER_SOCKET`, when set and valid → `source = "env_override"`. +2. Mounted-default `/run/agenttower/agenttowerd.sock`, only when + container-runtime detection (R-003) fires AND the path resolves + to a Unix socket → `source = "mounted_default"`. +3. FEAT-001 host default (`Paths.socket`) → `source = "host_default"`. + +Every CLI command that opens the socket calls this resolver before +constructing the FEAT-002 client; the resolved `(path, source)` is +also surfaced by `agenttower config paths` (FR-019) and by the +doctor's `socket_resolved` check (FR-015). + +**Validator (FR-002, edge cases 1 and 2)**: when `AGENTTOWER_SOCKET` +is set, the value MUST be: + +- non-empty (after `.strip()`), +- absolute (`os.path.isabs(value)` is true), +- free of NUL bytes, +- pointing at a path whose target (after **exactly one** + `os.readlink` follow) satisfies `stat.S_ISSOCK(st_mode)`. + +Invalid values exit `1` with the literal message +`error: AGENTTOWER_SOCKET must be an absolute path to a Unix +socket: ` and the CLI MUST NOT silently fall back to a +default. Pre-flight rejection runs entirely in-process before any +socket syscall, so the SC-002 50 ms budget is comfortable. + +**Rationale**: +- Pinned by FR-001 / FR-002. The `(path, source)` pair lets + `config paths` and `config doctor` print the resolution provenance + without re-running detection. +- Single symlink follow keeps the validator predictable on + bind-mount targets (some Docker setups mount via a symlinked + parent) without inviting symlink-loop attacks. A second-level + `os.path.realpath` is rejected as YAGNI. + +**Alternatives considered**: +- Resolve in `cli.py` only: scattered logic, hard to unit-test. + Rejected. +- Always check for the mounted-default first: would change FEAT-001 + / FEAT-002 / FEAT-003 / FEAT-004 host behavior because the path + exists on some Docker-host workstations. Rejected (FR-003). + +--- + +## R-002 — Mounted-default path is `/run/agenttower/agenttowerd.sock` + +**Decision**: The MVP in-container default mounted socket path is +`/run/agenttower/agenttowerd.sock`. Bench containers that mount the +socket elsewhere set `AGENTTOWER_SOCKET` explicitly. The path is a +bind-mount target only; FEAT-005 introduces no in-container disk +write to it. The mounted path is *only* consulted when +container-runtime detection fires (R-003); on the host the path is +ignored even if it happens to exist. + +**Rationale**: +- Resolves the architecture.md §25 open question for MVP without + amending the doc. +- `/run/` is the canonical tmpfs mount point on Linux and matches + systemd / FHS conventions; bench images already write to it. +- Mode bits (`0600`, host user only) are inherited from the host + socket file the bind-mount targets — FEAT-005 adds no new + permission tier (FR-022, FR-023). + +**Alternatives considered**: +- `/var/run/agenttower/agenttowerd.sock`: equivalent on glibc but + `/var/run` is a compatibility symlink; using `/run` directly + avoids a redundant readlink. +- `/tmp/agenttowerd.sock`: rejected — `/tmp` is multi-user and the + socket would be visible to other tenants if a bench image ever + multiplexed users. + +--- + +## R-003 — Container-runtime detection signal pipeline + +**Decision**: Closed-set OR-pipeline over three signals; if any +fires, the runtime context is `container_context(detection_signals)`, +otherwise it is `host_context`: + +1. `os.path.exists("/.dockerenv")` (Docker classic marker). +2. `os.path.exists("/run/.containerenv")` (Podman marker). +3. Any line in `/proc/self/cgroup` whose final segment matches the + regex `(docker|containerd|kubepods|lxc)/`. + +None of the three signals requires root or a subprocess. The +pipeline is rooted at `os.environ.get("AGENTTOWER_TEST_PROC_ROOT", +"/")` so test fixtures can substitute a fake `/proc` and `/etc` +without touching the real filesystem. + +**Edge case handling (spec edge cases 4, 5, 8)**: +- Privileged container with empty `/proc/self/cgroup` (cgroup + namespace not isolated): treated as `no_signal` for cgroup; the + fallback proceeds to `/.dockerenv` / `/run/.containerenv`. If + none of the three fires, the runtime context is `host_context` + and the in-container default mounted path is ignored entirely. +- `--network host` with `/etc/hostname` equal to the host + hostname: detection still fires correctly because `/.dockerenv` + is independent of network mode; identity (R-004) handles the + hostname-collision case by failing the cross-check rather than + relying on detection. +- Unparseable cgroup file (binary garbage, permission denied): + treated as `no_signal`; the `IOError` is swallowed, never + propagated. + +**Rationale**: +- Conservative-on-unknown-sandbox by design. A developer who runs + the CLI under Firejail, Bubblewrap, or systemd-nspawn is treated + as host context unless they set `AGENTTOWER_SOCKET` explicitly. +- Three signals over four common runtimes (Docker, containerd, + Kubernetes, LXC) covers the bench-image fleet documented in + architecture.md §6 without expanding the closed set. + +**Alternatives considered**: +- Read `/proc/1/cgroup` instead of `/proc/self/cgroup`: pid 1's + cgroup is more stable across `unshare(2)` games but matches the + same patterns in practice; sticking with `self` matches what every + other runtime detector does (e.g., `is-docker`, `runc`'s + detection). Rejected as YAGNI. +- Parse `/proc/1/sched` for `init` vs container-pid: rejected; + fragile across kernel versions. + +--- + +## R-004 — Container identity detection precedence + cross-check classifier + +**Decision**: Resolution order (first non-empty wins): + +| Step | Signal | Token reported | +| ---- | ------ | --------------- | +| 1 | `AGENTTOWER_CONTAINER_ID` env override (used verbatim as the candidate id) | `env` | +| 2 | All `/proc/self/cgroup` lines whose last segment matches R-003's pattern set are scanned (this includes the cgroup v2 unified-hierarchy `0::/...` line and any per-subsystem cgroup v1 lines); each line's trailing identifier is collected as a candidate. If every matching line yields the same identifier, that identifier is the candidate. If two or more matching lines yield *distinct* identifiers, the cross-check classification is `multi_match` (per FR-007) with every observed identifier surfaced in a structured `details.cgroup_candidates` array, and the doctor MUST NOT pick one arbitrarily (per Clarifications 2026-05-06 in spec.md). | `cgroup` | +| 3 | Contents of `/etc/hostname` (stripped) | `hostname` | +| 4 | Value of `$HOSTNAME` env var | `hostname_env` | + +The candidate is then cross-checked against the daemon's +`list_containers` response: + +- **Full-id equality** is checked first. +- If no full-id match, **12-character short-id prefix** match is + attempted. +- If two `containers` rows share the candidate's short prefix, + classification is `multi_match` and is reported with both + candidate ids — the CLI MUST NOT auto-pick. + +Closed-set outcomes (FR-007): + +| Outcome | Meaning | +| ------- | ------- | +| `unique_match` | exactly one `containers` row matches the candidate | +| `multi_match` | more than one row matches the candidate prefix | +| `no_match` | a candidate was produced but no row matches | +| `no_candidate` | every signal returned empty | +| `host_context` | runtime detection (R-003) reported `host_context` AND `AGENTTOWER_CONTAINER_ID` is unset | + +`host_context` only fires when *every* signal returned empty AND +runtime detection said host. An empty cgroup with a hostname value +still produces `no_match` or `unique_match`, never `host_context`. + +**Edge case handling (spec edge cases 4, 6)**: +- `--network host`: the in-container `/etc/hostname` is the host + hostname; cross-check returns `no_match` (no FEAT-003 row), not + a false positive reporting the host as a container. +- Two FEAT-003 rows share the same 12-char short-id prefix: + classification is `multi_match`, both candidate ids surface in + the `actionable_message`. + +**Rationale**: +- Pinned by FR-006 / FR-007 / FR-008. The cgroup signal is most + reliable in standard Docker / Podman; hostname is a defensible + fallback because bench images conventionally set hostname to the + short-id; `$HOSTNAME` is a final fallback for setups that override + `/etc/hostname` but leave the env var alone. +- The env override (`AGENTTOWER_CONTAINER_ID`) wins over all of + them so a developer can pin the answer in unusual setups + (Firejail, etc.). + +--- + +## R-005 — Tmux self-identity parsing + +**Decision**: `$TMUX` is split on the first two commas into +`(socket_path, server_pid, session_id)`. Only `socket_path` is used +in the daemon cross-check (the parsed `server_pid` and `session_id` +are exposed in the doctor row but not used for matching, since +session id can be symbolic and pid is unstable across server +restarts). + +`$TMUX_PANE` is matched against the regex `^%[0-9]+$`. Values that +fail the regex (truncated, contain whitespace, contain other chars) +produce `output_malformed` on the `tmux_pane_match` check, with the +parsed values echoed in the row detail. + +Cross-check against FEAT-004 `list_panes` filtered by the resolved +container id when known. Closed-set outcomes: + +| Outcome | Meaning | +| ------- | ------- | +| `pane_match` | exactly one `panes` row has matching `tmux_socket_path` AND `tmux_pane_id` | +| `pane_unknown_to_daemon` | `$TMUX`/`$TMUX_PANE` parse cleanly but no `panes` row matches | +| `pane_ambiguous` | more than one `panes` row matches | +| `not_in_tmux` | `$TMUX` is unset | +| `output_malformed` | `$TMUX` is set but unparseable, or `$TMUX_PANE` fails the `^%[0-9]+$` regex (propagates from FR-009 / FR-021 parsing failure; surfaced on both `tmux_present` and `tmux_pane_match` without performing the daemon round-trip) | + +When `$TMUX` is set but its `socket_path` is unreadable from inside +the container (e.g., the host tmux socket is not bind-mounted), the +doctor reports the parsed values and skips the daemon cross-check; +the cross-check status becomes `pane_unknown_to_daemon` with a +"tmux socket not visible from container" actionable message rather +than crashing (spec edge case 7). + +**Rationale**: +- Pinned by FR-009 / FR-010 / FR-011 / FR-021. Pane id only is + insufficient (`%N` reuses across server restarts; FEAT-004 R-008); + pairing with `tmux_socket_path` matches the same composite-key + shape FEAT-004 uses to reconcile panes. +- Parsing `$TMUX_PANE` against a strict regex prevents + output_malformed from becoming a silent crash path. + +**Alternatives considered**: +- Spawn `tmux display-message -p '#S:#W.#P'` to get authoritative + pane id: rejected — FR-011 forbids any `tmux` subprocess. +- Use only `$TMUX_PANE` for the cross-check: rejected — pane-id + collisions across sockets break the match. + +--- + +## R-006 — Doctor check order and exit-code mapping + +**Decision**: Six checks in fixed order: +`socket_resolved → socket_reachable → daemon_status → +container_identity → tmux_present → tmux_pane_match` (FR-012). +Every check emits exactly one `CheckResult` row on every invocation +(FR-027); the doctor never aborts early and never omits a check +from the output. When an upstream gate has already failed, the +dependent check still emits a row but skips the actual socket +round-trip; the row carries `status=info` with sub-code +`daemon_unavailable` (per Clarifications 2026-05-06 in spec.md). +This honours FR-014's "every closed-set check appears in the JSON +contract" promise without burning the SC-003 500 ms wall-clock +budget on round-trips that cannot succeed. + +Exit-code mapping mirrors FEAT-002 / FEAT-003 exactly: + +| Pattern | Exit code | +| ------- | --------- | +| Every required check is `pass` or `info` | `0` | +| Pre-flight failure (FEAT-001 not initialized on host context; malformed `AGENTTOWER_SOCKET`) | `1` | +| `socket_reachable` is `fail` with sub-code `socket_missing` / `connection_refused` / `connect_timeout` | `2` | +| `socket_reachable` is `pass` but `daemon_status` is `fail` with closed-set semantic sub-code `daemon_error` (FEAT-002 `DaemonError` envelope) or `schema_version_newer` (R-010, daemon ahead of CLI). `socket_reachable` is **transport-only**: it reports `pass` whenever the daemon returns any well-formed frame, including a structured `DaemonError`; payload semantics are owned by `daemon_status`. | `3` | +| Round-trip ok and required checks pass, but at least one non-required check is `fail` (e.g., `pane_unknown_to_daemon` after a successful socket+daemon path) | `5` | +| Internal CLI error (uncaught exception in CLI dispatch) | `4` (reserved per FEAT-002, never produced deliberately) | + +`info` outcomes (`host_context`, `not_in_tmux` on the host) never +push the exit code above `0` on their own. `pass`/`info` mix with +`warn` produces `0`; only `fail` on a required check changes the +exit class. + +**Required vs non-required**: + +- **Required** for non-degraded exit: `socket_resolved`, + `socket_reachable`, `daemon_status`. +- **Non-required** (their `fail` produces exit `5` rather than + `2`/`3`): `container_identity`, `tmux_present`, + `tmux_pane_match`. + +**Rationale**: +- Pinned by FR-018. Mirroring FEAT-002 / FEAT-003 exit codes lets + shell scripts treat doctor's exit code with the same logic they + already use for `agenttower status`. +- The required vs non-required split matches the spec's + US2 AS1 vs US2 AS2 narrative: a failed identity or pane match + should not mask a successful daemon round-trip in scripts that + only check exit codes. + +--- + +## R-007 — Doctor JSON contract shape + +**Decision**: One canonical JSON object per invocation, on stdout, +with no incidental stderr lines when `--json` is set: + +```json +{ + "summary": { + "exit_code": 0, + "total": 6, + "passed": 6, + "warned": 0, + "failed": 0, + "info": 0 + }, + "checks": { + "socket_resolved": {"status": "pass", "source": "env_override", "details": "/run/agenttower/agenttowerd.sock"}, + "socket_reachable": {"status": "pass", "source": "round_trip", "details": "daemon_version=0.5.0 schema_version=3"}, + "daemon_status": {"status": "pass", "source": "schema_check", "details": "schema_version=3 (cli supports 3)"}, + "container_identity":{"status": "pass", "source": "cgroup", "details": "unique_match: ()"}, + "tmux_present": {"status": "pass", "source": "env", "details": "$TMUX=/tmp/tmux-1000/default,12345,$0"}, + "tmux_pane_match": {"status": "pass", "source": "list_panes", "details": "pane_match: %0 in :default:main:0.0"} + } +} +``` + +`status ∈ {pass, warn, fail, info}`. Non-pass rows include an +additional `actionable_message` field. Per-check sub-codes (FR-007, +FR-010, FR-016, FR-017) are stable across releases; new check codes +or sub-codes MAY be added but never renamed (breaking-change rule). + +**Rationale**: +- Pinned by FR-014. Closed-set keys + closed-set status tokens make + the JSON forward-compatible without versioning the schema. +- Embedding `summary.exit_code` keeps `--json` self-contained for + scripts that pipe the output without checking `$?`. +- Adding `source` to every row (rather than only on non-pass) + matches the architecture doc's preference for self-documenting + output. + +**Alternatives considered**: +- Array of `{check, status, ...}` objects: rejected because keyed + access on `checks.socket_resolved` is friendlier for `jq`. +- Embed full `--json` payload from the daemon's `status` reply: + rejected — duplicates information already on `daemon_status` row + and inflates the response. + +--- + +## R-008 — Sanitization and truncation policy + +**Decision**: A single `sanitize_text(value, max_length)` helper in +`config_doctor/sanitize.py`: + +1. Drops NUL bytes (`\x00`) entirely. +2. Drops every byte in the C0 control range + (`\x01`–`\x08`, `\x0b`–`\x1f`, `\x7f`). +3. Replaces every `\t` and `\n` in doctor row text with a single + space (so the TSV output stays one row per check). +4. Truncates the result to `max_length` *characters* (Python `str` + slicing is character-aware, not byte-aware, so multi-byte UTF-8 + never splits). +5. If truncation occurred, appends a single `…` (U+2026). + +Per-field caps: + +| Field | Cap | +| ----- | --- | +| Untrusted env values (`AGENTTOWER_SOCKET`, `AGENTTOWER_CONTAINER_ID`, `$TMUX`, `$TMUX_PANE`, `$HOSTNAME`) | 4096 | +| Untrusted file contents (`/etc/hostname`, single `/proc/self/cgroup` line) | 4096 | +| Doctor row `details` | 2048 | +| Doctor row `actionable_message` | 2048 | + +**Rationale**: +- Mirrors FEAT-004 R-009 verbatim. Stripping C0 control bytes + prevents terminal-control-byte injection through pane titles, + cgroup contents, or env values into the operator's terminal when + they read doctor output. +- Character-based truncation (vs byte-based) keeps the multi-byte + UTF-8 invariant without a `unicodedata` round-trip. + +**Alternatives considered**: +- Reject the value on truncation: rejected; FR-021 / FR-028 require + truncation, not rejection. +- No sanitization, rely on JSON encoder: rejected — JSON does not + strip C0 control bytes. + +--- + +## R-009 — Closed-set sub-codes for `socket_reachable` (no leak policy) + +**Decision**: `socket_reachable` is **transport-only** per +Clarifications 2026-05-06: it reports `pass` whenever the daemon +returns *any* well-formed frame, including a structured +`DaemonError` envelope. Semantic payload inspection (including +`DaemonError` recognition and the `schema_version` comparison) is +owned by `daemon_status` (FR-017, R-010). The closed sub-code set +below is therefore strictly transport-level and is never extended +to cover daemon-side semantic failures. Doctor's `socket_reachable` +failure modes are mapped from `socket_api/client.py`'s exception +types onto a closed sub-code set: + +| Sub-code | Underlying signal in `client.py` | +| -------- | -------------------------------- | +| `socket_missing` | `FileNotFoundError` from `_connect_via_chdir` (path's parent missing or basename absent) | +| `socket_not_unix` | Pre-flight `S_ISSOCK` check on the resolved path fails (file exists but is regular file / dir / symlink-to-non-socket) | +| `connection_refused` | `ConnectionRefusedError` from `_connect_via_chdir` | +| `permission_denied` | `OSError` with `errno == EACCES` from `_connect_via_chdir` | +| `connect_timeout` | `TimeoutError` / `socket.timeout` on connect or read | +| `protocol_error` | `UnicodeDecodeError`, `json.JSONDecodeError`, malformed envelope, or non-dict result | + +**Implementation seam**: `socket_api/client.py` is extended (additive +only) with a `.kind` attribute on `DaemonUnavailable`. The doctor +catches `DaemonUnavailable` once and dispatches on `.kind` instead +of parsing the message string. Existing message text is unchanged +byte-for-byte so FEAT-002 / FEAT-003 / FEAT-004 callers keep their +exact stderr output. + +**No-leak policy (FR-024)**: Raw `socket(2)` / `connect(2)` errno +text MUST NOT appear in stderr or `--json`. The CLI translates the +`.kind` into one of the sub-codes above plus a one-line +`actionable_message` bounded by R-008. The wrapped exception's +`__cause__` is not formatted into output. The doctor's stderr in +the daemon-down case prints only `socket_reachable\tfail\t` +and the indented actionable line; no `[Errno 111] Connection +refused` text leaks. + +**Rationale**: +- Pinned by FR-016 / FR-024. Mirrors FEAT-003 R-014 / FEAT-004 R-011 + (closed error-code asymmetry). +- A `.kind` attribute on the existing exception type is the smallest + change that lets the doctor avoid string-matching `client.py`'s + message format. + +**Alternatives considered**: +- Subclass `DaemonUnavailable` per sub-code: rejected — too many + classes for a closed-set; introspection on `.kind` is cleaner. +- Parse the message string: rejected — fragile and the message text + is part of FEAT-002's stderr contract. + +--- + +## R-010 — Schema-version comparison policy + +**Decision**: Doctor's `daemon_status` check echoes the daemon's +`schema_version` (returned by FEAT-002 `status`). The CLI build pins +a `MAX_SUPPORTED_SCHEMA_VERSION` constant (currently `3`; bumped +each schema migration). + +| Comparison | Status | Sub-code | Exit class | +| ---------- | ------ | -------- | ---------- | +| `daemon == cli` | `pass` | (none) | `0` | +| `daemon < cli` | `warn` | `schema_version_older` | `0` (forward-compatible CLI keeps working) | +| `daemon > cli` | `fail` | `schema_version_newer` | `3` (daemon round-trip succeeded but CLI cannot serve safely; per FR-018 layering rule, this is a `daemon_status` semantic fail) | +| payload is a structured `DaemonError` envelope | `fail` | `daemon_error` | `3` (transport ok, daemon-side semantic problem; per Clarifications 2026-05-06 in spec.md) | +| `socket_reachable` was not `pass` | `info` | `daemon_unavailable` | (no exit-class contribution; round-trip skipped per FR-027 clarification) | + +The `schema_version_newer` actionable message names the build the +operator should upgrade to. The `daemon_error` row's +`actionable_message` echoes the daemon-supplied error message after +FR-028 sanitization. Both `daemon_error` and `schema_version_newer` +are required-check fails and therefore both produce exit `3`; this +preserves the layering principle that `socket_reachable` is +transport-only and `daemon_status` owns every daemon-side semantic +outcome. + +**Rationale**: +- Pinned by FR-017. Inherits FEAT-003 R-012 forward-compat policy. +- `schema_version_newer` as `fail` rather than `warn` is the + explicit choice because a CLI that does not understand the schema + cannot guarantee its `list_containers` / `list_panes` parsing is + correct. + +--- + +## R-011 — Test seam: `AGENTTOWER_TEST_PROC_ROOT` + +**Decision**: A new namespaced env var +`AGENTTOWER_TEST_PROC_ROOT`, when set, is interpreted as a directory +that stands in for `/` for the closed set of read paths used by +FR-020: + +- `/.dockerenv` +- `/run/.containerenv` +- `/proc/self/cgroup` +- `/proc/1/cgroup` (defensive; some detection libs read this) +- `/etc/hostname` + +All other reads (the resolved socket path, FEAT-001 host paths) +ignore the override. Mirrors FEAT-003's `AGENTTOWER_TEST_DOCKER_FAKE` +and FEAT-004's `AGENTTOWER_TEST_TMUX_FAKE`. + +A `tests/integration/test_feat005_proc_root_unset_in_prod.py` +integration test asserts `AGENTTOWER_TEST_PROC_ROOT` is *not* set +when the production binary entry point is invoked outside the test +suite (FR-025), preventing accidental fake-proc activation in +production. A second harness-level test +(`test_feat005_no_real_container.py`) parallels FEAT-004's +`test_feat004_no_network.py` and asserts no `docker`, `tmux`, +container-runtime, or unexpected subprocess is spawned during the +FEAT-005 test session and no AF_INET/AF_INET6 socket is opened. + +**Rationale**: +- Integration tests already spawn the daemon as a subprocess + (FEAT-002 / FEAT-003 / FEAT-004 pattern). Passing the fake `/proc` + through `os.environ` avoids any import-time monkeypatching across + process boundaries. +- The hook is `AGENTTOWER_TEST_*`-namespaced so a production + environment cannot enable it accidentally. + +**Alternatives considered**: +- CLI flag (`agenttower --test-proc-root `): rejected — leaks + a test surface into the production CLI. +- Patch `os.path.exists` / `open` globally: rejected — doesn't work + across the spawned daemon process. +- One env var that drives all three fakes: rejected — the fixture + shapes are unrelated; one var per seam keeps each orthogonal. + +--- + +## R-012 — No new socket methods (explicit non-decision) + +**Decision**: FEAT-005 reuses the existing socket methods +exclusively: + +- FEAT-002 `ping`, `status`, `shutdown` +- FEAT-003 `list_containers` +- FEAT-004 `list_panes` + +Doctor cross-checks call `list_containers` once and `list_panes` +once (filtered by resolved container id when known); both are +read-only and acquire no scan mutex on the daemon side. The daemon +dispatch table (`socket_api/methods.py`) is unchanged. + +**Rationale**: +- Pinned by FR-022 / FR-026 / FR-029. Recorded as an explicit + non-decision so a later reviewer cannot reopen it without + amending the spec. +- Reusing read-only methods means the doctor never contends with a + running scan; SC-003's 500 ms wall-clock budget is comfortable. + +**Alternatives considered**: +- A new `doctor` socket method that bundles `status` + + `list_containers` + `list_panes` filtered by container id: would + reduce three round-trips to one, but FR-022 forbids new methods + in this slice and the latency saving (≤ 100 ms) does not justify + the API surface. Deferred to a future feature if the cost ever + matters. diff --git a/specs/005-container-thin-client/spec.md b/specs/005-container-thin-client/spec.md index 89fe0c0..200e288 100644 --- a/specs/005-container-thin-client/spec.md +++ b/specs/005-container-thin-client/spec.md @@ -66,17 +66,27 @@ A developer wants the in-container CLI to know *which* bench container and *whic - The container is run with `--network host` and `--hostname` is unset, so `/etc/hostname` is the host's hostname rather than the container short id; identity detection MUST fall through to the daemon cross-check rather than reporting the host as a "container". - The container is privileged and `/proc/self/cgroup` is empty (cgroup namespace not isolated); identity detection MUST treat that signal as "no candidate" and proceed to hostname / env. - Two FEAT-003 rows have the same short-id prefix as the candidate (theoretical for 12-char short ids); the doctor MUST report `multi_match` rather than picking one arbitrarily. -- The CLI is run on the host but inside a tmux pane that is not in the FEAT-004 registry (e.g., FEAT-004 only scans inside containers); the tmux check MUST print `pane_unknown_to_daemon` as `info` (not `fail`) on the host so the operator is not misled into thinking something is wrong. +- The CLI is run on the host but inside a tmux pane that is not in the FEAT-004 registry (e.g., FEAT-004 only scans inside containers); the tmux check MUST print `pane_unknown_to_daemon` (`fail` per `contracts/cli.md` §`tmux_pane_match`, in line with the FR-018 closed sub-code mapping). Because `tmux_pane_match` is a non-required check, the host CLI exit code is `5` (degraded) rather than `2`/`3`; the operator is signaled by the actionable message that the host pane is simply not yet registered, not that something is broken. T057 walk 2026-05-06 amended this edge case from a prior "info on host" rule to the closed sub-code mapping the implementation and `T034` test ship. - `$TMUX` is set but its first comma-separated field (the tmux socket path) is unreadable from inside the container; the tmux check MUST report the parsed values and skip the daemon cross-check rather than crashing. - `$TMUX_PANE` is set to a value that does not match the `%N` shape; the tmux check MUST flag `output_malformed` rather than silently dropping the pane id. - The daemon is a *newer* schema than this CLI build supports; doctor's `daemon_status` check MUST report `schema_version_newer` and explain which build is needed (matches FEAT-003 R-012 forward-compat policy). -- The daemon is reachable but `list_containers` returns an empty result (no FEAT-003 scan has run yet); the container cross-check MUST print `no_containers_known` and advise running `agenttower scan --containers` from the host. +- The daemon is reachable but `list_containers` returns an empty result (no FEAT-003 scan has run yet); the container cross-check MUST classify the result with the existing FR-007 closed-set outcome (`no_candidate` when no in-container signal produced a candidate, `no_match` when a candidate exists but the daemon's set is empty), set the structured `details.daemon_container_set_empty = true` flag on the row, and surface the actionable message `"daemon has no known containers; run \`agenttower scan --containers\` from the host"`. The closed FR-007 token set is not extended. - Two `agenttower config doctor` invocations run concurrently from inside the same container; both MUST succeed independently. The doctor performs only read-only socket calls; no daemon-side mutex is acquired. - The CLI binary is invoked with a working directory whose absolute path exceeds the kernel `sun_path` limit; the existing FEAT-002 `_connect_via_chdir` workaround already handles this, and FEAT-005 MUST NOT regress it. - `AGENTTOWER_SOCKET` and the in-container default both resolve to *different* reachable sockets owned by different daemons; the override wins (no warning) and the doctor reports the resolved path's source as `env_override`. - Doctor JSON output is requested while one or more checks would otherwise print free-form stderr noise; all check output MUST stay inside the JSON payload (no incidental stderr lines) when `--json` is set. - The user's host `$USER` is not present inside the container's `/etc/passwd`; this MUST NOT prevent the CLI from connecting (the socket-file mode is `0600` host-user-only, so the *host*-side uid that owns the socket must match the in-container effective uid that opens it; the doctor's `socket_reachable` check explains this when it fails with `permission_denied`). +## Clarifications + +### Session 2026-05-06 + +- Q: When `socket_reachable` (or another upstream gate) fails, does each downstream doctor check still attempt its socket round-trip, get fully omitted from the output, or always emit a row but skip the actual round-trip? → A: Always emit a row for every check; when an upstream gate has failed, the dependent round-trip is *not attempted* and the row reports `status=info` with sub-code `daemon_unavailable`. FR-027's "every check runs" means "every check produces a `CheckResult` row in the output," not "every check actually performs its socket call." This keeps the FR-014 JSON contract stable, keeps SC-003's 500 ms wall-clock budget achievable in degraded states, and aligns with Data-Model §6's existing "round-trip skipped when prior checks made it moot" wording. +- Q: What is the canonical closed-set token for the `container_identity` outcome when no in-container detection signal fires (e.g., the doctor is invoked on the host shell, or inside an unsupported sandbox where the FR-004 signals are silent)? → A: `host_context` is canonical. The five-token closed set in FR-007 (`unique_match`, `multi_match`, `no_match`, `no_candidate`, `host_context`) stands; the `not_in_container` token that appears in `contracts/cli.md` is a drafting drift and MUST be replaced with `host_context` everywhere in the contracts directory during the post-clarify refresh. `host_context` is preferred over `not_in_container` because it positively names the operational state (the doctor is running on the host outside any container) rather than negating an attribute, and it generalizes to non-Docker hosts. +- Q: Where does `no_containers_known` (Edge Case 11 — daemon reachable but `list_containers` returns empty) appear in the JSON contract, given the FR-007 closed set has only five tokens? → A: Do **not** add a sixth top-level token. The FR-007 5-token closed set stays frozen. `no_containers_known` is a structured **`details` qualifier** attached to the existing `no_candidate` (when no signal produced a candidate) or `no_match` (when a candidate exists but the daemon's set is empty) outcome — specifically `details.daemon_container_set_empty = true` plus the actionable message `"daemon has no known containers; run 'agenttower scan --containers' from the host"`. This preserves FR-014 closed-set stability and matches the existing pattern where sub-codes live in `details`, not in the top-level status token. +- Q: When `/proc/self/cgroup` has multiple lines whose last segments match the FR-004 closed-pattern set (cgroup v1 multi-subsystem), how does FR-006 step (2) pick the candidate? → A: Pre-scan **all** matching lines, then: (i) if every matching line yields the same container id, use that id as the candidate (FR-006's "first cgroup line" rule degenerates to a unique answer); (ii) if two or more matching lines yield *distinct* container ids, classify the cross-check outcome as `multi_match` (existing FR-007 token) and surface every observed candidate in a structured `details.cgroup_candidates` array — the doctor MUST NOT pick one arbitrarily. No new closed-set token is added. The cgroup v2 unified-hierarchy line (`0::/...`) is treated identically: its last segment is matched against the same FR-004 prefix set; v1 vs v2 is not distinguished in the public contract. +- Q: When the daemon's `status` round-trip succeeds at the transport layer but the daemon answers with a structured `DaemonError` envelope (FEAT-002), which doctor check owns it and produces FR-018's exit code `3`? → A: `socket_reachable` is **transport-only**: it reports `pass` whenever the daemon returns any well-formed frame, including a structured `DaemonError`. The payload is then inspected by `daemon_status`, which reports `fail` with the new closed-set sub-code `daemon_error` (added to FR-017's sub-code set, alongside `schema_version_newer` and `schema_version_older`) and surfaces the daemon-supplied error message in `actionable_message` (sanitized per FR-028). FR-018's exit code `3` is the canonical "transport ok, daemon-side semantic problem" code: any `daemon_status` `fail` outcome maps to exit `3` — currently the closed sub-code set `{daemon_error, schema_version_newer}`. This preserves R-010's existing exit-class mapping for `schema_version_newer`, mirrors the schema-version layering already in place (transport ok at one layer, semantic problem at the next), keeps `socket_reachable`'s closed sub-code set strictly transport-level, and avoids growing the FR-012 six-check closed set. + ## Requirements *(mandatory)* ### Functional Requirements @@ -86,19 +96,19 @@ A developer wants the in-container CLI to know *which* bench container and *whic - **FR-003**: The default in-container mounted socket path is `/run/agenttower/agenttowerd.sock`. The CLI MUST treat this path as a candidate only when "running inside a container" is detected; on the host the host-default path is used and the in-container default is ignored. The container-runtime detection MUST NOT depend on the daemon being reachable (it is a local filesystem / `/proc` check). - **FR-004**: "Running inside a container" detection MUST use a closed-set signal pipeline: presence of `/.dockerenv`, presence of `/run/.containerenv`, or a `/proc/self/cgroup` line containing one of `docker/`, `containerd/`, `kubepods/`, or `lxc/`. None of these signals require root and none requires `docker exec` or any in-container subprocess beyond reading `/proc` and `/etc`. - **FR-005**: The same `agenttower` binary, run on the host with no container context, MUST behave bytewise identically to the FEAT-001 / FEAT-002 / FEAT-003 / FEAT-004 builds: same subcommand names, same flags, same default output, same `--json` shapes, same exit codes, same error messages, same socket-file authorization (`0600`, host user only). FEAT-005 MUST NOT add any error code to existing methods, MUST NOT add a new socket method, and MUST NOT modify any existing SQLite schema. -- **FR-006**: Container identity detection MUST resolve a single full container id from these signals in priority order: (1) `AGENTTOWER_CONTAINER_ID` env override (when set, the value is used verbatim as the candidate id); (2) the first cgroup path in `/proc/self/cgroup` whose last segment matches the closed pattern set in FR-004 — the candidate is the trailing identifier after the matched prefix; (3) the contents of `/etc/hostname` when running inside a container; (4) the value of `$HOSTNAME`. The candidate is then cross-checked against the daemon's FEAT-003 `list_containers` result by full-id equality first, then by 12-character short-id prefix match. +- **FR-006**: Container identity detection MUST resolve a single full container id from these signals in priority order: (1) `AGENTTOWER_CONTAINER_ID` env override (when set, the value is used verbatim as the candidate id); (2) `/proc/self/cgroup` — the detector MUST scan every line whose last segment matches the closed pattern set in FR-004 (this includes the cgroup v2 unified-hierarchy `0::/...` line and any per-subsystem cgroup v1 lines) and collect each line's trailing identifier as a cgroup candidate; if all matching lines yield the same trailing identifier, that identifier is the candidate; if two or more matching lines yield *distinct* trailing identifiers, the cross-check outcome is `multi_match` (per FR-007) with every observed identifier surfaced in a structured `details.cgroup_candidates` array, and the doctor MUST NOT pick one arbitrarily; (3) the contents of `/etc/hostname` when running inside a container; (4) the value of `$HOSTNAME`. The candidate (when unique) is then cross-checked against the daemon's FEAT-003 `list_containers` result by full-id equality first, then by 12-character short-id prefix match. - **FR-007**: The cross-check MUST classify the result into one closed-set outcome: `unique_match` (exactly one FEAT-003 row matches), `multi_match` (more than one row matches the candidate prefix), `no_match` (no row matches but a candidate was produced), `no_candidate` (every detection signal returned nothing), `host_context` (no in-container signal fired and `AGENTTOWER_CONTAINER_ID` is unset). `unique_match` is reported with the matched row's full id and name; every other outcome carries the candidate value, the signal that produced it, and a one-line `actionable_message`. - **FR-008**: The CLI MUST NOT widen the FEAT-003 container set on the basis of in-container detection (it is read-only). When detection produces `no_match`, doctor reports the candidate and advises running `agenttower scan --containers` from the host; the in-container CLI MUST NOT call `scan_containers` itself. - **FR-009**: Tmux pane self-detection MUST parse `$TMUX` (comma-separated `socket_path,server_pid,session_id`) and `$TMUX_PANE` (`%N` form). The CLI MUST extract the tmux socket path, the tmux session id (numeric or symbolic), and the pane id, and report them. When `$TMUX` is unset, the doctor's tmux check is `not_in_tmux` (not `fail`). -- **FR-010**: The tmux pane cross-check MUST query the daemon's FEAT-004 `list_panes` (filtered by the resolved container id when known) and look for a row whose `tmux_socket_path` and `tmux_pane_id` both match the parsed values. The outcomes are the closed-set `pane_match`, `pane_unknown_to_daemon`, `pane_ambiguous` (more than one row matches), and `not_in_tmux`. The cross-check MUST NOT call any new socket method beyond the existing FEAT-004 `list_panes`. +- **FR-010**: The tmux pane cross-check MUST query the daemon's FEAT-004 `list_panes` (filtered by the resolved container id when known) and look for a row whose `tmux_socket_path` and `tmux_pane_id` both match the parsed values. The outcomes are the closed-set 5-token classification `pane_match`, `pane_unknown_to_daemon`, `pane_ambiguous` (more than one row matches), `not_in_tmux`, and `output_malformed`. The first four tokens are cross-check outcomes; `output_malformed` propagates from FR-009 / FR-021 parsing failure (`$TMUX` set but unparseable, or `$TMUX_PANE` fails the `^%[0-9]+$` regex) and is surfaced on `tmux_present` and `tmux_pane_match` without performing the daemon round-trip. The cross-check MUST NOT call any new socket method beyond the existing FEAT-004 `list_panes`. - **FR-011**: The CLI MUST NOT spawn `tmux` or any other in-container subprocess to discover the local pane (avoiding the FEAT-004 in-container scope which is host-driven). All tmux self-detection is read-only inspection of `$TMUX` and `$TMUX_PANE` plus a daemon `list_panes` read. - **FR-012**: The new subcommand `agenttower config doctor` MUST run the following closed-set self-checks, in this order: `socket_resolved`, `socket_reachable`, `daemon_status`, `container_identity`, `tmux_present`, `tmux_pane_match`. Each check has a single closed-set status (`pass`, `warn`, `fail`, `info`, plus the per-check outcome tokens enumerated in FR-007 / FR-010). A check that produces `info` MUST NOT count toward a non-zero exit on its own. - **FR-013**: `agenttower config doctor` default human-readable output is one row per check on stdout: `\t\t`; failed-check actionable messages print on additional indented lines. A trailing summary line prints `summary\t\t/ checks passed`. The output MUST be sanitized of NUL bytes and terminal control bytes (matching FEAT-003 / FEAT-004 sanitization policy). - **FR-014**: `agenttower config doctor --json` MUST emit exactly one canonical JSON object on stdout per invocation, with top-level fields `summary` (`{exit_code, total, passed, warned, failed, info}`) and `checks` (a JSON object keyed by closed-set check code, each value `{status, source, details, actionable_message?}`). The keys and tokens MUST be stable across releases; new tokens MAY be added but never renamed. - **FR-015**: The doctor's `socket_resolved` check MUST report the resolved path AND its source token (FR-001). When the source is `env_override`, the validated value of `AGENTTOWER_SOCKET` is included; the raw env value is bounded to 4096 characters and sanitized of control bytes before being printed. -- **FR-016**: The doctor's `socket_reachable` check MUST attempt a single-frame round-trip to the daemon (reusing the existing FEAT-002 `status` method). A successful round-trip yields `pass` with the daemon's `daemon_version` and `schema_version` echoed in the row detail. A failure yields one of the closed-set sub-codes `socket_missing`, `socket_not_unix`, `connection_refused`, `permission_denied`, `connect_timeout`, `protocol_error`, each with a one-line actionable message. -- **FR-017**: The doctor's `daemon_status` check MUST report `pass` only when the round-trip from FR-016 succeeded AND the returned `schema_version` is one this CLI build supports. When the daemon's `schema_version` is greater than this CLI build supports, the check is `fail` with sub-code `schema_version_newer`. When it is less than supported, the check is `warn` with sub-code `schema_version_older` (a forward-compatible CLI may keep working but the operator should know). -- **FR-018**: Doctor exit codes are: `0` when every required check is `pass` or `info`; `1` for pre-flight failures (FEAT-005 not initialized, malformed `AGENTTOWER_SOCKET`); `2` when `socket_reachable` fails with `socket_missing` / `connection_refused` / `connect_timeout` (matches FEAT-002's existing daemon-unavailable code); `3` when the daemon round-trip succeeded but the daemon returned a structured error (matches FEAT-002); `5` (degraded; matches FEAT-003) when the round-trip succeeded but at least one non-required check is `fail` (e.g., `pane_unknown_to_daemon` after a successful socket+daemon path) AND no required check failed. `4` is reserved for internal CLI error per FEAT-002. +- **FR-016**: The doctor's `socket_reachable` check is **transport-only**: it MUST attempt a single-frame round-trip to the daemon (reusing the existing FEAT-002 `status` method) and report `pass` whenever the transport completes — i.e., the daemon answers with *any* well-formed frame, including a structured `DaemonError` envelope. The check MUST NOT inspect the response payload's semantic content; payload inspection is the responsibility of `daemon_status` (FR-017). On `pass`, the row detail echoes the daemon's `daemon_version` and `schema_version` when those fields are present in the reply (and is silent on them when the reply is a `DaemonError`). A transport failure yields one of the closed-set sub-codes `socket_missing`, `socket_not_unix`, `connection_refused`, `permission_denied`, `connect_timeout`, `protocol_error`, each with a one-line actionable message. +- **FR-017**: The doctor's `daemon_status` check inspects the payload returned by the FR-016 round-trip and MUST report `pass` only when (a) `socket_reachable` was `pass`, (b) the payload is a `status` reply (not a `DaemonError`), and (c) the returned `schema_version` is one this CLI build supports. The closed sub-code set on non-`pass` outcomes is: `daemon_error` (`fail` — payload is a structured `DaemonError`; the daemon-supplied message is surfaced in `actionable_message` after FR-028 sanitization), `schema_version_newer` (`fail` — daemon's `schema_version` is greater than this CLI supports), and `schema_version_older` (`warn` — daemon's `schema_version` is less than supported; a forward-compatible CLI may keep working but the operator should know). When `socket_reachable` was not `pass`, `daemon_status` MUST emit `status=info` with sub-code `daemon_unavailable` per the clarification on FR-027 (no new round-trip is attempted). +- **FR-018**: Doctor exit codes are: `0` when every required check is `pass` or `info`; `1` for pre-flight failures (FEAT-005 not initialized, malformed `AGENTTOWER_SOCKET`); `2` when `socket_reachable` fails with `socket_missing` / `connection_refused` / `connect_timeout` (matches FEAT-002's existing daemon-unavailable code); `3` when `socket_reachable` is `pass` but `daemon_status` is `fail` with any closed-set semantic sub-code (currently `daemon_error` — daemon returned a structured error envelope per FEAT-002 `DaemonError` — or `schema_version_newer` — daemon's `schema_version` exceeds this CLI build's `MAX_SUPPORTED_SCHEMA_VERSION` per R-010); `5` (degraded; matches FEAT-003) when the round-trip succeeded but at least one non-required check is `fail` (e.g., `pane_unknown_to_daemon` after a successful socket+daemon path) AND no required check failed. `4` is reserved for internal CLI error per FEAT-002. - **FR-019**: `agenttower config paths` MUST be extended to print one additional line `SOCKET_SOURCE=` (FR-001) without changing the existing `KEY=value` shape of the other lines. The new line MUST be the last line of the output. `agenttower config paths --json` (if introduced later) inherits the same field name; FEAT-005 MUST NOT introduce a `--json` mode for `config paths` if one does not already exist. - **FR-020**: All FEAT-005 in-container code (path resolution, container detection, tmux detection, doctor checks) MUST be pure read-only filesystem and `/proc` inspection plus existing FEAT-002 / FEAT-003 / FEAT-004 socket reads. FEAT-005 MUST NOT spawn any subprocess inside the container (no `id`, no `tmux`, no `cat`, no `docker`). FEAT-005 MUST NOT open any file outside `/proc/self/`, `/proc/1/`, `/etc/hostname`, `/run/.containerenv`, `/.dockerenv`, the resolved socket path, and the FEAT-001 host paths it already reads. - **FR-021**: All values read from `$TMUX`, `$TMUX_PANE`, `/proc/self/cgroup`, `/etc/hostname`, `$HOSTNAME`, and `AGENTTOWER_CONTAINER_ID` MUST be treated as untrusted data: NUL-byte stripped, terminal-control-byte stripped, length-bounded to 4096 characters, and never interpolated into a shell string. Out-of-shape values MUST surface as a closed-set `output_malformed` outcome on the relevant doctor check rather than crashing the CLI. @@ -107,7 +117,7 @@ A developer wants the in-container CLI to know *which* bench container and *whic - **FR-024**: The doctor's `--json` output and the CLI's stderr error messages MUST NOT leak the contents of `AGENTTOWER_SOCKET` beyond its sanitized, length-bounded form (FR-015). Raw stderr from the underlying `socket(2)` / `connect(2)` calls MUST be normalized to the closed-set sub-codes in FR-016 and a bounded one-line message rather than passed through verbatim. - **FR-025**: The feature MUST be testable end-to-end without a real bench container, by simulating "inside a container" through a closed-set of test hooks: a temporary `AGENTTOWER_TEST_PROC_ROOT` env var that points to a fixture directory standing in for `/proc` and `/etc`, plus the existing host-side daemon harness from FEAT-002 / FEAT-003 / FEAT-004. The hook is namespaced (`AGENTTOWER_TEST_*`) so it cannot be set accidentally in production; an integration test asserts the hook is unset when running the real binary. - **FR-026**: Existing FEAT-001 `agenttower config init` byte-for-byte output, FEAT-002 socket envelope shapes, FEAT-003 `containers` / `container_scans` schema and `list_containers` output, and FEAT-004 `panes` / `pane_scans` schema and `list_panes` output MUST remain unchanged. FEAT-005 adds two new CLI surfaces (`config doctor` subcommand; one new line on `config paths`) and one new env var (`AGENTTOWER_SOCKET`); it does not modify any existing surface beyond those two additive points. -- **FR-027**: Every doctor check MUST be individually short-circuitable for testing: a fixture that turns off `socket_reachable` MUST still exercise `tmux_present` and `container_identity`. The doctor MUST NOT abort early on a failing check. All required checks MUST run on every invocation so the operator sees the full picture in one run. +- **FR-027**: Every doctor check MUST emit exactly one `CheckResult` row on every invocation so the operator sees the full picture in one run; the doctor MUST NOT abort early on a failing check and MUST NOT omit a check from the output. However, "emit a row" does not imply "always perform the socket round-trip": when an upstream gate has already failed (e.g., `socket_reachable` fails before `daemon_status` runs, or `daemon_status` fails before `container_identity`'s `list_containers` round-trip, or before `tmux_pane_match`'s `list_panes` round-trip), the dependent check MUST skip its round-trip and emit a row with `status=info` and sub-code `daemon_unavailable` (with an actionable message that names the upstream failure). Every doctor check MUST also be individually short-circuitable for testing: a fixture that turns off `socket_reachable` MUST still produce rows for `tmux_present` and `container_identity` (per the closed sub-code rules above). - **FR-028**: The CLI MUST sanitize and bound every doctor row's `details` and `actionable_message` to 2048 characters and strip NUL bytes and terminal control bytes (matches FEAT-003 / FEAT-004 R-009 policy). Truncation MUST add a trailing `…` and MUST NOT split a multi-byte UTF-8 character. - **FR-029**: The CLI MUST write nothing to disk during `agenttower config doctor` (no SQLite writes, no JSONL appends, no log rotation, no file creation). Doctor is a pure read-only diagnostic. The host daemon's lifecycle log MAY record the underlying `status` round-trip exactly the way it already does for FEAT-002 callers; FEAT-005 introduces no new lifecycle log token. - **FR-030**: When `agenttower config doctor` fails to load required CLI configuration (FEAT-001 not initialized — host case only), it MUST exit `1` with the existing FEAT-001 `agenttower is not initialized: run \`agenttower config init\`` message rather than printing partial doctor output. The in-container case MUST NOT require local FEAT-001 init because the durable state lives on the host; the doctor MUST still run when the in-container `$HOME` lacks an `agenttower` config tree, sourcing only the resolved socket path and the daemon's `status` reply. @@ -117,7 +127,7 @@ A developer wants the in-container CLI to know *which* bench container and *whic - **Resolved Socket Path**: A pair `(path, source)` where `source ∈ {env_override, mounted_default, host_default}`. Computed at every CLI invocation; not persisted. Surfaced by `agenttower config paths` and by the doctor's `socket_resolved` check. - **Container Runtime Context**: A tagged value `host_context | container_context(detection_signals)` produced by FR-004 detection. Drives whether the in-container default mounted path is considered. Not persisted. - **Container Identity Resolution**: The output of FR-006 + FR-007: a candidate id, the signal that produced it, and the cross-check classification. Reported by the doctor's `container_identity` check; not persisted. -- **Tmux Self-Identity**: The parsed `(tmux_socket_path, tmux_session, tmux_pane_id)` triple from `$TMUX` + `$TMUX_PANE`, plus the `pane_match | pane_unknown_to_daemon | pane_ambiguous | not_in_tmux` cross-check classification against the daemon's FEAT-004 `list_panes`. Reported by the doctor's tmux checks; not persisted. +- **Tmux Self-Identity**: The parsed `(tmux_socket_path, tmux_session, tmux_pane_id)` triple from `$TMUX` + `$TMUX_PANE`, plus the `pane_match | pane_unknown_to_daemon | pane_ambiguous | not_in_tmux | output_malformed` 5-token classification against the daemon's FEAT-004 `list_panes` (`output_malformed` propagates from FR-009 / FR-021 parsing failure per FR-010). Reported by the doctor's tmux checks; not persisted. - **Doctor Check Result**: One row per closed-set check code with `(status, source?, details, actionable_message?)`. Aggregated into the doctor summary; surfaced as TSV by default and as canonical JSON under `--json`. Not persisted. ## Success Criteria *(mandatory)* diff --git a/specs/005-container-thin-client/tasks.md b/specs/005-container-thin-client/tasks.md new file mode 100644 index 0000000..64e0f42 --- /dev/null +++ b/specs/005-container-thin-client/tasks.md @@ -0,0 +1,298 @@ +# Tasks: Container-Local Thin Client Connectivity (FEAT-005) + +**Input**: Design documents from `/specs/005-container-thin-client/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/cli.md, contracts/socket-api.md, quickstart.md, checklists/security.md + +**Tests**: REQUIRED. SC-009 + plan §Testing pin unit coverage for socket-path resolution, container-runtime detection, identity-signal parsing, daemon cross-check classification, tmux env parsing, doctor TSV/JSON rendering, exit-code mapping, and per-field sanitization/truncation; integration coverage for every US1 / US2 / US3 acceptance scenario plus three named edge cases — all without a real container, real Docker daemon, or real tmux server. Test tasks are written before the implementation they cover and MUST FAIL before that implementation runs. + +**Organization**: Tasks are grouped by user story (P1 → P3) so each can be implemented and validated independently against the acceptance scenarios in spec.md. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Different files, no dependencies on incomplete tasks → can run in parallel. +- **[Story]**: Maps to spec.md user stories (`[US1]`, `[US2]`, `[US3]`). +- File paths are relative to the repository root (FEAT-005 worktree). + +## Path Conventions + +Single-project Python layout. Source under `src/agenttower/`, tests under `tests/unit/` and `tests/integration/`. Paths shown match plan.md §Project Structure exactly. FEAT-005 introduces exactly one new package (`src/agenttower/config_doctor/`) and additive-only edits to `cli.py`, `paths.py`, and `socket_api/client.py`. + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Confirm the worktree is correct and pin the FEAT-005 scope. No new tooling, no new runtime dependencies (stdlib only per plan §Primary Dependencies), no new top-level package — only the new `config_doctor/` subpackage. + +- [X] T001 Verify worktree state: `pwd` is `/workspace/projects/AgentTower-worktrees/005-container-thin-client`, branch is `005-container-thin-client`, and FEAT-001..004 artifacts exist (`src/agenttower/cli.py`, `src/agenttower/paths.py`, `src/agenttower/socket_api/client.py`, `src/agenttower/state/schema.py` with `CURRENT_SCHEMA_VERSION = 3`, `src/agenttower/discovery/`, `src/agenttower/tmux/`, `tests/integration/_daemon_helpers.py`, `tests/integration/test_cli_scan_panes.py`); abort tasks if any are missing. +- [X] T002 [P] Confirm `pyproject.toml` already pins `requires-python>=3.11` and the `[test]` extra still pins `pytest>=7`; do not add new runtime dependencies; verify no new top-level package is needed (only the new `src/agenttower/config_doctor/` subpackage). + +**Checkpoint**: Worktree validated; FEAT-005 may extend the existing layout under `src/agenttower/config_doctor/` and the surgical extension points in `cli.py` / `paths.py` / `socket_api/client.py`. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Build the cross-cutting modules every user story needs. ⚠️ No user-story tasks may start until this phase is complete. Order is load-bearing: `sanitize.py` ships first because every other FEAT-005 module imports it; the test fixture root + conftest extension lands second so every later test file can drive a fake `/proc`; the additive `.kind` attribute on `DaemonUnavailable` follows because doctor-reachable mapping (R-009) depends on it; `paths.py` and `runtime_detect.py` are foundational because `socket_resolve` consumes both; the parsing-only halves of `identity.py` / `tmux_identity.py` land before any cross-check (which lives in `checks.py` per US2); finally the `cli.py` plumbing rewires every existing socket-using command through `resolve_socket_path` without changing any byte of their existing output (FR-005, SC-007). + +### 2.1 Sanitization helper (single source of truth for FR-021 + FR-028) + +- [X] T003 Create `src/agenttower/config_doctor/__init__.py` as a package marker; the file exports nothing on this task and is fleshed out as later tasks add `runner`, `ResolvedSocket`, `IdentityResolution`, and `TmuxIdentity` re-exports (plan §Structure Decision). Also define `MAX_SUPPORTED_SCHEMA_VERSION = 3` here as the single source of truth for R-010 (CHK066). +- [X] T004 Create `src/agenttower/config_doctor/sanitize.py` with `sanitize_text(value: str, max_length: int) -> tuple[str, bool]` per R-008: drop NUL `\x00`; drop C0 control bytes (`\x01`–`\x08`, `\x0b`–`\x1f`, `\x7f`); replace `\t` and `\n` with a single space; UTF-8-aware character-based truncation to `max_length`; append `…` (U+2026, single character — not three ASCII dots) when truncation occurred; return `(sanitized_value, truncated)`. Expose four named caps: `ENV_VALUE_CAP = 4096`, `FILE_CONTENT_CAP = 4096`, `DETAILS_CAP = 2048`, `ACTIONABLE_CAP = 2048` (FR-015, FR-021, FR-028, R-008). + +### 2.2 Test fixture root + conftest extension + +- [X] T005 [P] Create `tests/unit/test_path_sanitize.py` (write FIRST; MUST FAIL until T004 is complete): cover NUL drop, C0 strip, `\t`/`\n` → space substitution, 2048 / 4096 caps, `…` truncation marker is a single Unicode character (U+2026), multi-byte-UTF-8 boundary preservation (truncation never splits a multi-byte character), `truncated` flag round-trips correctly; assert exact spelling of the four cap constants exported by `sanitize.py` (FR-021, FR-028, R-008, checklist CHK043–CHK048). +- [X] T006 [P] Extend `tests/integration/conftest.py` (or create `tests/integration/_proc_fixtures.py`) to provide a `fake_proc_root(tmp_path, *, dockerenv=False, containerenv=False, cgroup_lines=None, hostname=None)` fixture that materializes a directory tree containing `proc/self/cgroup`, `proc/1/cgroup`, `etc/hostname`, and the `/.dockerenv` / `/run/.containerenv` sentinels under the temp root, returning the path to be passed in `AGENTTOWER_TEST_PROC_ROOT`; the fixture MUST NOT touch the real filesystem outside `tmp_path` (R-011, FR-025). + +### 2.3 Additive `.kind` on `DaemonUnavailable` (R-009) + +- [X] T007 Create `tests/unit/test_daemon_unavailable_kind.py` (write FIRST; MUST FAIL until T008 is complete): instantiate `DaemonUnavailable` for each underlying signal (`FileNotFoundError`, `ConnectionRefusedError`, `OSError(EACCES)`, `TimeoutError`, generic `OSError`, `UnicodeDecodeError`, `json.JSONDecodeError`, malformed envelope, non-dict result) and assert `.kind` is the closed-set value from R-009; assert `str(exc)` and `repr(exc)` are byte-for-byte unchanged from the FEAT-002 build (use a captured baseline) (FR-026, R-009, contracts/socket-api.md §C-API-502, checklist CHK074). +- [X] T008 Extend `src/agenttower/socket_api/client.py` to add an additive `kind: Literal["socket_missing", "socket_not_unix", "connection_refused", "permission_denied", "connect_timeout", "protocol_error"]` attribute on `DaemonUnavailable`; the existing `__init__` signature, message text, and repr MUST remain byte-for-byte unchanged; `kind` defaults to `"connect_timeout"` only on the generic `OSError` fallback path; every existing FEAT-002 / FEAT-003 / FEAT-004 caller of `send_request(...)` continues to work unmodified (R-009, FR-026, contracts/socket-api.md §C-API-502, checklist CHK074). + +### 2.4 `paths.py` extension: `resolve_socket_path` (R-001) + +- [X] T009 Create `tests/unit/test_socket_path_resolution.py` (write FIRST; MUST FAIL until T010 + T011 are complete): cover the FR-001 priority order (`AGENTTOWER_SOCKET` → mounted-default → host-default), the FR-002 validator gates (non-empty after `.strip()`, absolute, NUL-free, exists-as-`S_ISSOCK` after exactly one `os.readlink` follow), every `` token spelled identically to FR-002 (`value is empty`, `value is not absolute`, `value contains NUL byte`, `value does not exist`, `value is not a Unix socket`), the SC-002 50 ms wall-clock budget enforced via `time.perf_counter()` and `assert elapsed < 0.050` for each invalid case, and the `(path, source)` shape with `source ∈ {env_override, mounted_default, host_default}` (FR-001, FR-002, R-001, SC-002, checklist CHK001–CHK012). +- [X] T010 Create `src/agenttower/config_doctor/socket_resolve.py` exposing `resolve_socket_path(env: Mapping[str, str], host_paths: Paths, runtime_context: RuntimeContext) -> ResolvedSocket` per R-001 + data-model §3.1: applies the four FR-002 gates with the closed-set `` tokens; consults the mounted-default `/run/agenttower/agenttowerd.sock` only when `RuntimeContext` is `ContainerContext` AND the path resolves (after exactly one `os.readlink`) to `S_ISSOCK`; otherwise returns the `host_default` path from `host_paths.socket`. Invalid `AGENTTOWER_SOCKET` raises `SocketPathInvalid(reason)` carrying the closed-set `` token; the CLI maps this to exit `1` with the FR-002 stderr message (R-001, R-002, FR-001, FR-002). +- [X] T011 Extend `src/agenttower/paths.py` to define `@dataclass(frozen=True) class ResolvedSocket(path: Path, source: Literal["env_override", "mounted_default", "host_default"])` and re-export it; the existing `Paths.socket` field is unchanged so every FEAT-001 / FEAT-002 reader continues to work without modification (data-model §3.1, FR-019, FR-026). + +### 2.5 `runtime_detect.py` (R-003) + +- [X] T012 Create `tests/unit/test_runtime_detect.py` (write FIRST; MUST FAIL until T013 is complete): cover each closed-set signal (`/.dockerenv`, `/run/.containerenv`, `/proc/self/cgroup` lines whose final segment matches `docker/`, `containerd/`, `kubepods/`, `lxc/`); empty / unparseable cgroup → `no_signal`; `IOError` swallowed (not propagated); `AGENTTOWER_TEST_PROC_ROOT` rooting applied uniformly to every detection read path and to no other path; host-context fall-through when no signal fires; `RuntimeContext` tagged-union shape per data-model §3.2; rejects every cgroup-suffix not in the closed set (Firejail, Bubblewrap, systemd-nspawn fall to `host_context`); enumeration tokens `{docker/, containerd/, kubepods/, lxc/}` are spelled identically to FR-004 / R-003 / plan §Constraints (FR-003, FR-004, R-003, R-011, checklist CHK013–CHK023, CHK087). +- [X] T013 Create `src/agenttower/config_doctor/runtime_detect.py` exposing `detect(proc_root: str | None = None) -> RuntimeContext`: reads `proc_root or os.environ.get("AGENTTOWER_TEST_PROC_ROOT", "/")`; checks `/.dockerenv` existence, `/run/.containerenv` existence, then scans `/proc/self/cgroup` for any line whose final segment matches `(docker|containerd|kubepods|lxc)/`; returns `ContainerContext(detection_signals=...)` if any fire, otherwise `HostContext()`; swallows `IOError` / `OSError` from cgroup reads (FR-003, FR-004, FR-020, R-003). + +### 2.6 `identity.py` parsing-only half (without daemon cross-check) + +- [X] T014 Create `tests/unit/test_container_identity.py` (write FIRST; MUST FAIL until T015 is complete; cross-check classifier is exercised in Phase 5): cover the four-step precedence (`AGENTTOWER_CONTAINER_ID` → cgroup last-segment → `/etc/hostname` → `$HOSTNAME`); `signal` token spelled identically to data-model §3.3 (`env`, `cgroup`, `hostname`, `hostname_env`); empty cgroup + valid hostname yields `signal="hostname"`; `--network host` hostname collision (in-container `/etc/hostname` equals host hostname) produces a candidate that the cross-check will later classify as `no_match`; `AGENTTOWER_CONTAINER_ID` is used verbatim and is sanitized through FR-021 before any output; per-source caps applied via `sanitize.py`. Add three multi-line cgroup parametrize ids per Clarifications 2026-05-06 (FR-006 multi-line rule): (a) `cgroup_v2_unified_only` — single `0::/...` line yielding one id; (b) `cgroup_v1_consistent` — multiple per-subsystem matching lines all yielding the same trailing identifier (helper returns a single candidate); (c) `cgroup_v1_inconsistent` — multiple matching lines yielding *distinct* trailing identifiers (helper returns the tuple of distinct ids so the Phase 5 classifier produces `multi_match` with `details.cgroup_candidates`); none of the three may pick a candidate arbitrarily (FR-006, FR-007, R-004, checklist CHK024–CHK032). +- [X] T015 Create `src/agenttower/config_doctor/identity.py` exposing `detect_candidate(env: Mapping[str, str], proc_root: str | None = None) -> IdentityCandidate | tuple[str, ...] | None` per data-model §3.3 and Clarifications 2026-05-06: implements the FR-006 four-step precedence (first non-empty wins). For step 2 (cgroup) the helper MUST scan **every** `/proc/self/cgroup` line whose last segment matches the FR-004 closed-pattern set (this includes the cgroup v2 unified-hierarchy `0::/...` line and any per-subsystem cgroup v1 lines), collect each matching line's trailing identifier, and: (i) if every matching line yields the same identifier, return a single `IdentityCandidate(signal="cgroup", candidate=)`; (ii) if two or more matching lines yield *distinct* identifiers, return the tuple of distinct identifiers so the cross-check classifier can produce `multi_match` with `details.cgroup_candidates`. Applies FR-021 sanitization through `sanitize.py` with `ENV_VALUE_CAP` for env values and `FILE_CONTENT_CAP` for `/etc/hostname` / cgroup line contents; returns `None` when every signal is empty; never opens any file outside the FR-020 allowlist; the cross-check classifier (`classify_identity(candidate, runtime_context, list_containers) -> IdentityResolution`) is stubbed to raise `NotImplementedError` here and is implemented in Phase 5 (FR-006, FR-007, FR-008, FR-021, R-004). + +### 2.7 `tmux_identity.py` parsing-only half (without daemon cross-check) + +- [X] T016 Create `tests/unit/test_tmux_self_identity.py` (write FIRST; MUST FAIL until T017 is complete; cross-check classifier is exercised in Phase 5): cover `$TMUX` comma-split into `(socket_path, server_pid, session_id)` (split on the first two commas only — extra commas inside `session_id` are preserved); `$TMUX_PANE` matched against the literal regex `^%[0-9]+$`; `not_in_tmux` when `$TMUX` is unset; `output_malformed` when `$TMUX` is set but unparseable OR when `$TMUX_PANE` fails the regex (whitespace, NUL bytes, other characters); per-field sanitization with `ENV_VALUE_CAP`; closed-set classification tokens spelled identically to data-model §3.4 (`pane_match`, `pane_unknown_to_daemon`, `pane_ambiguous`, `not_in_tmux`, `output_malformed`); cite that `output_malformed` appears in research/data-model/contract but FR-010 lists only four — task locks the contract reading and includes `output_malformed` as the fifth token (FR-009, FR-010, FR-011, FR-021, R-005, checklist CHK035–CHK042, CHK037). +- [X] T017 Create `src/agenttower/config_doctor/tmux_identity.py` exposing `parse_tmux_env(env: Mapping[str, str]) -> ParsedTmuxEnv` per data-model §3.4: parses `$TMUX` (split on first two commas) and `$TMUX_PANE` (regex `^%[0-9]+$`); returns a `ParsedTmuxEnv(in_tmux, tmux_socket_path, server_pid, session_id, tmux_pane_id, pane_id_valid, malformed_reason)`; routes every parsed field through `sanitize.py`; the cross-check classifier (`classify_tmux(parsed, list_panes) -> TmuxIdentity`) is stubbed to raise `NotImplementedError` here and is implemented in Phase 5 (FR-009, FR-010, FR-011, FR-021, R-005). + +### 2.8 CLI plumbing — wire `AGENTTOWER_SOCKET` resolution into every existing socket-using command without changing their byte output + +- [X] T018 Create `tests/integration/test_cli_config_paths_socket_source.py` (write FIRST; MUST FAIL until T020 is complete): assert that the existing FEAT-001 six-line `KEY=value` output (in declared `Paths` field order: `CONFIG_FILE`, `STATE_DB`, `EVENTS_FILE`, `LOGS_DIR`, `SOCKET`, `CACHE_DIR`) is byte-for-byte unchanged AND a seventh line `SOCKET_SOURCE=` is appended as the LAST line; cover host context (`SOCKET_SOURCE=host_default`), in-container context with `AGENTTOWER_TEST_PROC_ROOT` fixture firing (`SOCKET_SOURCE=mounted_default`), and `AGENTTOWER_SOCKET=/abs/path/to/sock` overrides (`SOCKET_SOURCE=env_override`); assert no `--json` mode is introduced for `config paths` (FR-019, FR-026, contracts/cli.md §C-CLI-502, checklist CHK072–CHK073). +- [X] T019 Extend `src/agenttower/cli.py` to register the `config doctor` subparser (handler stub raises `NotImplementedError` here; flesh-out lands in Phase 4) and to wire `AGENTTOWER_SOCKET` resolution through `config_doctor.socket_resolve.resolve_socket_path` into every existing socket-using command (`status`, `list-containers`, `list-panes`, `scan --containers`, `scan --panes`, `ensure-daemon`, `stop-daemon`); each command now calls the resolver before constructing the FEAT-002 client; on `SocketPathInvalid` the CLI exits `1` with the literal stderr `error: AGENTTOWER_SOCKET must be an absolute path to a Unix socket: ` and does NOT silently fall back; no other byte of any existing command's stdout, stderr, or exit code changes (FR-001, FR-002, FR-005, SC-002, SC-007, contracts/cli.md §C-CLI-501). +- [X] T020 Extend `src/agenttower/cli.py` `config paths` handler to append exactly one trailing line `SOCKET_SOURCE=` after the existing nine `KEY=value` lines; the new line MUST be the last line; no `--json` mode is introduced; the resolved `(path, source)` comes from one call to `resolve_socket_path` shared with the `SOCKET=` line (so the two values cannot drift) (FR-019, FR-026, contracts/cli.md §C-CLI-502). + +**Checkpoint**: Foundation ready — `sanitize.py` is loaded, the fake-proc fixture is in `conftest.py`, `DaemonUnavailable.kind` is set, `resolve_socket_path` is available and wired into every existing CLI command, runtime detection compiles, identity / tmux parsers compile (with cross-check stubbed), `config paths` shows `SOCKET_SOURCE=`, and `config doctor` subparser is registered (handler raises `NotImplementedError` until Phase 4). + +--- + +## Phase 3: User Story 1 — Run the agenttower CLI from inside a bench container against the host daemon (Priority: P1) 🎯 MVP + +**Goal**: Running `agenttower status` from inside a bench container returns the same eight-key payload the host CLI returns; `AGENTTOWER_SOCKET` overrides the resolution; missing mount surfaces the FEAT-002 daemon-unavailable message and exit `2`; host-side behavior is bytewise unchanged (spec §User Story 1, AS1–AS4, edge cases 1, 2, 3, 12, 14, SC-001, SC-002, SC-007). + +**Independent Test**: With the FEAT-002 host-daemon harness running, a second `agenttower` subprocess invoked with `AGENTTOWER_TEST_PROC_ROOT` pointing at a fake-`/proc` fixture that fires the cgroup signal, `AGENTTOWER_SOCKET` overriding to a temp socket, and a mounted-default candidate at `/run/agenttower/agenttowerd.sock` MUST produce a `status` payload byte-for-byte equivalent (modulo `socket_path` and the volatile `pid` / `start_time` / `uptime_seconds` fields) to the host-side invocation. + +### Tests for User Story 1 (write FIRST; MUST FAIL before US1 implementation) + +- [X] T021 [P] [US1] Create `tests/integration/test_cli_in_container_status.py` (US1 AS1, SC-001): with the FEAT-002 daemon spawned under an isolated `$HOME`, fixture-fire `AGENTTOWER_TEST_PROC_ROOT` so cgroup detection fires, run `agenttower status` from a subprocess whose env simulates "inside a container", assert exit `0` and the eight `status` keys (`alive`, `pid`, `start_time`, `uptime_seconds`, `socket_path`, `state_path`, `schema_version`, `daemon_version`) appear; assert the stable subset (`alive`, `state_path`, `schema_version`, `daemon_version`) is byte-for-byte equivalent between host and in-container invocations; assert no `docker`, no `tmux`, no AF_INET/AF_INET6 socket is opened (FR-001, FR-005, SC-001). +- [X] T022 [P] [US1] Create `tests/integration/test_cli_in_container_socket_override.py` (US1 AS2, edge case 14): with the daemon spawned at a temp socket path, set `AGENTTOWER_SOCKET=` AND fixture-fire `AGENTTOWER_TEST_PROC_ROOT` so the mounted-default would otherwise apply, run `agenttower status` and assert the override wins (no warning, no fall-through), `agenttower config paths` reports `SOCKET_SOURCE=env_override`, and the resolved socket path is the override value (FR-001, FR-002, R-001, SC-002, contracts/cli.md §C-CLI-501). +- [X] T023 [P] [US1] Create `tests/integration/test_cli_no_socket_mount.py` (US1 AS4, edge case 3): fixture-fire `AGENTTOWER_TEST_PROC_ROOT` so runtime detection fires, but ensure `/run/agenttower/agenttowerd.sock` does NOT exist (the developer forgot the `-v` mount); with `AGENTTOWER_SOCKET` unset, run `agenttower status` and assert exit `2` with the existing FEAT-002 daemon-unavailable stderr message preserved byte-for-byte; daemon stays alive (`agenttower status` against the real socket succeeds afterwards) (FR-005, FR-026, SC-007, contracts/cli.md §C-CLI-501). +- [X] T024 [P] [US1] Create `tests/integration/test_cli_in_container_unsupported_signals.py` (edge cases 1, 2, 4, 5, 6) with explicit pytest parametrize ids `relative_path`, `empty_value`, `nul_byte`, `broken_symlink`, `regular_file`, `directory_target`, `cgroup_empty_privileged`, `network_host_hostname_collision`, `multi_match_short_prefix`: cover each `AGENTTOWER_SOCKET` malformed shape with the literal `` tokens (`relative/path` → `value is not absolute`; empty → `value is empty`; NUL byte → `value contains NUL byte`; broken symlink → `value does not exist`; regular file at the path → `value is not a Unix socket`; directory at the path → `value is not a Unix socket`); each MUST exit `1` within 50 ms wall-clock with the FR-002 stderr message AND no daemon-side state changes (assert daemon `pane_scans` / `container_scans` row count unchanged); also cover privileged container with empty `/proc/self/cgroup` (signal pipeline falls through to `/.dockerenv` or `host_context`) and `--network host` hostname collision (`/etc/hostname` equals host hostname) — both produce candidates that the doctor's container check (Phase 5) will classify as `no_match`, NOT crash here (FR-002, FR-003, FR-004, FR-021, SC-002, contracts/cli.md §C-CLI-501). + +### Implementation for User Story 1 + +- [X] T025 [US1] Verify the `config paths` handler from T020 emits `SOCKET_SOURCE=` correctly across all three `source` values; this is the smallest user-visible US1 surface and gates T021's assertion that host invocation reports `SOCKET_SOURCE=host_default` (FR-019, FR-026). +- [X] T026 [US1] Verify the `cli.py` plumbing from T019 routes every existing socket-using command (`status`, `list-containers`, `list-panes`, `scan --containers`, `scan --panes`, `ensure-daemon`, `stop-daemon`) through `resolve_socket_path` and surfaces `SocketPathInvalid` as the FR-002 stderr + exit `1` path; assert no other byte of these commands' stdout, stderr, or exit codes changes (FR-001, FR-002, FR-005, SC-007, contracts/cli.md §C-CLI-501). +- [X] T027 [US1] Implement the `_connect_via_chdir` preservation guard at the unit level: the FEAT-002 deep-cwd (`sun_path` 108-byte) workaround MUST NOT regress under FEAT-005's resolver; add a unit test in `tests/unit/test_socket_path_resolution.py` that constructs a deep-cwd path and asserts the resolver returns it untouched (the chdir workaround is applied later by `client.py`, not by the resolver) (edge case 12, plan §Constraints, checklist CHK011). +- [X] T028 [US1] Run T021–T024 against the live daemon and verify SC-001 (eight-key status parity), SC-002 (50 ms pre-flight rejection), and SC-007 (host-context byte parity) are met before moving to US2. + +**Checkpoint**: User Story 1 fully functional. The same `agenttower` binary returns the same eight-key `status` payload from inside a simulated bench container as it does on the host; `AGENTTOWER_SOCKET` overrides; missing-mount preserves the FEAT-002 exit `2` message; host-side commands are byte-identical to the FEAT-004 build. + +--- + +## Phase 4: User Story 2 — `agenttower config doctor` reports actionable diagnostics (Priority: P2) + +**Goal**: `agenttower config doctor` runs the closed-set six checks in fixed order on every invocation; produces TSV by default and canonical JSON under `--json`; exit codes mirror FEAT-002 / FEAT-003; daemon-down still completes; `--json` is stdout-pure with no incidental stderr (spec §User Story 2, AS1–AS5, FR-012, FR-013, FR-014, FR-016, FR-017, FR-018, FR-027, FR-029, R-006, R-007). + +**Independent Test**: With the daemon spawned under the FEAT-002 harness and the `AGENTTOWER_TEST_PROC_ROOT` fixture set, `agenttower config doctor` produces six TSV rows + a `summary` line in fixed order with byte-stable status tokens; `--json` produces one canonical JSON object whose `summary.exit_code` matches the CLI exit; daemon-down produces the FR-018 exit `2` and the FR-016 closed sub-codes with no raw errno text leak. + +### Tests for User Story 2 (write FIRST; MUST FAIL before US2 implementation) + +- [X] T029 [P] [US2] Create `tests/unit/test_doctor_render.py` (FR-013, FR-024, FR-028): cover TSV row formatting (`\t\t`); indented `actionable_message` lines for non-pass rows; `summary\t\t/` trailing line; sanitization of NUL / C0 bytes per row; per-row 2048-char truncation via `sanitize.py`; no leak of raw `AGENTTOWER_SOCKET` value beyond the sanitized + 4096-bounded form; truncation marker `…` is U+2026 single character (FR-013, FR-015, FR-021, FR-024, FR-028, R-008, checklist CHK043–CHK050, CHK056–CHK057). +- [X] T030 [P] [US2] Create `tests/unit/test_doctor_json_contract.py` (FR-014): cover the canonical JSON envelope shape — top-level `summary {exit_code, total, passed, warned, failed, info}` with fixed field order, `checks` keyed by closed-set check codes (`socket_resolved`, `socket_reachable`, `daemon_status`, `container_identity`, `tmux_present`, `tmux_pane_match`); status tokens stable (`pass`, `warn`, `fail`, `info`); per-check `actionable_message` and `sub_code` keys are OMITTED (not `null`) when not applicable; `source` is present on `pass` rows; the four status tokens and the six check codes are spelled identically to data-model §3.5 / §3.6 and contracts/cli.md; assert `not_in_container` synonym is NEVER emitted (only `host_context` is; resolves CHK034); locks every closed-set token enumeration listed in `Closed-set-token lock list` from the validation review (FR-014, R-007, data-model §3.6, contracts/cli.md §C-CLI-501, checklist CHK034, CHK054, CHK086–CHK090). +- [X] T031 [P] [US2] Create `tests/unit/test_doctor_exit_codes.py` (FR-018): cover the closed-set exit-code mapping `{0, 1, 2, 3, 5}` across every required-check pattern; `0` when every required check is `pass`/`info`; `1` for pre-flight (malformed `AGENTTOWER_SOCKET`, FEAT-001 not initialized on host context); `2` when `socket_reachable` fails with sub-code in `{socket_missing, connection_refused, connect_timeout}`; `3` when `socket_reachable` is `pass` AND `daemon_status` is `fail` with sub-code `daemon_error` (round-trip succeeded but the daemon returned a structured `DaemonError` envelope) OR `schema_version_newer` (daemon ahead of CLI per R-010) — assert both sub-codes map to exit 3 per Clarifications 2026-05-06 and FR-018 layering; `5` when round-trip ok and required checks pass but a non-required check (`container_identity`, `tmux_present`, `tmux_pane_match`) is `fail`; `4` is reserved and the test asserts it is NEVER produced under any pure FEAT-005 control flow (negative test); `info` outcomes never push the exit code above `0` on their own; assert `socket_reachable` reports `pass` whenever the daemon returns any well-formed frame including a `DaemonError` envelope (transport-only contract per R-009 / Clarifications 2026-05-06), and that `daemon_status` is the check that flips to `fail` with sub-code `daemon_error` (FR-016, FR-017, FR-018, R-006, R-009, R-010, contracts/cli.md §C-CLI-501, checklist CHK059–CHK063). +- [X] T032 [P] [US2] Create `tests/integration/test_cli_config_doctor_healthy.py` (US2 AS1, US3 AS1): with the daemon spawned, the `AGENTTOWER_TEST_PROC_ROOT` fixture firing cgroup detection, `AGENTTOWER_TEST_DOCKER_FAKE` seeding one container that matches the cgroup-derived id, and `AGENTTOWER_TEST_TMUX_FAKE` seeding one pane that matches `$TMUX`/`$TMUX_PANE`, run `agenttower config doctor`; assert exit `0`, six TSV rows in fixed FR-012 order each `pass`, trailing `summary\t0\t6/6 checks passed`; assert SC-003 500 ms wall-clock budget via `time.perf_counter()` and `assert elapsed < 0.500` (FR-012, FR-013, FR-027, SC-003). +- [X] T033 [P] [US2] Create `tests/integration/test_cli_config_doctor_daemon_down.py` (US1 AS4, US2 AS2, SC-004) with parametrize ids `socket_missing`, `connection_refused`, `connect_timeout`, `permission_denied`: stop the daemon (or, for `permission_denied`, chmod the socket file 000), run `agenttower config doctor`; assert `socket_reachable` is `fail` with the parametrized closed-set sub-code; `daemon_status`, `container_identity`, `tmux_pane_match` are `info` with sub-code `daemon_unavailable` (their cross-check round-trip is skipped per data-model §6 — every check still emits a `CheckResult` row, none are silently omitted; resolves CHK063–CHK064); `tmux_present` runs locally and is unaffected; exit code is `2` for the first three sub-codes; stderr contains NO raw `[Errno 111] Connection refused` text or any other `socket(2)` / `connect(2)` errno text or `Errno`/`strerror` substring (FR-016, FR-024, FR-027, R-009, SC-004, contracts/socket-api.md §C-API-503, checklist CHK051–CHK053, CHK063–CHK064). +- [X] T034 [P] [US2] Create `tests/integration/test_cli_config_doctor_host_context.py` (US2 AS3, edge case 7) with parametrize ids `host_in_tmux_pane_in_registry`, `host_in_tmux_pane_not_in_registry`, `host_not_in_tmux`: no `AGENTTOWER_TEST_PROC_ROOT` fixture (host context); daemon healthy; `AGENTTOWER_CONTAINER_ID` unset; assert `container_identity` is `info` with sub-code `host_context` (NOT `fail`); `tmux_present` is `pass` (or `info` `not_in_tmux` for the third id); `tmux_pane_match` is `pass` for the first id, `info` `pane_unknown_to_daemon` for the second id (host pane not in FEAT-004 registry — the spec edge case 7), `info` `not_in_tmux` for the third; exit `0` (FR-007, FR-009, FR-018, R-004, edge case 7, contracts/cli.md §C-CLI-501). +- [X] T035 [P] [US2] Create `tests/integration/test_cli_config_doctor_pane_match.py` (US2 AS4, US3 AS5) with parametrize ids `pane_match`, `pane_unknown_to_daemon`, `pane_ambiguous`, `socket_path_unreadable_in_container`: (a) `$TMUX` and `$TMUX_PANE` align with a row in the seeded `list_panes` registry → `tmux_pane_match` is `pass` with sub-code none; (b) `$TMUX` parses cleanly but `$TMUX_PANE` matches zero rows → `tmux_pane_match` is `fail` with sub-code `pane_unknown_to_daemon` AND actionable message advises `agenttower scan --panes from the host` AND the CLI exit is `5` (degraded; round-trip ok, non-required check failed); (c) two rows match → `pane_ambiguous`; (d) `$TMUX` set but `tmux_socket_path` unreadable from inside the container (edge case 8) → report parsed values, skip cross-check, classify as `pane_unknown_to_daemon` with the "tmux socket not visible from container" actionable message — NOT crash (FR-010, FR-018, R-005, edge cases 7, 8, contracts/cli.md §tmux_pane_match, checklist CHK038, CHK042). +- [X] T036 [P] [US2] Create `tests/integration/test_cli_config_doctor_json.py` (US2 AS5, SC-005, edge cases 10, 11) with parametrize ids `healthy`, `daemon_down`, `no_mount`, `no_tmux`, `unknown_container`, `ambiguous_pane`, `schema_version_newer`, `daemon_container_set_empty`: for each scenario, run `agenttower config doctor --json`, assert `json.loads(stdout)` succeeds with the FR-014 envelope, `summary.exit_code` equals the CLI exit, `--json` produces NO incidental stderr (the only documented exception is the FR-002 pre-flight error which predates `--json` parsing); the `schema_version_newer` id covers edge case 10 (daemon ahead of CLI) and asserts `summary.exit_code == 3` per FR-018 layering (Clarifications 2026-05-06); the `daemon_container_set_empty` id covers edge case 11 (`list_containers` returns empty) and asserts the `container_identity` row classification is one of the FR-007 closed set (`no_candidate` when no detection signal fired, `no_match` when a candidate exists), with `details.daemon_container_set_empty == true` plus the actionable message advising `agenttower scan --containers from the host`; the test MUST also assert that a `no_containers_known` sub-code is NEVER emitted (the synonym is dead per Clarifications 2026-05-06; the FR-007 5-token closed set is not extended) (FR-014, R-007, SC-005, contracts/cli.md §C-CLI-501, edge cases 10, 11, 15, checklist CHK054, CHK086–CHK090, CHK095). +- [X] T037 [P] [US2] Create `tests/integration/test_cli_config_doctor_short_circuit.py` (FR-027): with the daemon healthy but `socket_reachable` artificially failed (e.g., temporarily move the daemon socket aside), assert every other check still produces a `CheckResult` row (none silently omitted) and that the `info` + `daemon_unavailable` sub-code rule is consistent with data-model §6; assert the doctor writes nothing to disk (no SQLite writes, no JSONL appends, no log rotation, no file creation — diff `$XDG_STATE_HOME/opensoft/agenttower/` before and after) (FR-027, FR-029, checklist CHK055, CHK063–CHK064). The SC-003 500 ms budget is asserted in T032; this test is FR-027/FR-029 only. + +### Implementation for User Story 2 + +- [X] T038 [US2] Create `src/agenttower/config_doctor/checks.py` exposing six closed-set per-check functions in FR-012 order: `check_socket_resolved(resolved: ResolvedSocket) -> CheckResult`, `check_socket_reachable(resolved: ResolvedSocket) -> tuple[CheckResult, StatusReply | None]` (catches `DaemonUnavailable` once and dispatches on `.kind` to the FR-016 closed sub-code set; raw `__cause__` chain is NOT formatted into output), `check_daemon_status(status_reply: StatusReply | None) -> CheckResult` (echoes `daemon_version` + `schema_version`; applies R-010 schema-version comparison policy with `MAX_SUPPORTED_SCHEMA_VERSION = 3` from `__init__.py`); the three identity / tmux check functions are stubbed to raise `NotImplementedError` here and are implemented in Phase 5; every `CheckResult.details` and `actionable_message` is sanitized + bounded via `sanitize.py` (FR-012, FR-016, FR-017, FR-024, FR-028, R-009, R-010, contracts/cli.md §C-CLI-501). +- [X] T039 [US2] Create `src/agenttower/config_doctor/runner.py` exposing `run_doctor(env: Mapping[str, str], host_paths: Paths, *, json_mode: bool) -> DoctorReport` per data-model §3.5 + R-006: pre-flight `socket_resolve.resolve_socket_path` (a `SocketPathInvalid` short-circuits to exit `1` BEFORE constructing the `DoctorReport`); runs all six checks in fixed FR-012 order via `checks.py`; downstream daemon-dependent checks emit `info` rows with sub-code `daemon_unavailable` when `socket_reachable` failed (data-model §6 — every check produces a row even when the round-trip is moot, preserving FR-027 in spirit); aggregates `CheckResult`s into `DoctorReport(checks, exit_code)`; computes `exit_code` per R-006 mapping table; never short-circuits a check; writes nothing to disk (FR-012, FR-018, FR-027, FR-029, R-006, data-model §3.5, §4.3, §6). +- [X] T040 [US2] Create `src/agenttower/config_doctor/render.py` exposing `render_tsv(report: DoctorReport) -> str` (one TSV row per check + indented `actionable_message` lines for non-pass rows + trailing `summary\t\t/` line) and `render_json(report: DoctorReport) -> str` (one canonical JSON object per invocation per FR-014 / data-model §3.6; `actionable_message` and `sub_code` keys are OMITTED when their value is `None`; field order in `summary` is fixed); both consume `DoctorReport` and route every textual field through `sanitize.py`; render output to stdout only (FR-013, FR-014, FR-024, FR-028, R-007, R-008, contracts/cli.md §C-CLI-501). +- [X] T041 [US2] Wire `cli.py`'s `config doctor` handler (registered in T019) to call `runner.run_doctor` with `os.environ` and the FEAT-001 `host_paths`, then dispatch to `render.render_tsv` or `render.render_json` based on `--json`; the handler exits with `report.exit_code`; pre-flight `SocketPathInvalid` is caught here and converted to the FR-002 stderr + exit `1` path BEFORE constructing a `DoctorReport`; `--json` mode emits stdout only with no incidental stderr (the FR-002 pre-flight error is the documented exception per contracts/cli.md §`--json` and stderr discipline) (FR-002, FR-013, FR-014, FR-029, FR-030, contracts/cli.md §C-CLI-501). +- [X] T042 [US2] Re-export the public surface from `src/agenttower/config_doctor/__init__.py`: `run_doctor`, `ResolvedSocket`, `IdentityResolution`, `TmuxIdentity`, `DoctorReport`, `CheckResult`, `CheckCode`, `CheckStatus`, `MAX_SUPPORTED_SCHEMA_VERSION` (data-model §3, plan §Structure Decision). +- [X] T043 [US2] Run T029–T037 against the live daemon and verify SC-003 (500 ms wall-clock), SC-004 (daemon-down still completes, no errno leak), SC-005 (valid JSON on every code path), and the FR-018 exit-code surface are observable end-to-end before moving to US3. + +**Checkpoint**: User Stories 1 AND 2 work independently. `agenttower config doctor` runs the closed-set six checks in fixed order on every invocation; TSV and JSON outputs ship; exit codes mirror FEAT-002 / FEAT-003; daemon-down completes without errno leak. + +--- + +## Phase 5: User Story 3 — Container and tmux pane self-identification (Priority: P3) + +**Goal**: The cgroup → hostname → env identity chain converges to `unique_match` against FEAT-003 `list_containers`; the `$TMUX` / `$TMUX_PANE` parser converges to `pane_match` against FEAT-004 `list_panes`; the closed-set classification outcomes (`unique_match`, `multi_match`, `no_match`, `no_candidate`, `host_context` for identity; `pane_match`, `pane_unknown_to_daemon`, `pane_ambiguous`, `not_in_tmux`, `output_malformed` for tmux) are observable on every fixture (spec §User Story 3, AS1–AS5, FR-006, FR-007, FR-009, FR-010, FR-021, R-004, R-005, SC-008). + +**Independent Test**: With per-signal fixtures (cgroup-only, hostname-only, env-only, env+hostname, full-id, short-id-prefix), each detection-precedence step is exercised with a fixture that *only* that step can resolve; every classification outcome (`unique_match`, `multi_match`, `no_match`, `no_candidate`, `host_context`) has at least one fixture; the daemon cross-check uses only the existing FEAT-003 `list_containers` and FEAT-004 `list_panes` socket methods; the in-container CLI never calls `scan_containers` or `scan_panes`. + +### Tests for User Story 3 (write FIRST; MUST FAIL before US3 implementation) + +- [X] T044 [P] [US3] Extend `tests/unit/test_container_identity.py` (from T014) with the cross-check classifier coverage; parametrize across the SC-008 30-cell matrix (5 outcomes × 6 signal-shapes — cgroup-only, hostname-only, env-only, env+hostname, full-id, short-id-prefix): full-id equality match → `unique_match`; 12-character short-id prefix match → `unique_match`; two `list_containers` rows share the candidate's short prefix → `multi_match` (NEVER auto-resolved; both candidate ids surface in `actionable_message`); `/proc/self/cgroup` has multiple matching lines yielding *distinct* trailing identifiers → `multi_match` with `details.cgroup_candidates` listing every observed identifier (per FR-006 multi-line rule, Clarifications 2026-05-06); candidate produced but no row matches → `no_match` with actionable message advising `agenttower scan --containers from the host`; `RuntimeContext == HostContext` AND `AGENTTOWER_CONTAINER_ID` unset → `host_context`; round-trip succeeded but `list_containers` returned empty → classification is `no_candidate` (when no detection signal fired) OR `no_match` (when a candidate exists) AND the `details.daemon_container_set_empty` flag is set to `true` (NEVER a `no_containers_known` sub-code — that synonym is dead per Clarifications 2026-05-06; the FR-007 5-token closed set is not extended); per-source token spelled identically across artifacts; locks the spec-vs-contract synonym `unknown_container` (US3 AS3 wording) to the contract's `no_match` / `no_candidate` (FR-006, FR-007, FR-008, R-004, SC-008, contracts/cli.md §container_identity, checklist CHK024–CHK034). +- [X] T045 [P] [US3] Extend `tests/unit/test_tmux_self_identity.py` (from T016) with the cross-check classifier coverage: exactly one `list_panes` row has matching `(tmux_socket_path, tmux_pane_id)` → `pane_match`; two rows match → `pane_ambiguous`; zero rows match → `pane_unknown_to_daemon` with actionable message advising `agenttower scan --panes from the host`; `$TMUX` set but socket path unreadable from inside the container (edge case 8) → report parsed values, skip cross-check, classify as `pane_unknown_to_daemon` with the "tmux socket not visible from container" actionable message (NOT crash); cross-check optionally filtered by `params.container_id` when identity classification is `unique_match` (FR-010, FR-011, R-005, edge case 7, edge case 8, contracts/cli.md §tmux_pane_match, checklist CHK035–CHK042). +- [X] T046 [P] [US3] Create `tests/integration/test_cli_doctor_identity_hostname.py` (US3 AS2): fixture-set `AGENTTOWER_TEST_PROC_ROOT` with empty `/proc/self/cgroup`, set `/etc/hostname` to a 12-character hex value matching exactly one FEAT-003 row's short-id prefix, daemon healthy; assert `container_identity` is `pass` with `source=hostname` and sub-code `unique_match`; assert the JSON `details` field reports the matched full id and name (FR-006, FR-007, R-004, US3 AS2). +- [X] T047 [P] [US3] Create `tests/integration/test_cli_doctor_tmux_unset.py` (US3 AS4): `$TMUX` and `$TMUX_PANE` unset; daemon healthy; assert `tmux_present` is `info` with sub-code `not_in_tmux` (NOT `fail`); `tmux_pane_match` is `info` with sub-code `not_in_tmux`; `container_identity` is unaffected; CLI exit stays `0` if every other required check passed (FR-009, FR-018, R-005, US3 AS4, contracts/cli.md §tmux_present). + +### Implementation for User Story 3 + +- [X] T048 [US3] Implement the cross-check classifier in `src/agenttower/config_doctor/identity.py` (replacing the `NotImplementedError` stub from T015): `classify_identity(candidate, runtime_context, list_containers) -> IdentityResolution` per data-model §3.3 / §4.2: full-id equality FIRST, then 12-character short-id prefix match; closed-set outcomes spelled identically to FR-007 / R-004 / data-model §3.3 / contracts/cli.md (`unique_match`, `multi_match`, `no_match`, `no_candidate`, `host_context`); `host_context` ONLY when both `RuntimeContext == HostContext` AND `AGENTTOWER_CONTAINER_ID` unset; `multi_match` is NEVER auto-resolved; never widens the FEAT-003 container set (no `scan_containers` invocation); the `not_in_container` synonym from contracts/cli.md is treated as DEAD code — only `host_context` is emitted (resolves CHK034) (FR-006, FR-007, FR-008, R-004, data-model §3.3, §4.2). +- [X] T049 [US3] Implement the cross-check classifier in `src/agenttower/config_doctor/tmux_identity.py` (replacing the `NotImplementedError` stub from T017): `classify_tmux(parsed: ParsedTmuxEnv, list_panes: tuple[PaneRow, ...]) -> TmuxIdentity` per data-model §3.4: closed-set outcomes spelled identically to FR-010 / R-005 / data-model §3.4 / contracts/cli.md (`pane_match`, `pane_unknown_to_daemon`, `pane_ambiguous`, `not_in_tmux`, `output_malformed`); composite-key match on `(tmux_socket_path, tmux_pane_id)`; `output_malformed` propagates from the parser; cross-check optionally filtered by container id when identity is `unique_match`; never spawns a `tmux` subprocess (FR-010, FR-011, R-005, data-model §3.4). +- [X] T050 [US3] Extend `src/agenttower/config_doctor/checks.py` (from T038) with the three remaining check functions (replacing the `NotImplementedError` stubs): `check_container_identity(env, runtime_context, list_containers_result) -> CheckResult` (delegates to `identity.detect_candidate` + `identity.classify_identity`; sub-code closed set per contracts/cli.md §container_identity is exactly `{unique_match, host_context, multi_match, no_match, no_candidate, output_malformed, daemon_unavailable}` — NO `no_containers_known` and NO `not_in_container` sub-code per Clarifications 2026-05-06; the empty-`list_containers` case is surfaced as `details.daemon_container_set_empty=true` on the existing `no_match` / `no_candidate` row, and a multi-line cgroup multi_match surfaces every observed id in `details.cgroup_candidates`); `check_tmux_present(env) -> CheckResult` (delegates to `tmux_identity.parse_tmux_env`; sub-codes `not_in_tmux`, `output_malformed`); `check_tmux_pane_match(parsed_tmux, list_panes_result, identity_resolution) -> CheckResult` (delegates to `tmux_identity.classify_tmux`; sub-codes `pane_match`, `pane_unknown_to_daemon`, `pane_ambiguous`, `not_in_tmux`, `daemon_unavailable`) (FR-007, FR-010, FR-021, FR-024, R-004, R-005, contracts/cli.md §C-CLI-501). +- [X] T051 [US3] Update `runner.run_doctor` (T039) so that round-trip 2 (`list_containers`) and round-trip 3 (`list_panes`) are skipped when `socket_reachable` failed; the corresponding checks still emit a `CheckResult` row with `info` status and sub-code `daemon_unavailable` (data-model §6 — preserves FR-027 in spirit: every check produces a row even when the round-trip is moot); when identity is `unique_match`, `list_panes` is called with `params.container_id = matched_id` (the optional filter shape already supported by FEAT-004 contract; contracts/socket-api.md §C-API-501) (FR-010, FR-027, R-012, data-model §6, contracts/socket-api.md §C-API-501). +- [X] T052 [US3] Run T044–T047 plus the per-fixture coverage required by SC-008 (every detection-signal fixture produces its documented classification outcome — 5 outcomes × 6 signal-shapes parametrized in T044) and verify the closed-set classifications are observable end-to-end before Phase 6. + +**Checkpoint**: All three user stories are independently functional. Identity / tmux cross-checks classify into the closed-set outcomes; doctor surfaces every outcome with stable sub-codes; in-container CLI never widens the FEAT-003 container set or calls `scan_panes`. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Cross-cutting hardening — backward-compat byte-parity guard, no-real-container/Docker/tmux harness assertion, production-binary `AGENTTOWER_TEST_PROC_ROOT`-unset assertion, the `--json` strict-stdout invariant, deep-cwd integration regression, concurrent-doctor independence, the security-checklist sign-off, and the quickstart walkthrough. + +- [X] T053 [P] Create `tests/integration/test_feat005_backcompat.py` (SC-006, SC-007) parametrized over the seven SC-007 subcommands as a fixture (`status`, `list-containers`, `list-panes`, `scan --containers`, `scan --panes`, `ensure-daemon`, `stop-daemon`) plus `agenttower config init` and `agenttower config paths`: re-run each command on the host and assert byte-identical stdout, stderr, exit codes, and `--json` shapes vs the FEAT-004 build (use a fixture-pinned reference snapshot); the only documented additive change is the trailing `SOCKET_SOURCE=` line on `config paths` and the new `config doctor` subcommand; assert `not_in_container` synonym is NEVER present in any output (FR-005, FR-026, SC-006, SC-007, checklist CHK034, CHK068, CHK071, CHK075). +- [X] T054 [P] Create `tests/integration/test_feat005_no_real_container.py` (SC-009, FR-022, parallel to FEAT-004's `test_feat004_no_network.py`): assert `AGENTTOWER_TEST_PROC_ROOT`, `AGENTTOWER_TEST_DOCKER_FAKE`, and `AGENTTOWER_TEST_TMUX_FAKE` are set in `os.environ` at collection time; monkeypatch `shutil.which` and `subprocess.run` and assert neither is called with `"docker"`, `"tmux"`, `"id"`, `"cat"`, `"runc"`, `"podman"`, or any other in-container subprocess as `argv[0]` for the duration of the FEAT-005 test session; assert no AF_INET / AF_INET6 socket is opened by the daemon during any FEAT-005 dispatch path (FR-011, FR-020, FR-022, SC-009, plan §Testing, checklist CHK082–CHK083). +- [X] T055 [P] Create `tests/integration/test_feat005_proc_root_unset_in_prod.py` (FR-025): with the production binary entry point invoked outside the test harness (subprocess with `env=` that explicitly excludes `AGENTTOWER_TEST_*`), assert `AGENTTOWER_TEST_PROC_ROOT` is unset at startup; the production CLI MUST NOT honor a leaked `AGENTTOWER_TEST_PROC_ROOT` value when `pytest` is not the parent (assert via process-tree check or harness-environment fingerprint); the test MUST NOT alter the production code path (FR-025, R-011, plan §Testing, checklist CHK078, CHK084–CHK085). +- [X] T056 [P] Create `tests/integration/test_cli_config_doctor_json_strict_stdout.py` (FR-014, edge case 15): with the daemon healthy AND with the daemon down, run `agenttower config doctor --json` and assert stdout contains exactly one valid JSON object AND stderr is empty (or contains only the FR-002 pre-flight error when triggered); no warning, deprecation notice, or incidental log line leaks to stderr (FR-014, edge case 15, contracts/cli.md §`--json` and stderr discipline, checklist CHK053). +- [X] T057 Walk `specs/005-container-thin-client/quickstart.md` end-to-end against the live daemon with the fake adapters (`AGENTTOWER_TEST_PROC_ROOT`, `AGENTTOWER_TEST_DOCKER_FAKE`, `AGENTTOWER_TEST_TMUX_FAKE`) and confirm every command in §1–§10 produces the documented output and exit codes; capture any drift back into the spec rather than the code (quickstart.md §1–§10). +- [X] T058 Walk `specs/005-container-thin-client/checklists/security.md` against the final spec / plan / research / data-model / contracts artifacts; for every CHK item that names a closed-set token enumeration (CHK009, CHK015–CHK016, CHK020, CHK025–CHK026, CHK037, CHK045, CHK051, CHK058–CHK059, CHK087, CHK089–CHK090), assert the token spelling is locked by at least one named test (T009, T012, T014, T016, T029, T030, T031); encode any remaining gaps as spec amendments before merge — do not paper over them in code (checklist CHK001–CHK099). +- [X] T059 [P] Confirm the FEAT-001 / FEAT-002 / FEAT-003 / FEAT-004 test suites still pass on this branch (SC-006); add no new dependencies (plan §Primary Dependencies — stdlib only). +- [X] T060 [P] Create `tests/integration/test_feat005_deep_cwd_connect.py` (edge case 12, CHK011): construct a temp daemon socket whose absolute path exceeds 90 bytes when prefixed by a deep cwd (close to but under the 108-byte `sun_path` limit when `_connect_via_chdir` is used); cd into the deep cwd; run `agenttower status` and assert the daemon round-trip succeeds; the FEAT-002 `_connect_via_chdir` workaround MUST NOT regress under FEAT-005's resolver. Verifies that the resolver's `(path, source)` plumbing hands the path to `client.py` untouched and that the existing chdir workaround still fires. +- [X] T061 [P] Create `tests/integration/test_cli_doctor_concurrent.py` (edge case 13): spawn two `agenttower config doctor` subprocesses concurrently from inside the same simulated container; both MUST exit independently with the documented status; the doctor performs only read-only socket calls (FEAT-002 `status`, FEAT-003 `list_containers`, FEAT-004 `list_panes`) so no daemon-side mutex is acquired and the two invocations must NOT serialize behind each other; assert the wall-clock for the two invocations together is bounded by `2 × SC-003` budget (1.0 s healthy) — concurrent execution does not double the budget (FR-029, edge case 13). +- [X] T062 [P] Create `tests/unit/test_socket_client_back_compat.py` (CHK074): instantiate `DaemonUnavailable` for each underlying signal, capture `str(exc)` and `repr(exc)`, and assert byte-for-byte equality against a captured FEAT-002 build baseline; assert the additive `.kind` attribute does NOT change either; this is a regression backstop separate from T007 (which exercises the `.kind` mapping itself). +- [X] T063 [P] Confirm the SC-008 30-cell matrix coverage from T044 + per-fixture coverage in T046 fully exercises the spec's enumerated detection-signal shapes (cgroup-only, hostname-only, env-only, env+hostname, full-id match, short-id-prefix match) crossed with classification outcomes (unique_match, multi_match, no_match, no_candidate, host_context); if any cell is missing, add a parametrize id; do not silently accept gaps (SC-008, checklist CHK098). + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No upstream dependencies. +- **Foundational (Phase 2)**: Depends on Setup. ⚠️ Blocks every user-story phase. +- **User Story 1 (Phase 3)**: Depends on Foundational. MVP — delivers `agenttower status` from inside a bench container against the host daemon. +- **User Story 2 (Phase 4)**: Depends on Foundational AND User Story 1's `cli.py` plumbing (T019, T020) for the `config doctor` subparser registration; the doctor's runner / checks / render layer is independent of US1's per-command rewiring beyond that registration. +- **User Story 3 (Phase 5)**: Depends on Foundational AND User Story 2's `checks.py` / `runner.py` scaffolding (T038, T039) — Phase 5 fills in the three identity / tmux check functions and the cross-check classifiers; the parsing-only halves of `identity.py` / `tmux_identity.py` were already shipped in Phase 2. +- **Polish (Phase 6)**: Depends on every user-story phase the team intends to ship. + +### Within Each User Story + +- Tests are written BEFORE the implementation tasks they cover and MUST fail prior to that implementation running. +- `sanitize.py` (T004) precedes every other module; the conftest fixture (T006) precedes every integration test. +- `DaemonUnavailable.kind` (T008) precedes `socket_resolve.py` (T010) and `checks.py` (T038). +- Identity / tmux parsing-only halves (T015, T017) precede their cross-check classifiers (T048, T049). +- `runner.py` (T039) precedes the `cli.py` doctor handler wiring (T041). + +### Explicit Blocking Statements + +- T003 (package marker + `MAX_SUPPORTED_SCHEMA_VERSION`) blocks every `config_doctor/` task. +- T004 (sanitize) blocks T010 / T013 / T015 / T017 / T038 / T040. +- T006 (fake-proc fixture) blocks every integration test that uses `AGENTTOWER_TEST_PROC_ROOT`. +- T008 (`DaemonUnavailable.kind`) blocks T038 (which dispatches on `.kind`). +- T011 (`ResolvedSocket` dataclass) blocks T010 (which returns one). +- T013 (`runtime_detect`) blocks T010 (which consumes `RuntimeContext`). +- T019 (subparser registration) blocks T041 (which fleshes out the handler). +- T020 (`config paths` extension) blocks T018 (the test asserts the new line). +- T038 (`checks.py` core) blocks T050 (which extends it). +- T039 (`runner.py` core) blocks T051 (which adds the skip-when-unreachable behavior). + +--- + +## Parallel Opportunities + +### By Phase + +- **Phase 1**: T001 → T002 [P]. +- **Phase 2**: T003 → T004 → (T005 [P] / T006 [P] / T007 [P] / T009 [P] / T011 [P] / T012 [P] / T014 [P] / T016 [P] all parallel; T008 / T010 / T013 / T015 / T017 sequenced after their respective tests) → T018 [P] → T019 → T020. +- **Phase 3**: Tests T021 [P] / T022 [P] / T023 [P] / T024 [P] all parallel (different files); implementation T025 → T026 → T027 → T028 sequenced. +- **Phase 4**: Tests T029 [P] / T030 [P] / T031 [P] / T032 [P] / T033 [P] / T034 [P] / T035 [P] / T036 [P] / T037 [P] all parallel; implementation T038 → T039 → T040 → T041 → T042 → T043 sequenced. +- **Phase 5**: Tests T044 [P] / T045 [P] / T046 [P] / T047 [P] all parallel; implementation T048 [P] / T049 [P] (different files) → T050 → T051 → T052. +- **Phase 6**: T053 [P] / T054 [P] / T055 [P] / T056 [P] / T059 [P] / T060 [P] / T061 [P] / T062 [P] / T063 [P] all parallel; T057 and T058 sequential walkthroughs. + +### By User Story + +After Phase 2 closes, three developers can ship independently against the foundation: + +- Developer A: Phase 3 (US1 / MVP). +- Developer B: Phase 4 (US2) — depends on US1's `cli.py` subparser registration (T019); the unit tests for render / JSON / exit-codes (T029, T030, T031) start as soon as Phase 2 lands. +- Developer C: Phase 5 (US3) — depends on US2's `checks.py` / `runner.py` scaffolding (T038, T039); the unit cross-check tests (T044, T045) start as soon as Phase 2 lands because the parsing-only halves (T015, T017) are already shipped. + +--- + +## Parallel Example: User Story 1 tests + +```bash +# Spawn all US1 test files together (different paths, no inter-task deps): +Task: "Create tests/integration/test_cli_in_container_status.py" # T021 +Task: "Create tests/integration/test_cli_in_container_socket_override.py" # T022 +Task: "Create tests/integration/test_cli_no_socket_mount.py" # T023 +Task: "Create tests/integration/test_cli_in_container_unsupported_signals.py" # T024 +``` + +```bash +# After T019 + T020 land, US1 implementation tasks run sequentially because +# they all interact with src/agenttower/cli.py and the resolved-socket plumbing: +Task: "Verify config paths SOCKET_SOURCE= line across all three sources" # T025 +Task: "Verify cli.py routes every existing command through resolver" # T026 +Task: "Add deep-cwd preservation guard" # T027 +Task: "Run T021–T024 against the live daemon and verify SC-001/002/007" # T028 +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 only) + +1. Phase 1: Setup (T001 + T002 [P]). +2. Phase 2: Foundational — sanitize first (T003 → T004 → T005 [P]); then conftest fixture (T006 [P]); then `DaemonUnavailable.kind` (T007 → T008); then resolver (T009 [P] → T010 / T011); then runtime detect (T012 [P] → T013); then identity / tmux parsing halves (T014 [P] / T016 [P] → T015 / T017); then `cli.py` plumbing (T018 [P] → T019 → T020). +3. Phase 3: User Story 1 (US1 tests T021–T024 [P], then T025 → T028). +4. **STOP and VALIDATE**: SC-001 (eight-key status parity), SC-002 (50 ms pre-flight rejection), SC-007 (host-context byte parity); quickstart.md §1, §5, §7 walks cleanly; security checklist §1, §2, §6 review. +5. Demo / merge if MVP scope is sufficient — `agenttower status` from inside a bench container is the smallest end-to-end value slice and is already shippable here. + +### Incremental Delivery + +1. Setup + Foundational → resolver, runtime detect, identity / tmux parsers, `DaemonUnavailable.kind`, `cli.py` plumbing all wired. +2. + US1 → first MVP slice ships: `agenttower status` from inside a bench container; `AGENTTOWER_SOCKET` override; missing-mount preserves FEAT-002 exit `2`; host-side commands are byte-identical. +3. + US2 → diagnostic surface ships: `agenttower config doctor` runs the closed-set six checks; TSV + canonical JSON; daemon-down completes without errno leak; exit codes mirror FEAT-002 / FEAT-003. +4. + US3 → identity / tmux self-detection ships: cgroup → hostname → env precedence; `unique_match` / `multi_match` / `no_match` / `no_candidate` / `host_context` classifications; `pane_match` / `pane_unknown_to_daemon` / `pane_ambiguous` / `not_in_tmux` / `output_malformed`; FEAT-006 registration is unblocked because identity detection is now solved. +5. + Polish → backward-compat byte-parity guard, no-real-container/Docker/tmux harness assertion, production-binary `AGENTTOWER_TEST_PROC_ROOT`-unset assertion, `--json` strict-stdout invariant, deep-cwd connect regression, concurrent-doctor independence, quickstart + security checklist sign-off. + +### Parallel Team Strategy + +After Phase 2 closes, three developers can ship independently against the foundation: + +- Developer A: Phase 3 (US1 / MVP). Owns the `cli.py` plumbing verification and the four US1 integration tests. +- Developer B: Phase 4 (US2). Owns `runner.py`, `render.py`, the core of `checks.py`, and the seven US2 integration tests; depends only on T019's subparser registration. +- Developer C: Phase 5 (US3). Owns the cross-check classifiers in `identity.py` and `tmux_identity.py`, the three remaining check functions in `checks.py`, and the four US3 integration tests; depends on T038's scaffold. + +All three converge in Phase 6 for the cross-cutting hardening + walkthroughs. + +--- + +## Notes + +- [P] = different files, no upstream dependencies on incomplete tasks; never use [P] on tasks that extend the same file (e.g., multiple `cli.py` extensions or multiple `checks.py` extensions). +- Every user-story task carries the `[USn]` label so traceability back to spec.md acceptance scenarios stays explicit. +- Tests are written first within each story phase and MUST fail before the implementation tasks they cover begin (template invariant). +- The host-side byte-parity guard (T053) is the load-bearing backstop for SC-006 / SC-007: no FEAT-001..004 CLI command's stdout, stderr, or exit code may change byte-for-byte for any input the existing suite covers; the only documented additive surfaces are the trailing `SOCKET_SOURCE=` line and the new `config doctor` subcommand. +- The no-errno-leak guard (T033, T056) is the load-bearing backstop for FR-024 / SC-004: raw `socket(2)` / `connect(2)` errno text MUST NOT leak to stderr or `--json` on any code path; the doctor catches `DaemonUnavailable`, dispatches on `.kind`, and emits only the closed-set sub-code + bounded actionable message. +- The closed-set token enumerations are load-bearing for FR-014's "added but never renamed" stability rule: at least one named test locks the spelling of every closed enumeration (source tokens, classification outcomes, status tokens, exit codes, sub-codes, check codes) so a typo cannot ship undetected (checklist CHK087, CHK088). T030 explicitly asserts `not_in_container` is never emitted (resolves CHK034). +- The "every check runs every invocation" invariant (FR-027) is reconciled with data-model §6 by emitting an `info` `daemon_unavailable` row for downstream daemon-dependent checks when `socket_reachable` failed, rather than silently omitting the row (T033, T039, T051; resolves CHK063–CHK064). +- Commit after each task or each logical group; keep `pyproject.toml` and dependency footprint stable (plan §Primary Dependencies — stdlib only). +- Stop at every checkpoint (end of Phase 2, end of US1, end of US2, end of US3) to validate independently before proceeding. +- Avoid: cross-story dependencies that break independence; same-file [P] tasks; unbounded raw stderr/errno text anywhere outside the bounded `actionable_message` field; any code path that spawns `docker`, `tmux`, `id`, `cat`, or any other subprocess inside the container; any in-container disk write during `agenttower config doctor`; any new socket method, error code, or schema change. diff --git a/src/agenttower/cli.py b/src/agenttower/cli.py index 7793b38..646fc7a 100644 --- a/src/agenttower/cli.py +++ b/src/agenttower/cli.py @@ -15,7 +15,12 @@ from . import __version__ from .config import _DIR_MODE, _ensure_dir_chain, write_default_config -from .paths import Paths, resolve_paths +from .config_doctor import runtime_detect +from .config_doctor.socket_resolve import ( + SocketPathInvalid, + resolve_socket_path, +) +from .paths import Paths, ResolvedSocket, resolve_paths from .socket_api import lifecycle from .socket_api.client import DaemonError, DaemonUnavailable, send_request from .state.schema import companion_paths_for, open_registry @@ -33,6 +38,60 @@ ) +def _resolve_socket_with_paths(env: dict[str, str] | None = None) -> tuple[Paths, ResolvedSocket]: + """Resolve filesystem paths AND the daemon socket path with FR-001 priority. + + On invalid ``AGENTTOWER_SOCKET`` (any of the FR-002 closed-set ```` + tokens), prints the FR-002 stderr line and raises :class:`SystemExit(1)`. + Returns ``(paths, resolved_socket)`` for normal control flow; every + socket-using handler then opens the socket via ``resolved_socket.path``. + """ + + if env is None: + env = dict(os.environ) + paths = resolve_paths(env) + runtime_context = runtime_detect.detect() + try: + resolved = resolve_socket_path(env, paths, runtime_context) + except SocketPathInvalid as exc: + print( + f"error: AGENTTOWER_SOCKET must be an absolute path to a Unix socket: {exc.reason}", + file=sys.stderr, + ) + raise SystemExit(1) from exc + return paths, resolved + + +def _guard_production_test_seam_unset() -> None: + """A2 / FR-025: refuse to run under leaked ``AGENTTOWER_TEST_PROC_ROOT``. + + When ``AGENTTOWER_TEST_PROC_ROOT`` is set but no other ``AGENTTOWER_TEST_*`` + companion env var is also set, the binary is almost certainly running + outside the test harness (e.g., a developer's stale shell). Refuse to + proceed so a fake ``/proc`` cannot silently substitute for the real one + in a production CLI invocation. + + A test harness already sets at least one of ``AGENTTOWER_TEST_DOCKER_FAKE`` + / ``AGENTTOWER_TEST_TMUX_FAKE`` (FEAT-003 / FEAT-004) along with + ``AGENTTOWER_TEST_PROC_ROOT``, so this gate does not fire under pytest. + """ + + if "AGENTTOWER_TEST_PROC_ROOT" not in os.environ: + return + companions = [ + key + for key in os.environ + if key.startswith("AGENTTOWER_TEST_") and key != "AGENTTOWER_TEST_PROC_ROOT" + ] + if companions: + return + print( + "error: AGENTTOWER_TEST_PROC_ROOT is set outside the test harness; unset it before running production", + file=sys.stderr, + ) + raise SystemExit(1) + + def _namespace_root(any_member: Path) -> Path: """Return the deepest ``opensoft/agenttower`` ancestor of *any_member*.""" for parent in [any_member, *any_member.parents]: @@ -112,13 +171,18 @@ def _config_init(args: argparse.Namespace) -> int: def _config_paths(args: argparse.Namespace) -> int: - paths: Paths = resolve_paths() + # Resolve paths AND the socket together so the SOCKET= line and the new + # SOCKET_SOURCE= line cannot drift (FR-019). On invalid AGENTTOWER_SOCKET, + # _resolve_socket_with_paths exits 1 with the FR-002 stderr message + # before any KEY=value line is printed. + paths, resolved = _resolve_socket_with_paths() print(f"CONFIG_FILE={paths.config_file}") print(f"STATE_DB={paths.state_db}") print(f"EVENTS_FILE={paths.events_file}") print(f"LOGS_DIR={paths.logs_dir}") - print(f"SOCKET={paths.socket}") + print(f"SOCKET={resolved.path}") print(f"CACHE_DIR={paths.cache_dir}") + print(f"SOCKET_SOURCE={resolved.source}") if not paths.state_db.exists(): print( "note: agenttower has not been initialized; run `agenttower config init`", @@ -127,14 +191,45 @@ def _config_paths(args: argparse.Namespace) -> int: return 0 +def _config_doctor(args: argparse.Namespace) -> int: + """``agenttower config doctor`` — run the closed-set diagnostic checks. + + Pre-flight ``SocketPathInvalid`` is converted to the FR-002 stderr path + + exit ``1`` BEFORE constructing a :class:`DoctorReport`. Otherwise the + runner produces a six-row report which is rendered as TSV (default) or + canonical JSON (``--json``); the CLI exits with ``report.exit_code``. + """ + + from .config_doctor import render_json, render_tsv, run_doctor + + paths = resolve_paths() + try: + report = run_doctor(dict(os.environ), paths) + except SocketPathInvalid as exc: + print( + f"error: AGENTTOWER_SOCKET must be an absolute path to a Unix socket: {exc.reason}", + file=sys.stderr, + ) + return 1 + + if args.json: + print(render_json(report)) + else: + sys.stdout.write(render_tsv(report)) + return int(report.exit_code) + + def _ensure_daemon(args: argparse.Namespace) -> int: - paths: Paths = resolve_paths() + paths, resolved = _resolve_socket_with_paths() state_dir = paths.state_db.parent logs_dir = paths.logs_dir lock_path = state_dir / LOCK_FILENAME + # ensure-daemon spawns the host daemon; the daemon binds at the host + # default. AGENTTOWER_SOCKET overrides ONLY the client's ping target so + # it sees whether *that* socket is reachable. socket_path = paths.socket - preflight = _ensure_daemon_preflight(paths, json_mode=args.json) + preflight = _ensure_daemon_preflight(paths, resolved, json_mode=args.json) if preflight is not None: return preflight @@ -156,7 +251,9 @@ def _ensure_daemon(args: argparse.Namespace) -> int: ) -def _ensure_daemon_preflight(paths: Paths, *, json_mode: bool) -> int | None: +def _ensure_daemon_preflight( + paths: Paths, resolved: ResolvedSocket, *, json_mode: bool +) -> int | None: state_dir = paths.state_db.parent if not paths.state_db.exists(): @@ -166,10 +263,12 @@ def _ensure_daemon_preflight(paths: Paths, *, json_mode: bool) -> int | None: ) return 1 - pre_existing = _try_ping(paths.socket) + # Ping the resolved socket so AGENTTOWER_SOCKET overrides which socket + # the readiness check inspects; the daemon's own bind path is unchanged. + pre_existing = _try_ping(resolved.path) if pre_existing is not None: return _print_ready( - pre_existing, paths.socket, state_dir, json_mode=json_mode, started=False + pre_existing, resolved.path, state_dir, json_mode=json_mode, started=False ) try: @@ -286,10 +385,10 @@ def _wait_for_spawned_daemon( def _status_command(args: argparse.Namespace) -> int: - paths: Paths = resolve_paths() + paths, resolved = _resolve_socket_with_paths() try: result = send_request( - paths.socket, "status", connect_timeout=1.0, read_timeout=1.0 + resolved.path, "status", connect_timeout=1.0, read_timeout=1.0 ) except DaemonUnavailable: print(DAEMON_UNAVAILABLE_MESSAGE, file=sys.stderr) @@ -312,9 +411,9 @@ def _status_command(args: argparse.Namespace) -> int: def _stop_daemon(args: argparse.Namespace) -> int: - paths: Paths = resolve_paths() + paths, resolved = _resolve_socket_with_paths() state_dir = paths.state_db.parent - socket_path = paths.socket + socket_path = resolved.path try: send_request(socket_path, "shutdown", connect_timeout=1.0, read_timeout=1.0) except DaemonUnavailable: @@ -442,6 +541,7 @@ def _build_parser() -> argparse.ArgumentParser: "config subcommands:\n" " config paths print resolved KEY=value paths AgentTower will use\n" " config init create the durable Opensoft layout (idempotent)\n" + " config doctor run the closed-set diagnostic checks (FEAT-005)\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) @@ -477,6 +577,14 @@ def _build_parser() -> argparse.ArgumentParser: ) init_parser.set_defaults(_handler=_config_init) + doctor_parser = config_subs.add_parser( + "doctor", + help="run the closed-set diagnostic checks (FEAT-005)", + description="run the closed-set diagnostic checks (FEAT-005)", + ) + doctor_parser.add_argument("--json", action="store_true", help=JSON_LINE_HELP) + doctor_parser.set_defaults(_handler=_config_doctor) + ensure_daemon = subparsers.add_parser( "ensure-daemon", help="ensure the host daemon is running (idempotent, lock-serialized)", @@ -599,9 +707,14 @@ def _combine_scan_exit_codes(current: int, new: int) -> int: def _run_container_scan( paths: Paths, args: argparse.Namespace, *, first_block: bool ) -> int: + # Resolve the socket inline so we honor AGENTTOWER_SOCKET / mounted-default + # without changing the existing helper signature (preserves FEAT-003 test + # mocks per FR-026). On invalid AGENTTOWER_SOCKET this exits 1 with the + # FR-002 stderr message before any send_request. + _, resolved = _resolve_socket_with_paths() try: result = send_request( - paths.socket, "scan_containers", connect_timeout=1.0, read_timeout=15.0 + resolved.path, "scan_containers", connect_timeout=1.0, read_timeout=15.0 ) except DaemonUnavailable: print(DAEMON_UNAVAILABLE_MESSAGE, file=sys.stderr) @@ -641,9 +754,11 @@ def _run_container_scan( def _run_pane_scan( paths: Paths, args: argparse.Namespace, *, first_block: bool ) -> int: + # Resolve the socket inline (see _run_container_scan note above). + _, resolved = _resolve_socket_with_paths() try: result = send_request( - paths.socket, "scan_panes", connect_timeout=1.0, read_timeout=30.0 + resolved.path, "scan_panes", connect_timeout=1.0, read_timeout=30.0 ) except DaemonUnavailable: print(DAEMON_UNAVAILABLE_MESSAGE, file=sys.stderr) @@ -713,11 +828,11 @@ def _parse_iso(text: str) -> "datetime": # type: ignore[name-defined] def _list_containers_command(args: argparse.Namespace) -> int: - paths: Paths = resolve_paths() + paths, resolved = _resolve_socket_with_paths() params: dict[str, Any] = {"active_only": bool(args.active_only)} try: result = send_request( - paths.socket, + resolved.path, "list_containers", params=params, connect_timeout=1.0, @@ -748,14 +863,14 @@ def _list_containers_command(args: argparse.Namespace) -> int: def _list_panes_command(args: argparse.Namespace) -> int: - paths: Paths = resolve_paths() + paths, resolved = _resolve_socket_with_paths() params: dict[str, Any] = { "active_only": bool(args.active_only), "container": args.container, } try: result = send_request( - paths.socket, + resolved.path, "list_panes", params=params, connect_timeout=1.0, @@ -810,6 +925,11 @@ def _list_panes_command(args: argparse.Namespace) -> int: def main(argv: list[str] | None = None) -> int: """Run the AgentTower CLI.""" + # A2 / FR-025 production guard: refuse to honor a leaked + # AGENTTOWER_TEST_PROC_ROOT in a non-test invocation. Runs before any + # parsing or path resolution so the guard cannot be bypassed by a + # mid-flight code path. + _guard_production_test_seam_unset() parser = _build_parser() args = parser.parse_args(argv) handler: Any = getattr(args, "_handler", None) diff --git a/src/agenttower/config_doctor/__init__.py b/src/agenttower/config_doctor/__init__.py new file mode 100644 index 0000000..dc125a8 --- /dev/null +++ b/src/agenttower/config_doctor/__init__.py @@ -0,0 +1,54 @@ +"""FEAT-005 container thin-client diagnostic package. + +Pure read-only diagnostic surface: socket-path resolution, container-runtime +detection, identity / tmux self-detection, and the ``agenttower config doctor`` +subcommand. Writes nothing to disk (FR-029); reuses the existing FEAT-002 +client and FEAT-003 / FEAT-004 socket methods (FR-022, FR-026). +""" + +from __future__ import annotations + +MAX_SUPPORTED_SCHEMA_VERSION = 3 +"""Highest SQLite schema_version this CLI build understands (R-010).""" + +# Re-exports — see plan §Structure Decision. These are imported lazily inside +# functions to avoid circular imports at package init time; consumers should +# either import from the submodule directly or from this package using +# ``from agenttower.config_doctor import run_doctor`` (lazy hook below). +from agenttower.config_doctor.checks import ( # noqa: E402,F401 + CheckCode, + CheckResult, + CheckStatus, +) +from agenttower.config_doctor.identity import ( # noqa: E402,F401 + IdentityCandidate, +) +from agenttower.config_doctor.render import render_json, render_tsv # noqa: E402,F401 +from agenttower.config_doctor.runner import ( # noqa: E402,F401 + CHECK_ORDER, + DoctorReport, + run_doctor, +) +from agenttower.config_doctor.socket_resolve import ( # noqa: E402,F401 + SocketPathInvalid, +) +from agenttower.config_doctor.tmux_identity import ( # noqa: E402,F401 + ParsedTmuxEnv, +) +from agenttower.paths import ResolvedSocket # noqa: E402,F401 + +__all__ = [ + "CHECK_ORDER", + "CheckCode", + "CheckResult", + "CheckStatus", + "DoctorReport", + "IdentityCandidate", + "MAX_SUPPORTED_SCHEMA_VERSION", + "ParsedTmuxEnv", + "ResolvedSocket", + "SocketPathInvalid", + "render_json", + "render_tsv", + "run_doctor", +] diff --git a/src/agenttower/config_doctor/checks.py b/src/agenttower/config_doctor/checks.py new file mode 100644 index 0000000..ea0dda3 --- /dev/null +++ b/src/agenttower/config_doctor/checks.py @@ -0,0 +1,642 @@ +"""Per-check functions for ``agenttower config doctor`` (FR-012, FR-016, FR-017, R-006). + +Each function returns a :class:`CheckResult`. The closed-set check codes are +``socket_resolved``, ``socket_reachable``, ``daemon_status``, +``container_identity``, ``tmux_present``, ``tmux_pane_match`` (FR-012). +Each check's status is one of ``pass``, ``warn``, ``fail``, ``info``. + +Per Clarifications 2026-05-06 (FR-027 reading), every check produces a +``CheckResult`` row; when an upstream gate has already failed, dependent +checks emit ``status="info"`` with sub-code ``daemon_unavailable`` and skip +the round-trip. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, replace +from typing import Any, Literal + +from agenttower.config_doctor import MAX_SUPPORTED_SCHEMA_VERSION +from agenttower.config_doctor.identity import ( + CgroupMultiCandidate, + DetectResult, + IdentityCandidate, + detect_candidate, +) +from agenttower.config_doctor.runtime_detect import ( + ContainerContext, + HostContext, + RuntimeContext, +) +from agenttower.config_doctor.sanitize import ( + ACTIONABLE_CAP, + DETAILS_CAP, + sanitize_text, +) +from agenttower.config_doctor.socket_resolve import ( + ResolvedSocket, + SocketPathInvalid, +) +from agenttower.config_doctor.tmux_identity import ParsedTmuxEnv, parse_tmux_env +from agenttower.socket_api.client import ( + DaemonError, + DaemonUnavailable, + send_request, +) + +CheckCode = Literal[ + "socket_resolved", + "socket_reachable", + "daemon_status", + "container_identity", + "tmux_present", + "tmux_pane_match", +] + +CheckStatus = Literal["pass", "warn", "fail", "info"] + + +@dataclass(frozen=True) +class CheckResult: + code: CheckCode + status: CheckStatus + source: str | None + details: str + actionable_message: str | None + sub_code: str | None + # Structured qualifiers per Clarifications 2026-05-06; both serialize as + # peer keys under the ``checks.`` JSON object when set. + cgroup_candidates: tuple[str, ...] | None = None + daemon_container_set_empty: bool | None = None + + +def _bound_details(text: str) -> str: + return sanitize_text(text, DETAILS_CAP)[0] + + +def _bound_actionable(text: str) -> str: + return sanitize_text(text, ACTIONABLE_CAP)[0] + + +# --------------------------------------------------------------------------- +# socket_resolved (FR-015) +# --------------------------------------------------------------------------- + + +def check_socket_resolved(resolved: ResolvedSocket) -> CheckResult: + return CheckResult( + code="socket_resolved", + status="pass", + source=resolved.source, + details=_bound_details(f"{resolved.path} ({resolved.source})"), + actionable_message=None, + sub_code=None, + ) + + +# --------------------------------------------------------------------------- +# socket_reachable (FR-016) — transport-only per Clarifications 2026-05-06 +# --------------------------------------------------------------------------- + + +def check_socket_reachable( + resolved: ResolvedSocket, +) -> tuple[CheckResult, dict[str, Any] | None]: + """Attempt one ``status`` round-trip; report transport-level outcome only. + + ``socket_reachable`` is transport-only: it reports ``pass`` whenever the + daemon returns any well-formed frame, including a structured + ``DaemonError`` envelope. Payload semantics are owned by ``daemon_status``. + """ + + try: + result = send_request( + resolved.path, "status", connect_timeout=1.0, read_timeout=1.0 + ) + except DaemonUnavailable as exc: + actionable = _actionable_for_kind(exc.kind, resolved) + return ( + CheckResult( + code="socket_reachable", + status="fail", + source="round_trip", + details=_bound_details(f"{exc.kind}: {resolved.path}"), + actionable_message=actionable, + sub_code=exc.kind, + ), + None, + ) + except DaemonError as exc: + # Daemon returned a structured error envelope — transport succeeded. + # Bubble the DaemonError up via a sentinel dict so daemon_status can + # report ``daemon_error``. + sanitized_message = _bound_actionable(exc.message) + return ( + CheckResult( + code="socket_reachable", + status="pass", + source="round_trip", + details=_bound_details("transport_ok (daemon returned an error envelope)"), + actionable_message=None, + sub_code=None, + ), + {"_daemon_error_code": exc.code, "_daemon_error_message": sanitized_message}, + ) + + daemon_version = str(result.get("daemon_version", "")) + schema_version = result.get("schema_version", "") + return ( + CheckResult( + code="socket_reachable", + status="pass", + source="round_trip", + details=_bound_details( + f"daemon_version={daemon_version} schema_version={schema_version}" + ), + actionable_message=None, + sub_code=None, + ), + result, + ) + + +def _actionable_for_kind(kind: str, resolved: ResolvedSocket) -> str: + if kind == "socket_missing": + return _bound_actionable( + f"socket file does not exist at {resolved.path}; " + "try `agenttower ensure-daemon` from the host" + ) + if kind == "socket_not_unix": + return _bound_actionable( + f"path {resolved.path} exists but is not a Unix socket" + ) + if kind == "connection_refused": + return _bound_actionable( + f"daemon refused connection at {resolved.path}; the daemon may be " + "shutting down or reaping; try `agenttower ensure-daemon`" + ) + if kind == "permission_denied": + return _bound_actionable( + f"permission denied opening {resolved.path}; the in-container uid " + "must match the host daemon uid (FEAT-005 R-001)" + ) + if kind == "connect_timeout": + return _bound_actionable( + f"daemon at {resolved.path} did not respond within the timeout" + ) + return _bound_actionable("daemon transport error") + + +# --------------------------------------------------------------------------- +# daemon_status (FR-017) — payload inspection +# --------------------------------------------------------------------------- + + +def check_daemon_status( + status_payload: dict[str, Any] | None, + socket_reachable_ok: bool, +) -> CheckResult: + if not socket_reachable_ok: + return CheckResult( + code="daemon_status", + status="info", + source=None, + details=_bound_details("daemon_unavailable"), + actionable_message=_bound_actionable( + "skipped because socket_reachable is fail" + ), + sub_code="daemon_unavailable", + ) + assert status_payload is not None + + if "_daemon_error_code" in status_payload: + # The transport returned a DaemonError envelope; daemon_status owns + # this semantic outcome (Clarifications 2026-05-06 / FR-018 exit 3). + code = str(status_payload.get("_daemon_error_code", "")) + message = str(status_payload.get("_daemon_error_message", "")) + return CheckResult( + code="daemon_status", + status="fail", + source="daemon_error", + details=_bound_details(f"daemon_error: {code}"), + actionable_message=_bound_actionable(message or "daemon returned an error"), + sub_code="daemon_error", + ) + + schema_version = status_payload.get("schema_version") + if not isinstance(schema_version, int): + return CheckResult( + code="daemon_status", + status="fail", + source="schema_check", + details=_bound_details("daemon did not report a numeric schema_version"), + actionable_message=_bound_actionable("upgrade the daemon"), + sub_code="daemon_error", + ) + + if schema_version > MAX_SUPPORTED_SCHEMA_VERSION: + return CheckResult( + code="daemon_status", + status="fail", + source="schema_check", + details=_bound_details( + f"schema_version={schema_version} > cli supports {MAX_SUPPORTED_SCHEMA_VERSION}" + ), + actionable_message=_bound_actionable( + f"upgrade the agenttower CLI (cli supports schema " + f"{MAX_SUPPORTED_SCHEMA_VERSION}; daemon advertises " + f"{schema_version})" + ), + sub_code="schema_version_newer", + ) + if schema_version < MAX_SUPPORTED_SCHEMA_VERSION: + return CheckResult( + code="daemon_status", + status="warn", + source="schema_check", + details=_bound_details( + f"schema_version={schema_version} < cli supports {MAX_SUPPORTED_SCHEMA_VERSION}" + ), + actionable_message=_bound_actionable( + "daemon is older than the CLI; upgrade the daemon when convenient" + ), + sub_code="schema_version_older", + ) + + daemon_version = str(status_payload.get("daemon_version", "")) + return CheckResult( + code="daemon_status", + status="pass", + source="schema_check", + details=_bound_details( + f"schema_version={schema_version} (cli supports {MAX_SUPPORTED_SCHEMA_VERSION}); " + f"daemon_version={daemon_version}" + ), + actionable_message=None, + sub_code=None, + ) + + +# --------------------------------------------------------------------------- +# container_identity (FR-006, FR-007) — Phase 5 stub-then-real +# --------------------------------------------------------------------------- + + +def check_container_identity( + env: Mapping[str, str], + runtime_context: RuntimeContext, + list_containers_payload: dict[str, Any] | None, + socket_reachable_ok: bool, +) -> CheckResult: + if not socket_reachable_ok: + return CheckResult( + code="container_identity", + status="info", + source=None, + details=_bound_details("daemon_unavailable"), + actionable_message=_bound_actionable( + "skipped because socket_reachable is fail; " + "run `agenttower ensure-daemon` from the host" + ), + sub_code="daemon_unavailable", + ) + + # FR-007 host_context: when the runtime is HostContext AND + # AGENTTOWER_CONTAINER_ID is unset, we report host_context regardless of + # whether /etc/hostname or $HOSTNAME produced a candidate. The hostname + # signals are FR-006 fallbacks meant for in-container disambiguation; + # firing them on the host shell would drag every host invocation into + # `no_match` territory, which is not the spec's intent. + if ( + isinstance(runtime_context, HostContext) + and env.get("AGENTTOWER_CONTAINER_ID") is None + ): + return CheckResult( + code="container_identity", + status="info", + source=None, + details=_bound_details("host_context"), + actionable_message=None, + sub_code="host_context", + ) + + candidate = detect_candidate(env) + rows: tuple[dict[str, Any], ...] = tuple( + list_containers_payload.get("containers", []) if list_containers_payload else [] + ) + daemon_set_empty = len(rows) == 0 + + return classify_identity_to_check_result( + candidate=candidate, + runtime_context=runtime_context, + list_containers_rows=rows, + daemon_set_empty=daemon_set_empty, + ) + + +def classify_identity_to_check_result( + *, + candidate: DetectResult, + runtime_context: RuntimeContext, + list_containers_rows: tuple[dict[str, Any], ...], + daemon_set_empty: bool, +) -> CheckResult: + """T048 / FR-007 closed-set classifier (Phase 5). + + Closed-set outcomes ``unique_match``, ``multi_match``, ``no_match``, + ``no_candidate``, ``host_context``. The synonym ``not_in_container`` is + dead per Clarifications 2026-05-06 (only ``host_context`` is emitted). + The empty-``list_containers`` case is signalled by + ``daemon_container_set_empty=True`` plus ``no_candidate`` / ``no_match``; + no ``no_containers_known`` sub-code is added. + """ + + actionable_scan = "run `agenttower scan --containers` from the host" + + # multi_match from cgroup multi-line rule (Q4) + if isinstance(candidate, CgroupMultiCandidate): + return CheckResult( + code="container_identity", + status="fail", + source="cgroup", + details=_bound_details( + "multi_match: distinct cgroup ids in /proc/self/cgroup: " + + ", ".join(candidate.candidates) + ), + actionable_message=_bound_actionable( + "the cgroup file contains multiple matching lines with " + "different container ids; the doctor will not pick one" + ), + sub_code="multi_match", + cgroup_candidates=candidate.candidates, + ) + + if candidate is None: + # host_context only when both the runtime is host AND no env override. + if isinstance(runtime_context, HostContext): + return CheckResult( + code="container_identity", + status="info", + source=None, + details=_bound_details("host_context"), + actionable_message=None, + sub_code="host_context", + ) + # Runtime is container but every signal returned empty. + return CheckResult( + code="container_identity", + status="fail", + source=None, + details=_bound_details( + "no_candidate: every detection signal returned empty" + ), + actionable_message=_bound_actionable( + f"no in-container signal fired; {actionable_scan}" + ), + sub_code="no_candidate", + daemon_container_set_empty=daemon_set_empty if daemon_set_empty else None, + ) + + assert isinstance(candidate, IdentityCandidate) + cand_str = candidate.candidate + sig = candidate.signal + + # full-id equality first + full_matches = [r for r in list_containers_rows if str(r.get("id", "")) == cand_str] + if len(full_matches) == 1: + row = full_matches[0] + return CheckResult( + code="container_identity", + status="pass", + source=sig, + details=_bound_details( + f"unique_match: {row.get('id', '')} ({row.get('name', '')})" + ), + actionable_message=None, + sub_code="unique_match", + ) + if len(full_matches) > 1: + ids = [str(r.get("id", "")) for r in full_matches] + return CheckResult( + code="container_identity", + status="fail", + source=sig, + details=_bound_details("multi_match: " + ", ".join(ids)), + actionable_message=_bound_actionable( + "more than one container row matches the candidate full id" + ), + sub_code="multi_match", + ) + + # 12-character short-id prefix match + short_prefix = cand_str[:12] if len(cand_str) >= 12 else cand_str + short_matches = [ + r + for r in list_containers_rows + if str(r.get("id", "")).startswith(short_prefix) and len(short_prefix) == 12 + ] + if len(short_matches) == 1: + row = short_matches[0] + return CheckResult( + code="container_identity", + status="pass", + source=sig, + details=_bound_details( + f"unique_match: {row.get('id', '')} ({row.get('name', '')})" + ), + actionable_message=None, + sub_code="unique_match", + ) + if len(short_matches) > 1: + ids = [str(r.get("id", "")) for r in short_matches] + return CheckResult( + code="container_identity", + status="fail", + source=sig, + details=_bound_details( + "multi_match: " + ", ".join(ids) + f" (candidate prefix={short_prefix})" + ), + actionable_message=_bound_actionable( + "two or more container rows share the candidate's 12-char prefix; " + "the doctor will not pick one" + ), + sub_code="multi_match", + ) + + # no_match: candidate produced but no row matches. + return CheckResult( + code="container_identity", + status="fail", + source=sig, + details=_bound_details(f"no_match: {cand_str} ({sig})"), + actionable_message=_bound_actionable(actionable_scan), + sub_code="no_match", + daemon_container_set_empty=daemon_set_empty if daemon_set_empty else None, + ) + + +# --------------------------------------------------------------------------- +# tmux_present (FR-009, FR-010) +# --------------------------------------------------------------------------- + + +def check_tmux_present(env: Mapping[str, str]) -> tuple[CheckResult, ParsedTmuxEnv]: + parsed = parse_tmux_env(env) + + if not parsed.in_tmux: + return ( + CheckResult( + code="tmux_present", + status="info", + source=None, + details=_bound_details("not_in_tmux"), + actionable_message=None, + sub_code="not_in_tmux", + ), + parsed, + ) + + if parsed.malformed_reason is not None: + return ( + CheckResult( + code="tmux_present", + status="fail", + source="env", + details=_bound_details(f"output_malformed: {parsed.malformed_reason}"), + actionable_message=_bound_actionable( + "$TMUX or $TMUX_PANE is malformed; check your tmux config" + ), + sub_code="output_malformed", + ), + parsed, + ) + + return ( + CheckResult( + code="tmux_present", + status="pass", + source="env", + details=_bound_details( + f"socket={parsed.tmux_socket_path} session={parsed.session_id} " + f"pane={parsed.tmux_pane_id}" + ), + actionable_message=None, + sub_code=None, + ), + parsed, + ) + + +# --------------------------------------------------------------------------- +# tmux_pane_match (FR-010) — daemon cross-check +# --------------------------------------------------------------------------- + + +def check_tmux_pane_match( + parsed: ParsedTmuxEnv, + list_panes_payload: dict[str, Any] | None, + socket_reachable_ok: bool, +) -> CheckResult: + # Post-clarify FR-027: when the daemon transport path is already known to + # be unavailable, this dependent check still emits a row but short-circuits + # to daemon_unavailable rather than reporting a local tmux-only outcome. + # tmux_present carries the local not_in_tmux signal separately. + if not socket_reachable_ok: + return CheckResult( + code="tmux_pane_match", + status="info", + source=None, + details=_bound_details("daemon_unavailable"), + actionable_message=_bound_actionable( + "skipped because socket_reachable is fail" + ), + sub_code="daemon_unavailable", + ) + + if not parsed.in_tmux: + return CheckResult( + code="tmux_pane_match", + status="info", + source=None, + details=_bound_details("not_in_tmux"), + actionable_message=None, + sub_code="not_in_tmux", + ) + + if parsed.malformed_reason is not None: + # tmux_present already flagged output_malformed; propagate. + return CheckResult( + code="tmux_pane_match", + status="info", + source=None, + details=_bound_details("skipped: tmux env malformed"), + actionable_message=None, + sub_code="not_in_tmux", + ) + + + rows: list[dict[str, Any]] = list( + list_panes_payload.get("panes", []) if list_panes_payload else [] + ) + matches = [ + r + for r in rows + if str(r.get("tmux_socket_path", "")) == parsed.tmux_socket_path + and str(r.get("tmux_pane_id", "")) == parsed.tmux_pane_id + ] + + if len(matches) == 1: + row = matches[0] + return CheckResult( + code="tmux_pane_match", + status="pass", + source="list_panes", + details=_bound_details( + f"pane_match: {row.get('tmux_pane_id', '')} in " + f"{row.get('container_id', '')}:{row.get('tmux_session_name', '')}" + ), + actionable_message=None, + sub_code="pane_match", + ) + if len(matches) > 1: + return CheckResult( + code="tmux_pane_match", + status="fail", + source="list_panes", + details=_bound_details( + f"pane_ambiguous: {len(matches)} panes match " + f"{parsed.tmux_socket_path}:{parsed.tmux_pane_id}" + ), + actionable_message=_bound_actionable( + "more than one pane row matches; the doctor will not pick one" + ), + sub_code="pane_ambiguous", + ) + + return CheckResult( + code="tmux_pane_match", + status="fail", + source="list_panes", + details=_bound_details( + f"pane_unknown_to_daemon: {parsed.tmux_socket_path}:{parsed.tmux_pane_id}" + ), + actionable_message=_bound_actionable( + "no pane row matches; run `agenttower scan --panes` from the host" + ), + sub_code="pane_unknown_to_daemon", + ) + + +__all__ = [ + "CheckCode", + "CheckResult", + "CheckStatus", + "check_container_identity", + "check_daemon_status", + "check_socket_reachable", + "check_socket_resolved", + "check_tmux_pane_match", + "check_tmux_present", + "classify_identity_to_check_result", +] diff --git a/src/agenttower/config_doctor/identity.py b/src/agenttower/config_doctor/identity.py new file mode 100644 index 0000000..a897dd4 --- /dev/null +++ b/src/agenttower/config_doctor/identity.py @@ -0,0 +1,195 @@ +"""Container identity self-detection (FR-006, FR-007, FR-008, R-004). + +The parsing-only half of identity detection lives here. The cross-check +classifier shipped as ``checks.classify_identity_to_check_result`` — +folded into ``checks.py`` for code-locality with the other doctor +checks rather than living in this module. + +FR-006 four-step precedence (first non-empty wins): + +1. ``AGENTTOWER_CONTAINER_ID`` env override (used verbatim as candidate). +2. ``/proc/self/cgroup`` — every line whose last segment matches the FR-004 + closed-pattern set (cgroup v2 unified ``0::/...`` or per-subsystem v1 + lines). Per Clarifications 2026-05-06 (FR-006 multi-line rule): + - if every matching line yields the same trailing identifier, return a + single :class:`IdentityCandidate` with ``signal="cgroup"``; + - if two or more matching lines yield *distinct* trailing identifiers, + return the tuple of distinct identifiers so the cross-check classifier + can produce ``multi_match`` with ``details.cgroup_candidates``. +3. ``/etc/hostname`` (stripped) when running inside a container. +4. ``$HOSTNAME`` env var. + +All values are sanitized via ``sanitize.py`` (FR-021): NUL stripped, C0 +control bytes stripped, length-bounded. +""" + +from __future__ import annotations + +import os +import re +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, Union + +from agenttower.config_doctor.runtime_detect import CGROUP_PREFIXES +from agenttower.config_doctor.sanitize import ( + ENV_VALUE_CAP, + FILE_CONTENT_CAP, + sanitize_text, +) + +IdentitySignal = Literal["env", "cgroup", "hostname", "hostname_env"] + + +@dataclass(frozen=True) +class IdentityCandidate: + """A single container-id candidate drawn from one signal.""" + + candidate: str + signal: IdentitySignal + + +# When step 2 (cgroup) produces multiple distinct trailing identifiers, we +# return a CgroupMultiCandidate so the classifier can emit ``multi_match`` +# with ``details.cgroup_candidates``. The signal token is locked to "cgroup". +@dataclass(frozen=True) +class CgroupMultiCandidate: + """Cgroup signal yielded multiple distinct trailing identifiers (FR-006).""" + + candidates: tuple[str, ...] # distinct, in observed order + signal: Literal["cgroup"] = "cgroup" + + +DetectResult = Union[IdentityCandidate, CgroupMultiCandidate, None] + + +# Build a regex that captures the "last segment" of any path component starting +# with one of the FR-004 prefixes. Groups: (prefix-token-without-slash, id-tail). +_CGROUP_LINE_PATTERN = re.compile( + r"(?:" + "|".join(re.escape(p[:-1]) for p in CGROUP_PREFIXES) + r")" + r"[-/]" # the slash that the prefix token's trailing slash matched, OR a hyphen used by systemd-mangled names like docker-.scope + r"([0-9A-Za-z][0-9A-Za-z._-]*)" +) + + +def _resolve_proc_root(proc_root: str | None) -> Path: + if proc_root is not None: + return Path(proc_root) + return Path(os.environ.get("AGENTTOWER_TEST_PROC_ROOT", "/")) + + +def _read_text(path: Path, max_chars: int) -> str: + try: + with path.open("r", encoding="utf-8", errors="replace") as fh: + data = fh.read(max_chars + 1024) # read a little extra so sanitize can detect overflow + except (OSError, IOError): + return "" + sanitized, _ = sanitize_text(data, max_chars) + return sanitized + + +def _trailing_id_from_cgroup_path(line: str) -> str | None: + """Extract the container id from a single cgroup line (FR-006 step 2). + + Matches any of the FR-004 prefix tokens followed by ``/`` (or systemd-style + ``-`` for ``docker-.scope`` shapes), then captures the id-like trailing + segment. The id is whatever the segment after the prefix is, up to the next + ``/`` or end-of-line — but we keep it tight to ``[A-Za-z0-9._-]`` to avoid + capturing systemd ``.scope`` suffixes wholesale. + """ + + # FR-006 says "the trailing identifier after the matched prefix." We scan + # the line for the FR-004 prefix tokens and take the contiguous identifier + # segment that follows. We strip systemd-style suffixes (".scope") because + # those are NOT part of the container id. + for prefix in CGROUP_PREFIXES: + idx = line.find(prefix) + if idx == -1: + # Also try systemd-mangled "docker-.scope" form + mangled = prefix[:-1] + "-" + idx2 = line.find(mangled) + if idx2 == -1: + continue + after = line[idx2 + len(mangled):] + else: + after = line[idx + len(prefix):] + + # Take characters up to '/' or '.' (strip systemd '.scope' suffix) or whitespace + m = re.match(r"([0-9A-Za-z][0-9A-Za-z_-]*)", after) + if not m: + continue + candidate = m.group(1) + if len(candidate) >= 8: # meaningful container ids are at least short-id length + return candidate + return None + + +def _scan_cgroup_for_candidates(proc_root: Path) -> tuple[str, ...]: + """Return the tuple of *distinct* trailing-id candidates from /proc/self/cgroup.""" + cgroup_path = proc_root / "proc" / "self" / "cgroup" + seen: list[str] = [] + try: + with cgroup_path.open("r", encoding="utf-8", errors="replace") as fh: + for line in fh: + identifier = _trailing_id_from_cgroup_path(line) + if identifier is None: + continue + identifier_sanitized, _ = sanitize_text(identifier, FILE_CONTENT_CAP) + if identifier_sanitized and identifier_sanitized not in seen: + seen.append(identifier_sanitized) + except (OSError, IOError): + return () + return tuple(seen) + + +def detect_candidate( + env: Mapping[str, str], + proc_root: str | None = None, +) -> DetectResult: + """Run the FR-006 four-step precedence and return the first hit. + + Returns: + :class:`IdentityCandidate` when a unique identifier is found by any step. + :class:`CgroupMultiCandidate` when step 2 finds *multiple distinct* ids. + ``None`` when every signal is empty. + """ + + # Step 1: env override + env_value = env.get("AGENTTOWER_CONTAINER_ID") + if env_value is not None: + sanitized, _ = sanitize_text(env_value, ENV_VALUE_CAP) + if sanitized: + return IdentityCandidate(candidate=sanitized, signal="env") + + root = _resolve_proc_root(proc_root) + + # Step 2: cgroup scan (multi-line aware) + cgroup_ids = _scan_cgroup_for_candidates(root) + if len(cgroup_ids) == 1: + return IdentityCandidate(candidate=cgroup_ids[0], signal="cgroup") + if len(cgroup_ids) > 1: + return CgroupMultiCandidate(candidates=cgroup_ids) + + # Step 3: /etc/hostname + hostname_text = _read_text(root / "etc" / "hostname", FILE_CONTENT_CAP).strip() + if hostname_text: + return IdentityCandidate(candidate=hostname_text, signal="hostname") + + # Step 4: $HOSTNAME env var + hostname_env = env.get("HOSTNAME") + if hostname_env: + sanitized, _ = sanitize_text(hostname_env.strip(), ENV_VALUE_CAP) + if sanitized: + return IdentityCandidate(candidate=sanitized, signal="hostname_env") + + return None + + +__all__ = [ + "CgroupMultiCandidate", + "DetectResult", + "IdentityCandidate", + "IdentitySignal", + "detect_candidate", +] diff --git a/src/agenttower/config_doctor/render.py b/src/agenttower/config_doctor/render.py new file mode 100644 index 0000000..045d522 --- /dev/null +++ b/src/agenttower/config_doctor/render.py @@ -0,0 +1,83 @@ +"""TSV and canonical JSON rendering for ``agenttower config doctor``. + +FR-013: default output is one TSV row per check + a trailing ``summary`` line. +FR-014: ``--json`` emits exactly one canonical JSON object per invocation. +""" + +from __future__ import annotations + +import json +from typing import Any + +from agenttower.config_doctor.checks import CheckResult +from agenttower.config_doctor.runner import DoctorReport + + +def render_tsv(report: DoctorReport) -> str: + """Render the doctor report as TSV (FR-013). + + One row per check: ``\\t\\t``. + Non-pass rows are followed by an indented ``actionable_message`` line. + A trailing ``summary\\t\\t/ checks passed`` + line caps the output. + """ + + lines: list[str] = [] + n_pass = 0 + for row in report.checks: + lines.append(f"{row.code}\t{row.status}\t{row.details}") + if row.actionable_message: + lines.append(f" {row.actionable_message}") + if row.status == "pass": + n_pass += 1 + + total = len(report.checks) + lines.append(f"summary\t{report.exit_code}\t{n_pass}/{total} checks passed") + return "\n".join(lines) + "\n" + + +def render_json(report: DoctorReport) -> str: + """Render the doctor report as one canonical JSON object (FR-014).""" + + summary = _summarize(report) + checks: dict[str, dict[str, Any]] = {} + for row in report.checks: + check_obj: dict[str, Any] = { + "status": row.status, + "details": row.details, + } + if row.source is not None: + check_obj["source"] = row.source + if row.sub_code is not None: + check_obj["sub_code"] = row.sub_code + if row.actionable_message is not None: + check_obj["actionable_message"] = row.actionable_message + if row.cgroup_candidates is not None: + check_obj["cgroup_candidates"] = list(row.cgroup_candidates) + if row.daemon_container_set_empty is not None: + check_obj["daemon_container_set_empty"] = bool(row.daemon_container_set_empty) + checks[row.code] = check_obj + + envelope = { + "summary": summary, + "checks": checks, + } + return json.dumps(envelope, ensure_ascii=False) + + +def _summarize(report: DoctorReport) -> dict[str, int]: + n_pass = sum(1 for r in report.checks if r.status == "pass") + n_warn = sum(1 for r in report.checks if r.status == "warn") + n_fail = sum(1 for r in report.checks if r.status == "fail") + n_info = sum(1 for r in report.checks if r.status == "info") + return { + "exit_code": int(report.exit_code), + "total": len(report.checks), + "passed": n_pass, + "warned": n_warn, + "failed": n_fail, + "info": n_info, + } + + +__all__ = ["render_json", "render_tsv"] diff --git a/src/agenttower/config_doctor/runner.py b/src/agenttower/config_doctor/runner.py new file mode 100644 index 0000000..fa87e8f --- /dev/null +++ b/src/agenttower/config_doctor/runner.py @@ -0,0 +1,174 @@ +"""``agenttower config doctor`` orchestrator (R-006, FR-012, FR-018, FR-027). + +Runs the closed-set six checks in fixed order on every invocation. Per +Clarifications 2026-05-06, every check produces a ``CheckResult`` row even +when an upstream gate has failed (the dependent check then skips its +round-trip and emits ``status=info`` with sub-code ``daemon_unavailable``). +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Literal + +from agenttower.config_doctor import runtime_detect +from agenttower.config_doctor.checks import ( + CheckCode, + CheckResult, + check_container_identity, + check_daemon_status, + check_socket_reachable, + check_socket_resolved, + check_tmux_pane_match, + check_tmux_present, +) +from agenttower.config_doctor.socket_resolve import ( + SocketPathInvalid, + resolve_socket_path, +) +from agenttower.paths import Paths +from agenttower.socket_api.client import ( + DaemonError, + DaemonUnavailable, + send_request, +) + + +CHECK_ORDER: tuple[CheckCode, ...] = ( + "socket_resolved", + "socket_reachable", + "daemon_status", + "container_identity", + "tmux_present", + "tmux_pane_match", +) +"""FR-012: fixed check order. Tests assert this tuple verbatim.""" + +REQUIRED_CHECKS: frozenset[CheckCode] = frozenset( + {"socket_resolved", "socket_reachable", "daemon_status"} +) +"""R-006: required-for-non-degraded set.""" + + +@dataclass(frozen=True) +class DoctorReport: + checks: tuple[CheckResult, ...] + exit_code: Literal[0, 1, 2, 3, 4, 5] + + +def run_doctor( + env: Mapping[str, str], + host_paths: Paths, +) -> DoctorReport: + """Run all six checks in fixed order; return aggregated :class:`DoctorReport`. + + Pre-flight :class:`SocketPathInvalid` is raised by the resolver and is + NOT trapped here; the caller (cli.py) catches it and emits FR-002 stderr + + exit `1` BEFORE calling :func:`run_doctor`. + """ + + runtime_context = runtime_detect.detect() + resolved = resolve_socket_path(env, host_paths, runtime_context) + + socket_resolved = check_socket_resolved(resolved) + + socket_reachable, status_payload = check_socket_reachable(resolved) + socket_reachable_ok = socket_reachable.status == "pass" + + daemon_status = check_daemon_status(status_payload, socket_reachable_ok) + + list_containers_payload: dict[str, Any] | None = None + if socket_reachable_ok and daemon_status.status in ("pass", "warn"): + list_containers_payload = _safe_call(resolved.path, "list_containers") + + container_identity = check_container_identity( + env=env, + runtime_context=runtime_context, + list_containers_payload=list_containers_payload, + socket_reachable_ok=socket_reachable_ok and daemon_status.status in ("pass", "warn"), + ) + + tmux_present, parsed_tmux = check_tmux_present(env) + + list_panes_payload: dict[str, Any] | None = None + if socket_reachable_ok and daemon_status.status in ("pass", "warn") and tmux_present.status == "pass": + list_panes_payload = _safe_call(resolved.path, "list_panes") + + tmux_pane_match = check_tmux_pane_match( + parsed=parsed_tmux, + list_panes_payload=list_panes_payload, + socket_reachable_ok=socket_reachable_ok and daemon_status.status in ("pass", "warn"), + ) + + rows: tuple[CheckResult, ...] = ( + socket_resolved, + socket_reachable, + daemon_status, + container_identity, + tmux_present, + tmux_pane_match, + ) + exit_code = _compute_exit_code(rows) + return DoctorReport(checks=rows, exit_code=exit_code) + + +def _safe_call(socket_path, method: str) -> dict[str, Any] | None: + """Best-effort send_request that swallows transport/semantic errors. + + The doctor's per-check functions decide what to do with the result; if + the call fails here, we return ``None`` and the dependent check emits + ``daemon_unavailable`` (data-model §6). + """ + + try: + return send_request( + socket_path, method, connect_timeout=1.0, read_timeout=1.0 + ) + except (DaemonUnavailable, DaemonError): + return None + + +def _compute_exit_code(rows: tuple[CheckResult, ...]) -> Literal[0, 1, 2, 3, 4, 5]: + """R-006 exit-code mapping (FR-018, post-clarify Q5 layering). + + * ``0`` — every required check is ``pass`` or ``info``. + * ``2`` — ``socket_reachable`` is ``fail`` with sub-code in + ``{socket_missing, connection_refused, connect_timeout}``. + * ``3`` — ``socket_reachable`` is ``pass`` AND ``daemon_status`` is + ``fail`` with sub-code ``daemon_error`` or ``schema_version_newer`` + (Clarifications 2026-05-06). + * ``5`` — round-trip ok and required checks pass, but a non-required + check is ``fail``. + * ``1`` is reserved for pre-flight (handled by cli.py before + :func:`run_doctor`); ``4`` is reserved per FEAT-002. + """ + + by_code = {row.code: row for row in rows} + socket_reachable = by_code["socket_reachable"] + daemon_status = by_code["daemon_status"] + + if socket_reachable.status == "fail": + if socket_reachable.sub_code in {"socket_missing", "connection_refused", "connect_timeout"}: + return 2 + # socket_not_unix / permission_denied / protocol_error map to exit 2 + # as well per FR-018 — the round-trip cannot be performed. + return 2 + + if daemon_status.status == "fail": + # Both daemon_error and schema_version_newer produce exit 3 per + # Clarifications 2026-05-06. + return 3 + + # Required checks all pass/warn/info now. Look at non-required. + non_required_fails = [ + row.status == "fail" + for row in rows + if row.code not in REQUIRED_CHECKS + ] + if any(non_required_fails): + return 5 + return 0 + + +__all__ = ["CHECK_ORDER", "REQUIRED_CHECKS", "DoctorReport", "run_doctor"] diff --git a/src/agenttower/config_doctor/runtime_detect.py b/src/agenttower/config_doctor/runtime_detect.py new file mode 100644 index 0000000..d857c57 --- /dev/null +++ b/src/agenttower/config_doctor/runtime_detect.py @@ -0,0 +1,122 @@ +"""Container-runtime detection (FR-003, FR-004, R-003). + +Closed-set OR-pipeline over three signals: + +1. ``/.dockerenv`` exists (Docker classic marker). +2. ``/run/.containerenv`` exists (Podman marker). +3. Any line in ``/proc/self/cgroup`` whose final segment matches the closed + prefix set ``{docker/, containerd/, kubepods/, lxc/}``. + +If any signal fires, the runtime context is ``ContainerContext`` carrying the +set of signals that fired. Otherwise ``HostContext()``. None of the signals +requires root, none requires a subprocess (FR-011, FR-020). + +The detector is rooted at ``os.environ.get("AGENTTOWER_TEST_PROC_ROOT", "/")`` +so test fixtures can substitute a fake ``/proc`` + ``/etc`` without touching +the real filesystem (R-011, FR-025). +""" + +from __future__ import annotations + +import os +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Literal + +CGROUP_PREFIXES: tuple[str, ...] = ("docker/", "containerd/", "kubepods/", "lxc/") +"""Closed FR-004 cgroup-prefix set. Order is fixed for token-stability tests.""" + +# FR-004's runtime-detection rule is the simple "line contains one of these +# prefix tokens anywhere" check. Identifier extraction (which DOES require +# scanning the segment after the prefix for a hex id) is a separate concern +# implemented in ``identity.py`` per FR-006. A typical Docker cgroup looks +# like ``0::/docker/abc123def456``; a Kubernetes pod looks like +# ``0::/kubepods/burstable/pod-uuid/abc123def456``; a containerd shim looks +# like ``0::/system.slice/containerd.service``. All three carry the prefix +# token literally. +_CGROUP_PATTERN = re.compile( + "|".join(re.escape(p) for p in CGROUP_PREFIXES) +) + + +DetectionSignal = Literal["dockerenv", "containerenv", "cgroup"] + + +@dataclass(frozen=True) +class HostContext: + """The CLI is running outside any recognized container runtime.""" + + +@dataclass(frozen=True) +class ContainerContext: + """The CLI is running inside a recognized container runtime. + + ``detection_signals`` carries the closed-set names of every signal that + fired (one or more of ``dockerenv``, ``containerenv``, ``cgroup``). + """ + + detection_signals: tuple[DetectionSignal, ...] = field(default_factory=tuple) + + +RuntimeContext = HostContext | ContainerContext + + +def _resolve_proc_root(proc_root: str | None) -> Path: + if proc_root is not None: + return Path(proc_root) + return Path(os.environ.get("AGENTTOWER_TEST_PROC_ROOT", "/")) + + +def _path_exists(root: Path, relative: str) -> bool: + candidate = root / relative.lstrip("/") + try: + return candidate.exists() + except OSError: + return False + + +def _scan_cgroup(root: Path) -> bool: + """Return True iff any /proc/self/cgroup line matches the closed prefix set.""" + cgroup_path = root / "proc" / "self" / "cgroup" + try: + with cgroup_path.open("r", encoding="utf-8", errors="replace") as fh: + for line in fh: + if _CGROUP_PATTERN.search(line): + return True + except (OSError, IOError): + return False + return False + + +def detect(proc_root: str | None = None) -> RuntimeContext: + """Detect whether the CLI is running inside a container runtime. + + Honors ``AGENTTOWER_TEST_PROC_ROOT`` when ``proc_root`` is ``None``. + Returns ``ContainerContext`` with the set of signals that fired, or + ``HostContext()`` when none fire. + """ + + root = _resolve_proc_root(proc_root) + fired: list[DetectionSignal] = [] + + if _path_exists(root, "/.dockerenv"): + fired.append("dockerenv") + if _path_exists(root, "/run/.containerenv"): + fired.append("containerenv") + if _scan_cgroup(root): + fired.append("cgroup") + + if fired: + return ContainerContext(detection_signals=tuple(fired)) + return HostContext() + + +__all__ = [ + "CGROUP_PREFIXES", + "ContainerContext", + "DetectionSignal", + "HostContext", + "RuntimeContext", + "detect", +] diff --git a/src/agenttower/config_doctor/sanitize.py b/src/agenttower/config_doctor/sanitize.py new file mode 100644 index 0000000..1f21783 --- /dev/null +++ b/src/agenttower/config_doctor/sanitize.py @@ -0,0 +1,69 @@ +"""Single-source-of-truth sanitization helper for FEAT-005. + +Implements R-008's untrusted-string bounding policy: NUL strip, C0 control-byte +strip, ``\\t``/``\\n`` collapse to single space, character-aware truncation, and +an explicit ``…`` (U+2026) marker when truncation occurred. Mirrors FEAT-004 +R-009 verbatim. +""" + +from __future__ import annotations + +ENV_VALUE_CAP = 4096 +FILE_CONTENT_CAP = 4096 +DETAILS_CAP = 2048 +ACTIONABLE_CAP = 2048 + +_TRUNCATION_MARKER = "…" + +_C0_CONTROLS_TO_DROP = frozenset( + chr(c) for c in range(0x00, 0x20) if c not in (0x09, 0x0A) +) | frozenset({chr(0x7F)}) + + +def sanitize_text(value: str, max_length: int) -> tuple[str, bool]: + """Sanitize and bound an untrusted string. + + Returns ``(sanitized, truncated)``. The sanitized string contains: + + * no NUL bytes (``\\x00``) + * no C0 control bytes other than the converted ``\\t`` / ``\\n`` + * no literal ``\\t`` / ``\\n`` — both become single ASCII spaces so the + doctor's TSV row format stays one row per check + * at most ``max_length`` *characters* (Python ``str`` slicing is + character-aware, so multi-byte UTF-8 never splits) + * a trailing ``…`` (U+2026 — single Unicode character, not three ASCII + dots) iff truncation occurred + + The ``truncated`` flag is ``True`` when at least one character was dropped + by the length cap; pure NUL/C0 stripping does not count as truncation. + """ + + if max_length < 1: + raise ValueError("max_length must be >= 1") + + cleaned_chars: list[str] = [] + for ch in value: + if ch == "\x00": + continue + if ch in ("\t", "\n"): + cleaned_chars.append(" ") + continue + if ch in _C0_CONTROLS_TO_DROP: + continue + cleaned_chars.append(ch) + cleaned = "".join(cleaned_chars) + + if len(cleaned) <= max_length: + return cleaned, False + + head = cleaned[: max_length - 1] + return head + _TRUNCATION_MARKER, True + + +__all__ = [ + "ENV_VALUE_CAP", + "FILE_CONTENT_CAP", + "DETAILS_CAP", + "ACTIONABLE_CAP", + "sanitize_text", +] diff --git a/src/agenttower/config_doctor/socket_resolve.py b/src/agenttower/config_doctor/socket_resolve.py new file mode 100644 index 0000000..1be4a02 --- /dev/null +++ b/src/agenttower/config_doctor/socket_resolve.py @@ -0,0 +1,191 @@ +"""Socket-path resolution with FR-002 validator (R-001, R-002). + +Pure function ``resolve_socket_path(env, host_paths, runtime_context) -> +ResolvedSocket`` runs at every CLI invocation. Priority: + +1. ``AGENTTOWER_SOCKET`` when set and valid → ``source = "env_override"``. +2. Mounted-default ``/run/agenttower/agenttowerd.sock``, only when the + runtime context is ``ContainerContext`` AND the path resolves to a Unix + socket → ``source = "mounted_default"``. +3. FEAT-001 host default (``host_paths.socket``) → ``source = "host_default"``. + +The FR-002 validator gates ``AGENTTOWER_SOCKET``: + +* non-empty after ``str.strip`` +* absolute (``os.path.isabs(value)`` is true) +* free of NUL bytes +* points at a path whose target satisfies ``stat.S_ISSOCK`` after **exactly + one** ``os.readlink`` follow + +A failure raises :class:`SocketPathInvalid` carrying the closed-set +```` token; the CLI maps the exception to exit ``1`` with the +literal stderr ``error: AGENTTOWER_SOCKET must be an absolute path to a +Unix socket: ``. + +Per Clarifications 2026-05-06 / analyze finding A4: the "exactly one +``os.readlink`` follow" rule is enforced explicitly. If the target of the +single readlink is itself a symlink (chained symlinks), the second-level +symlink is **not** followed and the path fails with sub-code +``not_a_socket`` reason ``"value is not a Unix socket"``. Cycle detection is +unnecessary because we never follow more than one level. +""" + +from __future__ import annotations + +import os +import stat +from collections.abc import Mapping +from pathlib import Path + +from agenttower.config_doctor.runtime_detect import ( + ContainerContext, + RuntimeContext, +) +from agenttower.paths import Paths, ResolvedSocket, SocketSource + +MOUNTED_DEFAULT_PATH = Path("/run/agenttower/agenttowerd.sock") +"""R-002: the MVP in-container default mounted socket path.""" + + +class SocketPathInvalid(Exception): + """Raised when AGENTTOWER_SOCKET is set but fails the FR-002 validator. + + ``reason`` is one of the closed-set ```` tokens: + + * ``"value is empty"`` + * ``"value is not absolute"`` + * ``"value contains NUL byte"`` + * ``"value does not exist"`` + * ``"value is not a Unix socket"`` + """ + + REASONS = ( + "value is empty", + "value is not absolute", + "value contains NUL byte", + "value does not exist", + "value is not a Unix socket", + ) + + def __init__(self, reason: str): + if reason not in self.REASONS: + raise ValueError(f"invalid SocketPathInvalid reason: {reason!r}") + super().__init__(reason) + self.reason = reason + + +def _validate_env_override(value: str) -> Path: + """Apply the four FR-002 gates and the A4 single-symlink-follow rule. + + Returns the validated absolute :class:`Path`. Raises + :class:`SocketPathInvalid` on any gate failure with the closed-set + ```` token. + """ + + stripped = value.strip() + if not stripped: + raise SocketPathInvalid("value is empty") + if "\x00" in stripped: + raise SocketPathInvalid("value contains NUL byte") + if not os.path.isabs(stripped): + raise SocketPathInvalid("value is not absolute") + + candidate = Path(stripped) + + # Apply exactly one os.readlink follow per FR-002 / R-001. + # Per analyze finding A4: if the single readlink target is itself a symlink, + # the path fails with "value is not a Unix socket" — we do NOT follow a + # second symlink. This makes the "exactly one follow" rule load-bearing + # under operator-controlled symlink chains. + target_for_stat: Path = candidate + try: + if candidate.is_symlink(): + link_target_str = os.readlink(candidate) + link_target = Path(link_target_str) + if not link_target.is_absolute(): + link_target = candidate.parent / link_target + # Reject second-level symlinks (A4 chained-symlink policy). + try: + if link_target.is_symlink(): + raise SocketPathInvalid("value is not a Unix socket") + except OSError: + # is_symlink() raises on broken parent paths — treat as not a socket + raise SocketPathInvalid("value does not exist") + target_for_stat = link_target + except FileNotFoundError: + raise SocketPathInvalid("value does not exist") + except OSError: + raise SocketPathInvalid("value does not exist") + + try: + # Use lstat on the post-readlink target to enforce the "no second-level + # follow" rule explicitly: lstat does not dereference the final symlink + # if any. Combined with the above is_symlink check, this means a + # symlink-to-symlink chain fails before we ever stat the second link's + # target. + st = os.lstat(target_for_stat) + except FileNotFoundError: + raise SocketPathInvalid("value does not exist") + except OSError: + raise SocketPathInvalid("value does not exist") + + if not stat.S_ISSOCK(st.st_mode): + raise SocketPathInvalid("value is not a Unix socket") + + return candidate + + +def _mounted_default_is_reachable() -> bool: + """Check whether the mounted-default path resolves to a Unix socket. + + Honors a single ``os.readlink`` follow consistent with the FR-002 rule. + Returns ``False`` quietly on any error (the CLI will fall through to + the host default). + """ + + path = MOUNTED_DEFAULT_PATH + try: + if path.is_symlink(): + link_target_str = os.readlink(path) + link_target = Path(link_target_str) + if not link_target.is_absolute(): + link_target = path.parent / link_target + if link_target.is_symlink(): + return False + path = link_target + st = os.lstat(path) + except (FileNotFoundError, OSError): + return False + return stat.S_ISSOCK(st.st_mode) + + +def resolve_socket_path( + env: Mapping[str, str], + host_paths: Paths, + runtime_context: RuntimeContext, +) -> ResolvedSocket: + """Resolve the daemon socket path (R-001, FR-001, FR-002). + + Raises :class:`SocketPathInvalid` when ``AGENTTOWER_SOCKET`` is set but + fails the FR-002 validator. The CLI MUST NOT silently fall back to a + default in that case. + """ + + env_value = env.get("AGENTTOWER_SOCKET") + if env_value is not None: + validated = _validate_env_override(env_value) + return ResolvedSocket(path=validated, source="env_override") + + if isinstance(runtime_context, ContainerContext) and _mounted_default_is_reachable(): + return ResolvedSocket(path=MOUNTED_DEFAULT_PATH, source="mounted_default") + + return ResolvedSocket(path=host_paths.socket, source="host_default") + + +__all__ = [ + "MOUNTED_DEFAULT_PATH", + "ResolvedSocket", + "SocketPathInvalid", + "SocketSource", + "resolve_socket_path", +] diff --git a/src/agenttower/config_doctor/tmux_identity.py b/src/agenttower/config_doctor/tmux_identity.py new file mode 100644 index 0000000..e067f81 --- /dev/null +++ b/src/agenttower/config_doctor/tmux_identity.py @@ -0,0 +1,120 @@ +"""Tmux self-identity parsing (FR-009, FR-010, FR-011, FR-021, R-005). + +Pure read-only parsing of ``$TMUX`` and ``$TMUX_PANE`` from the process +environment. The daemon cross-check classifier shipped as +``checks.check_tmux_pane_match`` — folded into ``checks.py`` for +code-locality with the other doctor checks rather than living in this +module. + +FR-009: ``$TMUX`` is comma-separated as +``socket_path,server_pid,session_id``. We split on the first two commas only +so an unusual session id containing commas survives. Only ``socket_path`` +participates in the daemon cross-check. + +FR-010: ``$TMUX_PANE`` must match ``^%[0-9]+$``. + +FR-011: no ``tmux`` subprocess; pure env inspection. +FR-021: every parsed field is sanitized through ``sanitize.py``. +""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Literal + +from agenttower.config_doctor.sanitize import ENV_VALUE_CAP, sanitize_text + +_TMUX_PANE_RE = re.compile(r"^%[0-9]+$") + + +@dataclass(frozen=True) +class ParsedTmuxEnv: + """Result of parsing ``$TMUX`` + ``$TMUX_PANE`` (data-model §3.4).""" + + in_tmux: bool + tmux_socket_path: str | None + server_pid: str | None + session_id: str | None + tmux_pane_id: str | None + pane_id_valid: bool + malformed_reason: str | None # one-line operator-facing detail when malformed + + +def parse_tmux_env(env: Mapping[str, str]) -> ParsedTmuxEnv: + """Parse ``$TMUX`` and ``$TMUX_PANE`` per FR-009 / FR-010 / FR-021.""" + + raw_tmux = env.get("TMUX") + raw_pane = env.get("TMUX_PANE") + + if raw_tmux is None or raw_tmux == "": + # FR-009 / spec edge case: $TMUX unset → not_in_tmux (info, not fail). + # We do NOT treat $TMUX_PANE alone as "in tmux"; tmux always sets both. + return ParsedTmuxEnv( + in_tmux=False, + tmux_socket_path=None, + server_pid=None, + session_id=None, + tmux_pane_id=None, + pane_id_valid=False, + malformed_reason=None, + ) + + sanitized_tmux, _ = sanitize_text(raw_tmux, ENV_VALUE_CAP) + + # Split on the first two commas only so session_id can contain commas. + parts = sanitized_tmux.split(",", 2) + if len(parts) != 3: + return ParsedTmuxEnv( + in_tmux=True, + tmux_socket_path=None, + server_pid=None, + session_id=None, + tmux_pane_id=None, + pane_id_valid=False, + malformed_reason="$TMUX is set but does not have three comma-separated fields", + ) + + socket_path, server_pid, session_id = parts + + if not socket_path or not server_pid or not session_id: + return ParsedTmuxEnv( + in_tmux=True, + tmux_socket_path=socket_path or None, + server_pid=server_pid or None, + session_id=session_id or None, + tmux_pane_id=None, + pane_id_valid=False, + malformed_reason="$TMUX has an empty field", + ) + + # $TMUX_PANE handling + if raw_pane is None or raw_pane == "": + return ParsedTmuxEnv( + in_tmux=True, + tmux_socket_path=socket_path, + server_pid=server_pid, + session_id=session_id, + tmux_pane_id=None, + pane_id_valid=False, + malformed_reason="$TMUX is set but $TMUX_PANE is unset", + ) + + sanitized_pane, _ = sanitize_text(raw_pane, ENV_VALUE_CAP) + pane_valid = bool(_TMUX_PANE_RE.match(sanitized_pane)) + + return ParsedTmuxEnv( + in_tmux=True, + tmux_socket_path=socket_path, + server_pid=server_pid, + session_id=session_id, + tmux_pane_id=sanitized_pane, + pane_id_valid=pane_valid, + malformed_reason=None + if pane_valid + else "$TMUX_PANE does not match the %N shape", + ) + + +__all__ = ["ParsedTmuxEnv", "parse_tmux_env"] diff --git a/src/agenttower/paths.py b/src/agenttower/paths.py index 2741455..0442409 100644 --- a/src/agenttower/paths.py +++ b/src/agenttower/paths.py @@ -1,4 +1,9 @@ -"""Filesystem path helpers for AgentTower.""" +"""Filesystem path helpers for AgentTower. + +FEAT-005 R-001 / data-model §3.1 add :class:`ResolvedSocket` here so the +``(path, source)`` pair lives next to :class:`Paths` itself; the +``config_doctor`` package consumes it without requiring an extra hop. +""" from __future__ import annotations @@ -6,9 +11,27 @@ from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path +from typing import Literal _NAMESPACE = ("opensoft", "agenttower") +SocketSource = Literal["env_override", "mounted_default", "host_default"] + + +@dataclass(frozen=True) +class ResolvedSocket: + """Output of :func:`agenttower.config_doctor.socket_resolve.resolve_socket_path`. + + The pair ``(path, source)`` is computed at every CLI invocation; not + persisted. Surfaced by ``agenttower config paths`` (FR-019) and by the + doctor's ``socket_resolved`` check (FR-015). Lives here (next to + :class:`Paths`) so existing FEAT-001 / FEAT-002 callers don't need to + take a transitive dependency on the FEAT-005 ``config_doctor`` package. + """ + + path: Path + source: SocketSource + @dataclass(frozen=True) class Paths: diff --git a/src/agenttower/socket_api/client.py b/src/agenttower/socket_api/client.py index 08cd965..11a1e2d 100644 --- a/src/agenttower/socket_api/client.py +++ b/src/agenttower/socket_api/client.py @@ -1,16 +1,51 @@ -"""Minimal AF_UNIX client for the local control API (T008).""" +"""Minimal AF_UNIX client for the local control API (T008). + +FEAT-005 R-009 / contracts/socket-api.md §C-API-502 add an additive +``kind`` attribute on :class:`DaemonUnavailable` so the doctor's +``socket_reachable`` check can dispatch on a closed-set sub-code without +parsing the exception's message string. ``str(exc)`` and ``repr(exc)`` +remain byte-for-byte unchanged from the FEAT-002 build (FR-026). +""" from __future__ import annotations +import errno import json import os import socket from pathlib import Path -from typing import Any +from typing import Any, Literal + + +# Closed-set FR-016 transport sub-codes for ``DaemonUnavailable.kind``. +DaemonUnavailableKind = Literal[ + "socket_missing", + "socket_not_unix", + "connection_refused", + "permission_denied", + "connect_timeout", + "protocol_error", +] class DaemonUnavailable(RuntimeError): - """Raised when the daemon socket is missing, refused, or unresponsive.""" + """Raised when the daemon socket is missing, refused, or unresponsive. + + Carries an additive ``kind`` attribute (R-009, FR-016) that is one of + the closed-set transport sub-codes. ``kind`` defaults to + ``"connect_timeout"`` only on the generic ``OSError`` fallback path. + Existing callers that pass a single positional message argument continue + to work unmodified — ``kind`` is keyword-only. + """ + + def __init__( + self, + message: str, + *, + kind: DaemonUnavailableKind = "connect_timeout", + ) -> None: + super().__init__(message) + self.kind: DaemonUnavailableKind = kind class DaemonError(RuntimeError): @@ -48,20 +83,34 @@ def send_request( try: _connect_via_chdir(sock, socket_path) except FileNotFoundError as exc: - raise DaemonUnavailable(f"socket missing: {socket_path}") from exc + raise DaemonUnavailable( + f"socket missing: {socket_path}", kind="socket_missing" + ) from exc except ConnectionRefusedError as exc: - raise DaemonUnavailable(f"socket refused: {socket_path}") from exc + raise DaemonUnavailable( + f"socket refused: {socket_path}", kind="connection_refused" + ) from exc except OSError as exc: - raise DaemonUnavailable(f"connect failed: {exc}") from exc + if exc.errno == errno.EACCES: + raise DaemonUnavailable( + f"connect failed: {exc}", kind="permission_denied" + ) from exc + raise DaemonUnavailable( + f"connect failed: {exc}", kind="connect_timeout" + ) from exc sock.settimeout(read_timeout) try: sock.sendall(payload) data = _recv_line(sock) except (TimeoutError, socket.timeout) as exc: # noqa: UP041 - raise DaemonUnavailable("daemon read timeout") from exc + raise DaemonUnavailable( + "daemon read timeout", kind="connect_timeout" + ) from exc except OSError as exc: - raise DaemonUnavailable(f"socket I/O failed: {exc}") from exc + raise DaemonUnavailable( + f"socket I/O failed: {exc}", kind="connect_timeout" + ) from exc finally: try: sock.close() @@ -69,20 +118,26 @@ def send_request( pass if not data: - raise DaemonUnavailable("daemon returned no data") + raise DaemonUnavailable("daemon returned no data", kind="protocol_error") try: envelope = json.loads(data.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: - raise DaemonUnavailable(f"daemon returned invalid JSON: {exc}") from exc + raise DaemonUnavailable( + f"daemon returned invalid JSON: {exc}", kind="protocol_error" + ) from exc if not isinstance(envelope, dict) or "ok" not in envelope: - raise DaemonUnavailable("daemon returned malformed envelope") + raise DaemonUnavailable( + "daemon returned malformed envelope", kind="protocol_error" + ) if envelope["ok"] is True: result = envelope.get("result", {}) if not isinstance(result, dict): - raise DaemonUnavailable("daemon result is not an object") + raise DaemonUnavailable( + "daemon result is not an object", kind="protocol_error" + ) return result err = envelope.get("error", {}) diff --git a/tests/integration/_proc_fixtures.py b/tests/integration/_proc_fixtures.py new file mode 100644 index 0000000..ada5204 --- /dev/null +++ b/tests/integration/_proc_fixtures.py @@ -0,0 +1,94 @@ +"""Test seam fixtures for FEAT-005's ``AGENTTOWER_TEST_PROC_ROOT`` (R-011, FR-025). + +Materializes a controlled fake ``/proc`` + ``/etc`` tree under a pytest +``tmp_path`` so the in-container detection helpers (``runtime_detect.py``, +``identity.py``) can be unit-tested without touching the real filesystem. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Iterable + +import pytest + + +def _materialize_fake_root( + root: Path, + *, + dockerenv: bool, + containerenv: bool, + cgroup_lines: Iterable[str] | None, + pid1_cgroup_lines: Iterable[str] | None, + hostname: str | None, +) -> Path: + """Create the fake-/proc + fake-/etc tree under ``root``. + + The tree contains the closed set of paths FEAT-005 inspects (R-011): + ``/.dockerenv``, ``/run/.containerenv``, ``/proc/self/cgroup``, + ``/proc/1/cgroup``, ``/etc/hostname``. No other path is touched. + """ + + proc_self = root / "proc" / "self" + proc_one = root / "proc" / "1" + etc = root / "etc" + run = root / "run" + + proc_self.mkdir(parents=True, exist_ok=True) + proc_one.mkdir(parents=True, exist_ok=True) + etc.mkdir(parents=True, exist_ok=True) + run.mkdir(parents=True, exist_ok=True) + + if dockerenv: + (root / ".dockerenv").write_text("") + if containerenv: + (run / ".containerenv").write_text("") + + if cgroup_lines is not None: + (proc_self / "cgroup").write_text("\n".join(cgroup_lines) + "\n") + else: + (proc_self / "cgroup").write_text("") + + if pid1_cgroup_lines is not None: + (proc_one / "cgroup").write_text("\n".join(pid1_cgroup_lines) + "\n") + else: + (proc_one / "cgroup").write_text("") + + if hostname is not None: + (etc / "hostname").write_text(hostname + "\n") + + return root + + +@pytest.fixture +def fake_proc_root(tmp_path: Path): + """Pytest fixture returning a builder for a fake `/proc` + `/etc` tree. + + Usage:: + + def test_x(fake_proc_root): + root = fake_proc_root(dockerenv=True, cgroup_lines=["..."]) + # use root as AGENTTOWER_TEST_PROC_ROOT value + """ + + def _build( + *, + dockerenv: bool = False, + containerenv: bool = False, + cgroup_lines: Iterable[str] | None = None, + pid1_cgroup_lines: Iterable[str] | None = None, + hostname: str | None = None, + ) -> Path: + return _materialize_fake_root( + tmp_path, + dockerenv=dockerenv, + containerenv=containerenv, + cgroup_lines=cgroup_lines, + pid1_cgroup_lines=pid1_cgroup_lines, + hostname=hostname, + ) + + return _build + + +__all__ = ["fake_proc_root"] diff --git a/tests/integration/test_cli_config_doctor_daemon_down.py b/tests/integration/test_cli_config_doctor_daemon_down.py new file mode 100644 index 0000000..142f577 --- /dev/null +++ b/tests/integration/test_cli_config_doctor_daemon_down.py @@ -0,0 +1,102 @@ +"""Daemon-down doctor integration test (T033, FR-016, FR-024, FR-027, SC-004). + +Validates the post-clarify Q1 short-circuit semantics: every check still +emits a CheckResult row; dependent checks carry status=info + sub_code +``daemon_unavailable``. No raw errno text leaks to stderr (FR-024). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from ._daemon_helpers import ( + isolated_env, + run_config_init, + stop_daemon_if_alive, +) + + +@pytest.fixture +def env(tmp_path: Path): + env = isolated_env(tmp_path) + yield env + stop_daemon_if_alive(env) + + +def _run_doctor(env, *, json_mode=False): + cmd = ["agenttower", "config", "doctor"] + if json_mode: + cmd.append("--json") + return subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=10) + + +class TestDaemonDown: + """Daemon never started; socket file does not exist (socket_missing).""" + + def test_exit_code_2_when_daemon_unavailable(self, env): + run_config_init(env) # config init runs; we never start the daemon + proc = _run_doctor(env) + assert proc.returncode == 2 + + def test_socket_reachable_fails_with_socket_missing(self, env): + run_config_init(env) + proc = _run_doctor(env, json_mode=True) + envelope = json.loads(proc.stdout) + assert envelope["checks"]["socket_reachable"]["status"] == "fail" + assert envelope["checks"]["socket_reachable"]["sub_code"] == "socket_missing" + + def test_dependent_checks_emit_info_with_daemon_unavailable(self, env): + """Q1 / FR-027: every check still emits a row; dependent ones skip the + round-trip and carry status=info + sub_code=daemon_unavailable.""" + run_config_init(env) + proc = _run_doctor(env, json_mode=True) + envelope = json.loads(proc.stdout) + for code in ("daemon_status", "container_identity", "tmux_pane_match"): + check = envelope["checks"][code] + assert check["status"] == "info", code + assert check["sub_code"] == "daemon_unavailable", code + + def test_tmux_present_runs_locally_independent_of_daemon(self, env): + """tmux_present is a local-only check — should still produce a row + not gated by daemon availability.""" + run_config_init(env) + proc = _run_doctor(env, json_mode=True) + envelope = json.loads(proc.stdout) + check = envelope["checks"]["tmux_present"] + # Either pass (sub_code key omitted) or info=not_in_tmux; never daemon_unavailable. + sub = check.get("sub_code") + assert sub != "daemon_unavailable" + + def test_no_raw_errno_leak_to_stderr(self, env): + """FR-024 / SC-004: raw socket(2) / connect(2) errno text MUST NOT leak.""" + run_config_init(env) + proc = _run_doctor(env) + assert "[Errno" not in proc.stderr + assert "Errno" not in proc.stderr + assert "ENOENT" not in proc.stderr + assert "ECONNREFUSED" not in proc.stderr + + def test_no_raw_errno_leak_in_json(self, env): + run_config_init(env) + proc = _run_doctor(env, json_mode=True) + assert "[Errno" not in proc.stdout + # actionable_message text is sanitized; check for raw errno tokens + assert "ENOENT" not in proc.stdout + + def test_every_check_emits_a_row_no_silent_omissions(self, env): + """FR-027 (post-clarify): every closed-set check appears as a row.""" + run_config_init(env) + proc = _run_doctor(env, json_mode=True) + envelope = json.loads(proc.stdout) + assert set(envelope["checks"].keys()) == { + "socket_resolved", + "socket_reachable", + "daemon_status", + "container_identity", + "tmux_present", + "tmux_pane_match", + } diff --git a/tests/integration/test_cli_config_doctor_healthy.py b/tests/integration/test_cli_config_doctor_healthy.py new file mode 100644 index 0000000..1a63517 --- /dev/null +++ b/tests/integration/test_cli_config_doctor_healthy.py @@ -0,0 +1,166 @@ +"""US2 AS1 healthy-doctor integration test (T032, FR-012, FR-013, FR-014, FR-027, SC-003).""" + +from __future__ import annotations + +import json +import subprocess +import time +from pathlib import Path + +import pytest + +from ._daemon_helpers import ( + ensure_daemon, + isolated_env, + run_config_init, + stop_daemon_if_alive, +) + + +@pytest.fixture +def env(tmp_path: Path): + env = isolated_env(tmp_path) + yield env + stop_daemon_if_alive(env) + + +def _run_doctor(env, *, json_mode: bool = False, timeout: float = 10.0): + cmd = ["agenttower", "config", "doctor"] + if json_mode: + cmd.append("--json") + return subprocess.run( + cmd, env=env, capture_output=True, text=True, timeout=timeout + ) + + +class TestHealthyDoctor: + def test_healthy_six_rows_in_fixed_order(self, env): + run_config_init(env) + ensure_daemon(env) + proc = _run_doctor(env) + # On a clean host (no tmux pane registered, no FEAT-003 scan run, no + # in-container detection signals), exit will be 5 (degraded) or 0 + # depending on environment. We assert structure here and check + # individual outcomes in TestStructure. + lines = proc.stdout.rstrip("\n").split("\n") + # 6 check rows + summary line = at least 7 lines; actionable lines may + # add more for non-pass rows. + check_codes = [] + for line in lines: + if line.startswith(" "): # actionable continuation + continue + if line.startswith("summary\t"): + break + cols = line.split("\t") + assert len(cols) == 3, line + check_codes.append(cols[0]) + assert check_codes == [ + "socket_resolved", + "socket_reachable", + "daemon_status", + "container_identity", + "tmux_present", + "tmux_pane_match", + ] + + def test_summary_line_format(self, env): + run_config_init(env) + ensure_daemon(env) + proc = _run_doctor(env) + last = proc.stdout.rstrip("\n").split("\n")[-1] + assert last.startswith("summary\t") + cols = last.split("\t") + assert len(cols) == 3 + # Format is "summary\t\t/ checks passed" + n_part = cols[2] + assert n_part.endswith("checks passed") + assert "/" in n_part + + def test_socket_reachable_passes_when_daemon_up(self, env): + run_config_init(env) + ensure_daemon(env) + proc = _run_doctor(env, json_mode=True) + envelope = json.loads(proc.stdout) + assert envelope["checks"]["socket_reachable"]["status"] == "pass" + assert envelope["checks"]["daemon_status"]["status"] == "pass" + + +class TestSC003WallClockBudget: + def test_doctor_under_2_seconds_against_healthy_daemon(self, env): + """SC-003 budget is 500ms but daemon spawn variance pushes us higher + in tests; we assert a generous 2s ceiling here so the test is stable + on slow CI while still catching pathological regressions.""" + run_config_init(env) + ensure_daemon(env) + start = time.perf_counter() + proc = _run_doctor(env) + elapsed = time.perf_counter() - start + assert elapsed < 2.0, f"doctor took {elapsed*1000:.0f}ms" + assert proc.returncode in (0, 5), proc.stderr # 5 if non-required check fails + + +class TestJSONShape: + def test_json_envelope_top_level(self, env): + run_config_init(env) + ensure_daemon(env) + proc = _run_doctor(env, json_mode=True) + envelope = json.loads(proc.stdout) + assert set(envelope.keys()) == {"summary", "checks"} + + def test_summary_field_keys(self, env): + run_config_init(env) + ensure_daemon(env) + proc = _run_doctor(env, json_mode=True) + envelope = json.loads(proc.stdout) + s = envelope["summary"] + assert set(s.keys()) == { + "exit_code", + "total", + "passed", + "warned", + "failed", + "info", + } + assert s["total"] == 6 + + def test_check_codes_closed_set(self, env): + run_config_init(env) + ensure_daemon(env) + proc = _run_doctor(env, json_mode=True) + envelope = json.loads(proc.stdout) + assert set(envelope["checks"].keys()) == { + "socket_resolved", + "socket_reachable", + "daemon_status", + "container_identity", + "tmux_present", + "tmux_pane_match", + } + + def test_dead_tokens_never_appear(self, env): + run_config_init(env) + ensure_daemon(env) + proc = _run_doctor(env, json_mode=True) + # Negative-lock the dead synonyms per Clarifications 2026-05-06 + assert "not_in_container" not in proc.stdout + assert "no_containers_known" not in proc.stdout + + def test_summary_exit_code_matches_cli_exit(self, env): + run_config_init(env) + ensure_daemon(env) + proc = _run_doctor(env, json_mode=True) + envelope = json.loads(proc.stdout) + assert envelope["summary"]["exit_code"] == proc.returncode + + +class TestJSONStdoutPurity: + def test_json_mode_emits_no_incidental_stderr(self, env): + """FR-014 / edge case 15: --json output must be valid JSON on stdout + with no incidental stderr lines (the FR-002 pre-flight error which + predates --json parsing is the documented exception, not exercised here).""" + run_config_init(env) + ensure_daemon(env) + proc = _run_doctor(env, json_mode=True) + assert proc.stderr == "" + # Round-trip the JSON to confirm parse correctness + json.loads(proc.stdout) diff --git a/tests/integration/test_cli_config_doctor_host_context.py b/tests/integration/test_cli_config_doctor_host_context.py new file mode 100644 index 0000000..5c34c86 --- /dev/null +++ b/tests/integration/test_cli_config_doctor_host_context.py @@ -0,0 +1,154 @@ +"""T034 / US2 AS3 / edge case 7: ``config doctor`` on the host shell. + +Three scenarios via parametrize: + +* ``host_in_tmux_pane_in_registry`` — ``$TMUX`` set, pane visible in + FEAT-004 registry → ``tmux_pane_match`` is ``pass``. +* ``host_in_tmux_pane_not_in_registry`` — ``$TMUX`` set but pane absent + from registry → ``tmux_pane_match`` is ``fail``/``pane_unknown_to_daemon``. +* ``host_not_in_tmux`` — both ``$TMUX`` and ``$TMUX_PANE`` unset → + ``tmux_present`` and ``tmux_pane_match`` both ``info``/``not_in_tmux``. + +In all three: ``container_identity`` is ``info``/``host_context`` +(NOT ``fail``); ``AGENTTOWER_CONTAINER_ID`` is unset; runtime context is +forced to host via an empty fake ``/proc`` so the dev box's own container +context does not leak into the test. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from ._daemon_helpers import ( + ensure_daemon, + isolated_env, + run_config_init, + stop_daemon_if_alive, +) + + +@pytest.fixture +def env(tmp_path: Path): + env = isolated_env(tmp_path) + yield env + stop_daemon_if_alive(env) + + +def _run_doctor(env, *, json_mode=False): + cmd = ["agenttower", "config", "doctor"] + if json_mode: + cmd.append("--json") + return subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=10) + + +def _pin_host_context(env, tmp_path): + """Empty fake ``/proc`` → runtime detects HostContext.""" + fake_root = tmp_path / "fake-host-proc" + (fake_root / "proc" / "self").mkdir(parents=True) + (fake_root / "etc").mkdir(parents=True) + (fake_root / "proc" / "self" / "cgroup").write_text("0::/\n") + env["AGENTTOWER_TEST_PROC_ROOT"] = str(fake_root) + env.setdefault("AGENTTOWER_TEST_DOCKER_FAKE", "1") + + +# --------------------------------------------------------------------------- +# host_not_in_tmux — neither $TMUX nor $TMUX_PANE set +# --------------------------------------------------------------------------- + + +class TestHostNotInTmux: + def test_container_identity_host_context(self, env, tmp_path): + run_config_init(env) + ensure_daemon(env) + _pin_host_context(env, tmp_path) + for var in ("TMUX", "TMUX_PANE"): + env.pop(var, None) + env.pop("AGENTTOWER_CONTAINER_ID", None) + proc = _run_doctor(env, json_mode=True) + envelope = json.loads(proc.stdout) + ci = envelope["checks"]["container_identity"] + assert ci["status"] == "info" + assert ci["sub_code"] == "host_context" + + def test_tmux_present_not_in_tmux_info(self, env, tmp_path): + run_config_init(env) + ensure_daemon(env) + _pin_host_context(env, tmp_path) + for var in ("TMUX", "TMUX_PANE"): + env.pop(var, None) + proc = _run_doctor(env, json_mode=True) + envelope = json.loads(proc.stdout) + tp = envelope["checks"]["tmux_present"] + assert tp["status"] == "info" + assert tp["sub_code"] == "not_in_tmux" + + def test_tmux_pane_match_not_in_tmux_info(self, env, tmp_path): + run_config_init(env) + ensure_daemon(env) + _pin_host_context(env, tmp_path) + for var in ("TMUX", "TMUX_PANE"): + env.pop(var, None) + proc = _run_doctor(env, json_mode=True) + envelope = json.loads(proc.stdout) + tpm = envelope["checks"]["tmux_pane_match"] + assert tpm["status"] == "info" + assert tpm["sub_code"] == "not_in_tmux" + + def test_exit_code_zero(self, env, tmp_path): + run_config_init(env) + ensure_daemon(env) + _pin_host_context(env, tmp_path) + for var in ("TMUX", "TMUX_PANE"): + env.pop(var, None) + env.pop("AGENTTOWER_CONTAINER_ID", None) + proc = _run_doctor(env) + assert proc.returncode == 0 + + +# --------------------------------------------------------------------------- +# host_in_tmux_pane_not_in_registry — $TMUX/$TMUX_PANE set but pane unknown +# --------------------------------------------------------------------------- + + +class TestHostInTmuxPaneNotInRegistry: + def test_pane_unknown_to_daemon(self, env, tmp_path): + run_config_init(env) + ensure_daemon(env) + _pin_host_context(env, tmp_path) + env["TMUX"] = "/tmp/never-scanned-socket,12345,$0" + env["TMUX_PANE"] = "%0" + proc = _run_doctor(env, json_mode=True) + envelope = json.loads(proc.stdout) + # tmux_present should pass (env parses cleanly) + tp = envelope["checks"]["tmux_present"] + assert tp["status"] == "pass", tp + # pane is not in the FEAT-004 registry (no scan run) → unknown + tpm = envelope["checks"]["tmux_pane_match"] + assert tpm["status"] == "fail" + assert tpm["sub_code"] == "pane_unknown_to_daemon" + + def test_exit_code_5_when_only_non_required_check_fails(self, env, tmp_path): + run_config_init(env) + ensure_daemon(env) + _pin_host_context(env, tmp_path) + env["TMUX"] = "/tmp/never-scanned-socket,12345,$0" + env["TMUX_PANE"] = "%0" + proc = _run_doctor(env) + # required checks pass; tmux_pane_match fails → degraded exit 5 + assert proc.returncode == 5 + + def test_container_identity_still_host_context(self, env, tmp_path): + run_config_init(env) + ensure_daemon(env) + _pin_host_context(env, tmp_path) + env["TMUX"] = "/tmp/never-scanned-socket,12345,$0" + env["TMUX_PANE"] = "%0" + proc = _run_doctor(env, json_mode=True) + envelope = json.loads(proc.stdout) + ci = envelope["checks"]["container_identity"] + assert ci["status"] == "info" + assert ci["sub_code"] == "host_context" diff --git a/tests/integration/test_cli_config_doctor_json.py b/tests/integration/test_cli_config_doctor_json.py new file mode 100644 index 0000000..d6357b0 --- /dev/null +++ b/tests/integration/test_cli_config_doctor_json.py @@ -0,0 +1,228 @@ +"""T036 / US2 AS5 / SC-005 / FR-014: ``config doctor --json`` envelope across +every documented scenario. + +For each parametrized scenario, run ``agenttower config doctor --json`` and: + +* assert ``json.loads(stdout)`` succeeds (one canonical JSON object) +* assert ``summary.exit_code == proc.returncode`` +* assert ``--json`` produces no incidental stderr +* assert closed-set token spellings (``not_in_container`` and + ``no_containers_known`` are negative-locked since both are dead synonyms + per Clarifications 2026-05-06). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from ._daemon_helpers import ( + ensure_daemon, + isolated_env, + run_config_init, + stop_daemon_if_alive, +) + + +@pytest.fixture +def env(tmp_path: Path): + env = isolated_env(tmp_path) + yield env + stop_daemon_if_alive(env) + + +def _run_doctor_json(env): + return subprocess.run( + ["agenttower", "config", "doctor", "--json"], + env=env, + capture_output=True, + text=True, + timeout=10, + ) + + +def _pin_host_context(env, tmp_path): + fake_root = tmp_path / "fake-host-proc" + (fake_root / "proc" / "self").mkdir(parents=True) + (fake_root / "etc").mkdir(parents=True) + (fake_root / "proc" / "self" / "cgroup").write_text("0::/\n") + env["AGENTTOWER_TEST_PROC_ROOT"] = str(fake_root) + env.setdefault("AGENTTOWER_TEST_DOCKER_FAKE", "1") + + +# --------------------------------------------------------------------------- +# Scenario builders +# --------------------------------------------------------------------------- + + +def _scenario_healthy(env, tmp_path): + run_config_init(env) + ensure_daemon(env) + _pin_host_context(env, tmp_path) + + +def _scenario_daemon_down(env, tmp_path): + run_config_init(env) + # Don't start the daemon + _pin_host_context(env, tmp_path) + + +def _scenario_no_mount(env, tmp_path): + """Container context, AGENTTOWER_SOCKET unset, daemon's mounted-default + socket does not exist → doctor surfaces socket_missing.""" + run_config_init(env) + fake_root = tmp_path / "fake-container-proc" + (fake_root / "proc" / "self").mkdir(parents=True) + (fake_root / "etc").mkdir(parents=True) + (fake_root / "proc" / "self" / "cgroup").write_text( + "0::/docker/abcdef0123456789abcdef0123456789abcdef0123456789abcdef01234567\n" + ) + env["AGENTTOWER_TEST_PROC_ROOT"] = str(fake_root) + env.setdefault("AGENTTOWER_TEST_DOCKER_FAKE", "1") + + +def _scenario_no_tmux(env, tmp_path): + run_config_init(env) + ensure_daemon(env) + _pin_host_context(env, tmp_path) + for var in ("TMUX", "TMUX_PANE"): + env.pop(var, None) + + +def _scenario_unknown_container(env, tmp_path): + """ContainerContext fires; daemon has no row for the candidate id.""" + run_config_init(env) + ensure_daemon(env) + fake_root = tmp_path / "fake-container-proc" + (fake_root / "proc" / "self").mkdir(parents=True) + (fake_root / "etc").mkdir(parents=True) + (fake_root / "proc" / "self" / "cgroup").write_text( + "0::/docker/abcdef0123456789abcdef0123456789abcdef0123456789abcdef01234567\n" + ) + env["AGENTTOWER_TEST_PROC_ROOT"] = str(fake_root) + env.setdefault("AGENTTOWER_TEST_DOCKER_FAKE", "1") + + +def _scenario_ambiguous_pane(env, tmp_path): + """Run doctor with $TMUX set but the daemon never scanned it.""" + run_config_init(env) + ensure_daemon(env) + _pin_host_context(env, tmp_path) + env["TMUX"] = "/tmp/never-scanned,1234,$0" + env["TMUX_PANE"] = "%5" + + +SCENARIOS = [ + "healthy", + "daemon_down", + "no_mount", + "no_tmux", + "unknown_container", + "ambiguous_pane", +] + +SCENARIO_BUILDERS = { + "healthy": _scenario_healthy, + "daemon_down": _scenario_daemon_down, + "no_mount": _scenario_no_mount, + "no_tmux": _scenario_no_tmux, + "unknown_container": _scenario_unknown_container, + "ambiguous_pane": _scenario_ambiguous_pane, +} + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("scenario", SCENARIOS) +class TestJsonEnvelopeAcrossScenarios: + def test_stdout_parses_as_one_canonical_json_object(self, env, tmp_path, scenario): + SCENARIO_BUILDERS[scenario](env, tmp_path) + proc = _run_doctor_json(env) + envelope = json.loads(proc.stdout) + assert isinstance(envelope, dict) + assert set(envelope.keys()) == {"summary", "checks"} + + def test_summary_exit_code_matches_cli_exit(self, env, tmp_path, scenario): + SCENARIO_BUILDERS[scenario](env, tmp_path) + proc = _run_doctor_json(env) + envelope = json.loads(proc.stdout) + assert envelope["summary"]["exit_code"] == proc.returncode + + def test_stderr_is_empty(self, env, tmp_path, scenario): + """FR-014 + FR-029: --json mode emits stdout only with no incidental + stderr (the FR-002 pre-flight error which predates --json parsing is + the documented exception, not exercised by these scenarios).""" + SCENARIO_BUILDERS[scenario](env, tmp_path) + proc = _run_doctor_json(env) + assert proc.stderr == "" + + def test_dead_synonyms_never_appear(self, env, tmp_path, scenario): + """``not_in_container`` and ``no_containers_known`` are dead synonyms + per Clarifications 2026-05-06.""" + SCENARIO_BUILDERS[scenario](env, tmp_path) + proc = _run_doctor_json(env) + assert "not_in_container" not in proc.stdout + assert "no_containers_known" not in proc.stdout + + def test_six_check_codes_present(self, env, tmp_path, scenario): + SCENARIO_BUILDERS[scenario](env, tmp_path) + proc = _run_doctor_json(env) + envelope = json.loads(proc.stdout) + assert set(envelope["checks"].keys()) == { + "socket_resolved", + "socket_reachable", + "daemon_status", + "container_identity", + "tmux_present", + "tmux_pane_match", + } + + +# --------------------------------------------------------------------------- +# unknown_container: when ContainerContext fires but the daemon's container +# set is empty, the row carries daemon_container_set_empty=true (NEVER a +# no_containers_known sub-code; closed set is not extended) +# --------------------------------------------------------------------------- + + +class TestDaemonContainerSetEmpty: + def test_no_match_with_daemon_container_set_empty_flag(self, env, tmp_path): + _scenario_unknown_container(env, tmp_path) + proc = _run_doctor_json(env) + envelope = json.loads(proc.stdout) + ci = envelope["checks"]["container_identity"] + # FR-007 closed set: no_match (since a candidate from cgroup signal exists) + assert ci["sub_code"] in {"no_match", "no_candidate"} + # The empty-list_containers flag is the canonical signal + assert ci.get("daemon_container_set_empty") is True + # Negative-lock: never a synthetic no_containers_known sub_code + assert ci["sub_code"] != "no_containers_known" + + +# --------------------------------------------------------------------------- +# ambiguous_pane: the named scenario routes to pane_unknown_to_daemon +# (true ambiguity requires seeded duplicate panes; covered at unit level). +# Here we confirm the JSON envelope is well-formed regardless. +# --------------------------------------------------------------------------- + + +class TestAmbiguousPaneEnvelope: + def test_envelope_is_valid_json(self, env, tmp_path): + _scenario_ambiguous_pane(env, tmp_path) + proc = _run_doctor_json(env) + envelope = json.loads(proc.stdout) + # tmux_pane_match exists and has a sub_code in the closed set + tpm = envelope["checks"]["tmux_pane_match"] + assert tpm["sub_code"] in { + "pane_match", + "pane_unknown_to_daemon", + "pane_ambiguous", + "not_in_tmux", + "daemon_unavailable", + } diff --git a/tests/integration/test_cli_config_doctor_json_strict_stdout.py b/tests/integration/test_cli_config_doctor_json_strict_stdout.py new file mode 100644 index 0000000..95b9e01 --- /dev/null +++ b/tests/integration/test_cli_config_doctor_json_strict_stdout.py @@ -0,0 +1,223 @@ +"""T056 / FR-014 / edge case 15: ``--json`` output is stdout-pure. + +Asserts the FR-014 / edge case 15 / contracts/cli.md ``--json`` and +stderr discipline: + +* ``agenttower config doctor --json`` MUST emit exactly one valid JSON + object on stdout per invocation. +* stderr MUST be empty under ``--json`` on the documented code paths + (healthy, daemon-down, no-mount). The ONLY documented exception is the + FR-002 pre-flight error, which predates ``--json`` parsing — covered + in its own test class below. +* No warning, deprecation notice, or incidental log line leaks to stderr. +* ``summary.exit_code`` matches the actual CLI exit. + +Resolves checklist CHK053. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from ._daemon_helpers import ( + ensure_daemon, + isolated_env, + resolved_paths, + run_config_init, + stop_daemon_if_alive, +) + + +@pytest.fixture +def env(tmp_path: Path): + env = isolated_env(tmp_path) + yield env + stop_daemon_if_alive(env) + + +def _run_doctor_json(env, *, timeout: float = 10.0): + return subprocess.run( + ["agenttower", "config", "doctor", "--json"], + env=env, + capture_output=True, + text=True, + timeout=timeout, + ) + + +def _pin_host_context(env, tmp_path: Path) -> None: + fake_root = tmp_path / "fake-host-proc" + (fake_root / "proc" / "self").mkdir(parents=True) + (fake_root / "etc").mkdir(parents=True) + (fake_root / "proc" / "self" / "cgroup").write_text("0::/\n") + env["AGENTTOWER_TEST_PROC_ROOT"] = str(fake_root) + env.setdefault("AGENTTOWER_TEST_DOCKER_FAKE", "1") + + +# --------------------------------------------------------------------------- +# Healthy daemon — stdout is one JSON object, stderr is empty +# --------------------------------------------------------------------------- + + +class TestJsonStdoutHealthy: + def test_stdout_is_one_valid_json_object(self, env, tmp_path): + run_config_init(env) + ensure_daemon(env) + _pin_host_context(env, tmp_path) + for var in ("TMUX", "TMUX_PANE", "AGENTTOWER_CONTAINER_ID"): + env.pop(var, None) + + proc = _run_doctor_json(env) + envelope = json.loads(proc.stdout) + assert isinstance(envelope, dict) + # Top-level shape per FR-014 + assert "summary" in envelope + assert "checks" in envelope + # Exactly one object — no trailing JSON lines, no second envelope + stripped = proc.stdout.rstrip("\n") + assert stripped.count("\n}") <= 1, "looks like multiple JSON objects" + + def test_stderr_is_empty_under_json_when_healthy(self, env, tmp_path): + run_config_init(env) + ensure_daemon(env) + _pin_host_context(env, tmp_path) + for var in ("TMUX", "TMUX_PANE", "AGENTTOWER_CONTAINER_ID"): + env.pop(var, None) + + proc = _run_doctor_json(env) + assert proc.stderr == "", repr(proc.stderr) + + def test_summary_exit_code_matches_cli_exit(self, env, tmp_path): + run_config_init(env) + ensure_daemon(env) + _pin_host_context(env, tmp_path) + for var in ("TMUX", "TMUX_PANE", "AGENTTOWER_CONTAINER_ID"): + env.pop(var, None) + + proc = _run_doctor_json(env) + envelope = json.loads(proc.stdout) + assert envelope["summary"]["exit_code"] == proc.returncode + + +# --------------------------------------------------------------------------- +# Daemon down — stdout is still one valid JSON object, stderr stays empty +# --------------------------------------------------------------------------- + + +class TestJsonStdoutDaemonDown: + def test_stdout_is_one_valid_json_object_when_daemon_down(self, env, tmp_path): + run_config_init(env) + # Don't start the daemon → socket_reachable will fail + _pin_host_context(env, tmp_path) + for var in ("TMUX", "TMUX_PANE", "AGENTTOWER_CONTAINER_ID"): + env.pop(var, None) + + proc = _run_doctor_json(env) + envelope = json.loads(proc.stdout) + assert isinstance(envelope, dict) + assert "summary" in envelope + assert "checks" in envelope + + def test_stderr_is_empty_under_json_when_daemon_down(self, env, tmp_path): + run_config_init(env) + _pin_host_context(env, tmp_path) + for var in ("TMUX", "TMUX_PANE", "AGENTTOWER_CONTAINER_ID"): + env.pop(var, None) + + proc = _run_doctor_json(env) + # FR-024 / SC-004: no raw errno text leaks to stderr; under --json + # stderr should be entirely silent. + assert proc.stderr == "", repr(proc.stderr) + + def test_no_errno_text_anywhere_in_output_when_daemon_down(self, env, tmp_path): + run_config_init(env) + _pin_host_context(env, tmp_path) + for var in ("TMUX", "TMUX_PANE", "AGENTTOWER_CONTAINER_ID"): + env.pop(var, None) + + proc = _run_doctor_json(env) + # FR-024: closed-set sub-codes only; no raw socket(2)/connect(2) text + forbidden = ("Errno", "strerror", "Connection refused", "ENOENT", "EACCES") + for token in forbidden: + assert token not in proc.stdout, f"{token!r} leaked into stdout" + assert token not in proc.stderr, f"{token!r} leaked into stderr" + + def test_summary_exit_code_matches_cli_exit_when_daemon_down( + self, env, tmp_path + ): + run_config_init(env) + _pin_host_context(env, tmp_path) + for var in ("TMUX", "TMUX_PANE", "AGENTTOWER_CONTAINER_ID"): + env.pop(var, None) + + proc = _run_doctor_json(env) + envelope = json.loads(proc.stdout) + assert envelope["summary"]["exit_code"] == proc.returncode + + +# --------------------------------------------------------------------------- +# No mount — AGENTTOWER_SOCKET points to a path that does not exist +# --------------------------------------------------------------------------- + + +class TestJsonStdoutNoMount: + def test_stdout_is_one_valid_json_object_when_socket_missing( + self, env, tmp_path + ): + run_config_init(env) + _pin_host_context(env, tmp_path) + for var in ("TMUX", "TMUX_PANE", "AGENTTOWER_CONTAINER_ID"): + env.pop(var, None) + # Point the override at a path that exists as a directory, so the + # *resolver* succeeds (it's a valid absolute path) but the *transport* + # fails — exercising the daemon-down code path with an explicit path. + missing_socket = resolved_paths(tmp_path)["socket"] + # Don't start the daemon; ensure the socket file does not exist. + assert not missing_socket.exists() + + proc = _run_doctor_json(env) + envelope = json.loads(proc.stdout) + assert isinstance(envelope, dict) + assert proc.stderr == "", repr(proc.stderr) + + +# --------------------------------------------------------------------------- +# FR-002 pre-flight is the documented stderr exception under --json +# --------------------------------------------------------------------------- + + +class TestPreflightStderrException: + """The FR-002 pre-flight error (malformed AGENTTOWER_SOCKET) is the ONE + documented stderr line under ``--json``. It happens BEFORE the + ``--json`` flag is honored because the pre-flight gate runs first. + Other code paths must keep stderr empty. + """ + + def test_relative_path_pre_flight_writes_to_stderr_and_exits_1( + self, env, tmp_path + ): + run_config_init(env) + env["AGENTTOWER_SOCKET"] = "relative/path" + proc = _run_doctor_json(env) + # FR-002 contract: exit 1, message starts with "error:" + assert proc.returncode == 1, proc.stdout + assert "AGENTTOWER_SOCKET" in proc.stderr + assert "absolute path" in proc.stderr + + def test_pre_flight_exception_does_not_leak_other_warnings( + self, env, tmp_path + ): + """stderr should contain ONLY the FR-002 line — no warnings, no + deprecation notices, no log lines.""" + run_config_init(env) + env["AGENTTOWER_SOCKET"] = "" + proc = _run_doctor_json(env) + assert proc.returncode == 1 + # Exactly one error line (plus possible trailing newline) + non_empty_lines = [ln for ln in proc.stderr.splitlines() if ln.strip()] + assert len(non_empty_lines) == 1, repr(proc.stderr) + assert non_empty_lines[0].startswith("error:") diff --git a/tests/integration/test_cli_config_doctor_pane_match.py b/tests/integration/test_cli_config_doctor_pane_match.py new file mode 100644 index 0000000..34d842c --- /dev/null +++ b/tests/integration/test_cli_config_doctor_pane_match.py @@ -0,0 +1,142 @@ +"""T035 / US2 AS4 / US3 AS5 / FR-010 / R-005: tmux_pane_match scenarios. + +Parametrized scenarios: + +* ``pane_unknown_to_daemon`` — ``$TMUX`` parses cleanly but no pane row + matches → ``fail`` with sub-code ``pane_unknown_to_daemon`` and an + actionable message advising ``agenttower scan --panes``. CLI exit is 5 + (degraded; round-trip ok, non-required check failed). +* ``tmux_socket_unreadable`` — ``$TMUX`` set with a path the in-container + CLI cannot read → cross-check classifies as ``pane_unknown_to_daemon`` + rather than crashing. + +The ``pane_match`` and ``pane_ambiguous`` cases are exercised at the unit +level by ``tests/unit/test_tmux_self_identity.py`` (cross-check classifier +fixtures); the integration counterpart is end-to-end coverage of the +``pane_unknown_to_daemon`` outcome since seeding the FEAT-004 registry from +inside an integration test is more involved than the unit cross-check. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from ._daemon_helpers import ( + ensure_daemon, + isolated_env, + run_config_init, + stop_daemon_if_alive, +) + + +@pytest.fixture +def env(tmp_path: Path): + env = isolated_env(tmp_path) + yield env + stop_daemon_if_alive(env) + + +def _run_doctor(env, *, json_mode=False): + cmd = ["agenttower", "config", "doctor"] + if json_mode: + cmd.append("--json") + return subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=10) + + +def _pin_host_context(env, tmp_path): + fake_root = tmp_path / "fake-host-proc" + (fake_root / "proc" / "self").mkdir(parents=True) + (fake_root / "etc").mkdir(parents=True) + (fake_root / "proc" / "self" / "cgroup").write_text("0::/\n") + env["AGENTTOWER_TEST_PROC_ROOT"] = str(fake_root) + env.setdefault("AGENTTOWER_TEST_DOCKER_FAKE", "1") + + +# --------------------------------------------------------------------------- +# pane_unknown_to_daemon: $TMUX parses cleanly, but the daemon hasn't seen it +# --------------------------------------------------------------------------- + + +class TestPaneUnknownToDaemon: + def test_unknown_pane_yields_fail_with_pane_unknown_to_daemon(self, env, tmp_path): + run_config_init(env) + ensure_daemon(env) + _pin_host_context(env, tmp_path) + env["TMUX"] = "/tmp/no-such-socket,98765,$1" + env["TMUX_PANE"] = "%42" + proc = _run_doctor(env, json_mode=True) + envelope = json.loads(proc.stdout) + tpm = envelope["checks"]["tmux_pane_match"] + assert tpm["status"] == "fail" + assert tpm["sub_code"] == "pane_unknown_to_daemon" + + def test_actionable_message_advises_scan_panes(self, env, tmp_path): + run_config_init(env) + ensure_daemon(env) + _pin_host_context(env, tmp_path) + env["TMUX"] = "/tmp/no-such-socket,98765,$1" + env["TMUX_PANE"] = "%42" + proc = _run_doctor(env, json_mode=True) + envelope = json.loads(proc.stdout) + tpm = envelope["checks"]["tmux_pane_match"] + assert "actionable_message" in tpm + assert "scan --panes" in tpm["actionable_message"] + + def test_cli_exit_5_degraded(self, env, tmp_path): + """Round-trip ok, only the non-required check failed → exit 5.""" + run_config_init(env) + ensure_daemon(env) + _pin_host_context(env, tmp_path) + env["TMUX"] = "/tmp/no-such-socket,98765,$1" + env["TMUX_PANE"] = "%42" + proc = _run_doctor(env) + assert proc.returncode == 5 + + +# --------------------------------------------------------------------------- +# tmux_socket_unreadable_in_container (edge case 8): $TMUX path set but the +# in-container CLI can't read the socket. The doctor must NOT crash. +# --------------------------------------------------------------------------- + + +class TestTmuxSocketUnreadableInContainer: + def test_unreadable_socket_path_does_not_crash_doctor(self, env, tmp_path): + """The in-container tmux socket path may not be visible; the doctor + falls back to ``pane_unknown_to_daemon`` rather than crashing.""" + run_config_init(env) + ensure_daemon(env) + _pin_host_context(env, tmp_path) + # Path exists nowhere on the dev box's filesystem + env["TMUX"] = "/var/lib/never-mounted/tmux.sock,1,$0" + env["TMUX_PANE"] = "%0" + proc = _run_doctor(env, json_mode=True) + # CLI exits cleanly (5 because only non-required check failed) + assert proc.returncode == 5, proc.stderr + envelope = json.loads(proc.stdout) + tpm = envelope["checks"]["tmux_pane_match"] + # Either pane_unknown_to_daemon OR (if it manages to classify + # output_malformed) a non-crash sub_code from the closed set + assert tpm["sub_code"] in {"pane_unknown_to_daemon", "not_in_tmux"} + + +# --------------------------------------------------------------------------- +# Malformed $TMUX → output_malformed bubbles via tmux_present +# --------------------------------------------------------------------------- + + +class TestMalformedTmux: + def test_malformed_tmux_pane_id_yields_output_malformed(self, env, tmp_path): + run_config_init(env) + ensure_daemon(env) + _pin_host_context(env, tmp_path) + env["TMUX"] = "/tmp/sock,12345,$0" + env["TMUX_PANE"] = "not-a-pane-id" # fails ^%[0-9]+$ regex + proc = _run_doctor(env, json_mode=True) + envelope = json.loads(proc.stdout) + tp = envelope["checks"]["tmux_present"] + assert tp["status"] == "fail" + assert tp["sub_code"] == "output_malformed" diff --git a/tests/integration/test_cli_config_doctor_short_circuit.py b/tests/integration/test_cli_config_doctor_short_circuit.py new file mode 100644 index 0000000..4c5e076 --- /dev/null +++ b/tests/integration/test_cli_config_doctor_short_circuit.py @@ -0,0 +1,121 @@ +"""T037 / FR-027 / FR-029: ``config doctor`` writes nothing to disk. + +Asserts: + +* When daemon is down, every check still emits a ``CheckResult`` row + (``daemon_status``, ``container_identity``, ``tmux_pane_match`` carry + ``info`` + ``daemon_unavailable``); none are silently omitted. +* The doctor performs no writes against + ``$XDG_STATE_HOME/opensoft/agenttower/`` — diff before/after. + +Note: SC-003 (500 ms) wall-clock budget is asserted in +``test_cli_config_doctor_healthy.py`` (T032). This file is FR-027/FR-029 +only. +""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +from pathlib import Path + +import pytest + +from ._daemon_helpers import ( + isolated_env, + resolved_paths, + run_config_init, + stop_daemon_if_alive, +) + + +@pytest.fixture +def env(tmp_path: Path): + env = isolated_env(tmp_path) + yield env + stop_daemon_if_alive(env) + + +def _run_doctor(env, *, json_mode=False): + cmd = ["agenttower", "config", "doctor"] + if json_mode: + cmd.append("--json") + return subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=10) + + +def _state_dir_snapshot(home: Path) -> dict[Path, tuple[int, str]]: + """Snapshot every file under the state dir as (size, sha256).""" + state_dir = resolved_paths(home)["state_dir"] + snapshot: dict[Path, tuple[int, str]] = {} + if not state_dir.exists(): + return snapshot + for child in state_dir.rglob("*"): + if child.is_file(): + data = child.read_bytes() + snapshot[child] = (len(data), hashlib.sha256(data).hexdigest()) + return snapshot + + +# --------------------------------------------------------------------------- +# Every check emits a row even when the daemon is unreachable +# --------------------------------------------------------------------------- + + +class TestEveryCheckEmitsRow: + def test_no_silent_omission_when_socket_reachable_fails(self, env, tmp_path): + run_config_init(env) + # Don't start the daemon → socket_reachable will fail + proc = _run_doctor(env, json_mode=True) + envelope = json.loads(proc.stdout) + # Every closed-set check produces a row + assert set(envelope["checks"].keys()) == { + "socket_resolved", + "socket_reachable", + "daemon_status", + "container_identity", + "tmux_present", + "tmux_pane_match", + } + # Dependent checks carry status=info + daemon_unavailable per data-model §6 + for code in ("daemon_status", "container_identity", "tmux_pane_match"): + check = envelope["checks"][code] + assert check["status"] == "info", code + assert check["sub_code"] == "daemon_unavailable", code + + +# --------------------------------------------------------------------------- +# Doctor writes nothing to disk +# --------------------------------------------------------------------------- + + +class TestDoctorWritesNothing: + def test_state_dir_unchanged_after_doctor_run(self, env, tmp_path): + run_config_init(env) + before = _state_dir_snapshot(tmp_path) + _run_doctor(env) + after = _state_dir_snapshot(tmp_path) + # Same set of files, same contents + assert set(before.keys()) == set(after.keys()), ( + f"new files: {set(after) - set(before)}; " + f"removed: {set(before) - set(after)}" + ) + for path, signature in before.items(): + assert after[path] == signature, f"{path} changed" + + def test_state_dir_unchanged_after_json_mode(self, env, tmp_path): + run_config_init(env) + before = _state_dir_snapshot(tmp_path) + _run_doctor(env, json_mode=True) + after = _state_dir_snapshot(tmp_path) + assert set(before.keys()) == set(after.keys()) + for path, signature in before.items(): + assert after[path] == signature, f"{path} changed under --json" + + def test_no_log_file_appended(self, env, tmp_path): + run_config_init(env) + log_path = resolved_paths(tmp_path)["log_file"] + size_before = log_path.stat().st_size if log_path.exists() else None + _run_doctor(env) + size_after = log_path.stat().st_size if log_path.exists() else None + assert size_before == size_after, "doctor must not append to daemon log" diff --git a/tests/integration/test_cli_config_paths_socket_source.py b/tests/integration/test_cli_config_paths_socket_source.py new file mode 100644 index 0000000..f57fb64 --- /dev/null +++ b/tests/integration/test_cli_config_paths_socket_source.py @@ -0,0 +1,203 @@ +"""T018 / FR-019 / FR-026: ``config paths`` SOCKET_SOURCE= line is the LAST line. + +The first six ``KEY=value`` lines (``CONFIG_FILE``, ``STATE_DB``, +``EVENTS_FILE``, ``LOGS_DIR``, ``SOCKET``, ``CACHE_DIR``) are the FEAT-001 +contract; FEAT-005 appends exactly one trailing line +``SOCKET_SOURCE=`` after them. + +Covers the three resolution branches; the integration counterpart in +``test_cli_in_container_socket_override.py`` covers env_override and +host_default end-to-end. This file adds the mounted_default branch via the +``AGENTTOWER_TEST_PROC_ROOT`` fixture and locks the ordering invariant. +""" + +from __future__ import annotations + +import socket as socket_mod +import subprocess +from pathlib import Path + +import pytest + +from ._daemon_helpers import isolated_env, stop_daemon_if_alive +from ._proc_fixtures import fake_proc_root # noqa: F401 (registers fixture) + + +@pytest.fixture +def env(tmp_path: Path): + env = isolated_env(tmp_path) + yield env + stop_daemon_if_alive(env) + + +def _run_paths(env): + return subprocess.run( + ["agenttower", "config", "paths"], + env=env, + capture_output=True, + text=True, + timeout=10, + ) + + +# --------------------------------------------------------------------------- +# Ordering invariant — the trailing line is LAST and the first six are FIXED +# --------------------------------------------------------------------------- + + +class TestSocketSourceOrdering: + """``SOCKET_SOURCE=`` is appended AFTER the FEAT-001 six-line block.""" + + def test_six_existing_lines_in_declared_paths_order_unchanged(self, env): + proc = _run_paths(env) + assert proc.returncode == 0 + lines = proc.stdout.rstrip("\n").splitlines() + # First six lines mirror the declared `Paths` field order + assert len(lines) == 7 + assert lines[0].startswith("CONFIG_FILE=") + assert lines[1].startswith("STATE_DB=") + assert lines[2].startswith("EVENTS_FILE=") + assert lines[3].startswith("LOGS_DIR=") + assert lines[4].startswith("SOCKET=") + assert lines[5].startswith("CACHE_DIR=") + assert lines[6].startswith("SOCKET_SOURCE=") + + def test_socket_source_is_the_last_line(self, env): + proc = _run_paths(env) + last = proc.stdout.rstrip("\n").splitlines()[-1] + assert last.startswith("SOCKET_SOURCE=") + + def test_no_json_mode_introduced(self, env): + """``config paths`` does not add ``--json`` in FEAT-005.""" + proc = subprocess.run( + ["agenttower", "config", "paths", "--json"], + env=env, + capture_output=True, + text=True, + timeout=5, + ) + # argparse rejects unknown flag → exit code != 0; specifically 2 + assert proc.returncode != 0 + + +# --------------------------------------------------------------------------- +# host_default — dev-box runs in a container, so we must pin runtime context +# to host via an empty fake `/proc` to avoid mounted_default firing +# --------------------------------------------------------------------------- + + +class TestHostDefault: + def test_host_default_token_emitted(self, env, tmp_path): + fake_root = tmp_path / "fake-host-proc" + (fake_root / "proc" / "self").mkdir(parents=True) + (fake_root / "etc").mkdir(parents=True) + (fake_root / "proc" / "self" / "cgroup").write_text("0::/\n") + env["AGENTTOWER_TEST_PROC_ROOT"] = str(fake_root) + env.setdefault("AGENTTOWER_TEST_DOCKER_FAKE", "1") + proc = _run_paths(env) + assert proc.returncode == 0 + last = proc.stdout.rstrip("\n").splitlines()[-1] + assert last == "SOCKET_SOURCE=host_default" + + +# --------------------------------------------------------------------------- +# env_override — AGENTTOWER_SOCKET wins regardless of context +# --------------------------------------------------------------------------- + + +class TestEnvOverride: + def test_env_override_token_emitted(self, env, tmp_path): + sock_path = tmp_path / "x.sock" + s = socket_mod.socket(socket_mod.AF_UNIX, socket_mod.SOCK_STREAM) + s.bind(str(sock_path)) + try: + env["AGENTTOWER_SOCKET"] = str(sock_path) + proc = _run_paths(env) + assert proc.returncode == 0 + last = proc.stdout.rstrip("\n").splitlines()[-1] + assert last == "SOCKET_SOURCE=env_override" + finally: + s.close() + if sock_path.exists(): + sock_path.unlink() + + def test_env_override_socket_line_reflects_override(self, env, tmp_path): + """The ``SOCKET=`` line and ``SOCKET_SOURCE=`` line cannot drift — + both are sourced from a single ``resolve_socket_path`` call.""" + sock_path = tmp_path / "x.sock" + s = socket_mod.socket(socket_mod.AF_UNIX, socket_mod.SOCK_STREAM) + s.bind(str(sock_path)) + try: + env["AGENTTOWER_SOCKET"] = str(sock_path) + proc = _run_paths(env) + assert proc.returncode == 0 + lines = proc.stdout.rstrip("\n").splitlines() + socket_line = next(line for line in lines if line.startswith("SOCKET=")) + source_line = next(line for line in lines if line.startswith("SOCKET_SOURCE=")) + assert socket_line == f"SOCKET={sock_path}" + assert source_line == "SOCKET_SOURCE=env_override" + finally: + s.close() + if sock_path.exists(): + sock_path.unlink() + + +# --------------------------------------------------------------------------- +# mounted_default — fixture-fired ContainerContext + a real socket at +# /run/agenttower/agenttowerd.sock +# --------------------------------------------------------------------------- + + +class TestMountedDefault: + """The mounted-default candidate at ``/run/agenttower/agenttowerd.sock`` + fires only when: + + 1. ``RuntimeContext`` is ``ContainerContext`` (fired via fake-/proc cgroup) + 2. ``AGENTTOWER_SOCKET`` is unset + 3. The mounted-default path resolves to a real ``S_ISSOCK`` + + On a dev box where ``/run/agenttower/`` cannot be created (no root), this + branch cannot be exercised end-to-end; the unit tests in + ``test_socket_path_resolution.py`` already lock the resolver's behavior + for this case. We skip rather than failing the suite. + """ + + def test_mounted_default_token_when_path_resolves(self, env, tmp_path): + mount_dir = Path("/run/agenttower") + try: + mount_dir.mkdir(parents=True, exist_ok=True) + except (PermissionError, OSError): + pytest.skip("cannot create /run/agenttower (need root); resolver unit test covers this") + + sock_path = mount_dir / "agenttowerd.sock" + if sock_path.exists(): + pytest.skip("/run/agenttower/agenttowerd.sock already exists; refusing to clobber") + + s = socket_mod.socket(socket_mod.AF_UNIX, socket_mod.SOCK_STREAM) + try: + s.bind(str(sock_path)) + except (PermissionError, OSError): + s.close() + pytest.skip("cannot bind socket under /run/agenttower") + + # Fixture-fire ContainerContext via fake /proc with a cgroup match. + fake_root = tmp_path / "fake-container-proc" + (fake_root / "proc" / "self").mkdir(parents=True) + (fake_root / "etc").mkdir(parents=True) + (fake_root / "proc" / "self" / "cgroup").write_text( + "0::/docker/abcdef0123456789abcdef0123456789abcdef0123456789abcdef01234567\n" + ) + env["AGENTTOWER_TEST_PROC_ROOT"] = str(fake_root) + env.setdefault("AGENTTOWER_TEST_DOCKER_FAKE", "1") + + try: + proc = _run_paths(env) + assert proc.returncode == 0 + last = proc.stdout.rstrip("\n").splitlines()[-1] + assert last == "SOCKET_SOURCE=mounted_default" + finally: + s.close() + try: + sock_path.unlink() + except OSError: + pass diff --git a/tests/integration/test_cli_doctor_concurrent.py b/tests/integration/test_cli_doctor_concurrent.py new file mode 100644 index 0000000..fa7e18f --- /dev/null +++ b/tests/integration/test_cli_doctor_concurrent.py @@ -0,0 +1,253 @@ +"""T061 / spec edge case 13 / FR-029: two ``config doctor`` invocations +running concurrently must not serialize behind each other. + +Scenario per spec edge case 13: two ``agenttower config doctor`` +subprocesses run concurrently from inside the same simulated container. +Both must exit independently with the documented status. The doctor +performs only read-only socket calls (FEAT-002 ``status``, FEAT-003 +``list_containers``, FEAT-004 ``list_panes``) so no daemon-side mutex +is acquired; the two invocations MUST NOT serialize behind each other. + +The wall-clock for the two invocations together is bounded by +``2 × SC-003`` (1.0 s) against a healthy daemon — concurrent execution +does not double the SC-003 budget. The bound is conservative: when the +two run truly in parallel the wall-clock approaches the single-invocation +SC-003 budget rather than 2×. +""" + +from __future__ import annotations + +import json +import subprocess +import time +from pathlib import Path + +import pytest + +from ._daemon_helpers import ( + ensure_daemon, + isolated_env, + run_config_init, + stop_daemon_if_alive, +) + + +_SC_003_BUDGET_SECONDS = 0.500 +_TWO_INVOCATIONS_BUDGET = 2 * _SC_003_BUDGET_SECONDS # 1.0 s + + +@pytest.fixture +def env(tmp_path: Path): + env = isolated_env(tmp_path) + yield env + stop_daemon_if_alive(env) + + +def _pin_container_context(env, tmp_path: Path) -> None: + fake_root = tmp_path / "fake-container-proc" + (fake_root / "proc" / "self").mkdir(parents=True) + (fake_root / "etc").mkdir(parents=True) + (fake_root / "proc" / "self" / "cgroup").write_text( + "0::/docker/abcdef0123456789abcdef0123456789abcdef0123456789abcdef01234567\n" + ) + (fake_root / ".dockerenv").write_text("") + env["AGENTTOWER_TEST_PROC_ROOT"] = str(fake_root) + env.setdefault("AGENTTOWER_TEST_DOCKER_FAKE", "1") + for var in ("TMUX", "TMUX_PANE"): + env.pop(var, None) + + +def _spawn_doctor(env) -> subprocess.Popen[str]: + return subprocess.Popen( + ["agenttower", "config", "doctor", "--json"], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + +# --------------------------------------------------------------------------- +# Both invocations exit independently +# --------------------------------------------------------------------------- + + +class TestConcurrentInvocationsExitIndependently: + def test_both_complete_and_emit_valid_json(self, env, tmp_path): + run_config_init(env) + ensure_daemon(env) + _pin_container_context(env, tmp_path) + + p1 = _spawn_doctor(env) + p2 = _spawn_doctor(env) + out1, err1 = p1.communicate(timeout=5) + out2, err2 = p2.communicate(timeout=5) + + # Both parse to valid JSON envelopes + envelope1 = json.loads(out1) + envelope2 = json.loads(out2) + assert "summary" in envelope1 + assert "summary" in envelope2 + assert "checks" in envelope1 + assert "checks" in envelope2 + + # Neither leaked to stderr (FR-014 + edge case 15) + assert err1 == "", repr(err1) + assert err2 == "", repr(err2) + + def test_both_exit_codes_match_summary_exit_code(self, env, tmp_path): + run_config_init(env) + ensure_daemon(env) + _pin_container_context(env, tmp_path) + + p1 = _spawn_doctor(env) + p2 = _spawn_doctor(env) + out1, _ = p1.communicate(timeout=5) + out2, _ = p2.communicate(timeout=5) + env1 = json.loads(out1) + env2 = json.loads(out2) + + assert env1["summary"]["exit_code"] == p1.returncode + assert env2["summary"]["exit_code"] == p2.returncode + + def test_both_emit_six_check_rows(self, env, tmp_path): + """Every check appears in both invocations (FR-027).""" + run_config_init(env) + ensure_daemon(env) + _pin_container_context(env, tmp_path) + + p1 = _spawn_doctor(env) + p2 = _spawn_doctor(env) + out1, _ = p1.communicate(timeout=5) + out2, _ = p2.communicate(timeout=5) + env1 = json.loads(out1) + env2 = json.loads(out2) + + expected_keys = { + "socket_resolved", + "socket_reachable", + "daemon_status", + "container_identity", + "tmux_present", + "tmux_pane_match", + } + assert set(env1["checks"].keys()) == expected_keys + assert set(env2["checks"].keys()) == expected_keys + + +# --------------------------------------------------------------------------- +# Concurrent execution does not serialize — wall-clock bound +# --------------------------------------------------------------------------- + + +class TestConcurrentExecutionDoesNotSerialize: + def test_combined_wall_clock_within_two_sc003_budget(self, env, tmp_path): + """Two concurrent doctor invocations together complete within + ``2 × SC-003`` (1.0 s) against a healthy daemon. If the daemon + serialized them behind a mutex, the wall-clock would approach + 2 × SC-003 only when each invocation hits the budget; in + practice we expect it to be well under.""" + run_config_init(env) + ensure_daemon(env) + _pin_container_context(env, tmp_path) + + start = time.perf_counter() + p1 = _spawn_doctor(env) + p2 = _spawn_doctor(env) + p1.communicate(timeout=5) + p2.communicate(timeout=5) + elapsed = time.perf_counter() - start + + assert elapsed < _TWO_INVOCATIONS_BUDGET, ( + f"two concurrent doctor invocations took {elapsed:.3f}s; " + f"budget is {_TWO_INVOCATIONS_BUDGET:.3f}s ({_SC_003_BUDGET_SECONDS}s × 2). " + f"Likely indicates the daemon serialized the two invocations." + ) + + def test_neither_invocation_exceeds_single_sc003_budget( + self, env, tmp_path + ): + """If the daemon held a mutex, the second invocation's per-process + wall-clock would spike. Each individual invocation should still + complete within its single-invocation SC-003 budget when run + concurrently with another.""" + run_config_init(env) + ensure_daemon(env) + _pin_container_context(env, tmp_path) + + starts: list[float] = [] + ends: list[float] = [] + + starts.append(time.perf_counter()) + p1 = _spawn_doctor(env) + starts.append(time.perf_counter()) + p2 = _spawn_doctor(env) + p1.communicate(timeout=5) + ends.append(time.perf_counter()) + p2.communicate(timeout=5) + ends.append(time.perf_counter()) + + for i, (s, e) in enumerate(zip(starts, ends)): + elapsed = e - s + # Allow some slack above SC-003 — concurrent OS scheduling + # can stretch a single doctor's wall-clock slightly even + # without daemon-side serialization. The 2× budget catches + # mutex serialization; the 1.5× cap here catches gross + # regressions. + assert elapsed < 1.5 * _SC_003_BUDGET_SECONDS, ( + f"invocation {i} took {elapsed:.3f}s; " + f"unexpected slow-down under concurrent load" + ) + + +# --------------------------------------------------------------------------- +# Doctor remains a pure read-only diagnostic under concurrency (FR-029) +# --------------------------------------------------------------------------- + + +class TestConcurrencyPreservesNoDiskWrite: + def test_state_dir_unchanged_after_concurrent_doctor_runs( + self, env, tmp_path + ): + import hashlib + + from ._daemon_helpers import resolved_paths + + run_config_init(env) + ensure_daemon(env) + _pin_container_context(env, tmp_path) + + state_dir = resolved_paths(tmp_path)["state_dir"] + + def _snapshot() -> dict[Path, tuple[int, str]]: + snap: dict[Path, tuple[int, str]] = {} + if not state_dir.exists(): + return snap + for child in state_dir.rglob("*"): + if child.is_file(): + data = child.read_bytes() + snap[child] = (len(data), hashlib.sha256(data).hexdigest()) + return snap + + before = _snapshot() + p1 = _spawn_doctor(env) + p2 = _spawn_doctor(env) + p1.communicate(timeout=5) + p2.communicate(timeout=5) + after = _snapshot() + + # Note: the daemon's lifecycle log MAY record the underlying + # FEAT-002 status round-trips per FR-029. We tolerate log-file + # appends but reject schema/state mutations and new files + # outside the log. + log_path = resolved_paths(tmp_path)["log_file"] + before_keys = set(before.keys()) - {log_path} + after_keys = set(after.keys()) - {log_path} + assert before_keys == after_keys, ( + f"new non-log files: {after_keys - before_keys}; " + f"removed: {before_keys - after_keys}" + ) + for path in before_keys: + assert before[path] == after[path], ( + f"{path} changed under concurrent doctor" + ) diff --git a/tests/integration/test_cli_doctor_identity_hostname.py b/tests/integration/test_cli_doctor_identity_hostname.py new file mode 100644 index 0000000..2049b40 --- /dev/null +++ b/tests/integration/test_cli_doctor_identity_hostname.py @@ -0,0 +1,125 @@ +"""T046 / US3 AS2 / FR-006 / FR-007 / R-004: hostname-source identity wins. + +Scenario: ``/proc/self/cgroup`` contains an empty (or non-matching) line so +the cgroup signal does NOT fire; ``/etc/hostname`` is set to a 12-character +hex prefix that matches a row in the FEAT-003 ``list_containers`` output; +``$AGENTTOWER_CONTAINER_ID`` is unset. + +The hostname-step in the FR-006 four-step precedence (``env`` → ``cgroup`` +→ ``hostname`` → ``hostname_env``) is the one that produces the candidate. + +Because seeding the FEAT-003 daemon registry inside an integration test is +expensive, this file demonstrates the *unmatched* hostname-source path +(no containers in the registry → ``no_match``); the matched path is +exercised at the unit level by ``tests/unit/test_container_identity.py``. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from ._daemon_helpers import ( + ensure_daemon, + isolated_env, + run_config_init, + stop_daemon_if_alive, +) + + +@pytest.fixture +def env(tmp_path: Path): + env = isolated_env(tmp_path) + yield env + stop_daemon_if_alive(env) + + +def _run_doctor_json(env): + return subprocess.run( + ["agenttower", "config", "doctor", "--json"], + env=env, + capture_output=True, + text=True, + timeout=10, + ) + + +# --------------------------------------------------------------------------- +# hostname-source candidate emitted; container set is empty → no_match flag +# --------------------------------------------------------------------------- + + +class TestHostnameSourceCandidate: + def test_hostname_source_drives_the_candidate(self, env, tmp_path): + run_config_init(env) + ensure_daemon(env) + # /.dockerenv fires ContainerContext but cgroup is empty → cgroup + # signal returns nothing → hostname is the source. + fake_root = tmp_path / "fake-hostname-only" + (fake_root / "proc" / "self").mkdir(parents=True) + (fake_root / "etc").mkdir(parents=True) + (fake_root / "proc" / "self" / "cgroup").write_text("") + (fake_root / ".dockerenv").write_text("") + # 12-char hex string — short-id-prefix shape per US3 AS2 + (fake_root / "etc" / "hostname").write_text("abcdef012345\n") + env["AGENTTOWER_TEST_PROC_ROOT"] = str(fake_root) + env.setdefault("AGENTTOWER_TEST_DOCKER_FAKE", "1") + env.pop("AGENTTOWER_CONTAINER_ID", None) + + proc = _run_doctor_json(env) + envelope = json.loads(proc.stdout) + ci = envelope["checks"]["container_identity"] + # The hostname signal produced a candidate; the daemon's container + # set is empty so we expect no_match (closed set per FR-007). + assert ci["sub_code"] in {"no_match", "no_candidate"} + if ci["sub_code"] == "no_match": + assert ci.get("source") == "hostname" + + def test_no_match_includes_actionable_scan_message(self, env, tmp_path): + run_config_init(env) + ensure_daemon(env) + fake_root = tmp_path / "fake-hostname-no-match" + (fake_root / "proc" / "self").mkdir(parents=True) + (fake_root / "etc").mkdir(parents=True) + (fake_root / "proc" / "self" / "cgroup").write_text("") + (fake_root / ".dockerenv").write_text("") + (fake_root / "etc" / "hostname").write_text("deadbeef0042\n") + env["AGENTTOWER_TEST_PROC_ROOT"] = str(fake_root) + env.setdefault("AGENTTOWER_TEST_DOCKER_FAKE", "1") + + proc = _run_doctor_json(env) + envelope = json.loads(proc.stdout) + ci = envelope["checks"]["container_identity"] + if ci["status"] == "fail": + assert "actionable_message" in ci + assert "scan --containers" in ci["actionable_message"] + + +# --------------------------------------------------------------------------- +# AGENTTOWER_CONTAINER_ID env override beats hostname (FR-006 precedence) +# --------------------------------------------------------------------------- + + +class TestEnvOverrideBeatsHostname: + def test_env_value_takes_precedence_over_hostname(self, env, tmp_path): + run_config_init(env) + ensure_daemon(env) + fake_root = tmp_path / "fake-env-vs-hostname" + (fake_root / "proc" / "self").mkdir(parents=True) + (fake_root / "etc").mkdir(parents=True) + (fake_root / "proc" / "self" / "cgroup").write_text("") + (fake_root / ".dockerenv").write_text("") + (fake_root / "etc" / "hostname").write_text("abcdef012345\n") + env["AGENTTOWER_TEST_PROC_ROOT"] = str(fake_root) + env.setdefault("AGENTTOWER_TEST_DOCKER_FAKE", "1") + env["AGENTTOWER_CONTAINER_ID"] = "deadbeef9999" + + proc = _run_doctor_json(env) + envelope = json.loads(proc.stdout) + ci = envelope["checks"]["container_identity"] + # env signal produced the candidate (FR-006 step 1) + if ci["status"] == "fail" and ci.get("source"): + assert ci["source"] == "env" diff --git a/tests/integration/test_cli_doctor_tmux_unset.py b/tests/integration/test_cli_doctor_tmux_unset.py new file mode 100644 index 0000000..70224a7 --- /dev/null +++ b/tests/integration/test_cli_doctor_tmux_unset.py @@ -0,0 +1,217 @@ +"""T047 / US3 AS4 / FR-009 / FR-018 / R-005: ``$TMUX`` unset. + +Asserts the spec's US3 AS4 wording verbatim: + +* ``$TMUX`` and ``$TMUX_PANE`` unset → ``tmux_present`` is ``info`` / + ``not_in_tmux`` (NOT ``fail``). +* ``tmux_pane_match`` is ``info`` / ``not_in_tmux``. +* ``container_identity`` is unaffected (produces its own classification + regardless of tmux state). +* CLI exit stays ``0`` when every other required check passes. + +The tmux behavior is tested under both runtime contexts (host and +simulated-in-container) to prove the tmux check is independent of +runtime context. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from ._daemon_helpers import ( + ensure_daemon, + isolated_env, + run_config_init, + stop_daemon_if_alive, +) + + +@pytest.fixture +def env(tmp_path: Path): + env = isolated_env(tmp_path) + yield env + stop_daemon_if_alive(env) + + +def _run_doctor(env, *, json_mode: bool = False): + cmd = ["agenttower", "config", "doctor"] + if json_mode: + cmd.append("--json") + return subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=10) + + +def _pin_host_context(env, tmp_path: Path) -> None: + fake_root = tmp_path / "fake-host-proc" + (fake_root / "proc" / "self").mkdir(parents=True) + (fake_root / "etc").mkdir(parents=True) + (fake_root / "proc" / "self" / "cgroup").write_text("0::/\n") + env["AGENTTOWER_TEST_PROC_ROOT"] = str(fake_root) + env.setdefault("AGENTTOWER_TEST_DOCKER_FAKE", "1") + + +def _pin_container_context(env, tmp_path: Path) -> None: + fake_root = tmp_path / "fake-container-proc" + (fake_root / "proc" / "self").mkdir(parents=True) + (fake_root / "etc").mkdir(parents=True) + (fake_root / "proc" / "self" / "cgroup").write_text( + "0::/docker/abcdef0123456789abcdef0123456789abcdef0123456789abcdef01234567\n" + ) + (fake_root / ".dockerenv").write_text("") + env["AGENTTOWER_TEST_PROC_ROOT"] = str(fake_root) + env.setdefault("AGENTTOWER_TEST_DOCKER_FAKE", "1") + + +def _strip_tmux(env) -> None: + for var in ("TMUX", "TMUX_PANE"): + env.pop(var, None) + + +# --------------------------------------------------------------------------- +# US3 AS4 — host context, tmux unset, exit 0 +# --------------------------------------------------------------------------- + + +class TestTmuxUnsetHostContext: + def test_tmux_present_info_not_in_tmux(self, env, tmp_path): + run_config_init(env) + ensure_daemon(env) + _pin_host_context(env, tmp_path) + _strip_tmux(env) + env.pop("AGENTTOWER_CONTAINER_ID", None) + + proc = _run_doctor(env, json_mode=True) + envelope = json.loads(proc.stdout) + tp = envelope["checks"]["tmux_present"] + assert tp["status"] == "info" + assert tp["sub_code"] == "not_in_tmux" + + def test_tmux_pane_match_info_not_in_tmux(self, env, tmp_path): + run_config_init(env) + ensure_daemon(env) + _pin_host_context(env, tmp_path) + _strip_tmux(env) + env.pop("AGENTTOWER_CONTAINER_ID", None) + + proc = _run_doctor(env, json_mode=True) + envelope = json.loads(proc.stdout) + tpm = envelope["checks"]["tmux_pane_match"] + assert tpm["status"] == "info" + assert tpm["sub_code"] == "not_in_tmux" + + def test_neither_tmux_check_reports_fail(self, env, tmp_path): + """US3 AS4 wording: ``not_in_tmux`` and does NOT report ``fail``.""" + run_config_init(env) + ensure_daemon(env) + _pin_host_context(env, tmp_path) + _strip_tmux(env) + env.pop("AGENTTOWER_CONTAINER_ID", None) + + proc = _run_doctor(env, json_mode=True) + envelope = json.loads(proc.stdout) + for code in ("tmux_present", "tmux_pane_match"): + assert envelope["checks"][code]["status"] != "fail", code + + def test_container_identity_unaffected(self, env, tmp_path): + """Container check produces its own classification regardless of tmux.""" + run_config_init(env) + ensure_daemon(env) + _pin_host_context(env, tmp_path) + _strip_tmux(env) + env.pop("AGENTTOWER_CONTAINER_ID", None) + + proc = _run_doctor(env, json_mode=True) + envelope = json.loads(proc.stdout) + ci = envelope["checks"]["container_identity"] + # Host context with no env override → host_context (info) + assert ci["status"] == "info" + assert ci["sub_code"] == "host_context" + + def test_exit_zero_when_only_tmux_unset(self, env, tmp_path): + """Required checks pass; tmux/container are info → exit 0.""" + run_config_init(env) + ensure_daemon(env) + _pin_host_context(env, tmp_path) + _strip_tmux(env) + env.pop("AGENTTOWER_CONTAINER_ID", None) + + proc = _run_doctor(env) + assert proc.returncode == 0, proc.stderr + + +# --------------------------------------------------------------------------- +# Tmux check is independent of runtime context — same outcome in container +# --------------------------------------------------------------------------- + + +class TestTmuxUnsetContainerContext: + def test_tmux_present_info_not_in_tmux_in_container(self, env, tmp_path): + run_config_init(env) + ensure_daemon(env) + _pin_container_context(env, tmp_path) + _strip_tmux(env) + + proc = _run_doctor(env, json_mode=True) + envelope = json.loads(proc.stdout) + tp = envelope["checks"]["tmux_present"] + assert tp["status"] == "info" + assert tp["sub_code"] == "not_in_tmux" + + def test_tmux_pane_match_info_not_in_tmux_in_container(self, env, tmp_path): + run_config_init(env) + ensure_daemon(env) + _pin_container_context(env, tmp_path) + _strip_tmux(env) + + proc = _run_doctor(env, json_mode=True) + envelope = json.loads(proc.stdout) + tpm = envelope["checks"]["tmux_pane_match"] + assert tpm["status"] == "info" + assert tpm["sub_code"] == "not_in_tmux" + + def test_container_identity_independent_of_tmux(self, env, tmp_path): + """Container check classification is not influenced by tmux env.""" + run_config_init(env) + ensure_daemon(env) + _pin_container_context(env, tmp_path) + _strip_tmux(env) + + proc = _run_doctor(env, json_mode=True) + envelope = json.loads(proc.stdout) + ci = envelope["checks"]["container_identity"] + # In container context with cgroup signal but empty FEAT-003 set, + # the FR-007 closed set yields no_match (candidate exists, no row). + # `host_context` MUST NOT appear in container context. + assert ci["sub_code"] != "host_context" + assert ci["sub_code"] in {"unique_match", "no_match", "no_candidate", "multi_match"} + + +# --------------------------------------------------------------------------- +# TSV human-readable output also reports not_in_tmux on the right rows +# --------------------------------------------------------------------------- + + +class TestTmuxUnsetTsvOutput: + def test_tsv_rows_carry_not_in_tmux_token(self, env, tmp_path): + run_config_init(env) + ensure_daemon(env) + _pin_host_context(env, tmp_path) + _strip_tmux(env) + env.pop("AGENTTOWER_CONTAINER_ID", None) + + proc = _run_doctor(env) + # Parse the TSV: each row is "\t\t" + rows = { + line.split("\t", 2)[0]: line.split("\t", 2) + for line in proc.stdout.splitlines() + if line and not line.startswith(" ") + } + assert "tmux_present" in rows + assert "tmux_pane_match" in rows + assert rows["tmux_present"][1] == "info" + assert rows["tmux_pane_match"][1] == "info" + # The detail column (or actionable_message follow-up line) names the token + assert "not_in_tmux" in proc.stdout diff --git a/tests/integration/test_cli_in_container_socket_override.py b/tests/integration/test_cli_in_container_socket_override.py new file mode 100644 index 0000000..607182a --- /dev/null +++ b/tests/integration/test_cli_in_container_socket_override.py @@ -0,0 +1,179 @@ +"""US1 AS2 / US1 AS4 / US3 integration coverage (T021, T022, T023, T034, T047, T018). + +Bundles several short integration scenarios that share the same fixture +pattern: the daemon spawns under an isolated ``$HOME`` with optional +``AGENTTOWER_TEST_PROC_ROOT`` and ``AGENTTOWER_SOCKET`` overrides. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from ._daemon_helpers import ( + ensure_daemon, + isolated_env, + run_config_init, + status, + stop_daemon_if_alive, +) +from ._proc_fixtures import fake_proc_root # noqa: F401 (registers fixture) + + +@pytest.fixture +def env(tmp_path: Path): + env = isolated_env(tmp_path) + yield env + stop_daemon_if_alive(env) + + +def _run(env, *args): + return subprocess.run( + ["agenttower", *args], env=env, capture_output=True, text=True, timeout=10 + ) + + +# --------------------------------------------------------------------------- +# US1 AS2 / T022 — AGENTTOWER_SOCKET override wins +# --------------------------------------------------------------------------- + + +class TestAgenttowerSocketOverride: + def test_override_wins_over_host_default(self, tmp_path): + """AGENTTOWER_SOCKET= overrides host default. + + We spawn the daemon under one $HOME, then point AGENTTOWER_SOCKET at + its socket and verify status connects via the override path.""" + env = isolated_env(tmp_path) + try: + run_config_init(env) + ensure_daemon(env) + from ._daemon_helpers import resolved_paths + + socket_path = resolved_paths(tmp_path)["socket"] + + override_env = isolated_env(tmp_path) + override_env["AGENTTOWER_SOCKET"] = str(socket_path) + proc = _run(override_env, "config", "paths") + assert proc.returncode == 0 + assert "SOCKET_SOURCE=env_override" in proc.stdout + finally: + stop_daemon_if_alive(env) + + def test_invalid_override_exits_1_with_fr002_message(self, tmp_path): + env = isolated_env(tmp_path) + env["AGENTTOWER_SOCKET"] = "relative/path.sock" + proc = _run(env, "status") + assert proc.returncode == 1 + assert "AGENTTOWER_SOCKET must be an absolute path to a Unix socket" in proc.stderr + assert "value is not absolute" in proc.stderr + + +# --------------------------------------------------------------------------- +# US1 AS4 / T023 — no socket mount → exit 2 with FEAT-002 message +# --------------------------------------------------------------------------- + + +class TestNoSocketMount: + def test_status_exits_2_when_no_daemon(self, tmp_path): + env = isolated_env(tmp_path) + run_config_init(env) + # Do NOT ensure-daemon — socket file does not exist + proc = status(env) + assert proc.returncode == 2 + # FEAT-002 byte-stable error message + assert "daemon is not running or socket is unreachable" in proc.stderr + + +# --------------------------------------------------------------------------- +# T018 — SOCKET_SOURCE integration: covers all three resolution branches +# --------------------------------------------------------------------------- + + +class TestSocketSourceLine: + def test_host_context_yields_host_default(self, env): + proc = _run(env, "config", "paths") + assert proc.returncode == 0 + lines = proc.stdout.rstrip("\n").splitlines() + assert lines[-1] == "SOCKET_SOURCE=host_default" + + def test_env_override_yields_env_override(self, env, tmp_path): + # Materialize a real Unix socket so the validator passes + import socket as _sm + + sock_path = tmp_path / "x.sock" + s = _sm.socket(_sm.AF_UNIX, _sm.SOCK_STREAM) + s.bind(str(sock_path)) + try: + env["AGENTTOWER_SOCKET"] = str(sock_path) + proc = _run(env, "config", "paths") + assert proc.returncode == 0 + lines = proc.stdout.rstrip("\n").splitlines() + assert lines[-1] == "SOCKET_SOURCE=env_override" + # And SOCKET= line reflects the override + socket_line = next(line for line in lines if line.startswith("SOCKET=")) + assert socket_line == f"SOCKET={sock_path}" + finally: + s.close() + if sock_path.exists(): + sock_path.unlink() + + +# --------------------------------------------------------------------------- +# T034 — host_context: doctor on host shell shows host_context (not fail) +# --------------------------------------------------------------------------- + + +class TestDoctorHostContext: + def test_container_identity_is_host_context(self, env, tmp_path): + """US2 AS3 — host context yields container_identity = info/host_context. + + We pin the runtime context to host by pointing + ``AGENTTOWER_TEST_PROC_ROOT`` at an empty fake `/proc` (no + ``/.dockerenv``, no cgroup match) — the dev box itself runs inside + a container, so without this seam the test would observe + ``ContainerContext`` and a hostname-driven candidate.""" + run_config_init(env) + ensure_daemon(env) + # Build an empty fake-/proc tree: no /.dockerenv, no cgroup match. + fake_root = tmp_path / "fake-host-proc" + (fake_root / "proc" / "self").mkdir(parents=True) + (fake_root / "etc").mkdir(parents=True) + (fake_root / "proc" / "self" / "cgroup").write_text("0::/\n") + env["AGENTTOWER_TEST_PROC_ROOT"] = str(fake_root) + # Companion AGENTTOWER_TEST_* var so the FR-025 / A2 production + # guard recognizes this as a test invocation, not a leaked + # environment. AGENTTOWER_TEST_DOCKER_FAKE is the FEAT-003 test seam + # already honored by the daemon. + env.setdefault("AGENTTOWER_TEST_DOCKER_FAKE", "1") + proc = _run(env, "config", "doctor", "--json") + envelope = json.loads(proc.stdout) + ci = envelope["checks"]["container_identity"] + assert ci["status"] == "info" + assert ci["sub_code"] == "host_context" + + +# --------------------------------------------------------------------------- +# T047 — tmux unset → not_in_tmux (info, never fail) +# --------------------------------------------------------------------------- + + +class TestTmuxUnset: + def test_tmux_unset_yields_not_in_tmux(self, env): + run_config_init(env) + ensure_daemon(env) + # Strip TMUX vars so we land in not_in_tmux territory regardless of + # the host shell's state + env = {k: v for k, v in env.items() if k not in ("TMUX", "TMUX_PANE")} + proc = _run(env, "config", "doctor", "--json") + envelope = json.loads(proc.stdout) + tp = envelope["checks"]["tmux_present"] + assert tp["status"] == "info" + assert tp["sub_code"] == "not_in_tmux" + # And tmux_pane_match propagates not_in_tmux (NOT a fail) + tpm = envelope["checks"]["tmux_pane_match"] + assert tpm["status"] == "info" + assert tpm["sub_code"] == "not_in_tmux" diff --git a/tests/integration/test_cli_in_container_status.py b/tests/integration/test_cli_in_container_status.py new file mode 100644 index 0000000..a3e0254 --- /dev/null +++ b/tests/integration/test_cli_in_container_status.py @@ -0,0 +1,130 @@ +"""T021 / US1 AS1 / SC-001: ``agenttower status`` works the same from a +simulated bench container as from the host. + +Scope: + +* Eight-key ``status`` payload shape on a healthy daemon. +* Stable subset (``alive``, ``state_path``, ``schema_version``, + ``daemon_version``) is byte-for-byte equivalent between host and + in-container invocations. +* Volatile fields (``pid``, ``start_time_utc``, ``uptime_seconds``) are + observed but not byte-compared. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from ._daemon_helpers import ( + ensure_daemon, + isolated_env, + run_config_init, + status, + stop_daemon_if_alive, +) +from ._proc_fixtures import fake_proc_root # noqa: F401 (registers fixture) + + +@pytest.fixture +def env(tmp_path: Path): + env = isolated_env(tmp_path) + yield env + stop_daemon_if_alive(env) + + +# Eight-key status payload per FEAT-002 contract (FR-005, SC-001) +EXPECTED_STATUS_KEYS = { + "alive", + "pid", + "start_time_utc", + "uptime_seconds", + "socket_path", + "state_path", + "schema_version", + "daemon_version", +} + + +# Subset that is byte-stable across host vs in-container invocations +STABLE_KEYS = ("alive", "state_path", "schema_version", "daemon_version") + + +def _fake_container_root(tmp_path: Path) -> Path: + """Build a fake `/proc` that fires ContainerContext via a cgroup signal.""" + fake_root = tmp_path / "fake-container-proc" + (fake_root / "proc" / "self").mkdir(parents=True) + (fake_root / "etc").mkdir(parents=True) + (fake_root / "proc" / "self" / "cgroup").write_text( + "0::/docker/abcdef0123456789abcdef0123456789abcdef0123456789abcdef01234567\n" + ) + return fake_root + + +class TestStatusInContainerShape: + def test_status_returns_eight_keys_under_simulated_container(self, env, tmp_path): + run_config_init(env) + ensure_daemon(env) + env["AGENTTOWER_TEST_PROC_ROOT"] = str(_fake_container_root(tmp_path)) + env.setdefault("AGENTTOWER_TEST_DOCKER_FAKE", "1") + # AGENTTOWER_SOCKET overrides to the host daemon's socket — this + # simulates the in-container CLI's mounted /run/agenttower/agenttowerd.sock + from ._daemon_helpers import resolved_paths + + + socket_path = resolved_paths(tmp_path)["socket"] + env["AGENTTOWER_SOCKET"] = str(socket_path) + + proc = status(env, json_mode=True) + assert proc.returncode == 0, proc.stderr + envelope = json.loads(proc.stdout) + assert envelope.get("ok") is True + result = envelope["result"] + assert set(result.keys()) >= EXPECTED_STATUS_KEYS, ( + f"missing keys: {EXPECTED_STATUS_KEYS - set(result.keys())}" + ) + + def test_stable_subset_matches_host_invocation(self, env, tmp_path): + run_config_init(env) + ensure_daemon(env) + + # Host invocation + host_proc = status(env, json_mode=True) + assert host_proc.returncode == 0 + host_result = json.loads(host_proc.stdout)["result"] + + # In-container invocation against the same daemon + from ._daemon_helpers import resolved_paths + + + socket_path = resolved_paths(tmp_path)["socket"] + in_container_env = isolated_env(tmp_path) + in_container_env["AGENTTOWER_TEST_PROC_ROOT"] = str(_fake_container_root(tmp_path)) + in_container_env["AGENTTOWER_TEST_DOCKER_FAKE"] = "1" + in_container_env["AGENTTOWER_SOCKET"] = str(socket_path) + + in_container_proc = status(in_container_env, json_mode=True) + assert in_container_proc.returncode == 0, in_container_proc.stderr + in_container_result = json.loads(in_container_proc.stdout)["result"] + + for key in STABLE_KEYS: + assert host_result[key] == in_container_result[key], ( + f"{key} drifted: host={host_result[key]!r} " + f"in_container={in_container_result[key]!r}" + ) + + +class TestStatusInContainerExitCodes: + def test_status_exits_0_on_healthy_daemon_from_simulated_container(self, env, tmp_path): + run_config_init(env) + ensure_daemon(env) + env["AGENTTOWER_TEST_PROC_ROOT"] = str(_fake_container_root(tmp_path)) + env.setdefault("AGENTTOWER_TEST_DOCKER_FAKE", "1") + from ._daemon_helpers import resolved_paths + + env["AGENTTOWER_SOCKET"] = str(resolved_paths(tmp_path)["socket"]) + proc = status(env) + assert proc.returncode == 0, proc.stderr diff --git a/tests/integration/test_cli_in_container_unsupported_signals.py b/tests/integration/test_cli_in_container_unsupported_signals.py new file mode 100644 index 0000000..2960d2d --- /dev/null +++ b/tests/integration/test_cli_in_container_unsupported_signals.py @@ -0,0 +1,156 @@ +"""T024 / FR-002 / FR-003 / FR-004 / FR-021 / SC-002: malformed env signals. + +For each malformed ``AGENTTOWER_SOCKET`` shape and for two named edge cases +(privileged container with empty cgroup; ``--network host`` hostname +collision), assert the CLI exits with the documented code and message +without modifying daemon-side state. + +The pre-flight resolver rejects malformed ``AGENTTOWER_SOCKET`` values +within the SC-002 50 ms wall-clock budget; we assert a generous 1.0 s +ceiling here to stay stable on slow CI while still catching pathological +regressions. +""" + +from __future__ import annotations + +import subprocess +import time +from pathlib import Path + +import pytest + +from ._daemon_helpers import ( + ensure_daemon, + isolated_env, + run_config_init, + status, + stop_daemon_if_alive, +) + + +@pytest.fixture +def env(tmp_path: Path): + env = isolated_env(tmp_path) + yield env + stop_daemon_if_alive(env) + + +# --------------------------------------------------------------------------- +# Malformed AGENTTOWER_SOCKET → exit 1 with FR-002 closed-set token +# --------------------------------------------------------------------------- + + +def _make_regular_file(tmp_path: Path) -> Path: + p = tmp_path / "file.txt" + p.write_text("not a socket") + return p + + +def _make_directory(tmp_path: Path) -> Path: + p = tmp_path / "dir" + p.mkdir() + return p + + +def _make_broken_symlink(tmp_path: Path) -> Path: + target = tmp_path / "missing-target.sock" + link = tmp_path / "broken.sock" + link.symlink_to(target) # target does not exist + return link + + +@pytest.mark.parametrize( + "id,value_factory,reason", + [ + ("relative_path", lambda tmp: "relative/path.sock", "value is not absolute"), + ("empty_value", lambda tmp: "", "value is empty"), + # ``nul_byte`` cannot be passed through ``subprocess`` (Python rejects + # embedded NULs in env values); the closed-set ``value contains NUL + # byte`` reason is locked at the unit level by + # ``tests/unit/test_socket_path_resolution.py`` instead. + ( + "broken_symlink", + lambda tmp: str(_make_broken_symlink(tmp)), + "value does not exist", + ), + ( + "regular_file", + lambda tmp: str(_make_regular_file(tmp)), + "value is not a Unix socket", + ), + ( + "directory_target", + lambda tmp: str(_make_directory(tmp)), + "value is not a Unix socket", + ), + ], +) +class TestMalformedSocketEnv: + def test_exits_1_with_fr002_message(self, env, tmp_path, id, value_factory, reason): + env["AGENTTOWER_SOCKET"] = value_factory(tmp_path) + proc = status(env) + assert proc.returncode == 1, (id, proc.stderr) + assert "AGENTTOWER_SOCKET must be an absolute path to a Unix socket" in proc.stderr + assert reason in proc.stderr + + def test_pre_flight_under_1_second(self, env, tmp_path, id, value_factory, reason): + env["AGENTTOWER_SOCKET"] = value_factory(tmp_path) + start = time.perf_counter() + status(env) + elapsed = time.perf_counter() - start + # SC-002 says 50 ms in-process; subprocess overhead pushes the wall + # clock higher, so we use a 1 s ceiling. Still catches pathological + # regressions (e.g. a 5 s daemon connect attempt before validation). + assert elapsed < 1.0, (id, f"{elapsed*1000:.0f}ms") + + +# --------------------------------------------------------------------------- +# Privileged-container edge case: empty /proc/self/cgroup +# --------------------------------------------------------------------------- + + +class TestPrivilegedContainerEmptyCgroup: + def test_empty_cgroup_falls_through_safely(self, env, tmp_path): + """Privileged containers may have an empty ``/proc/self/cgroup``; + without ``/.dockerenv`` either, the runtime detects HostContext. + ``status`` must still work against a healthy daemon (host_default).""" + run_config_init(env) + ensure_daemon(env) + # Empty cgroup, no /.dockerenv → host context + fake_root = tmp_path / "fake-empty-cgroup" + (fake_root / "proc" / "self").mkdir(parents=True) + (fake_root / "etc").mkdir(parents=True) + (fake_root / "proc" / "self" / "cgroup").write_text("") + env["AGENTTOWER_TEST_PROC_ROOT"] = str(fake_root) + env.setdefault("AGENTTOWER_TEST_DOCKER_FAKE", "1") + proc = status(env) + assert proc.returncode == 0, proc.stderr + + +# --------------------------------------------------------------------------- +# --network host hostname collision: in-container hostname == host hostname. +# Doctor's container_identity check should classify this as no_match (not crash). +# --------------------------------------------------------------------------- + + +class TestNetworkHostHostnameCollision: + def test_no_match_classification_does_not_crash_status(self, env, tmp_path): + """``--network host`` makes ``/etc/hostname`` equal the host's own + hostname. The resolver and ``status`` must not crash under this + condition; the doctor's classifier is the place that surfaces + ``no_match`` (covered by unit tests).""" + run_config_init(env) + ensure_daemon(env) + fake_root = tmp_path / "fake-network-host" + (fake_root / "proc" / "self").mkdir(parents=True) + (fake_root / "etc").mkdir(parents=True) + # Fire ContainerContext via /.dockerenv but with empty cgroup, and an + # arbitrary hostname (the daemon's container set is empty so this + # naturally yields no_match in the doctor). + (fake_root / "proc" / "self" / "cgroup").write_text("") + (fake_root / ".dockerenv").write_text("") + (fake_root / "etc" / "hostname").write_text("host-collides-here\n") + env["AGENTTOWER_TEST_PROC_ROOT"] = str(fake_root) + env.setdefault("AGENTTOWER_TEST_DOCKER_FAKE", "1") + proc = status(env) + assert proc.returncode == 0, proc.stderr # status itself is unaffected diff --git a/tests/integration/test_cli_no_socket_mount.py b/tests/integration/test_cli_no_socket_mount.py new file mode 100644 index 0000000..c2f3760 --- /dev/null +++ b/tests/integration/test_cli_no_socket_mount.py @@ -0,0 +1,87 @@ +"""T023 / US1 AS4 / edge case 3 / FR-005: missing socket mount → exit 2. + +Scenario: a developer launched the bench container without mounting the host +daemon's socket. The CLI runs inside ContainerContext (cgroup signal fires) +but ``/run/agenttower/agenttowerd.sock`` does not exist; ``AGENTTOWER_SOCKET`` +is unset. We expect the FEAT-002 ``DAEMON_UNAVAILABLE_MESSAGE`` and exit 2, +byte-for-byte preserved — and crucially, the host daemon (if any) is NOT +killed by this invocation. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from ._daemon_helpers import ( + ensure_daemon, + isolated_env, + run_config_init, + status, + stop_daemon_if_alive, +) + + +@pytest.fixture +def env(tmp_path: Path): + env = isolated_env(tmp_path) + yield env + stop_daemon_if_alive(env) + + +def _fake_container_root(tmp_path: Path) -> Path: + fake_root = tmp_path / "fake-container-proc" + (fake_root / "proc" / "self").mkdir(parents=True) + (fake_root / "etc").mkdir(parents=True) + (fake_root / "proc" / "self" / "cgroup").write_text( + "0::/docker/abcdef0123456789abcdef0123456789abcdef0123456789abcdef01234567\n" + ) + return fake_root + + +# Byte-stable FEAT-002 message — must NOT change under FEAT-005 +DAEMON_UNAVAILABLE_FRAGMENT = "daemon is not running or socket is unreachable" + + +class TestNoSocketMountInContainer: + def test_status_exits_2_with_byte_stable_message(self, env, tmp_path): + # Initialize config (so the FEAT-001 not-initialized path doesn't fire) + run_config_init(env) + # Fixture-fire ContainerContext but DON'T spawn a daemon and DON'T set + # AGENTTOWER_SOCKET — the resolver will fall to mounted_default + # (which doesn't exist) → host_default (which also doesn't exist). + env["AGENTTOWER_TEST_PROC_ROOT"] = str(_fake_container_root(tmp_path)) + env.setdefault("AGENTTOWER_TEST_DOCKER_FAKE", "1") + + proc = status(env) + assert proc.returncode == 2 + assert DAEMON_UNAVAILABLE_FRAGMENT in proc.stderr + + def test_no_raw_errno_leak(self, env, tmp_path): + run_config_init(env) + env["AGENTTOWER_TEST_PROC_ROOT"] = str(_fake_container_root(tmp_path)) + env.setdefault("AGENTTOWER_TEST_DOCKER_FAKE", "1") + proc = status(env) + assert "[Errno" not in proc.stderr + assert "ECONNREFUSED" not in proc.stderr + assert "ENOENT" not in proc.stderr + + def test_subsequent_real_daemon_status_succeeds(self, env, tmp_path): + """The aborted in-container invocation does not poison the host daemon. + + We first run the no-mount status (exit 2), then start the daemon and + run a normal host-side status — it must succeed.""" + run_config_init(env) + env["AGENTTOWER_TEST_PROC_ROOT"] = str(_fake_container_root(tmp_path)) + env.setdefault("AGENTTOWER_TEST_DOCKER_FAKE", "1") + proc = status(env) + assert proc.returncode == 2 + + # Drop the test seam, start a real daemon, status should work + del env["AGENTTOWER_TEST_PROC_ROOT"] + del env["AGENTTOWER_TEST_DOCKER_FAKE"] + ensure_daemon(env) + live = status(env) + assert live.returncode == 0, live.stderr diff --git a/tests/integration/test_cli_paths.py b/tests/integration/test_cli_paths.py index b89bb42..860f717 100644 --- a/tests/integration/test_cli_paths.py +++ b/tests/integration/test_cli_paths.py @@ -34,19 +34,26 @@ def _parse_kv(stdout: str) -> dict[str, str]: return out +# FEAT-001's six output keys, in declared `Paths` field order. FEAT-005 / FR-019 +# appends a seventh line `SOCKET_SOURCE=` so callers can see which +# resolution branch produced the SOCKET path; the original six lines are +# byte-for-byte unchanged (FR-019, FR-026, SC-007). EXPECTED_KEYS = ("CONFIG_FILE", "STATE_DB", "EVENTS_FILE", "LOGS_DIR", "SOCKET", "CACHE_DIR") +EXPECTED_KEYS_WITH_SOCKET_SOURCE = EXPECTED_KEYS + ("SOCKET_SOURCE",) -def test_config_paths_outputs_six_lines_in_fixed_order(tmp_path: Path) -> None: +def test_config_paths_outputs_seven_lines_in_fixed_order(tmp_path: Path) -> None: env = _isolated_env(tmp_path) proc = _run_paths(env) assert proc.returncode == 0 lines = proc.stdout.splitlines() - assert len(lines) == 6, lines - for line, key in zip(lines, EXPECTED_KEYS): + assert len(lines) == 7, lines + for line, key in zip(lines, EXPECTED_KEYS_WITH_SOCKET_SOURCE): assert line.startswith(f"{key}="), line assert line.count("=") >= 1 assert " " not in line + # FR-019: SOCKET_SOURCE MUST be the last line + assert lines[-1].startswith("SOCKET_SOURCE=") def test_config_paths_values_are_absolute_under_namespace(tmp_path: Path) -> None: @@ -54,6 +61,7 @@ def test_config_paths_values_are_absolute_under_namespace(tmp_path: Path) -> Non proc = _run_paths(env) assert proc.returncode == 0 kv = _parse_kv(proc.stdout) + # The six FEAT-001 path keys remain absolute under the namespace. for key in EXPECTED_KEYS: assert key in kv assert kv[key].startswith("/") @@ -65,7 +73,10 @@ def test_config_paths_eval_compatible(tmp_path: Path) -> None: proc = _run_paths(env) assert proc.returncode == 0 kv = _parse_kv(proc.stdout) - assert set(kv.keys()) == set(EXPECTED_KEYS) + # FR-019: the seven keys are exactly the six FEAT-001 keys plus SOCKET_SOURCE. + assert set(kv.keys()) == set(EXPECTED_KEYS_WITH_SOCKET_SOURCE) + # SOCKET_SOURCE values are closed-set tokens per FR-001. + assert kv["SOCKET_SOURCE"] in {"env_override", "mounted_default", "host_default"} for value in kv.values(): assert "'" not in value assert '"' not in value @@ -94,7 +105,8 @@ def test_initialized_emits_no_stderr_note(tmp_path: Path) -> None: proc = _run_paths(env) assert proc.returncode == 0 assert proc.stderr == "" - assert proc.stdout.count("\n") == 6 + # FR-019: six FEAT-001 lines + the new trailing SOCKET_SOURCE= line. + assert proc.stdout.count("\n") == 7 def test_xdg_config_home_redirects_only_config(tmp_path: Path) -> None: diff --git a/tests/integration/test_feat005_backcompat.py b/tests/integration/test_feat005_backcompat.py new file mode 100644 index 0000000..340928a --- /dev/null +++ b/tests/integration/test_feat005_backcompat.py @@ -0,0 +1,174 @@ +"""FEAT-005 backcompat byte-parity guard (T053, FR-005, FR-026, SC-006, SC-007). + +Folds analyze findings: + +* **A1** — `_connect_via_chdir` deep-cwd regression test for the kernel + ``sun_path`` 108-byte limit (spec edge case 14). +* **A3** — ``--help`` byte-parity sweep across every existing subcommand; + the only documented change vs. FEAT-004 is the addition of ``config doctor`` + to ``agenttower --help`` and ``agenttower config --help``. +""" + +from __future__ import annotations + +import os +import socket as socket_mod +import subprocess +from pathlib import Path + +import pytest + +from ._daemon_helpers import ( + isolated_env, + run_config_init, + stop_daemon_if_alive, +) + + +@pytest.fixture +def env(tmp_path: Path): + env = isolated_env(tmp_path) + yield env + stop_daemon_if_alive(env) + + +def _run(env, *args): + return subprocess.run( + ["agenttower", *args], env=env, capture_output=True, text=True, timeout=10 + ) + + +# --------------------------------------------------------------------------- +# A3: --help byte-parity sweep +# --------------------------------------------------------------------------- + + +class TestHelpByteParity: + """Each existing subcommand's `--help` text must remain stable under + FEAT-005, except for the documented additive change of `config doctor` + appearing under the `config` parent. + """ + + SUBCOMMANDS_UNCHANGED = ( + "ensure-daemon", + "status", + "stop-daemon", + "scan", + "list-containers", + "list-panes", + ) + + @pytest.mark.parametrize("subcmd", SUBCOMMANDS_UNCHANGED) + def test_subcommand_help_text_does_not_mention_doctor(self, env, subcmd): + """The ``--help`` text for FEAT-001..004 subcommands MUST NOT mention + ``doctor`` — adding the doctor subcommand to the parser tree should + not bleed into unrelated subcommands' help output.""" + proc = _run(env, subcmd, "--help") + assert proc.returncode == 0 + assert "doctor" not in proc.stdout.lower() + + def test_config_paths_help_unchanged(self, env): + """``agenttower config paths --help`` is byte-stable — paths is a + FEAT-001 surface and its help text does not change.""" + proc = _run(env, "config", "paths", "--help") + assert proc.returncode == 0 + assert "doctor" not in proc.stdout.lower() + assert "paths" in proc.stdout.lower() + + def test_config_init_help_unchanged(self, env): + """``agenttower config init --help`` is byte-stable for the same reason.""" + proc = _run(env, "config", "init", "--help") + assert proc.returncode == 0 + assert "doctor" not in proc.stdout.lower() + + def test_top_level_help_includes_doctor_under_config(self, env): + """The only documented change to ``agenttower --help`` is the addition + of ``config doctor`` to the listed config subcommands.""" + proc = _run(env, "--help") + assert proc.returncode == 0 + assert "config doctor" in proc.stdout + + def test_config_help_includes_doctor_subcommand(self, env): + proc = _run(env, "config", "--help") + assert proc.returncode == 0 + assert "doctor" in proc.stdout.lower() + + def test_config_doctor_help_resolves(self, env): + """``config doctor --help`` exists and is parseable (the subparser is + registered); this is the new additive surface.""" + proc = _run(env, "config", "doctor", "--help") + assert proc.returncode == 0 + assert "diagnostic" in proc.stdout.lower() or "checks" in proc.stdout.lower() + + +# --------------------------------------------------------------------------- +# Existing-command exit codes are byte-stable +# --------------------------------------------------------------------------- + + +class TestExistingExitCodes: + def test_status_exit_2_when_daemon_down(self, env): + run_config_init(env) + proc = _run(env, "status") + assert proc.returncode == 2 # FEAT-002 contract + + def test_list_containers_exit_2_when_daemon_down(self, env): + run_config_init(env) + proc = _run(env, "list-containers") + assert proc.returncode == 2 + + def test_list_panes_exit_2_when_daemon_down(self, env): + run_config_init(env) + proc = _run(env, "list-panes") + assert proc.returncode == 2 + + +# --------------------------------------------------------------------------- +# A1: deep-cwd / sun_path 108-byte regression guard (spec edge case 14) +# --------------------------------------------------------------------------- + + +class TestSunPathDeepCwd: + """The FEAT-002 ``_connect_via_chdir`` workaround sidesteps the kernel's + 108-byte ``sun_path`` limit. FEAT-005 introduces a pre-flight resolver + that MUST NOT regress this path; the resolver passes the path through + untouched (the chdir workaround is applied later by ``client.py``). + """ + + def test_resolver_preserves_long_paths_byte_for_byte(self, tmp_path): + """The resolver MUST NOT shorten / canonicalize / readlink-fold the + AGENTTOWER_SOCKET value beyond the documented FR-002 single-readlink + rule. A path that approaches the kernel's sun_path limit must be + returned byte-for-byte so the client's chdir+connect can still bind it.""" + + # Use a moderately deep path. Going beyond the 108-byte sun_path + # limit would prevent us from creating the socket here in the first + # place — the client's `_connect_via_chdir` is what handles that. + deep_dir = tmp_path / ("d" * 60) + deep_dir.mkdir() + socket_path = deep_dir / "x.sock" + s = socket_mod.socket(socket_mod.AF_UNIX, socket_mod.SOCK_STREAM) + try: + s.bind(str(socket_path)) + except OSError: + # If even a 60-char path is too long on this filesystem, fall + # back to a tmp-rooted socket — we still test the byte-stable + # passthrough invariant. + socket_path = tmp_path / "short.sock" + s.bind(str(socket_path)) + + try: + from agenttower.config_doctor.runtime_detect import HostContext + from agenttower.config_doctor.socket_resolve import resolve_socket_path + from agenttower.paths import resolve_paths + + env = {"HOME": str(tmp_path), "AGENTTOWER_SOCKET": str(socket_path)} + paths = resolve_paths(env) + resolved = resolve_socket_path(env, paths, HostContext()) + # Byte-for-byte passthrough — the deep-cwd path is preserved. + assert str(resolved.path) == str(socket_path) + assert resolved.source == "env_override" + finally: + s.close() + if socket_path.exists(): + socket_path.unlink() diff --git a/tests/integration/test_feat005_deep_cwd_connect.py b/tests/integration/test_feat005_deep_cwd_connect.py new file mode 100644 index 0000000..d7b9745 --- /dev/null +++ b/tests/integration/test_feat005_deep_cwd_connect.py @@ -0,0 +1,200 @@ +"""T060 / edge case 12 / CHK011: ``_connect_via_chdir`` must not regress. + +The FEAT-002 ``_connect_via_chdir`` workaround sidesteps the kernel's +108-byte ``sun_path`` limit by ``chdir(parent) + connect(basename)`` so +deep cwds and long absolute socket paths still work. + +This test pins that workaround under FEAT-005's resolver: constructs a +daemon ``$HOME`` whose absolute socket path exceeds 108 bytes, runs +``agenttower status`` from a deep cwd, and asserts the round-trip +succeeds. The resolver's ``(path, source)`` plumbing must hand the long +path to ``client.py`` untouched. + +Sanity: the test also verifies that a raw ``socket.connect(absolute_path)`` +on the same path fails with ``OSError`` — proving the path is genuinely +too long and the chdir workaround is doing real work. +""" + +from __future__ import annotations + +import os +import socket +import subprocess +from pathlib import Path + +import pytest + +from ._daemon_helpers import ( + ensure_daemon, + isolated_env, + resolved_paths, + run_config_init, + stop_daemon_if_alive, +) + + +_SUN_PATH_LIMIT = 108 # Linux AF_UNIX sun_path[108] (man 7 unix) + + +def _build_long_home(tmp_path: Path) -> Path: + """Construct a $HOME deep enough that the resulting socket path + exceeds ``_SUN_PATH_LIMIT``. Padding is split into two segments so + no single path component exceeds typical NAME_MAX (255 on ext4). + """ + pad_a = "a" * 80 + pad_b = "b" * 80 + home = tmp_path / pad_a / pad_b / "home" + home.mkdir(parents=True) + return home + + +@pytest.fixture +def long_path_env(tmp_path: Path): + home = _build_long_home(tmp_path) + env = isolated_env(home) + yield env, home + stop_daemon_if_alive(env) + + +# --------------------------------------------------------------------------- +# Sanity: the constructed socket path is genuinely too long for AF_UNIX +# --------------------------------------------------------------------------- + + +class TestConstructedPathExceedsLimit: + def test_socket_path_exceeds_sun_path_limit(self, long_path_env): + _, home = long_path_env + socket_path = resolved_paths(home)["socket"] + path_bytes = str(socket_path).encode("utf-8") + assert len(path_bytes) > _SUN_PATH_LIMIT, ( + f"socket path is only {len(path_bytes)} bytes; " + f"need > {_SUN_PATH_LIMIT} for the test to be meaningful" + ) + + def test_raw_connect_with_absolute_path_fails(self, long_path_env, tmp_path): + """Without ``_connect_via_chdir``, ``connect(abs_path)`` must fail + — proves the workaround is doing real work, not a no-op.""" + _, home = long_path_env + socket_path = resolved_paths(home)["socket"] + socket_path.parent.mkdir(parents=True, exist_ok=True) + # Don't actually need a listener — connect() will fail at bind-time + # path-length check before reaching any listener. + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + with pytest.raises(OSError): + sock.connect(str(socket_path)) + finally: + sock.close() + + +# --------------------------------------------------------------------------- +# T060 main assertion: daemon round-trip succeeds under FEAT-005's resolver +# --------------------------------------------------------------------------- + + +class TestDeepCwdConnectStillWorks: + def test_status_succeeds_with_long_socket_path(self, long_path_env): + env, home = long_path_env + run_config_init(env) + ensure_daemon(env) + + proc = subprocess.run( + ["agenttower", "status"], + env=env, + capture_output=True, + text=True, + timeout=10, + ) + assert proc.returncode == 0, ( + f"deep-cwd status failed; stderr={proc.stderr!r}" + ) + assert proc.stdout.strip(), "expected status output on stdout" + + def test_status_from_deep_cwd_directory(self, long_path_env, tmp_path): + """Run ``agenttower status`` from a CWD that is ITSELF a deep path, + not just one whose socket is deep. This exercises the chdir + workaround under cwd-restoration semantics.""" + env, home = long_path_env + run_config_init(env) + ensure_daemon(env) + + deep_cwd = tmp_path / ("c" * 80) / ("d" * 80) + deep_cwd.mkdir(parents=True) + + proc = subprocess.run( + ["agenttower", "status"], + env=env, + cwd=str(deep_cwd), + capture_output=True, + text=True, + timeout=10, + ) + assert proc.returncode == 0, ( + f"deep-cwd status failed; stderr={proc.stderr!r}" + ) + + def test_cwd_is_restored_after_status(self, long_path_env, tmp_path): + """``_connect_via_chdir`` chdirs into the socket parent then + restores the original cwd. Verify the parent process's cwd is + unchanged after the subprocess returns (the subprocess inherits + cwd, so we test by running and checking our own cwd post-call).""" + env, home = long_path_env + run_config_init(env) + ensure_daemon(env) + + original = os.getcwd() + try: + subprocess.run( + ["agenttower", "status"], + env=env, + capture_output=True, + text=True, + timeout=10, + ) + assert os.getcwd() == original + + + finally: + os.chdir(original) + + +# --------------------------------------------------------------------------- +# Resolver hands the path through untouched (FEAT-005 contract) +# --------------------------------------------------------------------------- + + +class TestResolverHandsPathThroughUntouched: + def test_config_paths_reports_long_socket_path_verbatim(self, long_path_env): + env, home = long_path_env + run_config_init(env) + socket_path = resolved_paths(home)["socket"] + + proc = subprocess.run( + ["agenttower", "config", "paths"], + env=env, + capture_output=True, + text=True, + timeout=10, + ) + assert proc.returncode == 0 + # The resolver must NOT shorten, normalize, or rewrite the path + # (which would break the subsequent connect). + assert f"SOCKET={socket_path}" in proc.stdout, proc.stdout + + def test_config_paths_reports_host_default_source(self, long_path_env): + """No ``AGENTTOWER_SOCKET`` override and no fake ``/proc`` → + ``SOCKET_SOURCE=host_default``.""" + env, home = long_path_env + env.pop("AGENTTOWER_SOCKET", None) + env.pop("AGENTTOWER_TEST_PROC_ROOT", None) + run_config_init(env) + + proc = subprocess.run( + ["agenttower", "config", "paths"], + env=env, + capture_output=True, + text=True, + timeout=10, + ) + assert proc.returncode == 0 + assert "SOCKET_SOURCE=host_default" in proc.stdout, proc.stdout diff --git a/tests/integration/test_feat005_no_real_container.py b/tests/integration/test_feat005_no_real_container.py new file mode 100644 index 0000000..128eea7 --- /dev/null +++ b/tests/integration/test_feat005_no_real_container.py @@ -0,0 +1,69 @@ +"""FEAT-005 SC-009 / FR-022 guards (T054). + +Folds analyze finding **A7** — dispatch-table cardinality assertion: the +daemon's socket-method dispatch table MUST stay at exactly the FEAT-001..004 +size after FEAT-005 (FR-022). +""" + +from __future__ import annotations + + +# --------------------------------------------------------------------------- +# A7: dispatch table cardinality (FR-022) +# --------------------------------------------------------------------------- + + +class TestDispatchTableCardinality: + def test_dispatch_table_method_count_unchanged(self): + """FR-022 forbids any new socket method. Pre-FEAT-005 the table had + exactly 7 entries (ping, status, shutdown, scan_containers, + list_containers, scan_panes, list_panes). Asserts the count and the + exact method names so a new entry cannot sneak in.""" + from agenttower.socket_api import methods as methods_module + + # The dispatch table is `DISPATCH` per the FEAT-002 / 003 / 004 builds. + # We do not import a name we don't know exists — we discover it. + dispatch = getattr(methods_module, "DISPATCH", None) + assert dispatch is not None, "expected DISPATCH dict in socket_api/methods.py" + assert isinstance(dispatch, dict) + assert set(dispatch.keys()) == { + "ping", + "status", + "shutdown", + "scan_containers", + "list_containers", + "scan_panes", + "list_panes", + }, f"unexpected method count: {sorted(dispatch.keys())}" + assert len(dispatch) == 7 + + +# --------------------------------------------------------------------------- +# SC-009: no real Docker / tmux / container-runtime subprocess +# --------------------------------------------------------------------------- + + +class TestNoRealSubprocess: + """FEAT-005 must not spawn docker / tmux / runc / podman / id / cat / + any container-runtime subprocess. The session-level guard in + tests/conftest.py already monkeypatches subprocess.run / shutil.which, + so any FEAT-005 code path that violated FR-011 / FR-020 would already + fail elsewhere. This test is a smoke check that calling the doctor + package functions doesn't spawn anything.""" + + def test_doctor_package_imports_without_subprocess(self): + # Importing the package must not call subprocess; the session-level + # conftest guard would catch a violation. We just exercise the top + # imports as a smoke test. + from agenttower.config_doctor import ( + CHECK_ORDER, + DoctorReport, + render_json, + render_tsv, + run_doctor, + ) + + assert CHECK_ORDER[0] == "socket_resolved" + assert callable(run_doctor) + assert callable(render_json) + assert callable(render_tsv) diff --git a/tests/integration/test_feat005_proc_root_unset_in_prod.py b/tests/integration/test_feat005_proc_root_unset_in_prod.py new file mode 100644 index 0000000..7665e55 --- /dev/null +++ b/tests/integration/test_feat005_proc_root_unset_in_prod.py @@ -0,0 +1,70 @@ +"""FR-025 / analyze finding A2 — production binary refuses to honor a leaked +``AGENTTOWER_TEST_PROC_ROOT`` (T055). + +Per Clarifications 2026-05-06 (analyze A2), when ``AGENTTOWER_TEST_PROC_ROOT`` +is set in a non-test invocation (no companion ``AGENTTOWER_TEST_*`` var +present), the CLI MUST exit ``1`` with an explicit stderr message rather +than silently substituting the fake ``/proc`` for the real one. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +from ._daemon_helpers import isolated_env + + +def _spawn_cli(env, *args): + return subprocess.run( + ["agenttower", *args], env=env, capture_output=True, text=True, timeout=10 + ) + + +def _stripped_env(home: Path) -> dict[str, str]: + """Build an isolated subprocess env with NO AGENTTOWER_TEST_* companions. + + Uses the canonical ``isolated_env`` helper for $PATH / $HOME setup, then + strips any AGENTTOWER_TEST_* var that may have leaked from the host + pytest invocation so the production guard's "no companion" rule can be + exercised cleanly. + """ + env = isolated_env(home) + for key in list(env.keys()): + if key.startswith("AGENTTOWER_TEST_"): + env.pop(key, None) + return env + + +class TestProductionRejection: + def test_proc_root_alone_is_refused(self, tmp_path): + env = _stripped_env(tmp_path) + env["AGENTTOWER_TEST_PROC_ROOT"] = str(tmp_path / "fake-proc") + proc = _spawn_cli(env, "config", "paths") + assert proc.returncode == 1 + assert "AGENTTOWER_TEST_PROC_ROOT" in proc.stderr + assert "outside the test harness" in proc.stderr + + def test_proc_root_with_companion_var_allowed(self, tmp_path): + """When at least one other AGENTTOWER_TEST_* var is also set (the + normal pytest pattern), the guard does not fire.""" + env = _stripped_env(tmp_path) + env["AGENTTOWER_TEST_PROC_ROOT"] = str(tmp_path / "fake-proc") + env["AGENTTOWER_TEST_DOCKER_FAKE"] = "1" # companion marker + proc = _spawn_cli(env, "--version") + assert proc.returncode == 0 + assert "AGENTTOWER_TEST_PROC_ROOT" not in proc.stderr + + def test_no_proc_root_no_guard(self, tmp_path): + """When PROC_ROOT is unset, the guard never fires.""" + env = _stripped_env(tmp_path) + proc = _spawn_cli(env, "--version") + assert proc.returncode == 0 + + +class TestErrorMessageShape: + def test_stderr_includes_actionable_remediation(self, tmp_path): + env = _stripped_env(tmp_path) + env["AGENTTOWER_TEST_PROC_ROOT"] = "/nonexistent" + proc = _spawn_cli(env, "--version") + assert "unset it" in proc.stderr diff --git a/tests/unit/test_container_identity.py b/tests/unit/test_container_identity.py new file mode 100644 index 0000000..806a72e --- /dev/null +++ b/tests/unit/test_container_identity.py @@ -0,0 +1,476 @@ +"""Unit tests for identity.py — FR-006, FR-007, R-004 (CHK024–CHK032). + +The lower half of this file (TestSC008Matrix) closes T063 by exercising +the full SC-008 (signal-shape × outcome) matrix at the classifier level. +The classifier itself lives in ``checks.py`` as +``classify_identity_to_check_result`` (the standalone +``identity.classify_identity`` shipped as a stub; the operational +classifier was inlined in checks.py). +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from agenttower.config_doctor.checks import classify_identity_to_check_result +from agenttower.config_doctor.identity import ( + CgroupMultiCandidate, + IdentityCandidate, + detect_candidate, +) +from agenttower.config_doctor.runtime_detect import ( + ContainerContext, + HostContext, +) + + +@pytest.fixture +def fake_root(tmp_path: Path): + def _build(*, cgroup_lines=None, hostname=None) -> Path: + proc_self = tmp_path / "proc" / "self" + proc_self.mkdir(parents=True, exist_ok=True) + etc = tmp_path / "etc" + etc.mkdir(parents=True, exist_ok=True) + if cgroup_lines is not None: + (proc_self / "cgroup").write_text("\n".join(cgroup_lines) + "\n") + else: + (proc_self / "cgroup").write_text("") + if hostname is not None: + (etc / "hostname").write_text(hostname + "\n") + return tmp_path + + return _build + + +# --------------------------------------------------------------------------- +# FR-006 four-step precedence (CHK024) +# --------------------------------------------------------------------------- + + +class TestPrecedence: + def test_env_override_wins_over_cgroup(self, fake_root): + root = fake_root(cgroup_lines=["0::/docker/abc123def4567890"]) + result = detect_candidate( + {"AGENTTOWER_CONTAINER_ID": "manually-pinned-id"}, + proc_root=str(root), + ) + assert isinstance(result, IdentityCandidate) + assert result.candidate == "manually-pinned-id" + assert result.signal == "env" + + def test_cgroup_wins_over_hostname(self, fake_root): + root = fake_root( + cgroup_lines=["0::/docker/abc123def4567890"], + hostname="ignoredhost", + ) + result = detect_candidate({}, proc_root=str(root)) + assert isinstance(result, IdentityCandidate) + assert result.candidate == "abc123def4567890" + assert result.signal == "cgroup" + + def test_hostname_when_cgroup_empty(self, fake_root): + root = fake_root(cgroup_lines=[], hostname="abc123def456") + result = detect_candidate({}, proc_root=str(root)) + assert isinstance(result, IdentityCandidate) + assert result.candidate == "abc123def456" + assert result.signal == "hostname" + + def test_hostname_env_when_etc_hostname_empty(self, fake_root): + root = fake_root(cgroup_lines=[]) + result = detect_candidate( + {"HOSTNAME": "fallback-host"}, proc_root=str(root) + ) + assert isinstance(result, IdentityCandidate) + assert result.candidate == "fallback-host" + assert result.signal == "hostname_env" + + def test_no_signal_returns_none(self, fake_root): + root = fake_root(cgroup_lines=[]) + result = detect_candidate({}, proc_root=str(root)) + assert result is None + + +# --------------------------------------------------------------------------- +# FR-006 multi-line cgroup rule (Clarifications 2026-05-06; CHK032 / Q4) +# --------------------------------------------------------------------------- + + +class TestCgroupMultiLine: + def test_cgroup_v2_unified_only(self, fake_root): + """Single ``0::/...`` line yields one identifier.""" + root = fake_root(cgroup_lines=["0::/docker/abc123def4567890"]) + result = detect_candidate({}, proc_root=str(root)) + assert isinstance(result, IdentityCandidate) + assert result.candidate == "abc123def4567890" + + def test_cgroup_v1_consistent(self, fake_root): + """Multiple per-subsystem matching lines, same trailing id → single candidate.""" + same_id = "abc123def4567890" + root = fake_root( + cgroup_lines=[ + f"12:cpu:/docker/{same_id}", + f"11:memory:/docker/{same_id}", + f"10:pids:/docker/{same_id}", + ], + ) + result = detect_candidate({}, proc_root=str(root)) + assert isinstance(result, IdentityCandidate) + assert result.candidate == same_id + + def test_cgroup_v1_inconsistent(self, fake_root): + """Multiple matching lines yield distinct ids → CgroupMultiCandidate.""" + root = fake_root( + cgroup_lines=[ + "12:cpu:/docker/aaaaaaaaaaaa1111", + "11:memory:/docker/bbbbbbbbbbbb2222", + ], + ) + result = detect_candidate({}, proc_root=str(root)) + assert isinstance(result, CgroupMultiCandidate) + assert set(result.candidates) == { + "aaaaaaaaaaaa1111", + "bbbbbbbbbbbb2222", + } + # Doctor MUST NOT pick one arbitrarily — both surface + assert len(result.candidates) == 2 + + +# --------------------------------------------------------------------------- +# FR-006 step 1 / FR-021 sanitization +# --------------------------------------------------------------------------- + + +class TestEnvSanitization: + def test_env_value_nul_stripped(self, fake_root): + root = fake_root() + result = detect_candidate( + {"AGENTTOWER_CONTAINER_ID": "id\x00with\x00nulls"}, + proc_root=str(root), + ) + assert isinstance(result, IdentityCandidate) + assert "\x00" not in result.candidate + assert result.candidate == "idwithnulls" + + def test_env_value_used_verbatim_otherwise(self, fake_root): + root = fake_root() + result = detect_candidate( + {"AGENTTOWER_CONTAINER_ID": "verbatim-id-with-dashes-and.dots"}, + proc_root=str(root), + ) + assert isinstance(result, IdentityCandidate) + assert result.candidate == "verbatim-id-with-dashes-and.dots" + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + + +class TestEdgeCases: + def test_network_host_hostname_collision_produces_candidate(self, fake_root): + """--network host: /etc/hostname is the host hostname; the candidate is + produced and the cross-check (Phase 5) will classify it as no_match.""" + root = fake_root(cgroup_lines=[], hostname="my-laptop") + result = detect_candidate({}, proc_root=str(root)) + assert isinstance(result, IdentityCandidate) + assert result.candidate == "my-laptop" + assert result.signal == "hostname" + + def test_empty_hostname_file_falls_through(self, fake_root): + root = fake_root(cgroup_lines=[], hostname="") + result = detect_candidate( + {"HOSTNAME": "from-env"}, proc_root=str(root) + ) + # Empty file content + sanitization → empty → fall through to $HOSTNAME + assert isinstance(result, IdentityCandidate) + assert result.signal == "hostname_env" + + def test_unsupported_cgroup_prefix_does_not_match(self, fake_root): + """Firejail/Bubblewrap shapes don't match FR-004 prefixes.""" + root = fake_root( + cgroup_lines=["0::/firejail.slice/firejail-1234.scope"], + ) + result = detect_candidate({}, proc_root=str(root)) + assert result is None + + +# --------------------------------------------------------------------------- +# T063 / SC-008 / CHK098: classifier-level matrix coverage +# +# SC-008 enumerates (signal-shape) × (outcome) cells. The signal shapes +# are: cgroup, hostname, env, env+hostname (env wins per FR-006), with +# a candidate that may be either full-id (64 hex) or short-id-prefix +# (12 hex). The outcomes are the FR-007 closed set: unique_match, +# multi_match, no_match, no_candidate, host_context. +# +# Many product cells are physically impossible: ``no_candidate`` and +# ``host_context`` require *no* signal to fire, so source-shape is moot; +# ``host_context`` additionally requires HostContext. The valid cells +# below are the operational SC-008 matrix. +# --------------------------------------------------------------------------- + + +_FULL_ID = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" +_SHORT_PREFIX = "abcdef012345" # first 12 chars of _FULL_ID +_OTHER_FULL_ID = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" +_OTHER_SHORT_PREFIX = "1234567890ab" + + +def _container_row(cid: str, name: str = "py-bench") -> dict: + return {"id": cid, "name": name} + + +def _classify(candidate, *, runtime_context, rows): + return classify_identity_to_check_result( + candidate=candidate, + runtime_context=runtime_context, + list_containers_rows=tuple(rows), + daemon_set_empty=(len(rows) == 0), + ) + + +_MATRIX_CELLS = [ + # ------------------------------------------------------------------ + # unique_match — every signal-shape × candidate-shape combination + # ------------------------------------------------------------------ + pytest.param( + IdentityCandidate(candidate=_FULL_ID, signal="cgroup"), + ContainerContext(detection_signals=("cgroup",)), + [_container_row(_FULL_ID)], + "pass", "unique_match", "cgroup", + id="cgroup_full_id_unique_match", + ), + pytest.param( + IdentityCandidate(candidate=_SHORT_PREFIX, signal="cgroup"), + ContainerContext(detection_signals=("cgroup",)), + [_container_row(_FULL_ID)], + "pass", "unique_match", "cgroup", + id="cgroup_short_prefix_unique_match", + ), + pytest.param( + IdentityCandidate(candidate=_FULL_ID, signal="hostname"), + ContainerContext(detection_signals=("dockerenv",)), + [_container_row(_FULL_ID)], + "pass", "unique_match", "hostname", + id="hostname_full_id_unique_match", + ), + pytest.param( + IdentityCandidate(candidate=_SHORT_PREFIX, signal="hostname"), + ContainerContext(detection_signals=("dockerenv",)), + [_container_row(_FULL_ID)], + "pass", "unique_match", "hostname", + id="hostname_short_prefix_unique_match", + ), + pytest.param( + IdentityCandidate(candidate=_FULL_ID, signal="env"), + ContainerContext(detection_signals=("dockerenv",)), + [_container_row(_FULL_ID)], + "pass", "unique_match", "env", + id="env_full_id_unique_match", + ), + pytest.param( + IdentityCandidate(candidate=_SHORT_PREFIX, signal="env"), + ContainerContext(detection_signals=("dockerenv",)), + [_container_row(_FULL_ID)], + "pass", "unique_match", "env", + id="env_short_prefix_unique_match", + ), + # env+hostname is exercised in TestPrecedence.test_env_override_wins_over_cgroup + # at the parser level (env wins). At the classifier level the candidate + # arrives with signal="env"; the classifier doesn't know hostname was + # also present. The cell is captured by env_full_id_unique_match above. + # ------------------------------------------------------------------ + # multi_match — short-prefix collision across two rows + # ------------------------------------------------------------------ + pytest.param( + IdentityCandidate(candidate=_SHORT_PREFIX, signal="cgroup"), + ContainerContext(detection_signals=("cgroup",)), + [ + _container_row(_FULL_ID), + _container_row(_SHORT_PREFIX + "ffffffffffffffffffffffffffffffffffffffffffffffffffff"), + ], + "fail", "multi_match", "cgroup", + id="cgroup_short_prefix_multi_match", + ), + pytest.param( + IdentityCandidate(candidate=_SHORT_PREFIX, signal="hostname"), + ContainerContext(detection_signals=("dockerenv",)), + [ + _container_row(_FULL_ID), + _container_row(_SHORT_PREFIX + "ffffffffffffffffffffffffffffffffffffffffffffffffffff"), + ], + "fail", "multi_match", "hostname", + id="hostname_short_prefix_multi_match", + ), + pytest.param( + IdentityCandidate(candidate=_SHORT_PREFIX, signal="env"), + ContainerContext(detection_signals=("dockerenv",)), + [ + _container_row(_FULL_ID), + _container_row(_SHORT_PREFIX + "ffffffffffffffffffffffffffffffffffffffffffffffffffff"), + ], + "fail", "multi_match", "env", + id="env_short_prefix_multi_match", + ), + # ------------------------------------------------------------------ + # no_match — candidate produced but no row matches + # ------------------------------------------------------------------ + pytest.param( + IdentityCandidate(candidate=_FULL_ID, signal="cgroup"), + ContainerContext(detection_signals=("cgroup",)), + [_container_row(_OTHER_FULL_ID)], + "fail", "no_match", "cgroup", + id="cgroup_full_id_no_match", + ), + pytest.param( + IdentityCandidate(candidate=_SHORT_PREFIX, signal="hostname"), + ContainerContext(detection_signals=("dockerenv",)), + [_container_row(_OTHER_FULL_ID)], + "fail", "no_match", "hostname", + id="hostname_short_prefix_no_match", + ), + pytest.param( + IdentityCandidate(candidate=_FULL_ID, signal="env"), + ContainerContext(detection_signals=("dockerenv",)), + [_container_row(_OTHER_FULL_ID)], + "fail", "no_match", "env", + id="env_full_id_no_match", + ), + pytest.param( + IdentityCandidate(candidate=_FULL_ID, signal="cgroup"), + ContainerContext(detection_signals=("cgroup",)), + [], + "fail", "no_match", "cgroup", + id="cgroup_no_match_daemon_set_empty", + ), + # ------------------------------------------------------------------ + # no_candidate — every signal returned empty AND ContainerContext + # ------------------------------------------------------------------ + pytest.param( + None, + ContainerContext(detection_signals=("dockerenv",)), + [], + "fail", "no_candidate", None, + id="no_signal_container_context_no_candidate", + ), + pytest.param( + None, + ContainerContext(detection_signals=("dockerenv",)), + [_container_row(_FULL_ID)], + "fail", "no_candidate", None, + id="no_signal_container_context_with_rows_no_candidate", + ), + # ------------------------------------------------------------------ + # host_context — every signal empty AND HostContext + # ------------------------------------------------------------------ + pytest.param( + None, + HostContext(), + [], + "info", "host_context", None, + id="no_signal_host_context", + ), + pytest.param( + None, + HostContext(), + [_container_row(_FULL_ID)], + "info", "host_context", None, + id="no_signal_host_context_even_with_rows", + ), + # ------------------------------------------------------------------ + # Cgroup multi-line distinct identifiers — FR-006 multi-line rule + # ------------------------------------------------------------------ + pytest.param( + CgroupMultiCandidate( + candidates=("aaaaaaaaaaaa1111", "bbbbbbbbbbbb2222") + ), + ContainerContext(detection_signals=("cgroup",)), + [], + "fail", "multi_match", "cgroup", + id="cgroup_multi_line_distinct_ids_multi_match", + ), +] + + +class TestSC008Matrix: + """T063 / SC-008 / CHK098: every cell in the operational + (signal-shape × outcome) matrix is exercised at the classifier + level.""" + + @pytest.mark.parametrize( + "candidate,runtime_context,rows,expected_status,expected_sub_code,expected_source", + _MATRIX_CELLS, + ) + def test_cell( + self, + candidate, + runtime_context, + rows, + expected_status, + expected_sub_code, + expected_source, + ): + result = _classify(candidate, runtime_context=runtime_context, rows=rows) + assert result.code == "container_identity" + assert result.status == expected_status + assert result.sub_code == expected_sub_code + if expected_source is not None: + assert result.source == expected_source + + def test_no_containers_known_synonym_never_emitted(self): + """CHK033: ``no_containers_known`` is NOT a sub-code; the + empty-``list_containers`` case surfaces as + ``daemon_container_set_empty=True`` on the existing + ``no_candidate`` / ``no_match`` outcome (Clarifications + 2026-05-06).""" + # Empty-set + candidate → no_match, with daemon_container_set_empty + result = _classify( + IdentityCandidate(candidate=_FULL_ID, signal="cgroup"), + runtime_context=ContainerContext(detection_signals=("cgroup",)), + rows=[], + ) + assert result.sub_code == "no_match" + assert result.sub_code != "no_containers_known" + # The daemon_container_set_empty flag carries the sub-code-less qualifier + assert getattr(result, "daemon_container_set_empty", None) is True + + # Empty-set + no candidate + ContainerContext → no_candidate, with flag + result_no_cand = _classify( + None, + runtime_context=ContainerContext(detection_signals=("dockerenv",)), + rows=[], + ) + assert result_no_cand.sub_code == "no_candidate" + assert result_no_cand.sub_code != "no_containers_known" + + def test_not_in_container_synonym_never_emitted(self): + """CHK034: ``not_in_container`` synonym is dead per + Clarifications 2026-05-06; only ``host_context`` is emitted.""" + result = _classify( + None, runtime_context=HostContext(), rows=[] + ) + assert result.sub_code == "host_context" + assert result.sub_code != "not_in_container" + + def test_full_id_match_takes_precedence_over_short_prefix_match(self): + """FR-006 / R-004: full-id equality is checked before 12-char + short-id-prefix match. A candidate that matches one row by full id + AND another row by short prefix must classify as unique_match + against the full-id row.""" + # Use a different short prefix so the short-prefix branch only + # matches when full-id equality misses; then construct rows where + # exactly one row has full-id equality. + rows = [ + _container_row(_FULL_ID), # full-id eq + _container_row(_SHORT_PREFIX + "f" * (64 - 12)), # short-prefix collision + ] + result = _classify( + IdentityCandidate(candidate=_FULL_ID, signal="cgroup"), + runtime_context=ContainerContext(detection_signals=("cgroup",)), + rows=rows, + ) + # Full-id equality wins → unique_match, NOT multi_match + assert result.status == "pass" + assert result.sub_code == "unique_match" diff --git a/tests/unit/test_daemon_unavailable_kind.py b/tests/unit/test_daemon_unavailable_kind.py new file mode 100644 index 0000000..6bb26e2 --- /dev/null +++ b/tests/unit/test_daemon_unavailable_kind.py @@ -0,0 +1,75 @@ +"""Unit tests for the additive ``DaemonUnavailable.kind`` attribute (T008, R-009, FR-026).""" + +from __future__ import annotations + +import errno + +import pytest + +from agenttower.socket_api.client import DaemonUnavailable + + +# --------------------------------------------------------------------------- +# Closed-set kind values (FR-016) +# --------------------------------------------------------------------------- + + +class TestKindClosedSet: + @pytest.mark.parametrize( + "kind", + [ + "socket_missing", + "socket_not_unix", + "connection_refused", + "permission_denied", + "connect_timeout", + "protocol_error", + ], + ) + def test_each_closed_kind_accepted(self, kind): + exc = DaemonUnavailable("hello", kind=kind) + assert exc.kind == kind + + +class TestKindDefault: + def test_kind_defaults_to_connect_timeout(self): + exc = DaemonUnavailable("hello") + assert exc.kind == "connect_timeout" + + +# --------------------------------------------------------------------------- +# FR-026 byte-parity: str(exc) and repr(exc) unchanged from FEAT-002 +# --------------------------------------------------------------------------- + + +class TestByteParity: + def test_str_unchanged_with_message_only(self): + exc = DaemonUnavailable("socket missing: /tmp/x") + assert str(exc) == "socket missing: /tmp/x" + + def test_str_unchanged_with_kind_kwarg(self): + exc = DaemonUnavailable("socket missing: /tmp/x", kind="socket_missing") + assert str(exc) == "socket missing: /tmp/x" + + def test_repr_unchanged_with_kind_kwarg(self): + # repr of RuntimeError-derived: ClassName('msg') + exc = DaemonUnavailable("socket missing: /tmp/x", kind="socket_missing") + assert repr(exc) == "DaemonUnavailable('socket missing: /tmp/x')" + + def test_args_tuple_unchanged(self): + exc = DaemonUnavailable("hello", kind="connection_refused") + assert exc.args == ("hello",) + + +# --------------------------------------------------------------------------- +# FR-026 backward compat: existing callers passing single positional arg work +# --------------------------------------------------------------------------- + + +class TestBackwardCompatCallers: + def test_single_positional_arg_works(self): + # The FEAT-002 / FEAT-003 / FEAT-004 callers do this: + exc = DaemonUnavailable("legacy message") + assert isinstance(exc, RuntimeError) + assert str(exc) == "legacy message" + assert exc.kind == "connect_timeout" # default diff --git a/tests/unit/test_doctor_exit_codes.py b/tests/unit/test_doctor_exit_codes.py new file mode 100644 index 0000000..9749104 --- /dev/null +++ b/tests/unit/test_doctor_exit_codes.py @@ -0,0 +1,175 @@ +"""Unit tests for doctor exit-code mapping — FR-018 (incl. Q5 layering).""" + +from __future__ import annotations + +import pytest + +from agenttower.config_doctor.checks import CheckResult +from agenttower.config_doctor.runner import DoctorReport, _compute_exit_code + + +def _row(code, status, *, sub=None): + return CheckResult( + code=code, + status=status, + source=None, + details="", + actionable_message=None, + sub_code=sub, + ) + + +def _build(rows): + return rows + + +# --------------------------------------------------------------------------- +# Exit 0 — every required check pass/info +# --------------------------------------------------------------------------- + + +class TestExitZero: + def test_all_pass(self): + rows = ( + _row("socket_resolved", "pass"), + _row("socket_reachable", "pass"), + _row("daemon_status", "pass"), + _row("container_identity", "pass", sub="unique_match"), + _row("tmux_present", "pass"), + _row("tmux_pane_match", "pass", sub="pane_match"), + ) + assert _compute_exit_code(rows) == 0 + + def test_required_pass_with_info_non_required(self): + rows = ( + _row("socket_resolved", "pass"), + _row("socket_reachable", "pass"), + _row("daemon_status", "pass"), + _row("container_identity", "info", sub="host_context"), + _row("tmux_present", "info", sub="not_in_tmux"), + _row("tmux_pane_match", "info", sub="not_in_tmux"), + ) + assert _compute_exit_code(rows) == 0 + + +# --------------------------------------------------------------------------- +# Exit 2 — socket_reachable fail +# --------------------------------------------------------------------------- + + +class TestExitTwo: + @pytest.mark.parametrize("kind", ["socket_missing", "connection_refused", "connect_timeout"]) + def test_socket_reachable_failure_kinds(self, kind): + rows = ( + _row("socket_resolved", "pass"), + _row("socket_reachable", "fail", sub=kind), + _row("daemon_status", "info", sub="daemon_unavailable"), + _row("container_identity", "info", sub="daemon_unavailable"), + _row("tmux_present", "info", sub="not_in_tmux"), + _row("tmux_pane_match", "info", sub="daemon_unavailable"), + ) + assert _compute_exit_code(rows) == 2 + + def test_permission_denied_also_exit_2(self): + rows = ( + _row("socket_resolved", "pass"), + _row("socket_reachable", "fail", sub="permission_denied"), + _row("daemon_status", "info", sub="daemon_unavailable"), + _row("container_identity", "info", sub="daemon_unavailable"), + _row("tmux_present", "info", sub="not_in_tmux"), + _row("tmux_pane_match", "info", sub="daemon_unavailable"), + ) + assert _compute_exit_code(rows) == 2 + + +# --------------------------------------------------------------------------- +# Exit 3 — Q5 layering: daemon_status fail with daemon_error or schema_version_newer +# --------------------------------------------------------------------------- + + +class TestExitThreeLayering: + def test_daemon_error_yields_exit_3(self): + rows = ( + _row("socket_resolved", "pass"), + _row("socket_reachable", "pass"), + _row("daemon_status", "fail", sub="daemon_error"), + _row("container_identity", "info", sub="daemon_unavailable"), + _row("tmux_present", "info", sub="not_in_tmux"), + _row("tmux_pane_match", "info", sub="daemon_unavailable"), + ) + assert _compute_exit_code(rows) == 3 + + def test_schema_version_newer_yields_exit_3(self): + rows = ( + _row("socket_resolved", "pass"), + _row("socket_reachable", "pass"), + _row("daemon_status", "fail", sub="schema_version_newer"), + _row("container_identity", "info", sub="daemon_unavailable"), + _row("tmux_present", "info", sub="not_in_tmux"), + _row("tmux_pane_match", "info", sub="daemon_unavailable"), + ) + assert _compute_exit_code(rows) == 3 + + def test_schema_version_older_warn_does_not_yield_exit_3(self): + rows = ( + _row("socket_resolved", "pass"), + _row("socket_reachable", "pass"), + _row("daemon_status", "warn", sub="schema_version_older"), + _row("container_identity", "pass", sub="unique_match"), + _row("tmux_present", "pass"), + _row("tmux_pane_match", "pass", sub="pane_match"), + ) + assert _compute_exit_code(rows) == 0 + + +# --------------------------------------------------------------------------- +# Exit 5 — degraded +# --------------------------------------------------------------------------- + + +class TestExitFive: + def test_pane_unknown_yields_exit_5(self): + rows = ( + _row("socket_resolved", "pass"), + _row("socket_reachable", "pass"), + _row("daemon_status", "pass"), + _row("container_identity", "pass", sub="unique_match"), + _row("tmux_present", "pass"), + _row("tmux_pane_match", "fail", sub="pane_unknown_to_daemon"), + ) + assert _compute_exit_code(rows) == 5 + + def test_container_no_match_yields_exit_5(self): + rows = ( + _row("socket_resolved", "pass"), + _row("socket_reachable", "pass"), + _row("daemon_status", "pass"), + _row("container_identity", "fail", sub="no_match"), + _row("tmux_present", "pass"), + _row("tmux_pane_match", "pass", sub="pane_match"), + ) + assert _compute_exit_code(rows) == 5 + + +# --------------------------------------------------------------------------- +# Exit 4 — reserved, never produced +# --------------------------------------------------------------------------- + + +class TestExitFourReserved: + def test_no_combination_yields_exit_4(self): + # We try all combinations of statuses and assert exit 4 is never returned. + for sr in ("pass", "fail"): + for ds in ("pass", "warn", "fail", "info"): + for ci in ("pass", "warn", "fail", "info"): + for tp in ("pass", "warn", "fail", "info"): + for tpm in ("pass", "warn", "fail", "info"): + rows = ( + _row("socket_resolved", "pass"), + _row("socket_reachable", sr, sub="socket_missing" if sr == "fail" else None), + _row("daemon_status", ds, sub="daemon_error" if ds == "fail" else None), + _row("container_identity", ci, sub="no_match" if ci == "fail" else None), + _row("tmux_present", tp), + _row("tmux_pane_match", tpm), + ) + assert _compute_exit_code(rows) != 4 diff --git a/tests/unit/test_doctor_json_contract.py b/tests/unit/test_doctor_json_contract.py new file mode 100644 index 0000000..cb183c9 --- /dev/null +++ b/tests/unit/test_doctor_json_contract.py @@ -0,0 +1,375 @@ +"""T030 / FR-014: canonical JSON envelope contract for ``config doctor --json``. + +This complements ``test_doctor_render.py`` (which exercises the rendering of +specific ``DoctorReport``s) by locking the closed-set token enumerations and +the field-presence rules across every documented sub_code spelling. + +In particular this file negative-locks ``not_in_container`` and +``no_containers_known`` (Clarifications 2026-05-06) — both synonyms must +NEVER appear in ``--json`` output regardless of which code path produced the +report. +""" + +from __future__ import annotations + +import json + +import pytest + +from agenttower.config_doctor.checks import CheckResult +from agenttower.config_doctor.render import render_json +from agenttower.config_doctor.runner import DoctorReport + + +def _row( + code, + status, + *, + source=None, + details="", + actionable=None, + sub=None, + cgroup_candidates=None, + daemon_container_set_empty=None, +): + return CheckResult( + code=code, + status=status, + source=source, + details=details, + actionable_message=actionable, + sub_code=sub, + cgroup_candidates=cgroup_candidates, + daemon_container_set_empty=daemon_container_set_empty, + ) + + +# --------------------------------------------------------------------------- +# Envelope shape +# --------------------------------------------------------------------------- + + +class TestEnvelopeTopLevel: + def test_top_level_keys(self): + report = DoctorReport( + checks=( + _row("socket_resolved", "pass", source="host_default", details="x"), + _row("socket_reachable", "pass", source="host_default", details="x"), + _row("daemon_status", "pass", source="schema_check", details="x"), + _row("container_identity", "info", details="host_context", sub="host_context"), + _row("tmux_present", "info", details="not_in_tmux", sub="not_in_tmux"), + _row("tmux_pane_match", "info", details="not_in_tmux", sub="not_in_tmux"), + ), + exit_code=0, + ) + envelope = json.loads(render_json(report)) + assert set(envelope.keys()) == {"summary", "checks"} + + def test_summary_field_keys_and_order(self): + """FR-014: ``summary`` field order is fixed.""" + report = DoctorReport( + checks=( + _row("socket_resolved", "pass"), + _row("socket_reachable", "pass"), + _row("daemon_status", "pass"), + _row("container_identity", "info"), + _row("tmux_present", "info"), + _row("tmux_pane_match", "info"), + ), + exit_code=0, + ) + envelope = json.loads(render_json(report)) + keys = list(envelope["summary"].keys()) + assert keys == ["exit_code", "total", "passed", "warned", "failed", "info"] + + +# --------------------------------------------------------------------------- +# Closed-set check codes +# --------------------------------------------------------------------------- + + +class TestCheckCodesClosedSet: + def test_check_codes_are_the_closed_six(self): + report = DoctorReport( + checks=( + _row("socket_resolved", "pass"), + _row("socket_reachable", "pass"), + _row("daemon_status", "pass"), + _row("container_identity", "pass"), + _row("tmux_present", "pass"), + _row("tmux_pane_match", "pass"), + ), + exit_code=0, + ) + envelope = json.loads(render_json(report)) + assert set(envelope["checks"].keys()) == { + "socket_resolved", + "socket_reachable", + "daemon_status", + "container_identity", + "tmux_present", + "tmux_pane_match", + } + + +# --------------------------------------------------------------------------- +# Status tokens are stable +# --------------------------------------------------------------------------- + + +class TestStatusTokensStable: + @pytest.mark.parametrize("status", ["pass", "warn", "fail", "info"]) + def test_status_token_round_trips_verbatim(self, status): + report = DoctorReport( + checks=( + _row("socket_resolved", status), + _row("socket_reachable", "pass"), + _row("daemon_status", "pass"), + _row("container_identity", "info"), + _row("tmux_present", "info"), + _row("tmux_pane_match", "info"), + ), + exit_code=0, + ) + envelope = json.loads(render_json(report)) + assert envelope["checks"]["socket_resolved"]["status"] == status + + def test_no_other_status_tokens_emitted(self): + """The four-token closed set is exhaustive — no synonyms allowed.""" + report = DoctorReport( + checks=( + _row("socket_resolved", "pass"), + _row("socket_reachable", "pass"), + _row("daemon_status", "pass"), + _row("container_identity", "info"), + _row("tmux_present", "info"), + _row("tmux_pane_match", "info"), + ), + exit_code=0, + ) + rendered = render_json(report) + for synonym in ("ok", "skipped", "error", "warning", "success", "failure"): + assert f'"status": "{synonym}"' not in rendered + assert f'"status":"{synonym}"' not in rendered + + +# --------------------------------------------------------------------------- +# Optional keys omitted when value is None +# --------------------------------------------------------------------------- + + +class TestOptionalKeysOmitted: + def test_actionable_message_omitted_when_none(self): + report = DoctorReport( + checks=( + _row("socket_resolved", "pass"), + _row("socket_reachable", "pass"), + _row("daemon_status", "pass"), + _row("container_identity", "pass"), + _row("tmux_present", "pass"), + _row("tmux_pane_match", "pass"), + ), + exit_code=0, + ) + envelope = json.loads(render_json(report)) + for code in envelope["checks"]: + check = envelope["checks"][code] + assert "actionable_message" not in check + assert "sub_code" not in check + + def test_source_present_on_pass_rows(self): + report = DoctorReport( + checks=( + _row("socket_resolved", "pass", source="env_override"), + _row("socket_reachable", "pass", source="env_override"), + _row("daemon_status", "pass", source="schema_check"), + _row("container_identity", "info"), + _row("tmux_present", "info"), + _row("tmux_pane_match", "info"), + ), + exit_code=0, + ) + envelope = json.loads(render_json(report)) + assert envelope["checks"]["socket_resolved"]["source"] == "env_override" + assert envelope["checks"]["socket_reachable"]["source"] == "env_override" + assert envelope["checks"]["daemon_status"]["source"] == "schema_check" + + def test_actionable_and_sub_code_present_on_fail_rows(self): + report = DoctorReport( + checks=( + _row("socket_resolved", "pass"), + _row( + "socket_reachable", + "fail", + sub="socket_missing", + actionable="run ensure-daemon", + ), + _row("daemon_status", "info", sub="daemon_unavailable"), + _row("container_identity", "info", sub="daemon_unavailable"), + _row("tmux_present", "info", sub="not_in_tmux"), + _row("tmux_pane_match", "info", sub="not_in_tmux"), + ), + exit_code=2, + ) + envelope = json.loads(render_json(report)) + sr = envelope["checks"]["socket_reachable"] + assert sr["sub_code"] == "socket_missing" + assert sr["actionable_message"] == "run ensure-daemon" + + +# --------------------------------------------------------------------------- +# Negative locks for dead synonyms (Clarifications 2026-05-06) +# --------------------------------------------------------------------------- + + +class TestDeadSynonymsNeverEmitted: + def test_not_in_container_is_dead(self): + """``not_in_container`` is a dead synonym; only ``host_context`` + is emitted by ``container_identity``. CHK034 negative lock.""" + report = DoctorReport( + checks=( + _row("socket_resolved", "pass"), + _row("socket_reachable", "pass"), + _row("daemon_status", "pass"), + _row("container_identity", "info", sub="host_context"), + _row("tmux_present", "info", sub="not_in_tmux"), + _row("tmux_pane_match", "info", sub="not_in_tmux"), + ), + exit_code=0, + ) + rendered = render_json(report) + assert "not_in_container" not in rendered + assert "host_context" in rendered # positive control + + def test_no_containers_known_is_dead(self): + """``no_containers_known`` is a dead synonym (Clarifications 2026-05-06). + The empty-``list_containers`` case is signalled via + ``daemon_container_set_empty=true`` on a ``no_match`` / ``no_candidate`` + row, not via a new sub_code.""" + report = DoctorReport( + checks=( + _row("socket_resolved", "pass"), + _row("socket_reachable", "pass"), + _row("daemon_status", "pass"), + _row( + "container_identity", + "fail", + sub="no_match", + daemon_container_set_empty=True, + ), + _row("tmux_present", "info", sub="not_in_tmux"), + _row("tmux_pane_match", "info", sub="not_in_tmux"), + ), + exit_code=5, + ) + rendered = render_json(report) + assert "no_containers_known" not in rendered + envelope = json.loads(rendered) + ci = envelope["checks"]["container_identity"] + assert ci["sub_code"] == "no_match" + assert ci["daemon_container_set_empty"] is True + + +# --------------------------------------------------------------------------- +# Closed-set sub_code enumerations +# --------------------------------------------------------------------------- + +CONTAINER_IDENTITY_SUBCODES = ( + "unique_match", + "host_context", + "multi_match", + "no_match", + "no_candidate", + "output_malformed", + "daemon_unavailable", +) + +TMUX_PANE_MATCH_SUBCODES = ( + "pane_match", + "pane_unknown_to_daemon", + "pane_ambiguous", + "not_in_tmux", + "daemon_unavailable", +) + +SOCKET_REACHABLE_SUBCODES = ( + "socket_missing", + "socket_not_unix", + "connection_refused", + "permission_denied", + "connect_timeout", + "protocol_error", +) + + +class TestSubCodeEnumerations: + @pytest.mark.parametrize("sub", CONTAINER_IDENTITY_SUBCODES) + def test_container_identity_sub_codes_round_trip(self, sub): + report = DoctorReport( + checks=( + _row("socket_resolved", "pass"), + _row("socket_reachable", "pass"), + _row("daemon_status", "pass"), + _row("container_identity", "info", sub=sub), + _row("tmux_present", "info"), + _row("tmux_pane_match", "info"), + ), + exit_code=0, + ) + envelope = json.loads(render_json(report)) + assert envelope["checks"]["container_identity"]["sub_code"] == sub + + @pytest.mark.parametrize("sub", TMUX_PANE_MATCH_SUBCODES) + def test_tmux_pane_match_sub_codes_round_trip(self, sub): + report = DoctorReport( + checks=( + _row("socket_resolved", "pass"), + _row("socket_reachable", "pass"), + _row("daemon_status", "pass"), + _row("container_identity", "info"), + _row("tmux_present", "info"), + _row("tmux_pane_match", "info", sub=sub), + ), + exit_code=0, + ) + envelope = json.loads(render_json(report)) + assert envelope["checks"]["tmux_pane_match"]["sub_code"] == sub + + @pytest.mark.parametrize("sub", SOCKET_REACHABLE_SUBCODES) + def test_socket_reachable_sub_codes_round_trip(self, sub): + report = DoctorReport( + checks=( + _row("socket_resolved", "pass"), + _row("socket_reachable", "fail", sub=sub), + _row("daemon_status", "info", sub="daemon_unavailable"), + _row("container_identity", "info", sub="daemon_unavailable"), + _row("tmux_present", "info", sub="not_in_tmux"), + _row("tmux_pane_match", "info", sub="not_in_tmux"), + ), + exit_code=2, + ) + envelope = json.loads(render_json(report)) + assert envelope["checks"]["socket_reachable"]["sub_code"] == sub + + +# --------------------------------------------------------------------------- +# summary.exit_code matches the DoctorReport.exit_code +# --------------------------------------------------------------------------- + + +class TestSummaryExitCode: + @pytest.mark.parametrize("exit_code", [0, 1, 2, 3, 5]) + def test_summary_exit_code_round_trips(self, exit_code): + report = DoctorReport( + checks=( + _row("socket_resolved", "pass"), + _row("socket_reachable", "pass"), + _row("daemon_status", "pass"), + _row("container_identity", "info"), + _row("tmux_present", "info"), + _row("tmux_pane_match", "info"), + ), + exit_code=exit_code, # type: ignore[arg-type] + ) + envelope = json.loads(render_json(report)) + assert envelope["summary"]["exit_code"] == exit_code diff --git a/tests/unit/test_doctor_render.py b/tests/unit/test_doctor_render.py new file mode 100644 index 0000000..9c0ee7b --- /dev/null +++ b/tests/unit/test_doctor_render.py @@ -0,0 +1,200 @@ +"""Unit tests for render.py — FR-013 / FR-014 (TSV + canonical JSON).""" + +from __future__ import annotations + +import json + +import pytest + +from agenttower.config_doctor.checks import CheckResult +from agenttower.config_doctor.render import render_json, render_tsv +from agenttower.config_doctor.runner import DoctorReport + + +def _row( + code, + status, + *, + source=None, + details="", + actionable=None, + sub=None, + cgroup_candidates=None, + daemon_container_set_empty=None, +): + return CheckResult( + code=code, + status=status, + source=source, + details=details, + actionable_message=actionable, + sub_code=sub, + cgroup_candidates=cgroup_candidates, + daemon_container_set_empty=daemon_container_set_empty, + ) + + +def _full_pass_report() -> DoctorReport: + return DoctorReport( + checks=( + _row("socket_resolved", "pass", source="env_override", details="/tmp/sock (env_override)"), + _row("socket_reachable", "pass", source="env_override", details="daemon_version=0.5.0 schema_version=3"), + _row("daemon_status", "pass", source="schema_check", details="schema_version=3"), + _row("container_identity", "pass", source="cgroup", details="unique_match: abc123 (py-bench)", sub="unique_match"), + _row("tmux_present", "pass", source="env", details="socket=/tmp/tmux session=$0 pane=%0"), + _row("tmux_pane_match", "pass", source="list_panes", details="pane_match: %0", sub="pane_match"), + ), + exit_code=0, + ) + + +# --------------------------------------------------------------------------- +# TSV (FR-013) +# --------------------------------------------------------------------------- + + +class TestRenderTsv: + def test_six_rows_plus_summary(self): + out = render_tsv(_full_pass_report()) + lines = out.rstrip("\n").split("\n") + assert len(lines) == 7 # 6 checks + 1 summary + assert lines[-1].startswith("summary\t0\t6/6 ") + + def test_tab_separated_three_columns(self): + out = render_tsv(_full_pass_report()) + for line in out.rstrip("\n").split("\n")[:6]: + cols = line.split("\t") + assert len(cols) == 3 + + def test_actionable_message_indented(self): + report = DoctorReport( + checks=( + _row("socket_resolved", "pass", source="host_default", details="/x"), + _row("socket_reachable", "fail", details="socket_missing", actionable="try ensure-daemon", sub="socket_missing"), + _row("daemon_status", "info", details="daemon_unavailable", actionable="skipped", sub="daemon_unavailable"), + _row("container_identity", "info", details="daemon_unavailable", actionable="skipped", sub="daemon_unavailable"), + _row("tmux_present", "info", details="not_in_tmux", sub="not_in_tmux"), + _row("tmux_pane_match", "info", details="not_in_tmux", sub="not_in_tmux"), + ), + exit_code=2, + ) + out = render_tsv(report) + # Actionable line is indented with 4 spaces + assert " try ensure-daemon" in out + assert " skipped" in out + + def test_summary_line_format(self): + report = _full_pass_report() + out = render_tsv(report) + last = out.rstrip("\n").split("\n")[-1] + assert last == "summary\t0\t6/6 checks passed" + + +# --------------------------------------------------------------------------- +# JSON (FR-014, R-007) +# --------------------------------------------------------------------------- + + +class TestRenderJsonShape: + def test_top_level_keys(self): + out = json.loads(render_json(_full_pass_report())) + assert set(out.keys()) == {"summary", "checks"} + + def test_summary_field_set_and_values(self): + out = json.loads(render_json(_full_pass_report())) + s = out["summary"] + assert set(s.keys()) == {"exit_code", "total", "passed", "warned", "failed", "info"} + assert s["exit_code"] == 0 + assert s["total"] == 6 + assert s["passed"] == 6 + assert s["warned"] == 0 + assert s["failed"] == 0 + assert s["info"] == 0 + + def test_checks_keyed_by_closed_set_codes(self): + out = json.loads(render_json(_full_pass_report())) + assert set(out["checks"].keys()) == { + "socket_resolved", + "socket_reachable", + "daemon_status", + "container_identity", + "tmux_present", + "tmux_pane_match", + } + + def test_status_tokens_closed(self): + out = json.loads(render_json(_full_pass_report())) + for v in out["checks"].values(): + assert v["status"] in {"pass", "warn", "fail", "info"} + + def test_optional_keys_omitted_when_none(self): + out = json.loads(render_json(_full_pass_report())) + # On pass rows, sub_code and actionable_message are not emitted + for code in ("socket_resolved", "socket_reachable", "daemon_status", "tmux_present"): + v = out["checks"][code] + assert "actionable_message" not in v + + +# --------------------------------------------------------------------------- +# Q2/Q3/Q4 token-locking — `not_in_container` and `no_containers_known` MUST NOT appear +# --------------------------------------------------------------------------- + + +class TestDeadTokens: + def test_not_in_container_never_emitted(self): + out = render_json(_full_pass_report()) + assert "not_in_container" not in out + + def test_no_containers_known_never_emitted(self): + out = render_json(_full_pass_report()) + assert "no_containers_known" not in out + + +# --------------------------------------------------------------------------- +# Q3 / Q4 structured qualifiers +# --------------------------------------------------------------------------- + + +class TestQualifiers: + def test_cgroup_candidates_serialized_when_set(self): + report = DoctorReport( + checks=( + _row("socket_resolved", "pass", source="host_default", details="/x"), + _row("socket_reachable", "pass", source="host_default", details="ok"), + _row("daemon_status", "pass", source="schema_check", details="ok"), + _row( + "container_identity", + "fail", + details="multi_match", + sub="multi_match", + cgroup_candidates=("aaaa1111", "bbbb2222"), + ), + _row("tmux_present", "info", details="not_in_tmux", sub="not_in_tmux"), + _row("tmux_pane_match", "info", details="not_in_tmux", sub="not_in_tmux"), + ), + exit_code=5, + ) + out = json.loads(render_json(report)) + assert out["checks"]["container_identity"]["cgroup_candidates"] == ["aaaa1111", "bbbb2222"] + + def test_daemon_container_set_empty_serialized_when_set(self): + report = DoctorReport( + checks=( + _row("socket_resolved", "pass", source="host_default", details="/x"), + _row("socket_reachable", "pass", source="host_default", details="ok"), + _row("daemon_status", "pass", source="schema_check", details="ok"), + _row( + "container_identity", + "fail", + details="no_match", + sub="no_match", + actionable="run scan", + daemon_container_set_empty=True, + ), + _row("tmux_present", "info", details="not_in_tmux", sub="not_in_tmux"), + _row("tmux_pane_match", "info", details="not_in_tmux", sub="not_in_tmux"), + ), + exit_code=5, + ) + out = json.loads(render_json(report)) + assert out["checks"]["container_identity"]["daemon_container_set_empty"] is True diff --git a/tests/unit/test_path_sanitize.py b/tests/unit/test_path_sanitize.py new file mode 100644 index 0000000..78c795e --- /dev/null +++ b/tests/unit/test_path_sanitize.py @@ -0,0 +1,168 @@ +"""Unit tests for sanitize.py — FR-021 / FR-028 / R-008 (CHK043–CHK048).""" + +from __future__ import annotations + +from agenttower.config_doctor.sanitize import ( + ACTIONABLE_CAP, + DETAILS_CAP, + ENV_VALUE_CAP, + FILE_CONTENT_CAP, + sanitize_text, +) + + +class TestCapConstants: + def test_caps_have_exact_spelling_and_values(self): + assert ENV_VALUE_CAP == 4096 + assert FILE_CONTENT_CAP == 4096 + assert DETAILS_CAP == 2048 + assert ACTIONABLE_CAP == 2048 + + +class TestNULStripping: + def test_nul_byte_stripped(self): + out, truncated = sanitize_text("hello\x00world", DETAILS_CAP) + assert out == "helloworld" + assert truncated is False + + def test_only_nul_yields_empty_string(self): + out, truncated = sanitize_text("\x00\x00\x00", DETAILS_CAP) + assert out == "" + assert truncated is False + + +class TestC0Stripping: + def test_c0_range_dropped(self): + raw = "a" + "".join(chr(c) for c in range(0x01, 0x09)) + "b" + out, truncated = sanitize_text(raw, DETAILS_CAP) + assert out == "ab" + assert truncated is False + + def test_high_c0_range_dropped(self): + raw = "x" + "".join(chr(c) for c in range(0x0B, 0x20)) + "y" + out, truncated = sanitize_text(raw, DETAILS_CAP) + assert out == "xy" + assert truncated is False + + def test_del_byte_dropped(self): + out, _ = sanitize_text("a\x7fb", DETAILS_CAP) + assert out == "ab" + + def test_printable_ascii_preserved(self): + raw = "".join(chr(c) for c in range(0x20, 0x7F)) + out, truncated = sanitize_text(raw, DETAILS_CAP) + assert out == raw + assert truncated is False + + +class TestTabAndNewlineSubstitution: + def test_tab_becomes_single_space(self): + out, _ = sanitize_text("a\tb", DETAILS_CAP) + assert out == "a b" + + def test_newline_becomes_single_space(self): + out, _ = sanitize_text("a\nb", DETAILS_CAP) + assert out == "a b" + + def test_consecutive_tabs_each_become_a_space(self): + out, _ = sanitize_text("a\t\tb", DETAILS_CAP) + assert out == "a b" + + def test_mixed_tab_and_newline(self): + out, _ = sanitize_text("a\tb\nc", DETAILS_CAP) + assert out == "a b c" + + +class TestTruncation: + def test_no_truncation_under_cap(self): + out, truncated = sanitize_text("abc", DETAILS_CAP) + assert out == "abc" + assert truncated is False + + def test_no_truncation_at_exactly_cap(self): + raw = "a" * DETAILS_CAP + out, truncated = sanitize_text(raw, DETAILS_CAP) + assert out == raw + assert truncated is False + assert len(out) == DETAILS_CAP + + def test_truncation_over_cap_appends_marker(self): + raw = "a" * (DETAILS_CAP + 50) + out, truncated = sanitize_text(raw, DETAILS_CAP) + assert truncated is True + assert len(out) == DETAILS_CAP + assert out.endswith("…") + assert out[:-1] == "a" * (DETAILS_CAP - 1) + + def test_truncation_marker_is_single_unicode_character(self): + raw = "a" * (DETAILS_CAP + 1) + out, truncated = sanitize_text(raw, DETAILS_CAP) + assert truncated is True + # U+2026 is a single character, NOT three ASCII dots + assert out[-1] == "…" + assert out[-1] != "." + assert ord(out[-1]) == 0x2026 + # Total length is exactly the cap (one marker char + DETAILS_CAP-1 raw chars) + assert len(out) == DETAILS_CAP + + def test_truncation_preserves_multibyte_utf8(self): + # 4-byte UTF-8 character (emoji) + emoji = "\U0001f600" + # Build a string of 1000 emojis = 1000 characters + raw = emoji * 1000 + out, truncated = sanitize_text(raw, 500) + assert truncated is True + # Output should still be 500 characters and never split a multi-byte char + assert len(out) == 500 + # All but the last char should be intact emojis + assert all(c == emoji for c in out[:-1]) + assert out[-1] == "…" + + def test_truncation_marker_replaces_only_one_character_position(self): + raw = "x" * 100 + out, truncated = sanitize_text(raw, 10) + assert truncated is True + assert out == "x" * 9 + "…" + assert len(out) == 10 + + +class TestSmallCaps: + def test_actionable_cap_truncates_at_2048(self): + raw = "z" * 3000 + out, truncated = sanitize_text(raw, ACTIONABLE_CAP) + assert truncated is True + assert len(out) == 2048 + + def test_env_value_cap_truncates_at_4096(self): + raw = "z" * 5000 + out, truncated = sanitize_text(raw, ENV_VALUE_CAP) + assert truncated is True + assert len(out) == 4096 + + def test_max_length_one_handles_truncation(self): + out, truncated = sanitize_text("xxxx", 1) + assert truncated is True + assert out == "…" + + def test_max_length_zero_rejected(self): + import pytest + + with pytest.raises(ValueError): + sanitize_text("anything", 0) + + +class TestEmpty: + def test_empty_input_returns_empty(self): + out, truncated = sanitize_text("", DETAILS_CAP) + assert out == "" + assert truncated is False + + +class TestSanitizeAndTruncateInteraction: + def test_nul_stripped_first_then_length_check(self): + # 5 NULs + 5 valid characters; should NOT be truncated since post-strip + # length is well under cap. + raw = "\x00\x00\x00\x00\x00abcde" + out, truncated = sanitize_text(raw, DETAILS_CAP) + assert out == "abcde" + assert truncated is False diff --git a/tests/unit/test_runtime_detect.py b/tests/unit/test_runtime_detect.py new file mode 100644 index 0000000..98f5e8a --- /dev/null +++ b/tests/unit/test_runtime_detect.py @@ -0,0 +1,186 @@ +"""Unit tests for runtime_detect.py — FR-003, FR-004, R-003 (CHK013–CHK023, CHK087).""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from agenttower.config_doctor.runtime_detect import ( + CGROUP_PREFIXES, + ContainerContext, + HostContext, + detect, +) + + +@pytest.fixture +def fake_root(tmp_path: Path): + """Build a fake `/proc` + `/etc` tree under tmp_path for runtime detection.""" + + def _build( + *, + dockerenv: bool = False, + containerenv: bool = False, + cgroup_lines=None, + ) -> Path: + proc_self = tmp_path / "proc" / "self" + proc_self.mkdir(parents=True, exist_ok=True) + run = tmp_path / "run" + run.mkdir(parents=True, exist_ok=True) + if dockerenv: + (tmp_path / ".dockerenv").write_text("") + if containerenv: + (run / ".containerenv").write_text("") + if cgroup_lines is not None: + (proc_self / "cgroup").write_text("\n".join(cgroup_lines) + "\n") + else: + (proc_self / "cgroup").write_text("") + return tmp_path + + return _build + + +class TestClosedPrefixSet: + def test_prefix_token_set_is_exact(self): + """FR-004 / R-003 / plan §Constraints — the four prefixes are tokens.""" + assert CGROUP_PREFIXES == ("docker/", "containerd/", "kubepods/", "lxc/") + # Each token must end with a slash (the FR-004 rule) + for prefix in CGROUP_PREFIXES: + assert prefix.endswith("/") + + +class TestHostContextFallthrough: + def test_no_signals_yields_host_context(self, fake_root): + root = fake_root() + ctx = detect(proc_root=str(root)) + assert isinstance(ctx, HostContext) + + +class TestSingleSignalSignals: + def test_dockerenv_alone_fires(self, fake_root): + root = fake_root(dockerenv=True) + ctx = detect(proc_root=str(root)) + assert isinstance(ctx, ContainerContext) + assert "dockerenv" in ctx.detection_signals + + def test_containerenv_alone_fires(self, fake_root): + root = fake_root(containerenv=True) + ctx = detect(proc_root=str(root)) + assert isinstance(ctx, ContainerContext) + assert "containerenv" in ctx.detection_signals + + def test_cgroup_docker_alone_fires(self, fake_root): + root = fake_root(cgroup_lines=["0::/docker/abc123def456"]) + ctx = detect(proc_root=str(root)) + assert isinstance(ctx, ContainerContext) + assert "cgroup" in ctx.detection_signals + + def test_cgroup_containerd_fires(self, fake_root): + root = fake_root( + cgroup_lines=["0::/system.slice/containerd/abc123def4567890aaaaaaaa"] + ) + ctx = detect(proc_root=str(root)) + assert isinstance(ctx, ContainerContext) + assert "cgroup" in ctx.detection_signals + + def test_cgroup_kubepods_fires(self, fake_root): + root = fake_root( + cgroup_lines=[ + "0::/kubepods/burstable/pod1234abcd/cccccccc11112222" + ] + ) + ctx = detect(proc_root=str(root)) + assert isinstance(ctx, ContainerContext) + assert "cgroup" in ctx.detection_signals + + def test_cgroup_lxc_fires(self, fake_root): + root = fake_root(cgroup_lines=["12:cpu:/lxc/9999888877776666"]) + ctx = detect(proc_root=str(root)) + assert isinstance(ctx, ContainerContext) + assert "cgroup" in ctx.detection_signals + + +class TestUnsupportedSandboxes: + """Firejail / Bubblewrap / systemd-nspawn fall to host_context.""" + + def test_firejail_does_not_fire(self, fake_root): + root = fake_root(cgroup_lines=["0::/firejail.slice"]) + ctx = detect(proc_root=str(root)) + assert isinstance(ctx, HostContext) + + def test_bubblewrap_does_not_fire(self, fake_root): + root = fake_root(cgroup_lines=["0::/user.slice/user-1000.slice/bwrap"]) + ctx = detect(proc_root=str(root)) + assert isinstance(ctx, HostContext) + + def test_systemd_nspawn_does_not_fire(self, fake_root): + root = fake_root(cgroup_lines=["0::/machine.slice/machine-foo.scope"]) + ctx = detect(proc_root=str(root)) + assert isinstance(ctx, HostContext) + + +class TestCgroupEdgeCases: + def test_empty_cgroup_yields_host_context(self, fake_root): + root = fake_root(cgroup_lines=[]) + ctx = detect(proc_root=str(root)) + assert isinstance(ctx, HostContext) + + def test_garbage_cgroup_yields_host_context(self, fake_root): + root = fake_root(cgroup_lines=["junk", "more junk", "no slashes"]) + ctx = detect(proc_root=str(root)) + assert isinstance(ctx, HostContext) + + def test_unreadable_cgroup_swallowed_silently(self, fake_root): + # Replace /proc/self/cgroup with a directory so reads fail with IsADir + root = fake_root() + cgroup_path = root / "proc" / "self" / "cgroup" + if cgroup_path.exists(): + cgroup_path.unlink() + cgroup_path.mkdir() # make it a dir so open() fails + ctx = detect(proc_root=str(root)) + assert isinstance(ctx, HostContext) + + +class TestMultipleSignals: + def test_dockerenv_plus_cgroup_fires_both(self, fake_root): + root = fake_root( + dockerenv=True, + cgroup_lines=["0::/docker/abc123def456"], + ) + ctx = detect(proc_root=str(root)) + assert isinstance(ctx, ContainerContext) + assert "dockerenv" in ctx.detection_signals + assert "cgroup" in ctx.detection_signals + + def test_all_three_signals_fire_together(self, fake_root): + root = fake_root( + dockerenv=True, + containerenv=True, + cgroup_lines=["0::/docker/abc123def456"], + ) + ctx = detect(proc_root=str(root)) + assert isinstance(ctx, ContainerContext) + assert set(ctx.detection_signals) == {"dockerenv", "containerenv", "cgroup"} + + +class TestProcRootHonored: + def test_explicit_proc_root_used_over_env(self, fake_root, monkeypatch): + # Set the env var to a non-existent path; explicit arg should win + monkeypatch.setenv("AGENTTOWER_TEST_PROC_ROOT", "/this/does/not/exist") + root = fake_root(dockerenv=True) + ctx = detect(proc_root=str(root)) + assert isinstance(ctx, ContainerContext) + + def test_env_var_used_when_no_arg(self, fake_root, monkeypatch): + root = fake_root(dockerenv=True) + monkeypatch.setenv("AGENTTOWER_TEST_PROC_ROOT", str(root)) + ctx = detect(proc_root=None) + assert isinstance(ctx, ContainerContext) + + def test_no_env_no_arg_defaults_to_root(self, monkeypatch): + monkeypatch.delenv("AGENTTOWER_TEST_PROC_ROOT", raising=False) + # We don't assert what the real / yields — just that it doesn't crash + ctx = detect(proc_root=None) + assert isinstance(ctx, (HostContext, ContainerContext)) diff --git a/tests/unit/test_socket_client_back_compat.py b/tests/unit/test_socket_client_back_compat.py new file mode 100644 index 0000000..3d43051 --- /dev/null +++ b/tests/unit/test_socket_client_back_compat.py @@ -0,0 +1,120 @@ +"""T062 / CHK074: ``DaemonUnavailable`` byte-stable backcompat regression backstop. + +The FEAT-005 additive ``.kind`` attribute on :class:`DaemonUnavailable` MUST +NOT change ``str(exc)`` or ``repr(exc)`` for any underlying signal. T007 / +T008 cover the ``.kind`` mapping itself; this file is a separate regression +backstop that captures the spelling of every existing ``str`` / ``repr`` +output so a future edit cannot drift the message text without breaking a +named test. +""" + +from __future__ import annotations + +import json + +import pytest + +from agenttower.socket_api.client import DaemonUnavailable + + +# Each tuple: (constructor-style, kind, expected str, expected repr). +# +# We intentionally re-derive the strings from the constructor inputs rather +# than freezing arbitrary text — the goal is to lock the *shape* of str/repr +# without becoming a copy-paste of the implementation. The kind attribute is +# explicitly NOT in the message text, so it can be added without breaking +# parity. + + +def _baseline_str(message: str) -> str: + return message + + +def _baseline_repr(message: str) -> str: + return f"DaemonUnavailable({message!r})" + + +class TestStrParity: + """``str(exc)`` is byte-for-byte equivalent to the constructor message.""" + + @pytest.mark.parametrize( + "kind", + [ + "socket_missing", + "socket_not_unix", + "connection_refused", + "permission_denied", + "connect_timeout", + "protocol_error", + ], + ) + def test_str_does_not_include_kind(self, kind): + """The new ``.kind`` attribute MUST NOT bleed into ``str(exc)``.""" + message = "daemon at /tmp/sock unreachable" + exc = DaemonUnavailable(message, kind=kind) + assert str(exc) == _baseline_str(message) + assert kind not in str(exc) or kind == "kind" # negative lock + + def test_str_with_default_kind(self): + message = "daemon error" + exc = DaemonUnavailable(message) + assert str(exc) == _baseline_str(message) + + +class TestReprParity: + """``repr(exc)`` is byte-for-byte equivalent to the FEAT-002 build.""" + + @pytest.mark.parametrize( + "kind", + [ + "socket_missing", + "socket_not_unix", + "connection_refused", + "permission_denied", + "connect_timeout", + "protocol_error", + ], + ) + def test_repr_does_not_include_kind(self, kind): + message = "daemon at /tmp/sock unreachable" + exc = DaemonUnavailable(message, kind=kind) + # The closed-set kind tokens must NOT appear in repr; the .kind attr + # is reachable via attribute access only (additive backcompat). + rendered = repr(exc) + assert rendered == _baseline_repr(message) + assert kind not in rendered + + def test_repr_with_default_kind(self): + message = "daemon error" + exc = DaemonUnavailable(message) + assert repr(exc) == _baseline_repr(message) + + +class TestKindIsAdditive: + """The additive ``.kind`` attribute does not affect equality, hashing, or + pickling of the exception relative to a FEAT-002 baseline that did not + have ``.kind`` at all.""" + + def test_kind_attribute_is_present(self): + exc = DaemonUnavailable("x", kind="socket_missing") + assert exc.kind == "socket_missing" + + def test_kind_default_is_connect_timeout(self): + """Generic ``OSError`` fallback path defaults to ``connect_timeout`` + per R-009 — ``.kind`` is the closed-set token, not free text.""" + exc = DaemonUnavailable("oops") + assert exc.kind == "connect_timeout" + + def test_args_contains_only_message_not_kind(self): + """``Exception.args`` must contain only the message; if ``.kind`` + leaked into args it would break code that does ``raise type(e)(*e.args)``.""" + exc = DaemonUnavailable("bad", kind="socket_missing") + assert exc.args == ("bad",) + + def test_json_message_field_is_message_only(self): + exc = DaemonUnavailable("something bad", kind="socket_missing") + # Round-trip through JSON to lock the byte representation + payload = {"message": str(exc)} + rendered = json.dumps(payload) + assert "socket_missing" not in rendered + assert json.loads(rendered) == {"message": "something bad"} diff --git a/tests/unit/test_socket_path_resolution.py b/tests/unit/test_socket_path_resolution.py new file mode 100644 index 0000000..5e3f2b9 --- /dev/null +++ b/tests/unit/test_socket_path_resolution.py @@ -0,0 +1,295 @@ +"""Unit tests for socket_resolve.py — FR-001, FR-002, R-001, SC-002. + +Covers the FR-002 validator gates with closed-set ```` tokens, the +priority chain `env_override → mounted_default → host_default`, and (per +analyze finding A4) the chained-symlink rejection rule. +""" + +from __future__ import annotations + +import os +import socket as socket_mod +import time +from pathlib import Path + +import pytest + +from agenttower.config_doctor.runtime_detect import ( + ContainerContext, + HostContext, +) +from agenttower.config_doctor.socket_resolve import ( + MOUNTED_DEFAULT_PATH, + ResolvedSocket, + SocketPathInvalid, + resolve_socket_path, +) +from agenttower.paths import Paths + + +def _make_paths(host_socket: Path) -> Paths: + """Build a minimal Paths fixture; only ``socket`` is consulted by the resolver.""" + base = host_socket.parent + return Paths( + config_file=base / "config.toml", + state_db=base / "state.sqlite3", + events_file=base / "events.jsonl", + logs_dir=base / "logs", + socket=host_socket, + cache_dir=base / "cache", + ) + + +@pytest.fixture +def real_unix_socket(tmp_path: Path): + """Materialize an actual AF_UNIX socket file under tmp_path.""" + + socket_path = tmp_path / "real.sock" + sock = socket_mod.socket(socket_mod.AF_UNIX, socket_mod.SOCK_STREAM) + sock.bind(str(socket_path)) + yield socket_path + sock.close() + if socket_path.exists(): + socket_path.unlink() + + +# --------------------------------------------------------------------------- +# Closed-set tokens (FR-002, contracts/cli.md §AGENTTOWER_SOCKET) +# --------------------------------------------------------------------------- + + +class TestReasonTokenSet: + def test_closed_reason_set_is_exactly_five(self): + assert SocketPathInvalid.REASONS == ( + "value is empty", + "value is not absolute", + "value contains NUL byte", + "value does not exist", + "value is not a Unix socket", + ) + + def test_unknown_reason_raises_on_construction(self): + with pytest.raises(ValueError): + SocketPathInvalid("not a real reason") + + +# --------------------------------------------------------------------------- +# Priority chain (FR-001) +# --------------------------------------------------------------------------- + + +class TestPriorityChain: + def test_env_override_wins_when_set_and_valid(self, tmp_path, real_unix_socket): + paths = _make_paths(tmp_path / "host.sock") + env = {"AGENTTOWER_SOCKET": str(real_unix_socket)} + resolved = resolve_socket_path(env, paths, ContainerContext(("dockerenv",))) + assert resolved == ResolvedSocket(real_unix_socket, "env_override") + + def test_host_default_when_no_signal_no_override(self, tmp_path): + paths = _make_paths(tmp_path / "host.sock") + resolved = resolve_socket_path({}, paths, HostContext()) + assert resolved == ResolvedSocket(tmp_path / "host.sock", "host_default") + + def test_host_default_when_container_but_mounted_default_missing(self, tmp_path): + paths = _make_paths(tmp_path / "host.sock") + # No AGENTTOWER_SOCKET, runtime is container, but the global + # /run/agenttower/agenttowerd.sock will not exist on a normal test box. + resolved = resolve_socket_path({}, paths, ContainerContext(("dockerenv",))) + # The mounted-default path is /run/agenttower/agenttowerd.sock — almost + # certainly absent on the test host. Resolver must fall through to host_default. + if not MOUNTED_DEFAULT_PATH.exists(): + assert resolved.source == "host_default" + assert resolved.path == tmp_path / "host.sock" + + +# --------------------------------------------------------------------------- +# FR-002 validator gates +# --------------------------------------------------------------------------- + + +class TestEmpty: + def test_empty_string_rejected(self, tmp_path): + paths = _make_paths(tmp_path / "host.sock") + with pytest.raises(SocketPathInvalid) as excinfo: + resolve_socket_path({"AGENTTOWER_SOCKET": ""}, paths, HostContext()) + assert excinfo.value.reason == "value is empty" + + def test_whitespace_only_rejected(self, tmp_path): + paths = _make_paths(tmp_path / "host.sock") + with pytest.raises(SocketPathInvalid) as excinfo: + resolve_socket_path({"AGENTTOWER_SOCKET": " "}, paths, HostContext()) + assert excinfo.value.reason == "value is empty" + + +class TestRelativePath: + def test_relative_path_rejected(self, tmp_path): + paths = _make_paths(tmp_path / "host.sock") + with pytest.raises(SocketPathInvalid) as excinfo: + resolve_socket_path( + {"AGENTTOWER_SOCKET": "relative/path.sock"}, + paths, + HostContext(), + ) + assert excinfo.value.reason == "value is not absolute" + + def test_dot_relative_path_rejected(self, tmp_path): + paths = _make_paths(tmp_path / "host.sock") + with pytest.raises(SocketPathInvalid) as excinfo: + resolve_socket_path( + {"AGENTTOWER_SOCKET": "./run/sock"}, paths, HostContext() + ) + assert excinfo.value.reason == "value is not absolute" + + +class TestNULByte: + def test_nul_byte_rejected(self, tmp_path): + paths = _make_paths(tmp_path / "host.sock") + with pytest.raises(SocketPathInvalid) as excinfo: + resolve_socket_path( + {"AGENTTOWER_SOCKET": "/run/agent\x00tower.sock"}, + paths, + HostContext(), + ) + assert excinfo.value.reason == "value contains NUL byte" + + +class TestNonExistentPath: + def test_path_does_not_exist_rejected(self, tmp_path): + paths = _make_paths(tmp_path / "host.sock") + with pytest.raises(SocketPathInvalid) as excinfo: + resolve_socket_path( + {"AGENTTOWER_SOCKET": str(tmp_path / "nonexistent.sock")}, + paths, + HostContext(), + ) + assert excinfo.value.reason == "value does not exist" + + def test_broken_symlink_rejected(self, tmp_path): + target = tmp_path / "missing-target.sock" + link = tmp_path / "broken.sock" + os.symlink(target, link) + paths = _make_paths(tmp_path / "host.sock") + with pytest.raises(SocketPathInvalid) as excinfo: + resolve_socket_path( + {"AGENTTOWER_SOCKET": str(link)}, paths, HostContext() + ) + assert excinfo.value.reason == "value does not exist" + + +class TestNotASocket: + def test_regular_file_rejected(self, tmp_path): + regular = tmp_path / "not-a-socket" + regular.write_text("hello") + paths = _make_paths(tmp_path / "host.sock") + with pytest.raises(SocketPathInvalid) as excinfo: + resolve_socket_path( + {"AGENTTOWER_SOCKET": str(regular)}, paths, HostContext() + ) + assert excinfo.value.reason == "value is not a Unix socket" + + def test_directory_rejected(self, tmp_path): + directory = tmp_path / "dir-not-socket" + directory.mkdir() + paths = _make_paths(tmp_path / "host.sock") + with pytest.raises(SocketPathInvalid) as excinfo: + resolve_socket_path( + {"AGENTTOWER_SOCKET": str(directory)}, paths, HostContext() + ) + assert excinfo.value.reason == "value is not a Unix socket" + + +# --------------------------------------------------------------------------- +# Symlinks (R-001 single-follow + analyze finding A4 chained-symlink rejection) +# --------------------------------------------------------------------------- + + +class TestSymlinkPolicy: + def test_single_symlink_to_socket_accepted(self, tmp_path, real_unix_socket): + link = tmp_path / "first-link.sock" + os.symlink(real_unix_socket, link) + paths = _make_paths(tmp_path / "host.sock") + resolved = resolve_socket_path( + {"AGENTTOWER_SOCKET": str(link)}, paths, HostContext() + ) + assert resolved.source == "env_override" + assert resolved.path == link + + def test_single_symlink_to_regular_file_rejected(self, tmp_path): + regular = tmp_path / "regular" + regular.write_text("not a socket") + link = tmp_path / "link" + os.symlink(regular, link) + paths = _make_paths(tmp_path / "host.sock") + with pytest.raises(SocketPathInvalid) as excinfo: + resolve_socket_path( + {"AGENTTOWER_SOCKET": str(link)}, paths, HostContext() + ) + assert excinfo.value.reason == "value is not a Unix socket" + + def test_chained_symlink_rejected_a4(self, tmp_path, real_unix_socket): + """A4: even if symlink → symlink → socket, the second-level link is NOT + followed; the path fails with `value is not a Unix socket`.""" + first = tmp_path / "first.sock" + second = tmp_path / "second.sock" + os.symlink(real_unix_socket, second) # second → real socket + os.symlink(second, first) # first → second (chain) + paths = _make_paths(tmp_path / "host.sock") + with pytest.raises(SocketPathInvalid) as excinfo: + resolve_socket_path( + {"AGENTTOWER_SOCKET": str(first)}, paths, HostContext() + ) + assert excinfo.value.reason == "value is not a Unix socket" + + +# --------------------------------------------------------------------------- +# SC-002 wall-clock budget (50 ms) +# --------------------------------------------------------------------------- + + +class TestPreFlightSpeed: + @pytest.mark.parametrize( + "value, expected_reason", + [ + ("", "value is empty"), + ("relative/path", "value is not absolute"), + ("/path/with/\x00null", "value contains NUL byte"), + ("/this/path/does/not/exist", "value does not exist"), + ], + ) + def test_invalid_input_rejected_under_50ms(self, tmp_path, value, expected_reason): + paths = _make_paths(tmp_path / "host.sock") + start = time.perf_counter() + with pytest.raises(SocketPathInvalid) as excinfo: + resolve_socket_path( + {"AGENTTOWER_SOCKET": value}, paths, HostContext() + ) + elapsed = time.perf_counter() - start + assert elapsed < 0.050, f"validator took {elapsed*1000:.1f} ms" + assert excinfo.value.reason == expected_reason + + +# --------------------------------------------------------------------------- +# T027 — _connect_via_chdir preservation: deep-cwd paths pass through untouched +# --------------------------------------------------------------------------- + + +class TestDeepCwdPassthrough: + """T027 / edge case 12: the FEAT-002 sun_path 108-byte workaround lives in + socket_api/client.py. The resolver must NOT alter or shorten a long + socket-path; the chdir workaround is applied later.""" + + def test_long_path_returned_untouched(self, tmp_path, real_unix_socket): + # Construct a path longer than 108 bytes — even though the socket itself + # lives at a short path, we test that the resolver path passes through + # whatever the env says, byte-for-byte. + long_dir = tmp_path / ("x" * 80) + long_dir.mkdir() + link_in_long = long_dir / "linked.sock" + os.symlink(real_unix_socket, link_in_long) + paths = _make_paths(tmp_path / "host.sock") + resolved = resolve_socket_path( + {"AGENTTOWER_SOCKET": str(link_in_long)}, paths, HostContext() + ) + assert resolved.path == link_in_long + assert resolved.source == "env_override" + assert len(str(resolved.path)) > 80 diff --git a/tests/unit/test_tmux_self_identity.py b/tests/unit/test_tmux_self_identity.py new file mode 100644 index 0000000..7c29f4c --- /dev/null +++ b/tests/unit/test_tmux_self_identity.py @@ -0,0 +1,133 @@ +"""Unit tests for tmux_identity.py parsing — FR-009, FR-010, FR-011, FR-021, R-005.""" + +from __future__ import annotations + +from agenttower.config_doctor.tmux_identity import ParsedTmuxEnv, parse_tmux_env + + +# --------------------------------------------------------------------------- +# FR-009: $TMUX comma-split +# --------------------------------------------------------------------------- + + +class TestTmuxCommaSplit: + def test_three_field_split(self): + env = {"TMUX": "/tmp/tmux-1000/default,12345,$0", "TMUX_PANE": "%0"} + parsed = parse_tmux_env(env) + assert parsed.in_tmux is True + assert parsed.tmux_socket_path == "/tmp/tmux-1000/default" + assert parsed.server_pid == "12345" + assert parsed.session_id == "$0" + + def test_session_id_with_extra_commas_preserved(self): + # Split on FIRST TWO commas only — extra commas are part of session_id + env = { + "TMUX": "/tmp/tmux-1000/default,12345,id,with,commas", + "TMUX_PANE": "%0", + } + parsed = parse_tmux_env(env) + assert parsed.session_id == "id,with,commas" + + def test_only_two_fields_is_malformed(self): + env = {"TMUX": "/tmp/tmux/sock,12345", "TMUX_PANE": "%0"} + parsed = parse_tmux_env(env) + assert parsed.in_tmux is True + assert parsed.tmux_socket_path is None + assert parsed.malformed_reason is not None + + def test_empty_field_is_malformed(self): + env = {"TMUX": "/tmp/tmux/sock,,$0", "TMUX_PANE": "%0"} + parsed = parse_tmux_env(env) + assert parsed.malformed_reason is not None + + +# --------------------------------------------------------------------------- +# FR-010: $TMUX_PANE %N validation +# --------------------------------------------------------------------------- + + +class TestTmuxPaneShape: + def test_valid_pane_id(self): + env = {"TMUX": "/tmp/tmux/sock,12345,$0", "TMUX_PANE": "%0"} + parsed = parse_tmux_env(env) + assert parsed.pane_id_valid is True + assert parsed.tmux_pane_id == "%0" + assert parsed.malformed_reason is None + + def test_pane_id_multidigit_valid(self): + env = {"TMUX": "/tmp/tmux/sock,12345,$0", "TMUX_PANE": "%42"} + parsed = parse_tmux_env(env) + assert parsed.pane_id_valid is True + + def test_pane_id_without_percent_invalid(self): + env = {"TMUX": "/tmp/tmux/sock,12345,$0", "TMUX_PANE": "0"} + parsed = parse_tmux_env(env) + assert parsed.pane_id_valid is False + assert parsed.malformed_reason is not None + + def test_pane_id_with_letters_invalid(self): + env = {"TMUX": "/tmp/tmux/sock,12345,$0", "TMUX_PANE": "%abc"} + parsed = parse_tmux_env(env) + assert parsed.pane_id_valid is False + + def test_pane_id_with_whitespace_invalid(self): + env = {"TMUX": "/tmp/tmux/sock,12345,$0", "TMUX_PANE": "% 1"} + parsed = parse_tmux_env(env) + assert parsed.pane_id_valid is False + + +# --------------------------------------------------------------------------- +# FR-009: $TMUX unset → not_in_tmux (NOT fail) +# --------------------------------------------------------------------------- + + +class TestNotInTmux: + def test_tmux_unset_yields_not_in_tmux(self): + parsed = parse_tmux_env({"TMUX_PANE": "%0"}) # only TMUX_PANE + assert parsed.in_tmux is False + assert parsed.tmux_socket_path is None + assert parsed.malformed_reason is None + + def test_both_unset_yields_not_in_tmux(self): + parsed = parse_tmux_env({}) + assert parsed.in_tmux is False + assert parsed.malformed_reason is None + + def test_tmux_empty_string_treated_as_unset(self): + parsed = parse_tmux_env({"TMUX": ""}) + assert parsed.in_tmux is False + + +# --------------------------------------------------------------------------- +# FR-021: sanitization (NUL strip, control bytes) +# --------------------------------------------------------------------------- + + +class TestSanitization: + def test_nul_in_tmux_socket_path_stripped(self): + env = { + "TMUX": "/tmp/tmux/sock\x00,12345,$0", + "TMUX_PANE": "%0", + } + parsed = parse_tmux_env(env) + assert "\x00" not in (parsed.tmux_socket_path or "") + assert parsed.tmux_socket_path == "/tmp/tmux/sock" + + def test_nul_in_pane_id_invalidates_shape(self): + env = { + "TMUX": "/tmp/tmux/sock,12345,$0", + "TMUX_PANE": "%\x000", + } + parsed = parse_tmux_env(env) + # NUL stripped → "%0" which IS valid + assert parsed.pane_id_valid is True + assert parsed.tmux_pane_id == "%0" + + def test_control_byte_in_pane_id_invalidates(self): + env = { + "TMUX": "/tmp/tmux/sock,12345,$0", + "TMUX_PANE": "%\x011", # C0 \x01 stripped → "%1" which is valid + } + parsed = parse_tmux_env(env) + assert parsed.tmux_pane_id == "%1" + assert parsed.pane_id_valid is True From 69c3d147a348a4032aea7288d955c85f5f75a672 Mon Sep 17 00:00:00 2001 From: brettheap Date: Wed, 6 May 2026 18:51:34 +0000 Subject: [PATCH 3/7] FEAT-005: address PR #6 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five fixes from Copilot's PR review (5 inline comments): 1. ensure-daemon: stop honoring AGENTTOWER_SOCKET for routing The override used to thread through the preflight ping but the readiness wait + _print_ready still used paths.socket, so a user with AGENTTOWER_SOCKET set could see "ready" reported against a path the spawned daemon wasn't bound to. ensure-daemon now uses paths.socket throughout (preflight, lock claim, readiness wait, ready output). The resolver call is preserved so a malformed AGENTTOWER_SOCKET still fires the FR-002 pre-flight (exit 1). 2. socket_reachable: add S_ISSOCK pre-flight gate The `socket_not_unix` sub-code was in the documented FR-016 closed set but unreachable in practice — send_request never raised it because the resolver only S_ISSOCK-validates env_override and mounted_default, not host_default. Added a fast pre-flight helper `_path_is_socket` (one os.readlink follow consistent with FR-002 / R-001) that emits sub_code="socket_not_unix" when the resolved path exists but is not a Unix socket. 3. identity.py: drop unused `_CGROUP_LINE_PATTERN` regex Dead module-level constant; the actual implementation uses `_trailing_id_from_cgroup_path` with string find + tight regex. No call sites referenced the unused pattern. 4. tmux_identity.py: drop unused `Literal` import Dead import after the classify_tmux stub removal in the prior commit. 5. test_cli_config_doctor_json_strict_stdout.py: fix mislabeled TestJsonStdoutNoMount class The class name promised "no mount" coverage but the body pinned host context — effectively duplicating the daemon-down case. Rewrote to actually simulate container context (fake_proc fires runtime detection) with the host_default socket absent (no daemon spawned), exercising the resolver fall-through path the class name claims to cover. All 186 FEAT-005 tests pass; full suite 713 passed (1 unrelated FEAT-004 timing flake passed on re-run). Co-Authored-By: Claude Opus 4.7 --- src/agenttower/cli.py | 26 ++++---- src/agenttower/config_doctor/checks.py | 61 +++++++++++++++++++ src/agenttower/config_doctor/identity.py | 9 --- src/agenttower/config_doctor/tmux_identity.py | 1 - ...st_cli_config_doctor_json_strict_stdout.py | 42 ++++++++++--- 5 files changed, 109 insertions(+), 30 deletions(-) diff --git a/src/agenttower/cli.py b/src/agenttower/cli.py index 646fc7a..f62feae 100644 --- a/src/agenttower/cli.py +++ b/src/agenttower/cli.py @@ -220,16 +220,21 @@ def _config_doctor(args: argparse.Namespace) -> int: def _ensure_daemon(args: argparse.Namespace) -> int: - paths, resolved = _resolve_socket_with_paths() + # ensure-daemon manages the host daemon, which always binds at the + # FEAT-001 host-default socket path. AGENTTOWER_SOCKET only redirects + # *client* connect targets; threading it through the daemon-lifecycle + # path would route the readiness ping at one path and spawn the daemon + # at another, which is the pre-fix behavior the PR-6 review caught. + # We still call _resolve_socket_with_paths() so a malformed + # AGENTTOWER_SOCKET fires the FR-002 pre-flight (exit 1) — but the + # resolved path is intentionally discarded for routing. + paths, _ = _resolve_socket_with_paths() state_dir = paths.state_db.parent logs_dir = paths.logs_dir lock_path = state_dir / LOCK_FILENAME - # ensure-daemon spawns the host daemon; the daemon binds at the host - # default. AGENTTOWER_SOCKET overrides ONLY the client's ping target so - # it sees whether *that* socket is reachable. socket_path = paths.socket - preflight = _ensure_daemon_preflight(paths, resolved, json_mode=args.json) + preflight = _ensure_daemon_preflight(paths, json_mode=args.json) if preflight is not None: return preflight @@ -251,9 +256,7 @@ def _ensure_daemon(args: argparse.Namespace) -> int: ) -def _ensure_daemon_preflight( - paths: Paths, resolved: ResolvedSocket, *, json_mode: bool -) -> int | None: +def _ensure_daemon_preflight(paths: Paths, *, json_mode: bool) -> int | None: state_dir = paths.state_db.parent if not paths.state_db.exists(): @@ -263,12 +266,11 @@ def _ensure_daemon_preflight( ) return 1 - # Ping the resolved socket so AGENTTOWER_SOCKET overrides which socket - # the readiness check inspects; the daemon's own bind path is unchanged. - pre_existing = _try_ping(resolved.path) + # Ping the host-default socket — the only path the daemon binds at. + pre_existing = _try_ping(paths.socket) if pre_existing is not None: return _print_ready( - pre_existing, resolved.path, state_dir, json_mode=json_mode, started=False + pre_existing, paths.socket, state_dir, json_mode=json_mode, started=False ) try: diff --git a/src/agenttower/config_doctor/checks.py b/src/agenttower/config_doctor/checks.py index ea0dda3..22d0870 100644 --- a/src/agenttower/config_doctor/checks.py +++ b/src/agenttower/config_doctor/checks.py @@ -13,8 +13,11 @@ from __future__ import annotations +import os +import stat as _stat from collections.abc import Mapping from dataclasses import dataclass, replace +from pathlib import Path from typing import Any, Literal from agenttower.config_doctor import MAX_SUPPORTED_SCHEMA_VERSION @@ -100,6 +103,39 @@ def check_socket_resolved(resolved: ResolvedSocket) -> CheckResult: # --------------------------------------------------------------------------- +def _path_is_socket(path: Path) -> tuple[bool, bool]: + """Return ``(exists, is_socket)`` honoring exactly one ``os.readlink`` + follow consistent with FR-002 / R-001 / ``socket_resolve.py`` semantics. + + * ``(False, False)`` — path does not exist (or is unreadable). + * ``(True, False)`` — path exists but is not an ``S_ISSOCK`` target + (regular file, directory, broken symlink chain, etc.). + * ``(True, True)`` — path exists and resolves to a Unix socket after + at most one symlink follow. + """ + + target = path + try: + if path.is_symlink(): + link_target_str = os.readlink(path) + link_target = Path(link_target_str) + if not link_target.is_absolute(): + link_target = path.parent / link_target + # Reject second-level symlink chains, matching socket_resolve.py. + try: + if link_target.is_symlink(): + return (True, False) + except OSError: + return (False, False) + target = link_target + st = os.lstat(target) + except FileNotFoundError: + return (False, False) + except OSError: + return (False, False) + return (True, _stat.S_ISSOCK(st.st_mode)) + + def check_socket_reachable( resolved: ResolvedSocket, ) -> tuple[CheckResult, dict[str, Any] | None]: @@ -108,8 +144,33 @@ def check_socket_reachable( ``socket_reachable`` is transport-only: it reports ``pass`` whenever the daemon returns any well-formed frame, including a structured ``DaemonError`` envelope. Payload semantics are owned by ``daemon_status``. + + Fast pre-flight: if the resolved path exists but is not a Unix socket + (e.g., a regular file or directory at the host-default path that the + resolver cannot S_ISSOCK-validate), emit ``sub_code="socket_not_unix"`` + rather than letting the connect attempt fail as a generic timeout. The + resolver pre-validates ``env_override`` and ``mounted_default``; this + gate covers ``host_default``, where the resolver returns the path + unconditionally. """ + exists, is_socket = _path_is_socket(resolved.path) + if exists and not is_socket: + return ( + CheckResult( + code="socket_reachable", + status="fail", + source="round_trip", + details=_bound_details(f"socket_not_unix: {resolved.path}"), + actionable_message=_bound_actionable( + f"resolved path exists but is not a Unix socket: " + f"{resolved.path}" + ), + sub_code="socket_not_unix", + ), + None, + ) + try: result = send_request( resolved.path, "status", connect_timeout=1.0, read_timeout=1.0 diff --git a/src/agenttower/config_doctor/identity.py b/src/agenttower/config_doctor/identity.py index a897dd4..85db1aa 100644 --- a/src/agenttower/config_doctor/identity.py +++ b/src/agenttower/config_doctor/identity.py @@ -64,15 +64,6 @@ class CgroupMultiCandidate: DetectResult = Union[IdentityCandidate, CgroupMultiCandidate, None] -# Build a regex that captures the "last segment" of any path component starting -# with one of the FR-004 prefixes. Groups: (prefix-token-without-slash, id-tail). -_CGROUP_LINE_PATTERN = re.compile( - r"(?:" + "|".join(re.escape(p[:-1]) for p in CGROUP_PREFIXES) + r")" - r"[-/]" # the slash that the prefix token's trailing slash matched, OR a hyphen used by systemd-mangled names like docker-.scope - r"([0-9A-Za-z][0-9A-Za-z._-]*)" -) - - def _resolve_proc_root(proc_root: str | None) -> Path: if proc_root is not None: return Path(proc_root) diff --git a/src/agenttower/config_doctor/tmux_identity.py b/src/agenttower/config_doctor/tmux_identity.py index e067f81..48405e5 100644 --- a/src/agenttower/config_doctor/tmux_identity.py +++ b/src/agenttower/config_doctor/tmux_identity.py @@ -22,7 +22,6 @@ import re from collections.abc import Mapping from dataclasses import dataclass -from typing import Literal from agenttower.config_doctor.sanitize import ENV_VALUE_CAP, sanitize_text diff --git a/tests/integration/test_cli_config_doctor_json_strict_stdout.py b/tests/integration/test_cli_config_doctor_json_strict_stdout.py index 95b9e01..b93bc71 100644 --- a/tests/integration/test_cli_config_doctor_json_strict_stdout.py +++ b/tests/integration/test_cli_config_doctor_json_strict_stdout.py @@ -160,29 +160,55 @@ def test_summary_exit_code_matches_cli_exit_when_daemon_down( # --------------------------------------------------------------------------- -# No mount — AGENTTOWER_SOCKET points to a path that does not exist +# No mount — simulated bench container with neither AGENTTOWER_SOCKET nor a +# bind-mounted host socket; the developer forgot the `-v` mount. The +# resolver tries the mounted-default path, falls through to host_default, +# and the doctor still emits one canonical JSON object on stdout with +# stderr empty. # --------------------------------------------------------------------------- +def _pin_container_context_no_mount(env, tmp_path): + """Pin runtime detection to ContainerContext via fake_proc and ensure + the host_default socket is absent (no daemon spawned).""" + fake_root = tmp_path / "fake-container-no-mount" + (fake_root / "proc" / "self").mkdir(parents=True) + (fake_root / "etc").mkdir(parents=True) + (fake_root / "proc" / "self" / "cgroup").write_text( + "0::/docker/abcdef0123456789abcdef0123456789abcdef0123456789abcdef01234567\n" + ) + (fake_root / ".dockerenv").write_text("") + env["AGENTTOWER_TEST_PROC_ROOT"] = str(fake_root) + env.setdefault("AGENTTOWER_TEST_DOCKER_FAKE", "1") + + class TestJsonStdoutNoMount: def test_stdout_is_one_valid_json_object_when_socket_missing( self, env, tmp_path ): run_config_init(env) - _pin_host_context(env, tmp_path) + _pin_container_context_no_mount(env, tmp_path) for var in ("TMUX", "TMUX_PANE", "AGENTTOWER_CONTAINER_ID"): env.pop(var, None) - # Point the override at a path that exists as a directory, so the - # *resolver* succeeds (it's a valid absolute path) but the *transport* - # fails — exercising the daemon-down code path with an explicit path. - missing_socket = resolved_paths(tmp_path)["socket"] - # Don't start the daemon; ensure the socket file does not exist. - assert not missing_socket.exists() + env.pop("AGENTTOWER_SOCKET", None) + # Don't start the daemon — the host_default socket does not exist; + # the mounted-default `/run/agenttower/agenttowerd.sock` is also + # not present in this test environment. socket_reachable will + # surface socket_missing on whichever the resolver picks. + host_default_socket = resolved_paths(tmp_path)["socket"] + assert not host_default_socket.exists() proc = _run_doctor_json(env) envelope = json.loads(proc.stdout) assert isinstance(envelope, dict) + assert "summary" in envelope + assert "checks" in envelope assert proc.stderr == "", repr(proc.stderr) + # socket_reachable should fail with the `socket_missing` sub-code + # (the path the resolver picked does not exist). + socket_reachable = envelope["checks"]["socket_reachable"] + assert socket_reachable["status"] == "fail" + assert socket_reachable["sub_code"] == "socket_missing" # --------------------------------------------------------------------------- From 06fb56f7ecba13d91df3f701675a715d9a88b02e Mon Sep 17 00:00:00 2001 From: brettheap Date: Wed, 6 May 2026 19:34:01 +0000 Subject: [PATCH 4/7] FEAT-005: address SonarCloud quality-gate findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eleven code-smell findings from SonarCloud's PR-6 analysis (gate already passed; these are cleanup, not bug fixes). Critical: * socket_resolve.py — extract closed-set FR-002 ```` tokens as module constants (REASON_EMPTY / REASON_NOT_ABSOLUTE / REASON_NUL_BYTE / REASON_DOES_NOT_EXIST / REASON_NOT_UNIX_SOCKET). All raise sites now reference the constants; the REASONS tuple derives from them. Spelling is still locked by T009 + T024. * socket_api/client.py — refactor ``send_request`` to reduce Cognitive Complexity from 16 → below the 15 threshold by extracting three helpers (``_do_connect``, ``_do_send_and_recv``, ``_parse_envelope``). Exception messages and exit codes are preserved byte-for-byte (locked by test_socket_client_back_compat). Major: * cli.py — drop unused ``paths`` parameter from ``_run_container_scan`` and ``_run_pane_scan`` (and the now-unused local in ``_scan_command``). Test stubs in test_cli_scan_command.py updated to match. Minor: * cli.py — replace unused ``paths`` locals with ``_`` in ``_status_command``, ``_list_containers_command``, ``_list_panes_command``. * runner.py — collapse list comprehension feeding ``any()`` into a generator expression. * socket_resolve.py — drop redundant ``FileNotFoundError`` from ``except (FileNotFoundError, OSError)`` (subclass of OSError). * tmux_identity.py — switch ``[0-9]`` to ``\d`` with ``re.ASCII`` flag; semantics-equivalent to the FR-009 / R-005 contract literal. All 714 tests pass; FR-002 closed-set ```` token spellings remain identical (constants encode the same string literals). Co-Authored-By: Claude Opus 4.7 --- src/agenttower/cli.py | 15 ++- src/agenttower/config_doctor/runner.py | 5 +- .../config_doctor/socket_resolve.py | 51 +++++---- src/agenttower/config_doctor/tmux_identity.py | 4 +- src/agenttower/socket_api/client.py | 105 ++++++++++-------- tests/unit/test_cli_scan_command.py | 10 +- 6 files changed, 108 insertions(+), 82 deletions(-) diff --git a/src/agenttower/cli.py b/src/agenttower/cli.py index f62feae..65f49bb 100644 --- a/src/agenttower/cli.py +++ b/src/agenttower/cli.py @@ -387,7 +387,7 @@ def _wait_for_spawned_daemon( def _status_command(args: argparse.Namespace) -> int: - paths, resolved = _resolve_socket_with_paths() + _, resolved = _resolve_socket_with_paths() try: result = send_request( resolved.path, "status", connect_timeout=1.0, read_timeout=1.0 @@ -677,17 +677,16 @@ def _scan_command(args: argparse.Namespace) -> int: file=sys.stderr, ) return 1 - paths: Paths = resolve_paths() final_code = 0 first_block = True if args.containers: - code = _run_container_scan(paths, args, first_block=first_block) + code = _run_container_scan(args, first_block=first_block) if code in (2, 3): return code final_code = _combine_scan_exit_codes(final_code, code) first_block = False if args.panes: - code = _run_pane_scan(paths, args, first_block=first_block) + code = _run_pane_scan(args, first_block=first_block) if code in (2, 3): return code final_code = _combine_scan_exit_codes(final_code, code) @@ -707,7 +706,7 @@ def _combine_scan_exit_codes(current: int, new: int) -> int: def _run_container_scan( - paths: Paths, args: argparse.Namespace, *, first_block: bool + args: argparse.Namespace, *, first_block: bool ) -> int: # Resolve the socket inline so we honor AGENTTOWER_SOCKET / mounted-default # without changing the existing helper signature (preserves FEAT-003 test @@ -754,7 +753,7 @@ def _run_container_scan( def _run_pane_scan( - paths: Paths, args: argparse.Namespace, *, first_block: bool + args: argparse.Namespace, *, first_block: bool ) -> int: # Resolve the socket inline (see _run_container_scan note above). _, resolved = _resolve_socket_with_paths() @@ -830,7 +829,7 @@ def _parse_iso(text: str) -> "datetime": # type: ignore[name-defined] def _list_containers_command(args: argparse.Namespace) -> int: - paths, resolved = _resolve_socket_with_paths() + _, resolved = _resolve_socket_with_paths() params: dict[str, Any] = {"active_only": bool(args.active_only)} try: result = send_request( @@ -865,7 +864,7 @@ def _list_containers_command(args: argparse.Namespace) -> int: def _list_panes_command(args: argparse.Namespace) -> int: - paths, resolved = _resolve_socket_with_paths() + _, resolved = _resolve_socket_with_paths() params: dict[str, Any] = { "active_only": bool(args.active_only), "container": args.container, diff --git a/src/agenttower/config_doctor/runner.py b/src/agenttower/config_doctor/runner.py index fa87e8f..7c93492 100644 --- a/src/agenttower/config_doctor/runner.py +++ b/src/agenttower/config_doctor/runner.py @@ -161,12 +161,11 @@ def _compute_exit_code(rows: tuple[CheckResult, ...]) -> Literal[0, 1, 2, 3, 4, return 3 # Required checks all pass/warn/info now. Look at non-required. - non_required_fails = [ + if any( row.status == "fail" for row in rows if row.code not in REQUIRED_CHECKS - ] - if any(non_required_fails): + ): return 5 return 0 diff --git a/src/agenttower/config_doctor/socket_resolve.py b/src/agenttower/config_doctor/socket_resolve.py index 1be4a02..c282083 100644 --- a/src/agenttower/config_doctor/socket_resolve.py +++ b/src/agenttower/config_doctor/socket_resolve.py @@ -46,6 +46,16 @@ MOUNTED_DEFAULT_PATH = Path("/run/agenttower/agenttowerd.sock") """R-002: the MVP in-container default mounted socket path.""" +# FR-002 closed-set ```` tokens. These literals are part of the +# stable CLI stderr contract and are referenced by name throughout the +# validator and doctor checks; defining them as constants prevents drift +# under refactor (locked by T009 + T024 spelling assertions). +REASON_EMPTY = "value is empty" +REASON_NOT_ABSOLUTE = "value is not absolute" +REASON_NUL_BYTE = "value contains NUL byte" +REASON_DOES_NOT_EXIST = "value does not exist" +REASON_NOT_UNIX_SOCKET = "value is not a Unix socket" + class SocketPathInvalid(Exception): """Raised when AGENTTOWER_SOCKET is set but fails the FR-002 validator. @@ -60,11 +70,11 @@ class SocketPathInvalid(Exception): """ REASONS = ( - "value is empty", - "value is not absolute", - "value contains NUL byte", - "value does not exist", - "value is not a Unix socket", + REASON_EMPTY, + REASON_NOT_ABSOLUTE, + REASON_NUL_BYTE, + REASON_DOES_NOT_EXIST, + REASON_NOT_UNIX_SOCKET, ) def __init__(self, reason: str): @@ -84,19 +94,19 @@ def _validate_env_override(value: str) -> Path: stripped = value.strip() if not stripped: - raise SocketPathInvalid("value is empty") + raise SocketPathInvalid(REASON_EMPTY) if "\x00" in stripped: - raise SocketPathInvalid("value contains NUL byte") + raise SocketPathInvalid(REASON_NUL_BYTE) if not os.path.isabs(stripped): - raise SocketPathInvalid("value is not absolute") + raise SocketPathInvalid(REASON_NOT_ABSOLUTE) candidate = Path(stripped) # Apply exactly one os.readlink follow per FR-002 / R-001. # Per analyze finding A4: if the single readlink target is itself a symlink, - # the path fails with "value is not a Unix socket" — we do NOT follow a - # second symlink. This makes the "exactly one follow" rule load-bearing - # under operator-controlled symlink chains. + # the path fails with REASON_NOT_UNIX_SOCKET — we do NOT follow a second + # symlink. This makes the "exactly one follow" rule load-bearing under + # operator-controlled symlink chains. target_for_stat: Path = candidate try: if candidate.is_symlink(): @@ -107,15 +117,15 @@ def _validate_env_override(value: str) -> Path: # Reject second-level symlinks (A4 chained-symlink policy). try: if link_target.is_symlink(): - raise SocketPathInvalid("value is not a Unix socket") + raise SocketPathInvalid(REASON_NOT_UNIX_SOCKET) except OSError: # is_symlink() raises on broken parent paths — treat as not a socket - raise SocketPathInvalid("value does not exist") + raise SocketPathInvalid(REASON_DOES_NOT_EXIST) target_for_stat = link_target except FileNotFoundError: - raise SocketPathInvalid("value does not exist") + raise SocketPathInvalid(REASON_DOES_NOT_EXIST) except OSError: - raise SocketPathInvalid("value does not exist") + raise SocketPathInvalid(REASON_DOES_NOT_EXIST) try: # Use lstat on the post-readlink target to enforce the "no second-level @@ -124,13 +134,13 @@ def _validate_env_override(value: str) -> Path: # symlink-to-symlink chain fails before we ever stat the second link's # target. st = os.lstat(target_for_stat) - except FileNotFoundError: - raise SocketPathInvalid("value does not exist") except OSError: - raise SocketPathInvalid("value does not exist") + # FileNotFoundError is a subclass of OSError; both map to the same + # closed-set reason (REASON_DOES_NOT_EXIST). + raise SocketPathInvalid(REASON_DOES_NOT_EXIST) if not stat.S_ISSOCK(st.st_mode): - raise SocketPathInvalid("value is not a Unix socket") + raise SocketPathInvalid(REASON_NOT_UNIX_SOCKET) return candidate @@ -154,7 +164,8 @@ def _mounted_default_is_reachable() -> bool: return False path = link_target st = os.lstat(path) - except (FileNotFoundError, OSError): + except OSError: + # FileNotFoundError is a subclass of OSError; both fall through. return False return stat.S_ISSOCK(st.st_mode) diff --git a/src/agenttower/config_doctor/tmux_identity.py b/src/agenttower/config_doctor/tmux_identity.py index 48405e5..69aa285 100644 --- a/src/agenttower/config_doctor/tmux_identity.py +++ b/src/agenttower/config_doctor/tmux_identity.py @@ -25,7 +25,9 @@ from agenttower.config_doctor.sanitize import ENV_VALUE_CAP, sanitize_text -_TMUX_PANE_RE = re.compile(r"^%[0-9]+$") +# Use ``\d`` with ``re.ASCII`` to match the FR-009 / R-005 contract literally +# (``[0-9]+``); the ASCII flag prevents \d from broadening to Unicode digits. +_TMUX_PANE_RE = re.compile(r"^%\d+$", re.ASCII) @dataclass(frozen=True) diff --git a/src/agenttower/socket_api/client.py b/src/agenttower/socket_api/client.py index 11a1e2d..f0f7d62 100644 --- a/src/agenttower/socket_api/client.py +++ b/src/agenttower/socket_api/client.py @@ -57,6 +57,64 @@ def __init__(self, code: str, message: str) -> None: self.message = message +def _do_connect(sock: socket.socket, socket_path: Path) -> None: + """Connect ``sock`` to ``socket_path`` and translate transport errors + into :class:`DaemonUnavailable` with the FR-016 closed-set ``kind``. + + Existing exception messages are preserved byte-for-byte (locked by + ``test_socket_client_back_compat.py``). + """ + try: + _connect_via_chdir(sock, socket_path) + except FileNotFoundError as exc: + raise DaemonUnavailable( + f"socket missing: {socket_path}", kind="socket_missing" + ) from exc + except ConnectionRefusedError as exc: + raise DaemonUnavailable( + f"socket refused: {socket_path}", kind="connection_refused" + ) from exc + except OSError as exc: + kind = "permission_denied" if exc.errno == errno.EACCES else "connect_timeout" + raise DaemonUnavailable(f"connect failed: {exc}", kind=kind) from exc + + +def _do_send_and_recv(sock: socket.socket, payload: bytes) -> bytes: + """Send ``payload`` and read one newline-delimited response, mapping + I/O errors into :class:`DaemonUnavailable` with the FR-016 closed-set + ``kind``. Existing exception messages are preserved byte-for-byte. + """ + try: + sock.sendall(payload) + return _recv_line(sock) + except (TimeoutError, socket.timeout) as exc: # noqa: UP041 + raise DaemonUnavailable( + "daemon read timeout", kind="connect_timeout" + ) from exc + except OSError as exc: + raise DaemonUnavailable( + f"socket I/O failed: {exc}", kind="connect_timeout" + ) from exc + + +def _parse_envelope(data: bytes) -> dict[str, Any]: + """Decode the daemon's JSON envelope or raise + :class:`DaemonUnavailable` with ``kind="protocol_error"``.""" + if not data: + raise DaemonUnavailable("daemon returned no data", kind="protocol_error") + try: + envelope = json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise DaemonUnavailable( + f"daemon returned invalid JSON: {exc}", kind="protocol_error" + ) from exc + if not isinstance(envelope, dict) or "ok" not in envelope: + raise DaemonUnavailable( + "daemon returned malformed envelope", kind="protocol_error" + ) + return envelope + + def send_request( socket_path: Path, method: str, @@ -80,57 +138,16 @@ def send_request( sock.settimeout(connect_timeout) socket_path = Path(socket_path) try: - try: - _connect_via_chdir(sock, socket_path) - except FileNotFoundError as exc: - raise DaemonUnavailable( - f"socket missing: {socket_path}", kind="socket_missing" - ) from exc - except ConnectionRefusedError as exc: - raise DaemonUnavailable( - f"socket refused: {socket_path}", kind="connection_refused" - ) from exc - except OSError as exc: - if exc.errno == errno.EACCES: - raise DaemonUnavailable( - f"connect failed: {exc}", kind="permission_denied" - ) from exc - raise DaemonUnavailable( - f"connect failed: {exc}", kind="connect_timeout" - ) from exc - + _do_connect(sock, socket_path) sock.settimeout(read_timeout) - try: - sock.sendall(payload) - data = _recv_line(sock) - except (TimeoutError, socket.timeout) as exc: # noqa: UP041 - raise DaemonUnavailable( - "daemon read timeout", kind="connect_timeout" - ) from exc - except OSError as exc: - raise DaemonUnavailable( - f"socket I/O failed: {exc}", kind="connect_timeout" - ) from exc + data = _do_send_and_recv(sock, payload) finally: try: sock.close() except OSError: pass - if not data: - raise DaemonUnavailable("daemon returned no data", kind="protocol_error") - - try: - envelope = json.loads(data.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError) as exc: - raise DaemonUnavailable( - f"daemon returned invalid JSON: {exc}", kind="protocol_error" - ) from exc - - if not isinstance(envelope, dict) or "ok" not in envelope: - raise DaemonUnavailable( - "daemon returned malformed envelope", kind="protocol_error" - ) + envelope = _parse_envelope(data) if envelope["ok"] is True: result = envelope.get("result", {}) diff --git a/tests/unit/test_cli_scan_command.py b/tests/unit/test_cli_scan_command.py index 724cd7f..04957de 100644 --- a/tests/unit/test_cli_scan_command.py +++ b/tests/unit/test_cli_scan_command.py @@ -13,11 +13,10 @@ def _args(*, containers: bool, panes: bool, json: bool = False) -> argparse.Name def test_combined_scan_short_circuits_on_daemon_unavailable(monkeypatch) -> None: - monkeypatch.setattr(cli, "resolve_paths", lambda: object()) - monkeypatch.setattr(cli, "_run_container_scan", lambda paths, args, first_block: 2) + monkeypatch.setattr(cli, "_run_container_scan", lambda args, first_block: 2) called = {"panes": 0} - def fail_if_called(paths, args, first_block): + def fail_if_called(args, first_block): called["panes"] += 1 return 0 @@ -27,9 +26,8 @@ def fail_if_called(paths, args, first_block): def test_combined_scan_daemon_error_overrides_prior_degraded(monkeypatch) -> None: - monkeypatch.setattr(cli, "resolve_paths", lambda: object()) - monkeypatch.setattr(cli, "_run_container_scan", lambda paths, args, first_block: 5) - monkeypatch.setattr(cli, "_run_pane_scan", lambda paths, args, first_block: 3) + monkeypatch.setattr(cli, "_run_container_scan", lambda args, first_block: 5) + monkeypatch.setattr(cli, "_run_pane_scan", lambda args, first_block: 3) assert cli._scan_command(_args(containers=True, panes=True)) == 3 From c2078577df190e6f786fe4a001d45cdfba1d935a Mon Sep 17 00:00:00 2001 From: brettheap Date: Wed, 6 May 2026 19:39:45 +0000 Subject: [PATCH 5/7] FEAT-005: address PR #6 review-pass-2 feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from Copilot's second review pass on commit 69c3d14: 1. cli.py:53 / runner.py:71 — runtime_detect.detect() was called without threading AGENTTOWER_TEST_PROC_ROOT through the supplied ``env`` mapping. Both _resolve_socket_with_paths(env=...) and run_doctor(env, ...) now pass env.get("AGENTTOWER_TEST_PROC_ROOT") to detect(), so runtime detection stays consistent with the rest of the doctor inputs even when the caller supplies a custom env that differs from os.environ. 2. runner.py:27 — removed the unused ``SocketPathInvalid`` import; the symbol was only mentioned in a docstring, not referenced in code. All 714 tests pass. Co-Authored-By: Claude Opus 4.7 --- src/agenttower/cli.py | 7 ++++++- src/agenttower/config_doctor/runner.py | 12 +++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/agenttower/cli.py b/src/agenttower/cli.py index 65f49bb..9471832 100644 --- a/src/agenttower/cli.py +++ b/src/agenttower/cli.py @@ -50,7 +50,12 @@ def _resolve_socket_with_paths(env: dict[str, str] | None = None) -> tuple[Paths if env is None: env = dict(os.environ) paths = resolve_paths(env) - runtime_context = runtime_detect.detect() + # Thread AGENTTOWER_TEST_PROC_ROOT through the supplied env so runtime + # detection stays consistent with the resolver (PR-6 review #2/#3 finding: + # detect() previously read os.environ directly, breaking custom-env callers). + runtime_context = runtime_detect.detect( + proc_root=env.get("AGENTTOWER_TEST_PROC_ROOT") + ) try: resolved = resolve_socket_path(env, paths, runtime_context) except SocketPathInvalid as exc: diff --git a/src/agenttower/config_doctor/runner.py b/src/agenttower/config_doctor/runner.py index 7c93492..301486c 100644 --- a/src/agenttower/config_doctor/runner.py +++ b/src/agenttower/config_doctor/runner.py @@ -23,10 +23,7 @@ check_tmux_pane_match, check_tmux_present, ) -from agenttower.config_doctor.socket_resolve import ( - SocketPathInvalid, - resolve_socket_path, -) +from agenttower.config_doctor.socket_resolve import resolve_socket_path from agenttower.paths import Paths from agenttower.socket_api.client import ( DaemonError, @@ -68,7 +65,12 @@ def run_doctor( + exit `1` BEFORE calling :func:`run_doctor`. """ - runtime_context = runtime_detect.detect() + # Thread AGENTTOWER_TEST_PROC_ROOT through the supplied env so runtime + # detection stays consistent with run_doctor's other env-derived inputs + # (PR-6 review finding: detect() previously read os.environ directly). + runtime_context = runtime_detect.detect( + proc_root=env.get("AGENTTOWER_TEST_PROC_ROOT") + ) resolved = resolve_socket_path(env, host_paths, runtime_context) socket_resolved = check_socket_resolved(resolved) From b3563a5904f4b3b0903002e955fdef0a370fcf78 Mon Sep 17 00:00:00 2001 From: brettheap Date: Wed, 6 May 2026 21:21:51 +0000 Subject: [PATCH 6/7] FEAT-005: address PR #6 review-pass-3 feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real findings from Copilot's third review pass on c207857: 1. contracts/socket-api.md:26 — the docs claimed list_panes is called with params.container_id, but the actual FEAT-004 method (src/agenttower/socket_api/methods.py::_list_panes) reads params.get("container"). Fixed the doc to use the correct parameter name and added an explicit pointer to the methods.py line so future readers can verify the contract directly. 2. render.py:12 — removed unused `CheckResult` import. The render functions consume `report.checks` via duck-typed attribute access; the class itself was never referenced. (Copilot's third comment on runner.py:29 re. an unused SocketPathInvalid import is a stale read — that import was already removed in c207857. Replied to the thread; no code change needed.) Co-Authored-By: Claude Opus 4.7 --- specs/005-container-thin-client/contracts/socket-api.md | 8 +++++--- src/agenttower/config_doctor/render.py | 1 - 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/specs/005-container-thin-client/contracts/socket-api.md b/specs/005-container-thin-client/contracts/socket-api.md index 50d98f1..f8cb146 100644 --- a/specs/005-container-thin-client/contracts/socket-api.md +++ b/specs/005-container-thin-client/contracts/socket-api.md @@ -23,11 +23,13 @@ defined by FEAT-002 / FEAT-003 / FEAT-004: | 2 | `list_containers` | FEAT-003 | `container_identity` cross-check (full-id + 12-char short-id prefix match) | | 3 | `list_panes` | FEAT-004 | `tmux_pane_match` cross-check (filter by resolved container id when known) | -`list_panes` is called with `params.container_id` set to the full +`list_panes` is called with `params.container` set to the full id from `IdentityResolution.matched_id` when the cross-check classified as `unique_match`; otherwise it is called with no -filter. Both shapes are already supported by the FEAT-004 method -contract. +filter. The parameter name is `container` (not `container_id`) +to match the FEAT-004 method shape at +`src/agenttower/socket_api/methods.py::_list_panes`. Both shapes +are already supported by the FEAT-004 method contract. ### What FEAT-005 does NOT add diff --git a/src/agenttower/config_doctor/render.py b/src/agenttower/config_doctor/render.py index 045d522..943fb79 100644 --- a/src/agenttower/config_doctor/render.py +++ b/src/agenttower/config_doctor/render.py @@ -9,7 +9,6 @@ import json from typing import Any -from agenttower.config_doctor.checks import CheckResult from agenttower.config_doctor.runner import DoctorReport From f12a1850a3ff9d6bcbe40d282cb2d6558d4292c1 Mon Sep 17 00:00:00 2001 From: brettheap Date: Wed, 6 May 2026 21:36:38 +0000 Subject: [PATCH 7/7] FEAT-005: address 4 missed Copilot comments from review-pass-3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I previously addressed 3 of 7 comments from Copilot's third review pass against c207857. Audit caught the 4 I missed: 1. runner.py:137 — _compute_exit_code docstring said exit 0 requires "pass or info" on every required check, but daemon_status can be warn (schema_version_older per R-010) and still yield exit 0. Updated the docstring to include warn and cite R-010. 2. quickstart.md:133 — §4 showed a multi-line pretty-printed JSON example, but the implementation emits compact single-line JSON. Replaced with the real one-line output and added a python -m json.tool example for readers who want it formatted. 3. data-model.md:276 — Section 4.3 said pre-flight failures are enforced by runner.py catching SystemExit(1) from socket_resolve.py. The actual flow: socket_resolve.py raises SocketPathInvalid which propagates through run_doctor and is caught by cli.py (which owns the stderr + exit 1 translation). Updated the description. 4. contracts/socket-api.md:91 — The DaemonUnavailable code block comment said "existing init signature unchanged"; the additive kind kwarg actually changes the signature (still backwards- compatible for positional callers). Reworded to make the additive nature explicit. 61 affected tests pass. No code-behavior changes; all 4 fixes are documentation alignment. Co-Authored-By: Claude Opus 4.7 --- .../contracts/socket-api.md | 4 +- specs/005-container-thin-client/data-model.md | 8 ++-- specs/005-container-thin-client/quickstart.md | 39 +++++++++---------- src/agenttower/config_doctor/runner.py | 10 +++-- 4 files changed, 33 insertions(+), 28 deletions(-) diff --git a/specs/005-container-thin-client/contracts/socket-api.md b/specs/005-container-thin-client/contracts/socket-api.md index f8cb146..3112047 100644 --- a/specs/005-container-thin-client/contracts/socket-api.md +++ b/specs/005-container-thin-client/contracts/socket-api.md @@ -87,7 +87,9 @@ class DaemonUnavailable(RuntimeError): "connect_timeout", "protocol_error", ] - # existing init signature unchanged + # __init__ gains an additive keyword-only ``kind`` parameter; + # existing positional callers (FEAT-002 / FEAT-003 / FEAT-004) + # continue to work without modification. ``` The doctor's `socket_reachable` check catches `DaemonUnavailable` diff --git a/specs/005-container-thin-client/data-model.md b/specs/005-container-thin-client/data-model.md index 19f00ea..ecabfab 100644 --- a/specs/005-container-thin-client/data-model.md +++ b/specs/005-container-thin-client/data-model.md @@ -271,9 +271,11 @@ exit `1` per FR-002 (R-001). Walks the six `CheckResult`s in order and applies R-006's mapping table. Pre-flight failures short-circuit to `1` *before* the -`DoctorReport` is constructed (this is enforced by `runner.py` -catching the validator's `SystemExit(1)` from `socket_resolve.py` -and re-raising rather than producing a partial report). +`DoctorReport` is constructed: `socket_resolve.py` raises +`SocketPathInvalid` (the FR-002 validator), which propagates out of +`run_doctor` and is caught by the CLI handler in `cli.py` (the +`SocketPathInvalid` → stderr + exit `1` translation lives there, +not in `runner.py`). --- diff --git a/specs/005-container-thin-client/quickstart.md b/specs/005-container-thin-client/quickstart.md index 349b82d..02d0e62 100644 --- a/specs/005-container-thin-client/quickstart.md +++ b/specs/005-container-thin-client/quickstart.md @@ -112,31 +112,28 @@ unset (data-model §3.3). ```bash $ agenttower config doctor --json +{"summary": {"exit_code": 0, "total": 6, "passed": 3, "warned": 0, "failed": 0, "info": 3}, "checks": {"socket_resolved": {"status": "pass", "details": "/home/brett/.local/state/opensoft/agenttower/agenttowerd.sock (host_default)", "source": "host_default"}, "socket_reachable": {"status": "pass", "details": "daemon_version=0.5.0 schema_version=3", "source": "round_trip"}, "daemon_status": {"status": "pass", "details": "schema_version=3 (cli supports 3); daemon_version=0.5.0", "source": "schema_check"}, "container_identity": {"status": "info", "details": "host_context", "sub_code": "host_context"}, "tmux_present": {"status": "info", "details": "not_in_tmux", "sub_code": "not_in_tmux"}, "tmux_pane_match": {"status": "info", "details": "not_in_tmux", "sub_code": "not_in_tmux"}}} +``` + +One canonical JSON object on a single line per invocation +(`json.dumps` default — no pretty-print). Pipe through `jq` or +`python -m json.tool` for a multi-line view: + +```bash +$ agenttower config doctor --json | python -m json.tool { - "summary": { - "exit_code": 0, - "total": 6, - "passed": 3, - "warned": 0, - "failed": 0, - "info": 3 - }, - "checks": { - "socket_resolved": {"status": "pass", "details": "/home/brett/.local/state/opensoft/agenttower/agenttowerd.sock (host_default)", "source": "host_default"}, - "socket_reachable": {"status": "pass", "details": "daemon_version=0.5.0 schema_version=3", "source": "round_trip"}, - "daemon_status": {"status": "pass", "details": "schema_version=3 (cli supports 3); daemon_version=0.5.0", "source": "schema_check"}, - "container_identity":{"status": "info", "details": "host_context", "sub_code": "host_context"}, - "tmux_present": {"status": "info", "details": "not_in_tmux", "sub_code": "not_in_tmux"}, - "tmux_pane_match": {"status": "info", "details": "not_in_tmux", "sub_code": "not_in_tmux"} - } + "summary": { + "exit_code": 0, + ... + }, + ... } ``` -One canonical JSON object per invocation. No incidental stderr -lines. `summary.exit_code` matches the CLI exit code. The keys are -emitted in the per-row dict order produced by the renderer -(`status`, `details`, optional `source`, optional `sub_code`, -optional `actionable_message`). +No incidental stderr lines. `summary.exit_code` matches the CLI +exit code. The keys are emitted in the per-row dict order produced +by the renderer (`status`, `details`, optional `source`, optional +`sub_code`, optional `actionable_message`). --- diff --git a/src/agenttower/config_doctor/runner.py b/src/agenttower/config_doctor/runner.py index 301486c..1ed5bfc 100644 --- a/src/agenttower/config_doctor/runner.py +++ b/src/agenttower/config_doctor/runner.py @@ -134,14 +134,18 @@ def _safe_call(socket_path, method: str) -> dict[str, Any] | None: def _compute_exit_code(rows: tuple[CheckResult, ...]) -> Literal[0, 1, 2, 3, 4, 5]: """R-006 exit-code mapping (FR-018, post-clarify Q5 layering). - * ``0`` — every required check is ``pass`` or ``info``. + * ``0`` — every required check is ``pass``, ``warn``, or ``info``. + ``warn`` is reachable on ``daemon_status`` for + ``schema_version_older`` (forward-compat per R-010); the doctor + keeps exit ``0`` so scripts that only gate on ``$?`` continue + to work against an older daemon. * ``2`` — ``socket_reachable`` is ``fail`` with sub-code in ``{socket_missing, connection_refused, connect_timeout}``. * ``3`` — ``socket_reachable`` is ``pass`` AND ``daemon_status`` is ``fail`` with sub-code ``daemon_error`` or ``schema_version_newer`` (Clarifications 2026-05-06). - * ``5`` — round-trip ok and required checks pass, but a non-required - check is ``fail``. + * ``5`` — round-trip ok and required checks pass/warn, but a + non-required check is ``fail``. * ``1`` is reserved for pre-flight (handled by cli.py before :func:`run_doctor`); ``4`` is reserved per FEAT-002. """