diff --git a/CHANGELOG.md b/CHANGELOG.md index 66bba664..245863fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,211 @@ PyPI version — not the changelog header. --- +## [2026-08-04] — manager-class git auth + fleet self-repair day + +**fix(drone)** — caller detection derives a project name from the registry +FILENAME when metadata declares none (`AIPASS_REGISTRY.json` → `aipass`, +`VERA-STUDIO_REGISTRY.json` → `vera-studio`); a declared +`metadata.project_name`/`name` still wins, and passports still outrank the +fallback entirely. The old code required a declared name, which AIPass's own +registry doesn't carry — so the framework repo was the one place the fallback +could never fire, and it failed in silence: callers at the AIPass root +(VERA's session, the Telegram scheduler hourly) were `CALLER:UNKNOWN` all +day, which is what stranded the feedback replies above. Found-but-rejected +registries now log WARNING naming the file and reason; the glob is sorted for +deterministic multi-registry resolution; a test asserts a derived name can +never earn git authority (owner-tier reads passports directly). One canary +self-caught and rewritten: the bare-suffix test asserted None, which the +caller's truthiness check made vacuous — now asserts the WARNING. 963 drone +tests green (+8), seedgo 100%. + +**fix(devpulse)** — feedback replies report delivery honestly. Live failure +caught by Patrick asking why VERA never heard back: all six of her feedback +messages arrived as `From: unknown` (her session ran drone from the AIPass +repo root, where caller detection finds no passport and the registry +fallback rejects a name-less `AIPASS_REGISTRY.json`), so three replies were +"saved" while delivery silently skipped to `src/aipass/unknown/`. compose.py +1.1.0: an anonymous send is now told AT SEND TIME that replies cannot reach +it (with the run-from-your-branch-dir fix named), and `reply` reports the +delivery outcome — `success()` on delivery, `error()` with the reason on +failure (which marks the command failed: a reply the sender never sees +SHOULD flip the exit code) — instead of claiming success on a thread-only +save. The six stored messages were repaired (sender + reply path) and the +three stranded replies hand-delivered the same evening. 452 devpulse tests +green (+5), compose.py 31/31 seedgo standards. + +**feat(drone)** — owner-tier git is earned, not listed (DPLAN-0281, Patrick +ruling: "project owners get git"). The hardcoded `allowed_callers: +["devpulse"]` is gone; a caller holds owner-tier iff all four checks pass: +manager-class citizen, tenant of THIS repo's registry (passport +`citizenship.registry_id` == registry `metadata.id`), listed with `owner: +true`, and presenting its passport from the registry-recorded home +(path-binding, F59 4.2a). devpulse-in-AIPass authorizes through the general +rule — no special case — and any external project's manager gains the same +standing in their own repo once P2 provisioning flips their class. Enforce +by default (all four checks live-verified against real data before +flipping); `AIPASS_GIT_AUTH_MODE=warn` for migration triage. AIPass-flow +verbs (dev-pr/merge/tag/…) refuse honestly in external repos until +translated — commit and sync work there today. Also: `find_repo_root` +recognizes any `*_REGISTRY.json` (external projects name theirs), +dict-authored registries get the same normalization as list-shaped, and the +dead `ALLOWED_CALLERS` decoy died with the list it shadowed. Router +caller-identity honesty landed alongside: a lost identity renders +`[CALLER:UNKNOWN]` plus one WARNING naming the real cwd, and the registry +fallback for external projects is reachable as documented. 44 new tests +(16 canaries + unstubbed-auth module tests), 955 drone green. By @drone, +verified by devpulse. + +**fix(tests)** — CI-only fallout from the auth rewrite, caught by the clean +checkout: seedgo's four Track-E tests pinned the dead `ALLOWED_CALLERS` +parity — replaced with one canary asserting no name-based caller list can +reappear; and drone's wrong-tenancy test now pins `AIPASS_REGISTRY` to its +fixture — `find_registry`'s cwd walk deliberately skips credential-failing +registries, so the mismatched fixture was passed over and resolution fell +through to the real registry locally (right wording, wrong reason) but to +not-found in CI, where `AIPASS_REGISTRY.json` is gitignored-absent. Seedgo +1304 green, drone 955 green. A third, Windows-only: the new router +cwd-logging test substring-matched path reprs — `str(tmp_path)` has +backslashes while the logged arg renders `WindowsPath('C:/...')` with +forward slashes — now compares Path values, separator-agnostic. + +**fix(trigger)** — rotation tail loss closed in BOTH log watchers. The old +`size shrank → reset to 0` rotation handling silently skipped every line +between the last read offset and the rotation cut — worst exactly during +incidents, when the unread tail is largest; a second defect seeked stale +offsets INTO the fresh file, reading garbage fragments. Now: inode identity +recorded beside the offset, rotation detected by inode change, and the +rotated-out file's unread tail drained before moving on (inode-matched, so +never a stale backup re-fired). Falsy/unknown inode degrades to old +behavior. Found by @trigger while disproving another branch's rotation +claim. 698 trigger tests green. + +**fix(hooks)** — edit_gate's newest-first guard no longer hard-blocks +legacy `session_number` branches from ever writing session memory (found by +VERA — the gate was stricter than the schema the rest of the fleet still +honors, with no compliance path). Two halves, both proven load-bearing by +staged canaries: a number-key alias (`number` wins over `session_number` +when both exist) so legacy arrays stay *guarded*, and an unreadable-schema +pass-through so an unrecognized future schema degrades to the +ordinal-independent ordering check instead of a permanent lockout. Block +messages now name the accepted keys. Live-proved through the real Claude +bridge: legacy prepend exits 0, tail-append and number-reuse still exit 2. +1335 hooks tests green (+9). By @hooks, verified by devpulse. + +**fix(ai_mail)** — wake-back no longer claims "woken" when the manager gate +skipped it (found by VERA in Vera-Studio field telemetry after her manager +flip; diagnosis exact, line for line). The gate's bool means "the dispatch +did what it should," not "an agent was woken" — a manager returns True +having deliberately woken nobody, and `_wake_sender` read that as woken. +New `skipped_manager` result tag keyed on the status object's structural +step (not prose-sniffing — substring matching is what let this hide), +docstrings now tell the truth about managers, and the unreachable @daemon +exception on wake-backs is explained in place. Gate behavior untouched. +839 ai_mail tests green (+9, canary-checked both directions). By @ai_mail, +verified by devpulse. + +**docs(flow)** — weekly_update playbook template v2, authored by VERA +(Vera-Studio) from her PPLAN-0017 run and landed from flow/dropbox: new +Step 0 reads the live subreddit for the last posted number before anything +else (an empty playbook is not evidence its post never fired — trusting one +cost a delete-and-repost of an immutable Reddit title), and a cold-tested +"Driving Chrome" section including the `pgrep -x chrome` correction +(`pgrep -f google-chrome` false-positives on the caller's own command +line). First cross-project template contribution. + +**feat(hooks)** — hooks_engine.log per-hook narration demoted out of the +default view (ruling delegated by Patrick, decided by devpulse: quiet noise +at the source, never mask it). prax's SystemLogger has no debug(), so +engine 1.2.0 gates the four per-hook narration sites (fire, complete, +skipped-disabled, budget) behind `AIPASS_HOOKS_VERBOSE_LOG=1` — silent by +default, restorable live, read per call. Lifecycle INFO, every WARNING and +ERROR, and engine.jsonl untouched. Measured under fleet load: 1865 of 1869 +lines demoted (~99.8%); the 4 survivors were legitimate git_gate blocks. +1326 hooks tests green (+5, suppression canary-checked), seedgo 100%. New +README "Two Log Streams" section. Flagged upstream: SystemLogger's missing +debug() is a real prax gap. By @hooks, verified by devpulse. + +**feat(aipass)** — `aipass init update` provisions external projects for +manager-class git (DPLAN-0281 P2). New `init/git_auth.py`: plans every +repair BEFORE writing (a refused run leaves the project untouched), mints +registry `metadata.id`, backfills the owner citizen's +`citizenship.registry_id`, flips builder→manager, records the branch path — +then `verify_git_auth()` independently re-reads disk and re-derives all +four owner-tier checks. Refuses honestly instead of guessing: no owner +marked, more than one owner, root-ish recorded paths (@drone's guardrail — +at-or-under binding would degrade to repo-wide), paths outside the repo, or +missing passports. `--dry-run` prints the plan, writes nothing. Canaried +against drone's real P1 gate: repaired fixture authorizes, un-repaired +refuses on class, a passport copied to a non-recorded dir refuses on +path-binding. 934 aipass tests green (+37), seedgo 100%. By @aipass, +verified by devpulse. P3 (run it on Vera-Studio live) is next. + +**feat(trigger)** — runaway WARNING tier is observe-only (Patrick ruling: +"observe only is good"). WARNING runaways record with full fidelity — +alerts.json, decision log, per-file cooldown — but no longer email or wake +anyone; CRITICAL keeps its bypass-all-mutes wake path untouched. New +decision outcome `observed` (not `suppressed` — it was recorded; not +`delivered` — nobody was told), and a WARNING now records even with no +email callback, where it previously early-returned recordless. The accepted +cost is written into the module docstring: a sustained sub-CRITICAL leak +pages nobody by design. 707 trigger tests green (+13), reverted-split +canary fails 8, five NO-OVERREACH tests pin the CRITICAL path. By @trigger, +verified by devpulse. + +**fix(trigger)** — follow-up: the seedgo unused_function gate (CI red on +PR#727) caught `_save_seen_hashes`/`_save_log_positions` orphaned since +#674's coalesced flush — only tests still called them. Deleted rather than +wired-to-nothing (the None-watcher "gap" doesn't exist: the flush merges +with existing on-disk JSON, preserving positions untouched). Their test +blocks repointed at the real write path `_flush_trigger_data`, and got +stronger: the old write-error tests asserted nothing; the replacements +assert the warning reaches the logger, canary-checked three ways. 694 +green, trigger audit back to 100%. + +**fix(ai_mail)** — the 6-week "unreproducible" dispatch failure +(2×468-adjacent fingerprints, 44 occurrences) root-caused and reproduced on +demand: sender identity resolves from AIPASS_CALLER_CWD, so running drone +from a non-branch dir (repo root) fails detection — while the error printed +the target's perfectly-valid cwd, sending every prior investigation passport +-hunting. The refusal is CORRECT (silent cwd fallback would forge sender +identity); the fix is diagnostic truth: the error now names the env var, +the walked path, and that process cwd is informational. Fingerprint prefix +preserved for medic grouping. 4 canary-checked tests, 830 green. By @ai_mail. + +**fix(hooks)** — engine "complete: 0 hooks" lie fixed: silent gates write no +stdout, so len(outputs) reported 0 on 97% of dispatches while gates fired +normally. Now counts executions; hooks_with_output added. Runaway +hooks_engine.log alert itself verdict'd NOT a hooks bug — fleet load +(24 claude processes, load 32 on 4 cores). engine.py 1.1.1, 4 canary tests, +1321 green. By @hooks. + +**fix(prax)** — log retention: backup_count 1→3 (rotation was discarding +history the watchers hadn't drained; ~28MB ceiling accepted), and dead +prax_logger_config.json read-keys found/wired (settings never matched what +load read). By @prax under @trigger dispatch. 1084 green. + +## [2026-08-02] — TG slash relay: /context fired from Telegram comes back to the chat + +**feat(skills)** — CC informational slash commands now round-trip from +Telegram (Patrick ask: stop pick-and-choosing which builtins work remotely). +The bot injects an allowlisted informational command (`/context`; extend via +`informational_commands` config) as raw text — no relay prefix, or CC would +read it as prose — then a daemon-thread watcher tails the CC transcript from +the injection baseline and relays the command's stdout back to the chat as +HTML `
` chunks. Local commands produce no assistant turn, so this path
+deliberately writes NO pending file and starts NO heartbeat (nothing for the
+Stop hook to strand — the stuck-pending lesson applied, not relearned);
+90s timeout edits the placeholder to an honest failure. Scope-guarded twice:
+watcher only starts from TG-inbound handling and the scan is bounded to
+lines after the baseline — a desk or remote-control `/context` can never
+surprise-echo to the phone. Found en route: current CC emits `/context`
+twice (ANSI TUI panel + clean-markdown isMeta twin); the twin is preferred.
+`/cost` verified-not-assumed and left OUT (zero invocations exist on this
+machine to pin its shape). Side-effect passthrough (`clear`/`compact`/
+`prep`/`memo`) byte-identical behavior. 51 new tests (canary-checked: each
+guarantee broken in turn, tests bite), 1010 telegram green, seedgo 100%.
+Built by @skills; live-proven end-to-end including a real Telegram hop.
+
## [2026-08-02] — install ends with hooks alive: setup enrolls itself; hook test runner stops ghost-arming live sessions
**feat(setup)** — setup.sh now enrolls the repo it just installed in the hook
diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py b/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py
index d47c99aa..871027bc 100644
--- a/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py
+++ b/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py
@@ -1,9 +1,9 @@
# =================== AIPass ====================
# Name: dispatch_monitor.py
# Description: Agent Lifecycle Monitor
-# Version: 2.1.0
+# Version: 2.2.0
# Created: 2026-03-02
-# Modified: 2026-07-31
+# Modified: 2026-08-04
# =============================================
"""
@@ -93,12 +93,14 @@ def _connect_broker(repo_root: Path, branch_name: str) -> socket.socket:
def _wake_sender(sender: str, branch_email: str, exit_code: int, lock_file: str) -> str:
"""Wake the dispatcher back after target completion.
- Any citizen sender gets woken back (same availability checks as
- normal wake — interactive session, active lock, depth cap).
+ Builder-class citizens get woken back, subject to the same availability
+ checks as a normal wake (interactive session, active lock, depth cap).
+ Managers never are: wake_branch's manager gate delivers the mail and skips
+ the wake by design, so a manager dispatcher is only ever mailed back.
Returns a result tag for the dispatch_wake.log:
success, blocked_occupied, blocked_locked, blocked_depth,
- skipped_sender, skipped_self, failed
+ skipped_sender, skipped_self, skipped_manager, failed
"""
if not sender or not sender.strip():
logger.info("[monitor] Wake-back skipped — no sender")
@@ -119,8 +121,22 @@ def _wake_sender(sender: str, branch_email: str, exit_code: int, lock_file: str)
from aipass.ai_mail.apps.handlers.dispatch.wake import wake_branch
os.environ["AIPASS_WAKE_DEPTH"] = str(depth + 1)
+ # sender="" terminates the wake-back chain. It also means the @daemon
+ # exception inside the manager gate can never apply here — a wake-back is
+ # never a daemon-scheduled self-wake — so managers always hit the skip path.
wake_status, success = wake_branch(sender, auto=True, sender="")
+ # Must precede the success check: the manager gate returns True having woken
+ # nothing, so trusting the bool alone logged "woken" for a wake that never
+ # happened. The status object was honest all along; read it instead.
+ manager_step = wake_status.find_step("manager")
+ if manager_step and manager_step[0] == "info":
+ logger.info(
+ "[monitor] Wake-back skipped — sender %s is citizen_class=manager (mail delivered, never woken)",
+ sender,
+ )
+ return "skipped_manager"
+
if success:
logger.info("[monitor] Wake-back: %s woken after %s completed (exit %d)", sender, branch_email, exit_code)
return "success"
diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/wake.py b/src/aipass/ai_mail/apps/handlers/dispatch/wake.py
index e93c87f5..4a0d42c2 100644
--- a/src/aipass/ai_mail/apps/handlers/dispatch/wake.py
+++ b/src/aipass/ai_mail/apps/handlers/dispatch/wake.py
@@ -1,9 +1,9 @@
# =================== AIPass ====================
# Name: wake.py
# Description: Manual Branch Wake Handler
-# Version: 2.0.1
+# Version: 2.1.0
# Created: 2026-03-02
-# Modified: 2026-07-31
+# Modified: 2026-08-04
# =============================================
"""
@@ -115,6 +115,18 @@ def format(self) -> str:
lines.append(f"{icon} {label} → {detail}")
return "\n".join(lines)
+ def find_step(self, label: str) -> Optional[Tuple[str, str, str]]:
+ """Return the last (status, label, detail) recorded under `label`, or None.
+
+ Lets callers read a specific gate's own verdict instead of pattern-matching
+ the prose in `summary`. Needed because the overall success bool cannot express
+ "delivered, but deliberately not woken" — see the manager gate in wake_branch.
+ """
+ for step in reversed(self.steps):
+ if step[1] == label:
+ return step
+ return None
+
@property
def summary(self) -> str:
"""Single-line summary from last step."""
@@ -577,6 +589,13 @@ def wake_branch(
Returns:
Tuple of (DispatchStatus with all steps, overall success bool)
+
+ The bool means "the dispatch did what it should", NOT "an agent was woken".
+ A manager target returns True having deliberately woken nothing — mail is
+ delivered and the wake is skipped by design (see Step 3). Callers that need
+ to know whether a process actually started must check
+ status.find_step("manager"): "info" = gate skipped the wake, "ok" = the
+ @daemon self-wake exception applied and the spawn went ahead.
"""
json_handler.log_operation(
"wake_branch", {"branch": branch_email, "fresh": fresh, "auto": auto, "model": model or DEFAULT_MODEL}
@@ -670,10 +689,11 @@ def wake_branch(
prompt = f"Hi. {custom_message} "
else:
prompt = f"{DEFAULT_PROMPT} "
- # Monitor owns lock cleanup end-to-end — telling the agent to delete it
- # too let a second monitor spawn onto a "clear" lock while the first was
- # still alive, then have its own unconditional cleanup steal the second
- # monitor's lock out from under it (lock-theft, observed 2026-07-31).
+ # Monitor owns lock cleanup end-to-end, so the prompt no longer tells the
+ # agent to delete the lock: an agent deleting it while its own monitor is
+ # still alive lets a second monitor spawn onto a "clear" lock, and the two
+ # then race over one lock file. Rationale from reading the cleanup paths —
+ # not a logged incident; the monitor's PID-verified cleanup is the guard.
prompt += (
"IMPORTANT: run any sub-agents synchronously (foreground) and wait for them to "
"finish before ending your turn — headless dispatch kills orphaned background "
diff --git a/src/aipass/ai_mail/apps/handlers/users/user.py b/src/aipass/ai_mail/apps/handlers/users/user.py
index aae07868..ea91a1be 100644
--- a/src/aipass/ai_mail/apps/handlers/users/user.py
+++ b/src/aipass/ai_mail/apps/handlers/users/user.py
@@ -1,9 +1,9 @@
# =================== AIPass ====================
# Name: user.py
# Description: User Info Handler
-# Version: 2.0.0
+# Version: 2.1.0
# Created: 2025-11-30
-# Modified: 2025-11-30
+# Modified: 2026-08-04
# =============================================
"""
@@ -18,6 +18,7 @@
# =============================================
# IMPORTS
# =============================================
+import os
from pathlib import Path
from typing import Dict, Optional
@@ -32,6 +33,37 @@
# =============================================
+def _detection_failure_reason() -> str:
+ """Explain WHY sender detection failed, naming the path actually walked.
+
+ Sender identity comes from the AIPASS_CALLER_* env vars that drone sets,
+ NOT from this process's working directory — drone runs the target branch
+ with cwd=, so Path.cwd() here is always a valid branch and
+ is never the thing that failed. Reporting it as the cause sent two separate
+ investigations chasing a phantom passport problem (error 0bd8b4f5).
+ """
+ caller_branch = os.environ.get("AIPASS_CALLER_BRANCH")
+ caller_cwd = os.environ.get("AIPASS_CALLER_CWD")
+
+ if caller_branch:
+ return (
+ f"AIPASS_CALLER_BRANCH={caller_branch!r} is not a known sender: no matching "
+ "contact, no entry in the AIPass or caller registry, and AIPASS_CALLER_CWD "
+ "is unset so no external identity could be synthesized."
+ )
+ if caller_cwd:
+ return (
+ f"AIPASS_CALLER_CWD={caller_cwd} is not inside a branch — no "
+ ".trinity/passport.json at or above it. The caller invoked drone from "
+ "outside any branch directory (e.g. the repo root); re-run from within "
+ "the sending branch."
+ )
+ return (
+ "No AIPASS_CALLER_BRANCH or AIPASS_CALLER_CWD was set, and no "
+ ".trinity/passport.json was found at or above the working directory."
+ )
+
+
def get_current_user() -> Dict:
"""
Get current user's information from branch detection (AIPASS_REGISTRY.json)
@@ -58,9 +90,10 @@ def get_current_user() -> Dict:
if not branch_info:
raise RuntimeError(
- "BRANCH DETECTION FAILED: Could not detect branch from current directory.\n"
- "AI_MAIL must be called from within a branch directory (with .trinity/passport.json).\n"
- f"Current directory: {Path.cwd()}\n"
+ "BRANCH DETECTION FAILED: Could not resolve the sending branch.\n"
+ f"{_detection_failure_reason()}\n"
+ f"(This process's working directory is {Path.cwd()} — informational only, "
+ "sender identity is NOT resolved from it.)\n"
"No fallback configured - this is intentional to catch bugs."
)
diff --git a/src/aipass/ai_mail/tests/test_dispatch_monitor.py b/src/aipass/ai_mail/tests/test_dispatch_monitor.py
index c971d1aa..e27f3b0a 100644
--- a/src/aipass/ai_mail/tests/test_dispatch_monitor.py
+++ b/src/aipass/ai_mail/tests/test_dispatch_monitor.py
@@ -40,6 +40,7 @@
_wrap_for_sandbox,
main,
)
+from aipass.ai_mail.apps.handlers.dispatch.wake import DispatchStatus
# --- Fixtures --------------------------------------------------------
@@ -2827,3 +2828,85 @@ def fake_run(cmd, stdout_path, stderr_fh, cwd, spawn_env, branch_email, pass_fds
assert exc_info.value.code == 0
mock_bounce.assert_not_called()
+
+
+# === Wake-back honesty: manager senders (VERA field report) =================
+#
+# wake_branch's manager gate delivers the mail and skips the wake by design, but
+# returns (status, True) — so _wake_sender read success=True and logged
+# "woken after ... completed" for a wake that never happened. The status object
+# was honest; the bool was not. These pin the honest tag.
+
+
+class TestWakeBackManagerHonesty:
+ """A manager sender is mailed, never woken — the log must say exactly that."""
+
+ @staticmethod
+ def _manager_gate_status():
+ """Real DispatchStatus as wake_branch's manager gate leaves it."""
+ status = DispatchStatus()
+ status.ok("resolve", "@devpulse → /repo/src/aipass/devpulse")
+ status.info("manager", "@devpulse is a manager — mail only, wake skipped")
+ return status
+
+ @staticmethod
+ def _patch_wake(monkeypatch, status, success):
+ monkeypatch.delenv("AIPASS_WAKE_DEPTH", raising=False)
+ mock_wake = MagicMock(return_value=(status, success))
+ monkeypatch.setattr(
+ "aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch",
+ mock_wake,
+ )
+ return mock_wake
+
+ def test_manager_sender_returns_skipped_manager(self, monkeypatch):
+ """The gate's True must not be reported as a successful wake."""
+ monkeypatch.setattr(mod, "logger", MagicMock())
+ self._patch_wake(monkeypatch, self._manager_gate_status(), True)
+ result = _wake_sender("@devpulse", "@ai_mail", 0, "/fake/lock")
+ assert result == "skipped_manager"
+
+ def test_manager_wake_back_never_claims_woken(self, monkeypatch):
+ """No log line may assert the manager was woken."""
+ mock_logger = MagicMock()
+ monkeypatch.setattr(mod, "logger", mock_logger)
+ self._patch_wake(monkeypatch, self._manager_gate_status(), True)
+
+ _wake_sender("@devpulse", "@ai_mail", 0, "/fake/lock")
+
+ formats = [c.args[0] for c in mock_logger.info.call_args_list if c.args]
+ assert not any("woken after" in f for f in formats), f"claimed a wake: {formats}"
+ assert any("skipped" in f for f in formats), f"no skip logged: {formats}"
+
+ def test_daemon_bypassed_manager_still_reports_success(self, monkeypatch):
+ """The @daemon self-wake exception records manager as ok — that IS a real wake."""
+ monkeypatch.setattr(mod, "logger", MagicMock())
+ status = DispatchStatus()
+ status.ok("manager", "@devpulse manager gate bypassed — daemon-scheduled self-wake")
+ status.ok("spawn", "agent started")
+ self._patch_wake(monkeypatch, status, True)
+ result = _wake_sender("@devpulse", "@ai_mail", 0, "/fake/lock")
+ assert result == "success"
+
+ def test_builder_sender_unaffected(self, monkeypatch):
+ """A normal citizen has no manager step and still reports success."""
+ monkeypatch.setattr(mod, "logger", MagicMock())
+ status = DispatchStatus()
+ status.ok("resolve", "@prax → /repo/src/aipass/prax")
+ status.ok("spawn", "agent started")
+ self._patch_wake(monkeypatch, status, True)
+ result = _wake_sender("@prax", "@ai_mail", 0, "/fake/lock")
+ assert result == "success"
+
+ def test_skipped_manager_reaches_the_wake_log(self, monkeypatch, tmp_path):
+ """The honest tag is what lands in dispatch_wake.log."""
+ monkeypatch.setattr(mod, "logger", MagicMock())
+ lock_file = tmp_path / ".ai_mail.local" / ".dispatch.lock"
+ lock_file.parent.mkdir(parents=True)
+ lock_file.write_text("{}", encoding="utf-8")
+
+ _log_wake_result("@ai_mail", "@devpulse", 0, "skipped_manager", str(lock_file))
+
+ written = (tmp_path / "logs" / "dispatch_wake.log").read_text(encoding="utf-8")
+ assert "wake_result=skipped_manager" in written
+ assert "sender=@devpulse" in written
diff --git a/src/aipass/ai_mail/tests/test_user_paths.py b/src/aipass/ai_mail/tests/test_user_paths.py
index 1d9e449a..ef9cd0cd 100644
--- a/src/aipass/ai_mail/tests/test_user_paths.py
+++ b/src/aipass/ai_mail/tests/test_user_paths.py
@@ -28,7 +28,7 @@
from pathlib import Path
from unittest.mock import patch
-from aipass.ai_mail.apps.handlers.users.user import get_user_by_email, get_all_users
+from aipass.ai_mail.apps.handlers.users.user import get_all_users, get_current_user, get_user_by_email
# ─── Fixtures ────────────────────────────────────────────
@@ -267,3 +267,62 @@ def test_empty_registry_returns_empty_dict(self, tmp_path):
with patch("aipass.ai_mail.apps.handlers.users.branch_detection.BRANCH_REGISTRY_PATH", fake_path):
users = get_all_users()
assert users == {}
+
+
+# ─── Detection-failure diagnostics (error 0bd8b4f5) ──────
+#
+# drone runs the target branch with cwd=, so Path.cwd() inside
+# ai_mail is ALWAYS a valid branch dir and is never the cause of failure.
+# Sender identity comes from the AIPASS_CALLER_* env vars. The old message
+# blamed the (valid) working directory and sent two investigations chasing a
+# phantom passport problem.
+
+
+class TestDetectionFailureDiagnostics:
+ """get_current_user()'s failure message must name the real cause."""
+
+ def test_non_branch_caller_cwd_names_the_env_var(self, tmp_path, monkeypatch):
+ """Caller ran drone from outside a branch — say so, and name the path."""
+ monkeypatch.delenv("AIPASS_CALLER_BRANCH", raising=False)
+ monkeypatch.setenv("AIPASS_CALLER_CWD", str(tmp_path))
+
+ with pytest.raises(RuntimeError) as exc_info:
+ get_current_user()
+
+ msg = str(exc_info.value)
+ assert "AIPASS_CALLER_CWD" in msg
+ assert str(tmp_path) in msg
+ assert "not inside a branch" in msg
+
+ def test_failure_message_does_not_blame_the_valid_cwd(self, tmp_path, monkeypatch):
+ """The process CWD is valid — it must not be presented as the cause."""
+ monkeypatch.delenv("AIPASS_CALLER_BRANCH", raising=False)
+ monkeypatch.setenv("AIPASS_CALLER_CWD", str(tmp_path))
+
+ with pytest.raises(RuntimeError) as exc_info:
+ get_current_user()
+
+ msg = str(exc_info.value)
+ # The old wording told the reader to go look for a passport in cwd
+ assert "must be called from within a branch directory" not in msg
+ assert "informational only" in msg
+
+ def test_unknown_caller_branch_reported(self, monkeypatch):
+ """An unresolvable AIPASS_CALLER_BRANCH is named explicitly."""
+ monkeypatch.setenv("AIPASS_CALLER_BRANCH", "ghostbranch")
+ monkeypatch.delenv("AIPASS_CALLER_CWD", raising=False)
+
+ with pytest.raises(RuntimeError) as exc_info:
+ get_current_user()
+
+ msg = str(exc_info.value)
+ assert "ghostbranch" in msg
+ assert "not a known sender" in msg
+
+ def test_fingerprint_prefix_preserved(self, tmp_path, monkeypatch):
+ """Keep the BRANCH DETECTION FAILED prefix — trigger fingerprints on it."""
+ monkeypatch.delenv("AIPASS_CALLER_BRANCH", raising=False)
+ monkeypatch.setenv("AIPASS_CALLER_CWD", str(tmp_path))
+
+ with pytest.raises(RuntimeError, match="BRANCH DETECTION FAILED"):
+ get_current_user()
diff --git a/src/aipass/ai_mail/tests/test_wake.py b/src/aipass/ai_mail/tests/test_wake.py
index 0962e37b..8d8897f7 100644
--- a/src/aipass/ai_mail/tests/test_wake.py
+++ b/src/aipass/ai_mail/tests/test_wake.py
@@ -94,6 +94,39 @@ def test_dispatch_status_summary_empty():
assert ds.summary == "no status"
+def test_dispatch_status_find_step_returns_match():
+ """find_step() returns the full (status, label, detail) tuple."""
+ ds = DispatchStatus()
+ ds.ok("resolve", "found")
+ ds.info("manager", "@devpulse is a manager — mail only, wake skipped")
+ assert ds.find_step("manager") == ("info", "manager", "@devpulse is a manager — mail only, wake skipped")
+
+
+def test_dispatch_status_find_step_missing_returns_none():
+ """find_step() returns None when the label was never recorded."""
+ ds = DispatchStatus()
+ ds.ok("resolve", "found")
+ assert ds.find_step("manager") is None
+
+
+def test_dispatch_status_find_step_distinguishes_gate_outcomes():
+ """The manager label carries ok (daemon bypass) vs info (skipped) — callers rely on it."""
+ skipped = DispatchStatus()
+ skipped.info("manager", "mail only, wake skipped")
+ bypassed = DispatchStatus()
+ bypassed.ok("manager", "gate bypassed — daemon-scheduled self-wake")
+ assert skipped.find_step("manager")[0] == "info"
+ assert bypassed.find_step("manager")[0] == "ok"
+
+
+def test_dispatch_status_find_step_returns_last_occurrence():
+ """A repeated label resolves to the most recent record."""
+ ds = DispatchStatus()
+ ds.info("spawn", "first try")
+ ds.ok("spawn", "second try")
+ assert ds.find_step("spawn") == ("ok", "spawn", "second try")
+
+
def test_dispatch_status_format_output():
"""format() produces multi-line output with icons."""
ds = DispatchStatus()
diff --git a/src/aipass/aipass/README.md b/src/aipass/aipass/README.md
index dcac76da..ce0b010c 100644
--- a/src/aipass/aipass/README.md
+++ b/src/aipass/aipass/README.md
@@ -42,7 +42,7 @@ aipass/
│ ├── handlers/
│ │ ├── cross_os/ # Cross-OS pre-flight: gap_registry, preflight, run_record
│ │ ├── handoff_platform/ # Platform-specific handoff detection
-│ │ ├── init/ # bootstrap.py, scaffold_content.py
+│ │ ├── init/ # bootstrap.py, scaffold_content.py, git_auth.py
│ │ ├── new_project/ # Project creation logic (registry, template, scaffold, git init)
│ │ │ └── adopt.py # Project adoption logic (additive scaffold onto an existing dir)
│ │ ├── json/ # JSON read/write utilities
@@ -53,7 +53,7 @@ aipass/
│ │ ├── system_detect/ # OS, shell, Python, RAM, CPU
│ │ └── ui/ # Progress bars, menus, banners
│ └── plugins/
-├── tests/ # 785 passing
+├── tests/ # 934 passing
├── requirements.project.txt # Project-specific Python dependencies
├── .trinity/ # Identity + session history + observations
└── README.md
@@ -72,6 +72,8 @@ aipass/
| `aipass doctor --cross-os --e2e` | ...also runs the real e2e wiring suite (heavy, opt-in) |
| `aipass doctor --cross-os --record [PATH]` | Write a machine-filled Run Record for the human acceptance pass |
| `aipass init` | 10-stage guided setup (resumable) |
+| `aipass init update [target]` | Refresh managed scaffold + provision owner-tier git auth |
+| `aipass init update --dry-run` | Preview the git-auth repairs only — writes nothing |
| `aipass install` | One-command bootstrap — clone + setup.sh + hooks, then hand off to init |
| `aipass profile` | Show/edit user profile |
| `aipass new ` | Create a project in projects/ — own git repo, AIPass scaffold, resident agent |
@@ -103,7 +105,7 @@ Humans only. Nothing in AIPass depends on this branch.
## Tests
-723 passing — `pytest src/aipass/aipass/tests/`
+934 passing — `pytest src/aipass/aipass/tests/`
## Known Issues
@@ -111,4 +113,4 @@ Humans only. Nothing in AIPass depends on this branch.
## Last Updated
-Last Updated: 2026-07-17
+Last Updated: 2026-08-04
diff --git a/src/aipass/aipass/apps/handlers/init/__init__.py b/src/aipass/aipass/apps/handlers/init/__init__.py
index 98c2ce96..e160d90c 100644
--- a/src/aipass/aipass/apps/handlers/init/__init__.py
+++ b/src/aipass/aipass/apps/handlers/init/__init__.py
@@ -6,7 +6,12 @@
# Modified: 2026-05-04
# =============================================
-"""Init handler package — public entry point for bootstrap and scaffold_content."""
+"""Init handler package — public entry point for bootstrap and scaffold_content.
+
+``git_auth`` is deliberately NOT re-exported here. Importing this package must
+stay safe on a machine where prax does not exist yet (bootstrap.py runs during
+first install); git_auth uses prax and is imported by its full module path.
+"""
from aipass.aipass.apps.handlers.init.bootstrap import (
_sanitize_name,
diff --git a/src/aipass/aipass/apps/handlers/init/git_auth.py b/src/aipass/aipass/apps/handlers/init/git_auth.py
new file mode 100644
index 00000000..9af215eb
--- /dev/null
+++ b/src/aipass/aipass/apps/handlers/init/git_auth.py
@@ -0,0 +1,486 @@
+# =================== AIPass ====================
+# Name: git_auth.py
+# Description: Init handler — provision a project for manager-class git (owner-tier)
+# Version: 1.0.0
+# Created: 2026-08-04
+# Modified: 2026-08-04
+# =============================================
+
+"""
+Git-auth provisioning handler - PRIVATE implementation
+
+Project-side counterpart to drone's owner-tier gate (DPLAN-0281). Drone grants
+git write to a caller iff ALL FOUR of these hold:
+
+ 1. the caller's passport declares ``citizen_class: manager``
+ 2. tenancy — passport ``citizenship.registry_id`` == registry ``metadata.id``
+ 3. the project registry lists the caller with ``owner: true``
+ 4. path-binding — the passport is presented from at/under the registry-recorded
+ path for that entry
+
+This handler makes those four true for a consuming project, or refuses with an
+error that names the exact fix. It repairs; it never guesses:
+
+ - mints ``metadata.id`` when the registry has none
+ - backfills the owner's passport ``citizenship.registry_id`` to match
+ - flips the owner's ``citizen_class`` to ``manager``
+ - writes ``owner: true`` onto the owner's registry entry, and records the
+ owner's real branch directory as its path
+
+GUARDRAIL (from @drone, non-negotiable): an owner entry must NEVER record the
+repo root or a dot path. Path-binding is at-or-under, so a root path degrades
+authority to repo-wide — any directory in the repo could then host a forged
+passport and hold git. That case refuses instead of repairing.
+
+Owner selection never guesses. The citizen already marked owner (registry entry
+``owner: true``, or passport ``citizenship.owner: true``) IS the owner. With
+none marked, or more than one, the run refuses and says what to add.
+
+RULES:
+ - No CLI output — returns dicts, raises GitAuthRefusal; the module prints
+ - Reads and writes go through json_handler (atomic write + fsync)
+ - No hardcoded paths
+"""
+
+import os
+import uuid
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Tuple
+
+from aipass.prax import logger
+
+from aipass.aipass.apps.handlers.json import json_handler
+
+MANAGER_CLASS = "manager"
+
+# Directories never worth walking when locating a citizen's branch directory.
+_SKIP_DIRS = frozenset({".git", ".venv", "venv", "node_modules", "__pycache__", ".mypy_cache", ".pytest_cache"})
+
+# Depth cap for that walk — citizens live a few levels down (src//),
+# never at the bottom of a deep tree.
+_MAX_SCAN_DEPTH = 6
+
+
+class GitAuthRefusal(ValueError):
+ """Provisioning cannot proceed honestly — message names the required fix."""
+
+
+# =============================================================================
+# JSON I/O
+# =============================================================================
+
+
+def _read_json(path: Path) -> Dict[str, Any]:
+ """Read a JSON object from *path*, raising GitAuthRefusal on bad content."""
+ data = json_handler.load_path(path)
+ if not isinstance(data, dict):
+ raise GitAuthRefusal(f"{path} could not be read as a JSON object — fix or restore the file, then re-run")
+ return data
+
+
+def _read_passport(path: Path) -> Optional[Dict[str, Any]]:
+ """Read a passport, or None when it is missing or unreadable.
+
+ Used by the scans, where one unreadable passport must not sink the run —
+ the owner's own passport is read with ``_read_json`` and does refuse.
+ """
+ if not path.is_file():
+ return None
+ data = json_handler.load_path(path)
+ if not isinstance(data, dict):
+ logger.warning("[git-auth] Passport at %s is not readable JSON — skipping", path)
+ return None
+ return data
+
+
+def _write_json(path: Path, data: Dict[str, Any]) -> None:
+ """Write *data* to *path* atomically, refusing loudly if the write fails.
+
+ A half-written registry locks every citizen out of its own project, so the
+ file is only ever swapped in complete — and a failed write is never
+ reported as a repair.
+ """
+ if not json_handler.save_path(path, data):
+ raise GitAuthRefusal(f"{path} could not be written — check file permissions, then re-run")
+
+
+# =============================================================================
+# Registry + passport reading
+# =============================================================================
+
+
+def find_registry(target: Path) -> Optional[Path]:
+ """Walk up from *target* for a ``*_REGISTRY.json``; None when there is none.
+
+ Matches drone's discovery: the glob convention (``VERA-STUDIO_REGISTRY.json``)
+ and sorted-first so a directory holding two registries resolves the same way
+ on every platform.
+ """
+ current = target.resolve()
+ for candidate in [current, *current.parents]:
+ matches = sorted(candidate.glob("*_REGISTRY.json"))
+ if matches:
+ return matches[0]
+ return None
+
+
+def _entries(registry_data: Dict[str, Any]) -> List[Dict[str, Any]]:
+ """Return branch entries as a list of live dicts (both registry shapes).
+
+ Registries are authored as a list of objects or as a name-keyed dict. The
+ dicts inside are returned by reference, so mutating one here mutates what
+ gets written back — the file's own shape is preserved, never reshaped.
+ """
+ branches = registry_data.get("branches", [])
+ if isinstance(branches, dict):
+ found = []
+ for key, entry in branches.items():
+ if isinstance(entry, dict):
+ entry.setdefault("name", key)
+ found.append(entry)
+ return found
+ return [entry for entry in branches if isinstance(entry, dict)]
+
+
+def _citizen_class(passport: Dict[str, Any]) -> str:
+ """Read citizen_class from either passport layout, '' when absent."""
+ identity = passport.get("identity", {})
+ branch_info = passport.get("branch_info", {})
+ return identity.get("citizen_class") or branch_info.get("citizen_class") or ""
+
+
+def _resolved_path(entry: Dict[str, Any], repo_root: Path) -> Optional[Path]:
+ """Resolve an entry's recorded path against the repo root, None when unset.
+
+ Relative paths resolve against the repo root — never CWD, which would bind
+ authority to wherever the user happened to be standing.
+ """
+ raw = entry.get("path")
+ if not raw or not str(raw).strip():
+ return None
+ recorded = Path(str(raw))
+ if not recorded.is_absolute():
+ recorded = repo_root / recorded
+ try:
+ return recorded.resolve()
+ except OSError as exc:
+ logger.warning("Registry path %s could not be resolved: %s", recorded, exc)
+ return None
+
+
+def _locate_branch_dir(repo_root: Path, name: str) -> Optional[Path]:
+ """Find the citizen's real branch directory by its passport, or None.
+
+ Used only when the registry records no path at all. The repo root is never
+ a candidate — a root-level passport would mean root-level path-binding,
+ which is exactly what the guardrail exists to prevent.
+ """
+ wanted = name.lower()
+ root_depth = len(repo_root.parts)
+ for dirpath, dirnames, _filenames in os.walk(repo_root):
+ current = Path(dirpath)
+ if len(current.parts) - root_depth >= _MAX_SCAN_DEPTH:
+ dirnames[:] = []
+ continue
+ dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
+ if current == repo_root:
+ continue
+ passport = _read_passport(current / ".trinity" / "passport.json")
+ if passport is None:
+ continue
+ branch_name = passport.get("branch_info", {}).get("branch_name") or passport.get("identity", {}).get("name")
+ if str(branch_name or "").lower() == wanted:
+ return current.resolve()
+ return None
+
+
+# =============================================================================
+# Owner selection — marked, never guessed
+# =============================================================================
+
+
+def _select_owner(entries: List[Dict[str, Any]], repo_root: Path) -> Tuple[Dict[str, Any], bool]:
+ """Return (owner_entry, needs_owner_flag). Refuses rather than guessing.
+
+ First source is the registry's own ``owner: true``. Only when no entry
+ carries it do passports get consulted, and a passport claim is then written
+ back to the registry (drone reads the registry, not the passport).
+ """
+ flagged = [entry for entry in entries if entry.get("owner") is True]
+ if len(flagged) == 1:
+ return flagged[0], False
+ if len(flagged) > 1:
+ names = ", ".join(sorted(str(entry.get("name", "?")) for entry in flagged))
+ raise GitAuthRefusal(
+ f"more than one citizen is marked owner: true ({names}) — owner-tier binds to exactly one "
+ "citizen, so remove owner: true from every entry except the project owner, then re-run"
+ )
+
+ claimed = []
+ for entry in entries:
+ branch_dir = _resolved_path(entry, repo_root)
+ if branch_dir is None:
+ continue
+ passport = _read_passport(branch_dir / ".trinity" / "passport.json")
+ if passport is None:
+ continue
+ if passport.get("citizenship", {}).get("owner") is True:
+ claimed.append(entry)
+
+ if len(claimed) == 1:
+ return claimed[0], True
+ if len(claimed) > 1:
+ names = ", ".join(sorted(str(entry.get("name", "?")) for entry in claimed))
+ raise GitAuthRefusal(
+ f"more than one passport claims citizenship.owner: true ({names}) — mark the one project "
+ "owner with owner: true in the registry, then re-run"
+ )
+
+ listed = ", ".join(sorted(str(entry.get("name", "?")) for entry in entries)) or "(no citizens listed)"
+ raise GitAuthRefusal(
+ 'no citizen is marked as the project owner, and this never guesses — add "owner": true to the '
+ f"owning citizen's entry in the registry, then re-run. Citizens listed: {listed}"
+ )
+
+
+# =============================================================================
+# Independent verification — re-read from disk, never trust the repair's own count
+# =============================================================================
+
+
+def verify_git_auth(registry_path: Path, owner_name: str) -> List[str]:
+ """Re-check drone's four conditions from disk; return the failures.
+
+ Deliberately independent of the repair pass: it re-reads both files and
+ re-derives every answer, so a repair that reported success but wrote the
+ wrong thing still shows up as a failure here.
+ """
+ failures: List[str] = []
+ try:
+ registry_data = _read_json(registry_path)
+ except GitAuthRefusal as exc:
+ logger.warning("[git-auth] Verification could not read registry %s: %s", registry_path, exc)
+ return [str(exc)]
+
+ repo_root = registry_path.parent.resolve()
+ entry = next(
+ (e for e in _entries(registry_data) if str(e.get("name", "")).lower() == owner_name.lower()),
+ None,
+ )
+ if entry is None:
+ return [f"check 3 (owner flag): '{owner_name}' is not listed in {registry_path.name}"]
+
+ registry_id = registry_data.get("metadata", {}).get("id")
+ if not registry_id:
+ failures.append(f"check 2 (tenancy): {registry_path.name} still declares no metadata.id")
+ if entry.get("owner") is not True:
+ failures.append(f"check 3 (owner flag): entry for '{owner_name}' is not marked owner: true")
+
+ branch_dir = _resolved_path(entry, repo_root)
+ if branch_dir is None:
+ failures.append(f"check 4 (path-binding): entry for '{owner_name}' records no path")
+ return failures
+ if branch_dir == repo_root:
+ failures.append(f"check 4 (path-binding): entry for '{owner_name}' records the repo root")
+ return failures
+
+ passport_path = branch_dir / ".trinity" / "passport.json"
+ if not passport_path.is_file():
+ failures.append(f"check 1 (manager class): no passport at {passport_path}")
+ return failures
+ try:
+ passport = _read_json(passport_path)
+ except GitAuthRefusal as exc:
+ logger.warning("[git-auth] Verification could not read passport %s: %s", passport_path, exc)
+ failures.append(str(exc))
+ return failures
+
+ if _citizen_class(passport) != MANAGER_CLASS:
+ failures.append(
+ f"check 1 (manager class): '{owner_name}' is citizen_class "
+ f"'{_citizen_class(passport) or 'unset'}', not '{MANAGER_CLASS}'"
+ )
+ passport_id = passport.get("citizenship", {}).get("registry_id")
+ if registry_id and passport_id != registry_id:
+ failures.append(
+ f"check 2 (tenancy): '{owner_name}' passport registry_id "
+ f"{passport_id or '(unset)'} != registry metadata.id {registry_id}"
+ )
+ return failures
+
+
+# =============================================================================
+# Public entry point
+# =============================================================================
+
+
+def provision_git_auth(target: Path, dry_run: bool = False) -> Dict[str, Any]:
+ """Provision or repair *target* so its owner citizen can hold git.
+
+ Plans every repair before writing anything, so a run that must refuse
+ (root path, no marked owner, missing passport) leaves the project exactly
+ as it found it rather than half-provisioned.
+
+ Args:
+ target: A directory inside the project (the registry is found upward).
+ dry_run: Plan and report the repairs without writing them.
+
+ Returns:
+ dict with ``registry``, ``owner``, ``repairs``, ``already_ok``,
+ ``verified``, ``verify_failures``, ``dry_run``.
+
+ Raises:
+ GitAuthRefusal: The four conditions cannot be made true honestly. The
+ message names what a human must add or correct.
+ """
+ registry_path = find_registry(Path(target))
+ if registry_path is None:
+ raise GitAuthRefusal(
+ f"no *_REGISTRY.json found at or above {Path(target).resolve()} — "
+ "this is not an AIPass project; run 'aipass init' first"
+ )
+
+ repo_root = registry_path.parent.resolve()
+ registry_data = _read_json(registry_path)
+ entries = _entries(registry_data)
+
+ owner_entry, needs_owner_flag = _select_owner(entries, repo_root)
+ owner_name = str(owner_entry.get("name", "?"))
+
+ repairs: List[str] = []
+ already_ok: List[str] = []
+ registry_dirty = False
+
+ # --- check 3: owner: true on the registry entry ---
+ if needs_owner_flag:
+ owner_entry["owner"] = True
+ registry_dirty = True
+ repairs.append(f"registry: marked '{owner_name}' owner: true (its passport already claimed ownership)")
+ else:
+ already_ok.append(f"registry: '{owner_name}' already marked owner: true")
+
+ # --- check 2 (registry half): metadata.id ---
+ registry_id = registry_data.get("metadata", {}).get("id")
+ if not registry_id:
+ registry_id = str(uuid.uuid4())
+ registry_data.setdefault("metadata", {})["id"] = registry_id
+ registry_dirty = True
+ repairs.append(f"registry: minted metadata.id {registry_id}")
+ else:
+ already_ok.append(f"registry: metadata.id already set ({registry_id})")
+
+ # --- check 4: path-binding, with the never-a-root-path guardrail ---
+ raw_path = owner_entry.get("path")
+ branch_dir = _resolved_path(owner_entry, repo_root)
+ if branch_dir is not None and branch_dir == repo_root:
+ raise GitAuthRefusal(
+ f"registry entry for '{owner_name}' records path '{raw_path}', which is the project root "
+ f"({repo_root}). Path-binding is at-or-under, so a root path would let a passport placed "
+ "ANYWHERE in this repo hold git. Record the citizen's own branch directory instead "
+ f"(for example 'src//{owner_name.lower()}'), then re-run"
+ )
+
+ if branch_dir is None:
+ located = _locate_branch_dir(repo_root, owner_name)
+ if located is None:
+ raise GitAuthRefusal(
+ f"registry entry for '{owner_name}' records no path, and no directory under {repo_root} "
+ 'holds a passport with that branch_name — add "path" pointing at the citizen\'s own '
+ "branch directory (never the repo root), then re-run"
+ )
+ try:
+ recorded = located.relative_to(repo_root).as_posix()
+ except ValueError as exc:
+ # Symlinked citizen dir that resolves outside the root — record the
+ # absolute path so path-binding still has something exact to compare.
+ logger.info("[git-auth] %s is not under %s (%s) — recording absolute path", located, repo_root, exc)
+ recorded = str(located)
+ owner_entry["path"] = recorded
+ branch_dir = located
+ registry_dirty = True
+ repairs.append(f"registry: recorded path '{recorded}' for '{owner_name}' (found by its passport)")
+ else:
+ if repo_root not in branch_dir.parents:
+ raise GitAuthRefusal(
+ f"registry entry for '{owner_name}' records path '{raw_path}', which resolves outside the "
+ f"project ({branch_dir}). Path-binding compares against the passport's own directory inside "
+ "this repo, so an outside path can never match. Record the citizen's branch directory "
+ "relative to the project root, then re-run"
+ )
+ if not branch_dir.is_dir():
+ raise GitAuthRefusal(
+ f"registry entry for '{owner_name}' records path '{raw_path}', but {branch_dir} does not "
+ "exist. Correct the path to the citizen's real branch directory, then re-run"
+ )
+ already_ok.append(f"registry: '{owner_name}' path already bound to {branch_dir}")
+
+ # --- checks 1 + 2 (passport half) ---
+ passport_path = branch_dir / ".trinity" / "passport.json"
+ if not passport_path.is_file():
+ raise GitAuthRefusal(
+ f"'{owner_name}' has no passport at {passport_path} — an owner entry must point at a real "
+ "citizen directory. Create the citizen (drone @spawn create ) or correct the entry's "
+ "path, then re-run"
+ )
+
+ passport = _read_json(passport_path)
+ passport_dirty = False
+
+ current_class = _citizen_class(passport)
+ if current_class != MANAGER_CLASS:
+ passport.setdefault("identity", {})["citizen_class"] = MANAGER_CLASS
+ # Older passports carry the class under branch_info too. Drone reads
+ # identity first, but a stale second copy is a trap for the next reader.
+ if "citizen_class" in passport.get("branch_info", {}):
+ passport["branch_info"]["citizen_class"] = MANAGER_CLASS
+ passport_dirty = True
+ repairs.append(f"passport: '{owner_name}' citizen_class {current_class or '(unset)'} → {MANAGER_CLASS}")
+ else:
+ already_ok.append(f"passport: '{owner_name}' already citizen_class {MANAGER_CLASS}")
+
+ passport_id = passport.get("citizenship", {}).get("registry_id")
+ if passport_id != registry_id:
+ passport.setdefault("citizenship", {})["registry_id"] = registry_id
+ passport_dirty = True
+ repairs.append(f"passport: '{owner_name}' citizenship.registry_id {passport_id or '(unset)'} → {registry_id}")
+ else:
+ already_ok.append(f"passport: '{owner_name}' tenancy already matches metadata.id")
+
+ # --- apply ---
+ if not dry_run:
+ if registry_dirty:
+ _write_json(registry_path, registry_data)
+ logger.info("[git-auth] Registry %s updated for owner %s", registry_path.name, owner_name)
+ if passport_dirty:
+ _write_json(passport_path, passport)
+ logger.info("[git-auth] Passport for %s updated", owner_name)
+
+ verify_failures = [] if dry_run else verify_git_auth(registry_path, owner_name)
+ if verify_failures:
+ logger.warning("[git-auth] Verification failed after repair: %s", "; ".join(verify_failures))
+
+ # Granting a citizen git write is worth an audit trail — who, where, and
+ # exactly what was changed to make it true.
+ json_handler.log_operation(
+ "git_auth_provision",
+ {
+ "registry": str(registry_path),
+ "owner": owner_name,
+ "owner_path": str(branch_dir),
+ "repairs": repairs,
+ "verify_failures": verify_failures,
+ "dry_run": dry_run,
+ },
+ )
+
+ return {
+ "registry": str(registry_path),
+ "repo_root": str(repo_root),
+ "owner": owner_name,
+ "owner_path": str(branch_dir),
+ "repairs": repairs,
+ "already_ok": already_ok,
+ "verified": (not verify_failures) if not dry_run else None,
+ "verify_failures": verify_failures,
+ "dry_run": dry_run,
+ }
diff --git a/src/aipass/aipass/apps/modules/init_flow.py b/src/aipass/aipass/apps/modules/init_flow.py
index 22cb4a7f..10e37c18 100644
--- a/src/aipass/aipass/apps/modules/init_flow.py
+++ b/src/aipass/aipass/apps/modules/init_flow.py
@@ -921,6 +921,8 @@ def print_help() -> None:
console.print(" [green]aipass init run --template [/green] [dim]# select template[/dim]")
console.print(" [green]aipass init run --dry-run[/green] [dim]# walk all stages, no writes[/dim]")
console.print(" [green]aipass init --list[/green] [dim]# list available templates[/dim]")
+ console.print(" [green]aipass init update [target][/green] [dim]# refresh scaffold + git auth[/dim]")
+ console.print(" [green]aipass init update --dry-run[/green] [dim]# preview git-auth repairs only[/dim]")
console.print()
console.print("[yellow]STAGES:[/yellow] 10 stages, each saved — resume on ctrl-C")
console.print()
@@ -966,11 +968,64 @@ def _handle_init_scaffold(args: list[str]) -> int:
return 1
+def _run_git_auth_provisioning(target: Path, dry_run: bool = False) -> int:
+ """Provision the project for manager-class git; print every repair.
+
+ Returns 0 when the four owner-tier conditions hold (or already held), 1 when
+ the run refused. A refusal is not a crash — it names what a human must add,
+ and nothing was written.
+ """
+ from aipass.aipass.apps.handlers.init.git_auth import GitAuthRefusal, provision_git_auth
+
+ console.print()
+ console.print("[bold]Git authorization (owner-tier)[/bold]")
+ try:
+ result = provision_git_auth(target, dry_run=dry_run)
+ except GitAuthRefusal as exc:
+ cli_error(f"Cannot provision git authorization: {exc}")
+ logger.warning("[init_flow] git-auth provisioning refused: %s", exc)
+ json_handler.log_operation("aipass_git_auth", {"target": str(target), "refusal": str(exc)})
+ return 1
+
+ repairs = result["repairs"]
+ verb = "would repair" if dry_run else "repaired"
+ if repairs:
+ success(f"Owner '{result['owner']}' — {verb} {len(repairs)} item(s):")
+ for item in repairs:
+ console.print(f" + {item}")
+ else:
+ success(f"Owner '{result['owner']}' already holds owner-tier — nothing to repair.")
+ for item in result["already_ok"]:
+ console.print(f" [dim]· {item}[/dim]")
+
+ if dry_run:
+ console.print("[dim]Preview only — no files written.[/dim]")
+ elif result["verified"]:
+ success("Verified against all four owner-tier checks.")
+ else:
+ cli_error("Repairs written, but verification still fails:")
+ for item in result["verify_failures"]:
+ console.print(f" - {item}")
+
+ # No log_operation on this path — the handler already recorded exactly what
+ # it wrote; a second entry here would only duplicate it.
+ return 0 if dry_run or result["verified"] else 1
+
+
def _handle_init_update(args: list[str]) -> int:
- """Handle `aipass init update [target]` — refresh managed scaffold files."""
+ """Handle `aipass init update [target] [--dry-run]` — refresh scaffold + git auth."""
from aipass.aipass.apps.handlers.init.bootstrap import update_project
- target = Path(args[0]) if args else Path.cwd()
+ dry_run = "--dry-run" in args
+ positional = [a for a in args if not a.startswith("--")]
+ target = Path(positional[0]) if positional else Path.cwd()
+
+ if dry_run:
+ # Scaffold refresh has no preview mode, so say so rather than implying
+ # this previewed the whole command.
+ console.print("[dim]Preview mode — scaffold refresh skipped; git-auth provisioning is planned only.[/dim]")
+ return _run_git_auth_provisioning(target, dry_run=True)
+
try:
result = update_project(target)
updated = result.get("updated_files", [])
@@ -1015,12 +1070,16 @@ def _handle_init_update(args: list[str]) -> int:
logger.warning("[init_flow] registry sync during update skipped: %s", sync_exc)
json_handler.log_operation("aipass_init_update", {"target": str(target), "result": result})
- return 0
except Exception as exc:
logger.warning("[init_flow] update failed: %s", exc)
cli_error(f"Update failed: {exc}")
return 1
+ # Last, so it wins: sync-registry above may migrate a legacy citizen_class,
+ # and the owner's class must end up 'manager' for drone to grant git. Outside
+ # the try so a git-auth fault is never reported as a scaffold-update failure.
+ return _run_git_auth_provisioning(target)
+
def _handle_init_agent(args: list[str]) -> int:
"""Handle `aipass init agent ` — create a new agent via spawn."""
diff --git a/src/aipass/aipass/tests/test_git_auth.py b/src/aipass/aipass/tests/test_git_auth.py
new file mode 100644
index 00000000..b4c45d6e
--- /dev/null
+++ b/src/aipass/aipass/tests/test_git_auth.py
@@ -0,0 +1,472 @@
+# =================== AIPass ====================
+# Name: test_git_auth.py
+# Description: Tests for the init git-auth provisioning handler (DPLAN-0281 P2)
+# Version: 1.0.0
+# Created: 2026-08-04
+# Modified: 2026-08-04
+# =============================================
+
+"""Tests for ``aipass init update``'s git-auth provisioning (DPLAN-0281 P2).
+
+Covers the repair set that makes drone's four owner-tier checks true for a
+consuming project, the guardrail refusals that must never be repaired around,
+and the independent post-repair verification.
+"""
+
+import json
+from pathlib import Path
+from typing import Any, Dict, Optional
+
+import pytest
+
+from aipass.aipass.apps.handlers.init.git_auth import (
+ GitAuthRefusal,
+ find_registry,
+ provision_git_auth,
+ verify_git_auth,
+)
+
+
+# =============================================================================
+# Fixtures / builders
+# =============================================================================
+
+
+def _write(path: Path, data: Dict[str, Any]) -> None:
+ """Write a JSON file, creating parent directories as needed."""
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(data, indent=2), encoding="utf-8")
+
+
+def build_project(
+ root: Path,
+ *,
+ registry_id: Optional[str] = "8fb38c96-880d-43d6-823b-98f4b9559194",
+ owner_flag: bool = True,
+ owner_path: Optional[str] = "src/demo/vera",
+ citizen_class: str = "builder",
+ passport_registry_id: Optional[str] = "8fb38c96-880d-43d6-823b-98f4b9559194",
+ passport_owner: Optional[bool] = None,
+ make_passport: bool = True,
+ branches_as_dict: bool = False,
+) -> Path:
+ """Create a minimal external AIPass project and return its registry path.
+
+ Defaults reproduce the live Vera-Studio shape: owner seated, tenancy
+ matching, real recorded path — only the manager-class flip missing.
+ """
+ metadata: Dict[str, Any] = {"name": "DEMO", "version": "1.0.0"}
+ if registry_id is not None:
+ metadata["id"] = registry_id
+
+ vera: Dict[str, Any] = {"name": "VERA", "email": "@vera", "created": "2026-04-08"}
+ if owner_path is not None:
+ vera["path"] = owner_path
+ if owner_flag:
+ vera["owner"] = True
+
+ writer: Dict[str, Any] = {"name": "WRITER", "path": "src/demo/writer", "created": "2026-05-01"}
+
+ branches: Any
+ if branches_as_dict:
+ branches = {"vera": vera, "writer": writer}
+ else:
+ branches = [vera, writer]
+
+ registry_path = root / "DEMO_REGISTRY.json"
+ _write(registry_path, {"metadata": metadata, "branches": branches})
+
+ if make_passport and owner_path:
+ citizenship: Dict[str, Any] = {"registered": True}
+ if passport_registry_id is not None:
+ citizenship["registry_id"] = passport_registry_id
+ if passport_owner is not None:
+ citizenship["owner"] = passport_owner
+ _write(
+ root / owner_path / ".trinity" / "passport.json",
+ {
+ "branch_info": {"branch_name": "VERA", "path": owner_path},
+ "identity": {"citizen_class": citizen_class, "role": "ceo"},
+ "citizenship": citizenship,
+ },
+ )
+ return registry_path
+
+
+def read_json(path: Path) -> Dict[str, Any]:
+ """Read a JSON file written by the builders."""
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
+def owner_entry(registry_path: Path, name: str = "VERA") -> Dict[str, Any]:
+ """Return the named branch entry from either registry shape."""
+ branches = read_json(registry_path)["branches"]
+ if isinstance(branches, dict):
+ branches = list(branches.values())
+ return next(b for b in branches if b.get("name") == name)
+
+
+# =============================================================================
+# Registry discovery
+# =============================================================================
+
+
+def test_find_registry_walks_up_from_a_subdirectory(tmp_path: Path) -> None:
+ """A citizen standing in its own branch dir still finds the project registry."""
+ registry_path = build_project(tmp_path)
+ found = find_registry(tmp_path / "src" / "demo" / "vera")
+ assert found == registry_path
+
+
+def test_find_registry_returns_none_outside_a_project(tmp_path: Path) -> None:
+ """A directory with no registry above it is not an AIPass project."""
+ assert find_registry(tmp_path) is None
+
+
+def test_provision_refuses_when_no_registry_exists(tmp_path: Path) -> None:
+ """Refusal names 'aipass init' rather than crashing on a missing registry."""
+ with pytest.raises(GitAuthRefusal) as exc:
+ provision_git_auth(tmp_path)
+ assert "aipass init" in str(exc.value)
+
+
+# =============================================================================
+# The repair set
+# =============================================================================
+
+
+def test_flips_owner_citizen_class_to_manager(tmp_path: Path) -> None:
+ """The live Vera-Studio case: everything set but the builder→manager flip."""
+ build_project(tmp_path)
+
+ result = provision_git_auth(tmp_path)
+
+ passport = read_json(tmp_path / "src" / "demo" / "vera" / ".trinity" / "passport.json")
+ assert passport["identity"]["citizen_class"] == "manager"
+ assert result["owner"] == "VERA"
+ assert result["verified"] is True
+ assert any("citizen_class builder → manager" in r for r in result["repairs"])
+ assert len(result["repairs"]) == 1
+
+
+def test_mints_metadata_id_and_backfills_passport_tenancy(tmp_path: Path) -> None:
+ """A registry with no id gets one, and the owner's passport is aligned to it."""
+ registry_path = build_project(tmp_path, registry_id=None, passport_registry_id=None)
+
+ result = provision_git_auth(tmp_path)
+
+ minted = read_json(registry_path)["metadata"]["id"]
+ passport = read_json(tmp_path / "src" / "demo" / "vera" / ".trinity" / "passport.json")
+ assert minted
+ assert passport["citizenship"]["registry_id"] == minted
+ assert result["verified"] is True
+ assert any("minted metadata.id" in r for r in result["repairs"])
+
+
+def test_realigns_a_passport_that_belongs_to_another_registry(tmp_path: Path) -> None:
+ """A stale registry_id from another project is corrected, not left to deny tenancy."""
+ build_project(tmp_path, passport_registry_id="00000000-0000-0000-0000-000000000000")
+
+ result = provision_git_auth(tmp_path)
+
+ passport = read_json(tmp_path / "src" / "demo" / "vera" / ".trinity" / "passport.json")
+ assert passport["citizenship"]["registry_id"] == "8fb38c96-880d-43d6-823b-98f4b9559194"
+ assert result["verified"] is True
+
+
+def test_seats_owner_flag_from_a_passport_claim(tmp_path: Path) -> None:
+ """Registry entry lacks owner: true but the passport claims it — never guessed."""
+ registry_path = build_project(tmp_path, owner_flag=False, passport_owner=True)
+
+ result = provision_git_auth(tmp_path)
+
+ assert owner_entry(registry_path)["owner"] is True
+ assert result["owner"] == "VERA"
+ assert any("owner: true" in r for r in result["repairs"])
+ assert result["verified"] is True
+
+
+def test_records_missing_path_from_the_citizens_own_passport(tmp_path: Path) -> None:
+ """An entry with no path is bound to the directory its passport actually lives in."""
+ registry_path = build_project(tmp_path, owner_path=None)
+ _write(
+ tmp_path / "src" / "demo" / "vera" / ".trinity" / "passport.json",
+ {
+ "branch_info": {"branch_name": "VERA"},
+ "identity": {"citizen_class": "builder"},
+ "citizenship": {"registry_id": "8fb38c96-880d-43d6-823b-98f4b9559194"},
+ },
+ )
+
+ result = provision_git_auth(tmp_path)
+
+ assert owner_entry(registry_path)["path"] == "src/demo/vera"
+ assert result["verified"] is True
+
+
+def test_honest_no_op_when_everything_already_holds(tmp_path: Path) -> None:
+ """A provisioned project reports zero repairs, not a fake success."""
+ build_project(tmp_path, citizen_class="manager")
+
+ result = provision_git_auth(tmp_path)
+
+ assert result["repairs"] == []
+ assert result["verified"] is True
+ # One line per condition already satisfied: owner flag, metadata.id,
+ # path-binding, manager class, tenancy.
+ assert len(result["already_ok"]) == 5
+
+
+def test_second_run_is_idempotent(tmp_path: Path) -> None:
+ """Re-running repairs nothing and still verifies."""
+ build_project(tmp_path)
+ first = provision_git_auth(tmp_path)
+ second = provision_git_auth(tmp_path)
+
+ assert first["repairs"]
+ assert second["repairs"] == []
+ assert second["verified"] is True
+
+
+def test_dict_shaped_registry_is_repaired_without_reshaping(tmp_path: Path) -> None:
+ """A name-keyed registry is repaired in place, keeping its authored shape."""
+ registry_path = build_project(tmp_path, branches_as_dict=True)
+
+ result = provision_git_auth(tmp_path)
+
+ assert isinstance(read_json(registry_path)["branches"], dict)
+ assert result["owner"] == "VERA"
+ assert result["verified"] is True
+
+
+def test_stale_branch_info_class_is_updated_too(tmp_path: Path) -> None:
+ """A second copy of the class under branch_info must not be left stale."""
+ build_project(tmp_path)
+ passport_path = tmp_path / "src" / "demo" / "vera" / ".trinity" / "passport.json"
+ passport = read_json(passport_path)
+ passport["branch_info"]["citizen_class"] = "builder"
+ _write(passport_path, passport)
+
+ provision_git_auth(tmp_path)
+
+ assert read_json(passport_path)["branch_info"]["citizen_class"] == "manager"
+
+
+# =============================================================================
+# Guardrail — the repo-root path refusal
+# =============================================================================
+
+
+@pytest.mark.parametrize("root_path", [".", "./", ""])
+def test_refuses_a_repo_root_owner_path(tmp_path: Path, root_path: str) -> None:
+ """Path-binding is at-or-under: a root path would let any directory hold git."""
+ registry_path = build_project(tmp_path, owner_path=root_path)
+ _write(
+ tmp_path / ".trinity" / "passport.json",
+ {"branch_info": {"branch_name": "VERA"}, "identity": {"citizen_class": "builder"}},
+ )
+ before = registry_path.read_text(encoding="utf-8")
+
+ with pytest.raises(GitAuthRefusal) as exc:
+ provision_git_auth(tmp_path)
+
+ message = str(exc.value)
+ assert "project root" in message or "no directory under" in message
+ assert registry_path.read_text(encoding="utf-8") == before
+
+
+def test_refuses_an_absolute_repo_root_owner_path(tmp_path: Path) -> None:
+ """An absolute path to the repo root is the same guardrail breach as '.'."""
+ build_project(tmp_path, owner_path=str(tmp_path))
+
+ with pytest.raises(GitAuthRefusal) as exc:
+ provision_git_auth(tmp_path)
+
+ assert "project root" in str(exc.value)
+
+
+def test_refuses_a_path_outside_the_project(tmp_path: Path) -> None:
+ """A path outside the repo can never match path-binding, so it refuses."""
+ outside = tmp_path.parent / "elsewhere"
+ outside.mkdir(exist_ok=True)
+ build_project(tmp_path, owner_path=str(outside))
+
+ with pytest.raises(GitAuthRefusal) as exc:
+ provision_git_auth(tmp_path)
+
+ assert "outside the project" in str(exc.value)
+
+
+def test_refuses_when_the_recorded_path_does_not_exist(tmp_path: Path) -> None:
+ """A path pointing at nothing refuses instead of binding authority to a ghost."""
+ build_project(tmp_path, owner_path="src/demo/ghost", make_passport=False)
+
+ with pytest.raises(GitAuthRefusal) as exc:
+ provision_git_auth(tmp_path)
+
+ assert "does not" in str(exc.value)
+
+
+def test_refuses_when_the_owner_directory_has_no_passport(tmp_path: Path) -> None:
+ """An owner entry must point at a real citizen, and the refusal says how to make one."""
+ build_project(tmp_path, make_passport=False)
+ (tmp_path / "src" / "demo" / "vera").mkdir(parents=True)
+
+ with pytest.raises(GitAuthRefusal) as exc:
+ provision_git_auth(tmp_path)
+
+ assert "no passport" in str(exc.value)
+ assert "drone @spawn create" in str(exc.value)
+
+
+# =============================================================================
+# Owner selection — marked, never guessed
+# =============================================================================
+
+
+def test_refuses_when_no_citizen_is_marked_owner(tmp_path: Path) -> None:
+ """With nobody marked, the refusal names the exact key to add and who is listed."""
+ build_project(tmp_path, owner_flag=False)
+
+ with pytest.raises(GitAuthRefusal) as exc:
+ provision_git_auth(tmp_path)
+
+ message = str(exc.value)
+ assert '"owner": true' in message
+ assert "VERA" in message and "WRITER" in message
+
+
+def test_refuses_when_two_citizens_are_marked_owner(tmp_path: Path) -> None:
+ """Two owners is ambiguous — it refuses and names both."""
+ registry_path = build_project(tmp_path)
+ data = read_json(registry_path)
+ data["branches"][1]["owner"] = True
+ _write(registry_path, data)
+
+ with pytest.raises(GitAuthRefusal) as exc:
+ provision_git_auth(tmp_path)
+
+ assert "more than one citizen" in str(exc.value)
+ assert "VERA, WRITER" in str(exc.value)
+
+
+def test_refuses_when_two_passports_claim_ownership(tmp_path: Path) -> None:
+ """Two passport claims are ambiguous — the registry must settle it."""
+ build_project(tmp_path, owner_flag=False, passport_owner=True)
+ _write(
+ tmp_path / "src" / "demo" / "writer" / ".trinity" / "passport.json",
+ {
+ "branch_info": {"branch_name": "WRITER"},
+ "identity": {"citizen_class": "builder"},
+ "citizenship": {"owner": True},
+ },
+ )
+
+ with pytest.raises(GitAuthRefusal) as exc:
+ provision_git_auth(tmp_path)
+
+ assert "more than one passport" in str(exc.value)
+
+
+# =============================================================================
+# Dry run
+# =============================================================================
+
+
+def test_dry_run_plans_repairs_without_writing(tmp_path: Path) -> None:
+ """Preview reports the full plan and leaves both files byte-identical."""
+ registry_path = build_project(tmp_path, registry_id=None, passport_registry_id=None)
+ passport_path = tmp_path / "src" / "demo" / "vera" / ".trinity" / "passport.json"
+ registry_before = registry_path.read_text(encoding="utf-8")
+ passport_before = passport_path.read_text(encoding="utf-8")
+
+ result = provision_git_auth(tmp_path, dry_run=True)
+
+ assert result["dry_run"] is True
+ assert result["verified"] is None
+ assert len(result["repairs"]) >= 2
+ assert registry_path.read_text(encoding="utf-8") == registry_before
+ assert passport_path.read_text(encoding="utf-8") == passport_before
+
+
+def test_dry_run_still_refuses_a_root_path(tmp_path: Path) -> None:
+ """The guardrail holds in preview mode too."""
+ build_project(tmp_path, owner_path=".")
+
+ with pytest.raises(GitAuthRefusal):
+ provision_git_auth(tmp_path, dry_run=True)
+
+
+# =============================================================================
+# Independent verification
+# =============================================================================
+
+
+def test_verify_reports_every_failing_check(tmp_path: Path) -> None:
+ """Verification names each failing check rather than a single pass/fail."""
+ registry_path = build_project(
+ tmp_path,
+ registry_id=None,
+ owner_flag=False,
+ passport_registry_id=None,
+ )
+
+ failures = verify_git_auth(registry_path, "VERA")
+
+ joined = " | ".join(failures)
+ assert "check 1" in joined
+ assert "check 2" in joined
+ assert "check 3" in joined
+
+
+def test_verify_is_clean_on_a_provisioned_project(tmp_path: Path) -> None:
+ """A repaired project verifies with no failures."""
+ registry_path = build_project(tmp_path)
+ provision_git_auth(tmp_path)
+
+ assert verify_git_auth(registry_path, "VERA") == []
+
+
+def test_verify_matches_the_owner_name_case_insensitively(tmp_path: Path) -> None:
+ """Registries differ on casing ('VERA' vs 'vera') — lookup must not."""
+ registry_path = build_project(tmp_path, citizen_class="manager")
+
+ assert verify_git_auth(registry_path, "vera") == []
+
+
+def test_verify_names_an_unlisted_caller(tmp_path: Path) -> None:
+ """A caller absent from the registry fails check 3 by name."""
+ registry_path = build_project(tmp_path)
+
+ failures = verify_git_auth(registry_path, "GHOST")
+
+ assert len(failures) == 1
+ assert "not listed" in failures[0]
+
+
+def test_verify_catches_a_registry_edited_after_repair(tmp_path: Path) -> None:
+ """Verification re-reads from disk — it never trusts the repair's own report."""
+ registry_path = build_project(tmp_path)
+ provision_git_auth(tmp_path)
+
+ data = read_json(registry_path)
+ data["branches"][0].pop("owner")
+ _write(registry_path, data)
+
+ failures = verify_git_auth(registry_path, "VERA")
+ assert any("check 3" in f for f in failures)
+
+
+# =============================================================================
+# Corrupt input
+# =============================================================================
+
+
+def test_refuses_an_unreadable_registry(tmp_path: Path) -> None:
+ """Corrupt JSON refuses with a fix instruction, never a stack trace."""
+ (tmp_path / "DEMO_REGISTRY.json").write_text("{not json", encoding="utf-8")
+
+ with pytest.raises(GitAuthRefusal) as exc:
+ provision_git_auth(tmp_path)
+
+ assert "could not be read" in str(exc.value)
diff --git a/src/aipass/aipass/tests/test_init_flow.py b/src/aipass/aipass/tests/test_init_flow.py
index 52a3802e..d660a3d0 100644
--- a/src/aipass/aipass/tests/test_init_flow.py
+++ b/src/aipass/aipass/tests/test_init_flow.py
@@ -681,6 +681,9 @@ def test_clean_check_prints_ok(self, tmp_path: Path) -> None:
"aipass.aipass.apps.handlers.init.bootstrap.update_project",
return_value={"updated_files": [], "already_current": []},
),
+ # Subject here is the sync-registry branch; git-auth provisioning
+ # (which needs a real project on disk) has its own suite below.
+ patch(f"{_MOD_UPDATE}._run_git_auth_provisioning", return_value=0),
patch(f"{_MOD_UPDATE}.subprocess.run", return_value=check_proc) as mock_run,
patch(f"{_MOD_UPDATE}.console"),
patch(f"{_MOD_UPDATE}.success") as mock_success,
@@ -703,6 +706,9 @@ def test_issues_trigger_fix(self, tmp_path: Path) -> None:
"aipass.aipass.apps.handlers.init.bootstrap.update_project",
return_value={"updated_files": [], "already_current": []},
),
+ # Subject here is the sync-registry branch; git-auth provisioning
+ # (which needs a real project on disk) has its own suite below.
+ patch(f"{_MOD_UPDATE}._run_git_auth_provisioning", return_value=0),
patch(
f"{_MOD_UPDATE}.subprocess.run",
side_effect=[check_proc, fix_proc],
@@ -729,6 +735,9 @@ def test_fix_failure_degrades_silently(self, tmp_path: Path) -> None:
"aipass.aipass.apps.handlers.init.bootstrap.update_project",
return_value={"updated_files": [], "already_current": []},
),
+ # Subject here is the sync-registry branch; git-auth provisioning
+ # (which needs a real project on disk) has its own suite below.
+ patch(f"{_MOD_UPDATE}._run_git_auth_provisioning", return_value=0),
patch(
f"{_MOD_UPDATE}.subprocess.run",
side_effect=[check_proc, fix_proc],
@@ -747,6 +756,9 @@ def test_sync_missing_drone_degrades_silently(self, tmp_path: Path) -> None:
"aipass.aipass.apps.handlers.init.bootstrap.update_project",
return_value={"updated_files": [], "already_current": []},
),
+ # Subject here is the sync-registry branch; git-auth provisioning
+ # (which needs a real project on disk) has its own suite below.
+ patch(f"{_MOD_UPDATE}._run_git_auth_provisioning", return_value=0),
patch(f"{_MOD_UPDATE}.subprocess.run", side_effect=FileNotFoundError("drone not found")),
patch(f"{_MOD_UPDATE}.console"),
patch(f"{_MOD_UPDATE}.json_handler"),
@@ -763,6 +775,9 @@ def test_sync_timeout_degrades_silently(self, tmp_path: Path) -> None:
"aipass.aipass.apps.handlers.init.bootstrap.update_project",
return_value={"updated_files": [], "already_current": []},
),
+ # Subject here is the sync-registry branch; git-auth provisioning
+ # (which needs a real project on disk) has its own suite below.
+ patch(f"{_MOD_UPDATE}._run_git_auth_provisioning", return_value=0),
patch(f"{_MOD_UPDATE}.subprocess.run", side_effect=_sp.TimeoutExpired(cmd="drone", timeout=30)),
patch(f"{_MOD_UPDATE}.console"),
patch(f"{_MOD_UPDATE}.json_handler"),
@@ -771,6 +786,118 @@ def test_sync_timeout_degrades_silently(self, tmp_path: Path) -> None:
assert rc == 0
+class TestInitUpdateGitAuth:
+ """Tests for git-auth provisioning wired into `aipass init update` (DPLAN-0281 P2)."""
+
+ @staticmethod
+ def _project(root: Path, citizen_class: str = "builder") -> None:
+ """Write a minimal external project whose owner is one flip from git."""
+ (root / "DEMO_REGISTRY.json").write_text(
+ json.dumps(
+ {
+ "metadata": {"id": "abc-123"},
+ "branches": [{"name": "VERA", "path": "src/demo/vera", "owner": True}],
+ }
+ ),
+ encoding="utf-8",
+ )
+ passport = root / "src" / "demo" / "vera" / ".trinity" / "passport.json"
+ passport.parent.mkdir(parents=True)
+ passport.write_text(
+ json.dumps(
+ {
+ "branch_info": {"branch_name": "VERA"},
+ "identity": {"citizen_class": citizen_class},
+ "citizenship": {"registry_id": "abc-123"},
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ def test_update_provisions_git_auth_after_sync(self, tmp_path: Path) -> None:
+ """The class flip lands, and it runs AFTER sync-registry (which may migrate it)."""
+ self._project(tmp_path)
+ with (
+ patch(
+ "aipass.aipass.apps.handlers.init.bootstrap.update_project",
+ return_value={"updated_files": [], "already_current": []},
+ ),
+ patch(f"{_MOD_UPDATE}.subprocess.run", return_value=MagicMock(returncode=0, stdout="", stderr="")),
+ patch(f"{_MOD_UPDATE}.console"),
+ patch(f"{_MOD_UPDATE}.success") as mock_success,
+ patch(f"{_MOD_UPDATE}.json_handler"),
+ ):
+ rc = _handle_init_update([str(tmp_path)])
+
+ assert rc == 0
+ passport = json.loads(
+ (tmp_path / "src" / "demo" / "vera" / ".trinity" / "passport.json").read_text(encoding="utf-8")
+ )
+ assert passport["identity"]["citizen_class"] == "manager"
+ assert any("Verified against all four" in str(c) for c in mock_success.call_args_list)
+
+ def test_update_reports_honest_no_op(self, tmp_path: Path) -> None:
+ """An already-provisioned owner is reported as nothing to repair, not as a repair."""
+ self._project(tmp_path, citizen_class="manager")
+ with (
+ patch(
+ "aipass.aipass.apps.handlers.init.bootstrap.update_project",
+ return_value={"updated_files": [], "already_current": []},
+ ),
+ patch(f"{_MOD_UPDATE}.subprocess.run", return_value=MagicMock(returncode=0, stdout="", stderr="")),
+ patch(f"{_MOD_UPDATE}.console"),
+ patch(f"{_MOD_UPDATE}.success") as mock_success,
+ patch(f"{_MOD_UPDATE}.json_handler"),
+ ):
+ rc = _handle_init_update([str(tmp_path)])
+
+ assert rc == 0
+ assert any("nothing to repair" in str(c) for c in mock_success.call_args_list)
+
+ def test_refusal_exits_nonzero_and_names_the_fix(self, tmp_path: Path) -> None:
+ """No citizen marked owner — update refuses instead of guessing one."""
+ (tmp_path / "DEMO_REGISTRY.json").write_text(
+ json.dumps({"metadata": {"id": "abc-123"}, "branches": [{"name": "VERA", "path": "src/demo/vera"}]}),
+ encoding="utf-8",
+ )
+ with (
+ patch(
+ "aipass.aipass.apps.handlers.init.bootstrap.update_project",
+ return_value={"updated_files": [], "already_current": []},
+ ),
+ patch(f"{_MOD_UPDATE}.subprocess.run", return_value=MagicMock(returncode=0, stdout="", stderr="")),
+ patch(f"{_MOD_UPDATE}.console"),
+ patch(f"{_MOD_UPDATE}.success"),
+ patch(f"{_MOD_UPDATE}.cli_error") as mock_error,
+ patch(f"{_MOD_UPDATE}.json_handler"),
+ ):
+ rc = _handle_init_update([str(tmp_path)])
+
+ assert rc == 1
+ assert any('"owner": true' in str(c) for c in mock_error.call_args_list)
+
+ def test_dry_run_skips_the_scaffold_refresh_and_writes_nothing(self, tmp_path: Path) -> None:
+ """--dry-run touches nothing: no scaffold refresh, no sync-registry, no writes."""
+ self._project(tmp_path)
+ passport_path = tmp_path / "src" / "demo" / "vera" / ".trinity" / "passport.json"
+ before = passport_path.read_text(encoding="utf-8")
+
+ with (
+ patch("aipass.aipass.apps.handlers.init.bootstrap.update_project") as mock_update,
+ patch(f"{_MOD_UPDATE}.subprocess.run") as mock_run,
+ patch(f"{_MOD_UPDATE}.console"),
+ patch(f"{_MOD_UPDATE}.success") as mock_success,
+ patch(f"{_MOD_UPDATE}.json_handler"),
+ ):
+ rc = _handle_init_update([str(tmp_path), "--dry-run"])
+
+ assert rc == 0
+ mock_update.assert_not_called()
+ mock_run.assert_not_called()
+ assert passport_path.read_text(encoding="utf-8") == before
+ assert any("would repair" in str(c) for c in mock_success.call_args_list)
+
+
# =============================================================================
# TestTemplateSelector
# =============================================================================
diff --git a/src/aipass/aipass/tests/test_json_handler.py b/src/aipass/aipass/tests/test_json_handler.py
index e848c016..62cf1140 100644
--- a/src/aipass/aipass/tests/test_json_handler.py
+++ b/src/aipass/aipass/tests/test_json_handler.py
@@ -414,3 +414,36 @@ def test_reimport_after_mock(self, tmp_path):
def test_unknown_returns_false():
"""validate_json_structure returns False for unrecognized json_type."""
assert jh_mod.validate_json_structure({}, "bogus") is False
+
+
+# =============================================================================
+# save_path / load_path: arbitrary-path round trip
+# =============================================================================
+
+
+class TestSavePath:
+ """Tests for save_path — the atomic writer git-auth provisioning relies on."""
+
+ def test_round_trips_through_load_path(self, tmp_path):
+ """Data written by save_path reads back identically via load_path."""
+ target = tmp_path / "nested" / "DEMO_REGISTRY.json"
+ payload = {"metadata": {"id": "abc-123"}, "branches": [{"name": "VERA", "owner": True}]}
+
+ assert jh_mod.save_path(target, payload) is True
+ assert jh_mod.load_path(target) == payload
+
+ def test_overwrite_leaves_no_temp_files(self, tmp_path):
+ """A second write replaces the file without leaving .tmp debris behind."""
+ target = tmp_path / "state.json"
+ jh_mod.save_path(target, {"v": 1})
+ jh_mod.save_path(target, {"v": 2})
+
+ assert jh_mod.load_path(target) == {"v": 2}
+ assert [p.name for p in tmp_path.iterdir()] == ["state.json"]
+
+ def test_returns_false_when_the_path_is_unwritable(self, tmp_path):
+ """An OS error is reported as False, never as a silent success."""
+ blocker = tmp_path / "blocker"
+ blocker.write_text("not a directory", encoding="utf-8")
+
+ assert jh_mod.save_path(blocker / "child.json", {"a": 1}) is False
diff --git a/src/aipass/devpulse/apps/handlers/feedback/compose.py b/src/aipass/devpulse/apps/handlers/feedback/compose.py
index 0b8e4225..6dfbbb50 100644
--- a/src/aipass/devpulse/apps/handlers/feedback/compose.py
+++ b/src/aipass/devpulse/apps/handlers/feedback/compose.py
@@ -1,9 +1,9 @@
# =================== AIPass ====================
# Name: compose.py
# Description: Compose operations — send feedback and reply to messages
-# Version: 1.0.0
+# Version: 1.1.0
# Created: 2026-04-11
-# Modified: 2026-04-11
+# Modified: 2026-08-04
# =============================================
"""
@@ -25,7 +25,7 @@
generate_id,
)
-from aipass.cli.apps.modules import err_console, error
+from aipass.cli.apps.modules import err_console, error, success, warning
from aipass.devpulse.apps.handlers.json import json_handler
console = err_console
@@ -107,6 +107,21 @@ def send_feedback(from_branch: str, subject: str, body: str, ai_mail_path: str =
logger.info(f"[FEEDBACK] Received feedback from {from_branch}: {subject}")
console.print(f"[green]Feedback received (id: {msg_id}).[/green]")
+ # Anonymous sender = no return address. Say so NOW, at send time, to the
+ # one party who can fix it — a reply to 'unknown' can never be delivered
+ # (live failure 2026-08-04: 6 messages arrived anonymous, 3 replies
+ # silently stranded for 5 hours).
+ if from_branch == "unknown" or not ai_mail_path:
+ logger.warning(
+ f"[FEEDBACK] Sender unresolved (branch={from_branch}, "
+ f"reply_path={ai_mail_path or 'none'}) — replies cannot be delivered"
+ )
+ warning(
+ "your identity could not be resolved — replies to this feedback CANNOT reach you",
+ details="Run drone from inside your branch directory (where "
+ ".trinity/passport.json lives) so replies have a return address.",
+ )
+
return msg_id
@@ -149,27 +164,42 @@ def reply_to(msg_id: str, body: str) -> bool:
console.print(f"[green]Reply added to thread {msg_id}.[/green]")
- # Deliver to sender's ai_mail using stored reply path
+ # Deliver to sender's ai_mail using stored reply path — and report the
+ # outcome honestly. A reply the sender never sees is not a sent reply
+ # (live failure 2026-08-04: 3 replies to 'unknown' silently stranded).
sender = msg.get("from", "")
reply_path = msg.get("reply_path", "")
if sender:
- _deliver_to_ai_mail(sender, msg.get("subject", ""), body, msg_id, reply_path)
+ delivered, reason = _deliver_to_ai_mail(sender, msg.get("subject", ""), body, msg_id, reply_path)
+ if delivered:
+ success(f"Delivered to {sender}'s ai_mail inbox.")
+ else:
+ error(
+ f"NOT delivered to {sender}: {reason}",
+ suggestion="The reply is saved in the thread only — the sender will not see it.",
+ )
+ else:
+ error("NOT delivered: message has no sender recorded.")
return True
-def _deliver_to_ai_mail(to_branch: str, subject: str, body: str, thread_id: str, reply_path: str = "") -> None:
+def _deliver_to_ai_mail(
+ to_branch: str, subject: str, body: str, thread_id: str, reply_path: str = ""
+) -> tuple[bool, str]:
"""Deliver a reply to the sender's ai_mail inbox.
Writes directly to the sender's .ai_mail.local/inbox.json.
- If the path does not exist or delivery fails, logs a warning
- and skips silently.
Args:
to_branch: Target branch name.
subject: Original message subject (prefixed with Re:).
body: Reply body text.
thread_id: Original feedback message ID for reference.
+ reply_path: Sender inbox path captured at send time.
+
+ Returns:
+ tuple: (delivered, reason) — reason explains a failed delivery.
"""
# Use stored reply_path (works for external projects), fall back to AIPass internal
if reply_path:
@@ -178,15 +208,17 @@ def _deliver_to_ai_mail(to_branch: str, subject: str, body: str, thread_id: str,
ai_mail_path = _AIPASS_ROOT / to_branch / ".ai_mail.local" / "inbox.json"
if not ai_mail_path.exists():
- logger.warning(f"[FEEDBACK] ai_mail inbox not found for {to_branch} at {ai_mail_path} — skipping delivery")
- return
+ reason = f"ai_mail inbox not found at {ai_mail_path}"
+ logger.warning(f"[FEEDBACK] {reason} for {to_branch} — skipping delivery")
+ return False, reason
try:
with open(ai_mail_path, encoding="utf-8") as f:
inbox = json.load(f)
except (json.JSONDecodeError, OSError) as e:
+ reason = f"failed to read inbox: {e}"
logger.warning(f"[FEEDBACK] Failed to read {to_branch} ai_mail inbox: {e}")
- return
+ return False, reason
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
mail_id = generate_id()
@@ -214,5 +246,8 @@ def _deliver_to_ai_mail(to_branch: str, subject: str, body: str, thread_id: str,
json.dump(inbox, f, indent=2)
f.write("\n")
logger.info(f"[FEEDBACK] Reply delivered to {to_branch} ai_mail")
+ return True, "delivered"
except OSError as e:
+ reason = f"failed to write inbox: {e}"
logger.warning(f"[FEEDBACK] Failed to write to {to_branch} ai_mail: {e}")
+ return False, reason
diff --git a/src/aipass/devpulse/tests/test_feedback_compose.py b/src/aipass/devpulse/tests/test_feedback_compose.py
index 738478a4..3b21a9d7 100644
--- a/src/aipass/devpulse/tests/test_feedback_compose.py
+++ b/src/aipass/devpulse/tests/test_feedback_compose.py
@@ -222,3 +222,73 @@ def test_skips_delivery_on_corrupt_ai_mail(self, inbox_with_message, mock_aipass
assert result is True
data = storage.load_inbox()
assert len(data["messages"][0]["thread"]) == 1
+
+
+class TestHonestDeliveryReporting:
+ """Delivery outcomes must be surfaced, never silently skipped (2026-08-04 live failure)."""
+
+ @pytest.fixture
+ def inbox_with_message(self, mock_feedback_dir):
+ """Inbox seeded with one message from seedgo."""
+ storage.save_inbox(
+ {
+ "mailbox": "feedback",
+ "total_messages": 1,
+ "unread_count": 1,
+ "messages": [
+ {
+ "id": "aaa11111",
+ "from": "seedgo",
+ "subject": "Test feedback",
+ "body": "Original message.",
+ "timestamp": "2026-04-11T10:00:00",
+ "read": True,
+ "thread": [],
+ "reply_path": "",
+ },
+ ],
+ }
+ )
+
+ def test_deliver_returns_success_tuple(self, tmp_path):
+ """_deliver_to_ai_mail returns (True, 'delivered') on success."""
+ ai_mail_dir = tmp_path / "seedgo" / ".ai_mail.local"
+ ai_mail_dir.mkdir(parents=True)
+ inbox_path = ai_mail_dir / "inbox.json"
+ with open(inbox_path, "w", encoding="utf-8") as f:
+ json.dump({"messages": []}, f)
+
+ delivered, reason = compose._deliver_to_ai_mail("seedgo", "Subj", "Body", "aaa11111", str(inbox_path))
+ assert delivered is True
+ assert reason == "delivered"
+
+ def test_deliver_returns_failure_reason_when_inbox_missing(self, tmp_path):
+ """_deliver_to_ai_mail returns (False, reason) naming the missing path."""
+ missing = tmp_path / "nowhere" / "inbox.json"
+ delivered, reason = compose._deliver_to_ai_mail("ghost", "Subj", "Body", "aaa11111", str(missing))
+ assert delivered is False
+ assert str(missing) in reason
+
+ def test_send_warns_when_sender_unknown(self, empty_inbox):
+ """send_feedback tells an anonymous sender that replies cannot reach them."""
+ with patch.object(compose, "warning") as mock_warning:
+ compose.send_feedback("unknown", "Subj", "Body", "")
+ printed = " ".join(str(c) for c in mock_warning.call_args_list)
+ assert "CANNOT reach you" in printed
+
+ def test_send_no_warning_when_sender_resolved(self, empty_inbox, tmp_path):
+ """send_feedback stays quiet when the sender has a return address."""
+ inbox_path = tmp_path / ".ai_mail.local" / "inbox.json"
+ inbox_path.parent.mkdir(parents=True)
+ inbox_path.write_text("{}", encoding="utf-8")
+ with patch.object(compose, "warning") as mock_warning:
+ compose.send_feedback("seedgo", "Subj", "Body", str(inbox_path))
+ mock_warning.assert_not_called()
+
+ def test_reply_reports_undelivered(self, inbox_with_message, mock_aipass_root):
+ """reply_to raises a real error() when the sender inbox is unreachable."""
+ with patch.object(compose, "error") as mock_error:
+ result = compose.reply_to("aaa11111", "Reply into the void")
+ assert result is True
+ printed = " ".join(str(c) for c in mock_error.call_args_list)
+ assert "NOT delivered" in printed
diff --git a/src/aipass/drone/apps/handlers/git/lock_handler.py b/src/aipass/drone/apps/handlers/git/lock_handler.py
index cd94e03b..2442e531 100644
--- a/src/aipass/drone/apps/handlers/git/lock_handler.py
+++ b/src/aipass/drone/apps/handlers/git/lock_handler.py
@@ -82,11 +82,17 @@ def _pid_alive(pid: int) -> bool:
def find_repo_root() -> Path:
- """Walk up from CWD looking for AIPASS_REGISTRY.json, fallback to git rev-parse."""
+ """Walk up from CWD looking for a *_REGISTRY.json, fallback to git rev-parse.
+
+ Any ``*_REGISTRY.json`` marks a project root, not just AIPass's own —
+ external projects name theirs after themselves (VERA-STUDIO_REGISTRY.json),
+ and hardcoding the AIPass name sent them down the rev-parse fallback while
+ registry resolution found the real root, so the two could disagree.
+ """
cwd = Path.cwd()
current = cwd
while current != current.parent:
- if (current / "AIPASS_REGISTRY.json").exists():
+ if any(current.glob("*_REGISTRY.json")):
return current
current = current.parent
diff --git a/src/aipass/drone/apps/handlers/router_handler.py b/src/aipass/drone/apps/handlers/router_handler.py
index b5b1cbbe..536194b8 100644
--- a/src/aipass/drone/apps/handlers/router_handler.py
+++ b/src/aipass/drone/apps/handlers/router_handler.py
@@ -39,11 +39,54 @@ def find_entry_point(branch_path: str, branch_name: str) -> Path:
return entry_point
+_REGISTRY_SUFFIX = "_REGISTRY.json"
+
+
+def _project_name_from_registry(reg_file: Path) -> str | None:
+ """Derive a project name from a registry file, or None if it cannot be read.
+
+ Prefers a declared ``metadata.project_name``/``name``, then falls back to the
+ filename: AIPASS_REGISTRY.json → 'aipass', VERA-STUDIO_REGISTRY.json →
+ 'vera-studio'. The filename is the one thing every registry provably has —
+ AIPass's own metadata carries only version/last_updated/total_branches/id, so
+ requiring a declared name made the framework repo the one place this fallback
+ could never fire.
+ """
+ try:
+ with open(reg_file, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ except Exception as exc:
+ logger.warning("Caller detection: registry %s found but unreadable: %s", reg_file, exc)
+ return None
+
+ meta = data.get("metadata", {}) if isinstance(data, dict) else {}
+ declared = meta.get("project_name") or meta.get("name")
+ if declared:
+ return str(declared).lower().replace(" ", "-")
+
+ derived = reg_file.name[: -len(_REGISTRY_SUFFIX)].lower().replace(" ", "-")
+ if not derived:
+ # A file named exactly '_REGISTRY.json' leaves nothing to derive from.
+ logger.warning("Caller detection: registry %s yields no usable project name", reg_file)
+ return None
+
+ logger.info(
+ "Caller detection: registry %s declares no metadata.name — using filename-derived '%s'",
+ reg_file.name,
+ derived,
+ )
+ return derived
+
+
def detect_caller_branch_name(cwd: Path) -> str | None:
"""Walk up from cwd to find .trinity/passport.json and extract branch name.
- Falls back to project name from registry if no passport found (external projects
- calling from project root without a branch-level CWD).
+ Falls back to the project name from the registry when no passport is found —
+ a caller standing at a project root rather than in a branch. That resolves to
+ the PROJECT, never to a citizen, and deliberately so: CWD is identity, and a
+ registry file proves which project you are in, not who you are. Nothing here
+ grants authority; git's owner-tier reads passports directly and a name
+ derived here can never satisfy it.
"""
current = cwd.resolve()
for _ in range(10):
@@ -58,32 +101,41 @@ def detect_caller_branch_name(cwd: Path) -> str | None:
name = data.get("branch_info", {}).get("branch_name")
if not name:
name = data.get("identity", {}).get("name")
- return name
+ if name:
+ return name
+ logger.warning("Passport at %s names no branch — trying registry fallback", passport)
except Exception as exc:
- logger.warning("Failed to read passport at %s: %s", passport, exc)
- return None
+ logger.warning("Failed to read passport at %s: %s — trying registry fallback", passport, exc)
+ # A passport was found but is unusable. Stop the walk-up rather than
+ # continue: a parent branch's passport would misattribute identity.
+ # Fall through to the registry fallback, which the docstring promises
+ # and the old `return None` here silently skipped.
+ break
parent = current.parent
if parent == current:
break
current = parent
- # Fallback: detect project name from registry file (external projects at root)
+ # Fallback: detect project name from registry file (callers at a project root)
current = cwd.resolve()
for _ in range(10):
- for reg_file in current.glob("*_REGISTRY.json"):
- try:
- with open(reg_file, "r", encoding="utf-8") as f:
- data = json.load(f)
- meta = data.get("metadata", {})
- project_name = meta.get("project_name") or meta.get("name")
- if project_name:
- return project_name.lower().replace(" ", "-")
- except Exception as exc:
- logger.info("Failed to read registry %s: %s", reg_file, exc)
+ # sorted() so a directory holding two registries resolves the same way
+ # every time, matching registry_handler._first_registry_in.
+ for reg_file in sorted(current.glob(f"*{_REGISTRY_SUFFIX}")):
+ project_name = _project_name_from_registry(reg_file)
+ if project_name:
+ return project_name
parent = current.parent
if parent == current:
break
current = parent
+
+ # Single log site for a lost caller identity — every caller of this function
+ # gets the breadcrumb without any of them re-logging it. WARNING, not ERROR:
+ # the branch that actually refuses the work owns the page (see auth.py).
+ # Without the cwd this failure is invisible — the downstream error names the
+ # TARGET's directory, which sends investigation to the wrong branch entirely.
+ logger.warning("Caller branch detection failed — no passport or registry found from cwd %s", cwd)
return None
diff --git a/src/aipass/drone/apps/modules/registry.py b/src/aipass/drone/apps/modules/registry.py
index 635cec93..de6788ec 100644
--- a/src/aipass/drone/apps/modules/registry.py
+++ b/src/aipass/drone/apps/modules/registry.py
@@ -16,14 +16,28 @@
from typing import List, Optional
from aipass.prax import logger
+from aipass.drone.apps.handlers.exceptions import RegistryError, RegistryMismatchError
from aipass.drone.apps.handlers.json import json_handler
from aipass.drone.apps.handlers.registry_handler import (
+ get_registry_path,
load_registry,
get_all_branches,
get_branch_by_name,
)
-__all__ = ["load_registry", "get_all_branches", "get_branch_by_name"]
+# The registry's public contract. get_registry_path and the error types belong
+# here alongside the readers: a caller that has to say WHICH registry refused it,
+# or handle a tenancy mismatch, cannot do either through the readers alone — and
+# reaching into the handler for them is exactly the encapsulation break this
+# module exists to prevent.
+__all__ = [
+ "load_registry",
+ "get_all_branches",
+ "get_branch_by_name",
+ "get_registry_path",
+ "RegistryError",
+ "RegistryMismatchError",
+]
def print_introspection():
diff --git a/src/aipass/drone/apps/modules/router.py b/src/aipass/drone/apps/modules/router.py
index 90a19d36..8044a854 100644
--- a/src/aipass/drone/apps/modules/router.py
+++ b/src/aipass/drone/apps/modules/router.py
@@ -107,7 +107,9 @@ def route_command(
caller = detect_caller_branch_name(Path.cwd())
if not caller:
caller = os.environ.get("AIPASS_BRANCH_NAME")
- caller_tag = f" [CALLER:{caller.upper()}]" if caller else ""
+ # UNKNOWN, not an empty tag: an omitted caller reads as "not applicable" and
+ # hides the gap. detect_caller_branch_name already logged the cwd.
+ caller_tag = f" [CALLER:{caller.upper()}]" if caller else " [CALLER:UNKNOWN]"
logger.info(
"Routing @%s%s → %s %s (timeout=%ds)",
branch_name,
diff --git a/src/aipass/drone/apps/plugins/devpulse_ops/auth.py b/src/aipass/drone/apps/plugins/devpulse_ops/auth.py
index 3938e2e8..439d8c1b 100644
--- a/src/aipass/drone/apps/plugins/devpulse_ops/auth.py
+++ b/src/aipass/drone/apps/plugins/devpulse_ops/auth.py
@@ -6,23 +6,29 @@
# Modified: 2026-03-30
# =============================================
-"""Passport-based authorization for devpulse operations.
+"""Passport-based authorization for git operations.
-Verifies the calling branch identity by walking up from CWD to locate
-``.trinity/passport.json`` and checking the branch name against the
-allowed-callers list.
+Identifies the calling branch by walking up from CWD to locate
+``.trinity/passport.json``. Read-only commands need only a valid passport.
+Write commands are authorized per-repo against that project's own registry:
+manager class, matching tenancy, owner flag, and a passport presented from the
+registry-recorded home. See ``_owner_tier_refusal`` for the full rule.
"""
from __future__ import annotations
import json
+import os
from pathlib import Path
+from typing import NamedTuple
from aipass.prax import logger
from aipass.drone.apps.handlers.json import json_handler
-from aipass.seedgo.apps.modules.permissions import TRUSTED_CROSS_WRITERS
-
-ALLOWED_CALLERS: list[str] = list(TRUSTED_CROSS_WRITERS)
+from aipass.drone.apps.modules.registry import (
+ RegistryMismatchError,
+ get_registry_path,
+ load_registry,
+)
GIT_ACCESS_TIERS: dict[str, dict] = {
"global": {
@@ -44,11 +50,39 @@
"delete-branch",
"tag",
],
- "allowed_callers": ["devpulse"],
- "description": "Write operations — project owner only",
+ "description": "Write operations — the project's own manager only",
},
}
+# Owner-tier is earned, not listed (DPLAN-0281, Patrick ruling). A caller holds it
+# iff ALL of these hold — devpulse-in-AIPass qualifies through the general rule,
+# with no special case, and any project's manager qualifies in their own repo:
+# 1. passport citizen_class == manager
+# 2. tenancy: passport citizenship.registry_id == registry metadata.id (F59 4.1)
+# 3. the registry for THAT repo lists the caller with owner: true
+# 4. path-binding: the passport lives at/under the registry-recorded path (F59 4.2a)
+#
+# Check 4 is the one that closes T-A: a rogue sub-agent can forge a passport naming
+# any branch and copy the registry id off local disk, but it cannot place that
+# passport inside the real manager's directory without defeating the pre-edit hook
+# layer and OS permissions first.
+_MANAGER_CLASS = "manager"
+
+# Rollback is one env var (F59 6.1). 'warn' logs the refusal and allows, for
+# migration triage; anything else enforces. Deliberately not a config file —
+# a rollback that needs an edit is not a rollback.
+_AUTH_MODE_ENV = "AIPASS_GIT_AUTH_MODE"
+
+# Owner-tier verbs that encode AIPass's OWN dev→PR→main flow: they assume a `dev`
+# branch, our PR conventions, or pyproject versioning. Against an arbitrary project
+# repo they would half-run and leave a mess, so they refuse honestly until
+# translated (DPLAN-0281 P1 scope is commit + sync working for a manager at home).
+_AIPASS_FLOW_VERBS = frozenset({"dev-pr", "pr", "close-pr", "merge", "smart-sync", "fix", "tag", "delete-branch"})
+
+# The framework repo's own registry filename. A repo whose registry is named
+# anything else is an external project consuming AIPass as a service.
+_AIPASS_REGISTRY_NAME = "AIPASS_REGISTRY.json"
+
# Real git verbs drone deliberately does not expose — staging and remote work are
# folded into higher-level commands. Without a pointer the refusal is a dead end.
@@ -65,10 +99,24 @@ def _rerouted_hint(command: str) -> str:
return f" {hint}" if hint else ""
-def _find_caller() -> str:
- """Walk up from CWD to find passport.json and return branch name.
+class Caller(NamedTuple):
+ """An identified caller: who they claim to be, and where they said it from.
+
+ ``home`` is load-bearing — it is the directory the passport was found in, and
+ owner-tier binds authority to that location (F59 4.2a). A name alone is
+ self-reported and forgeable; a name plus a location the attacker cannot
+ occupy is not.
+ """
+
+ name: str
+ home: Path
+ passport: dict
+
+
+def _resolve_caller() -> Caller:
+ """Walk up from CWD to find passport.json and return the caller's identity.
- Returns the branch name, or raises PermissionError if no passport found.
+ Raises PermissionError if no readable, named passport is found.
"""
current = Path.cwd().resolve()
for _ in range(10):
@@ -84,7 +132,7 @@ def _find_caller() -> str:
msg = f"Passport at {passport_path} has no branch_name"
logger.error(msg)
raise PermissionError(msg)
- return name
+ return Caller(name=name, home=current, passport=data)
except PermissionError:
raise
except Exception as exc:
@@ -103,12 +151,145 @@ def _find_caller() -> str:
raise PermissionError(msg)
+def _find_caller() -> str:
+ """Return just the calling branch's name (global tier needs nothing more)."""
+ return _resolve_caller().name
+
+
+def _citizen_class(passport: dict) -> str:
+ """Read citizen_class from either passport layout, '' when absent."""
+ identity = passport.get("identity", {})
+ branch_info = passport.get("branch_info", {})
+ return identity.get("citizen_class") or branch_info.get("citizen_class") or ""
+
+
+def _registry_entry(registry_data: dict, name: str) -> dict | None:
+ """Find a branch's entry in a registry, case-insensitively.
+
+ Registries differ on casing — AIPass records 'devpulse', Vera-Studio 'VERA' —
+ and ``_load_registry_data`` lowercases the keys only for the LIST shape it
+ normalizes. A registry authored as a dict is passed through untouched, keys
+ and all, so a plain lowercase lookup would miss 'VERA' and read as "not
+ listed" — a denial that has nothing to do with authority. Both shapes are
+ matched case-insensitively (F59 4.1: wrap the shared loader, don't change it).
+ """
+ branches = registry_data.get("branches", {})
+ key = name.lower()
+ if isinstance(branches, dict):
+ entry = branches.get(key)
+ if entry is not None:
+ return entry
+ return next((v for k, v in branches.items() if str(k).lower() == key), None)
+ for entry in branches:
+ if isinstance(entry, dict) and str(entry.get("name", "")).lower() == key:
+ return entry
+ return None
+
+
+def _recorded_home(entry: dict, repo_root: Path) -> Path | None:
+ """Resolve a registry entry's recorded path, or None when it records none.
+
+ Registries record these relative ('src/aipass/devpulse'), and
+ ``_load_registry_data`` resolves them to absolute — but only for the LIST
+ shape it normalizes. A dict-authored registry arrives with its paths exactly
+ as written, so resolve here too rather than trusting the loader to have done
+ it: an unresolved relative path would resolve against CWD and bind authority
+ to wherever the caller happened to be standing.
+ """
+ raw = entry.get("path")
+ if not raw:
+ return None
+ recorded = Path(raw)
+ if not recorded.is_absolute():
+ recorded = repo_root / recorded
+ try:
+ return recorded.resolve()
+ except OSError as exc:
+ # Returning None here reads downstream as "records no path", which would
+ # send someone hunting a registry entry that is in fact present and fine.
+ logger.warning("Registry path %s could not be resolved: %s", recorded, exc)
+ return None
+
+
+def _owner_tier_refusal(command: str, caller: Caller) -> str | None:
+ """Return why owner-tier is refused for this caller, or None if authorized.
+
+ Every branch names the check that refused, so a passport/registry drift is
+ diagnosable from a single log line instead of a bisect. Fails CLOSED: any
+ check that cannot be completed is a refusal, never a silent pass.
+ """
+ citizen_class = _citizen_class(caller.passport)
+ if citizen_class != _MANAGER_CLASS:
+ return (
+ f"caller '{caller.name}' is citizen_class '{citizen_class or 'unset'}' — "
+ f"owner-tier requires '{_MANAGER_CLASS}'"
+ )
+
+ try:
+ registry_path = get_registry_path()
+ registry_data = load_registry()
+ except RegistryMismatchError as exc:
+ # The shared loader runs its own credential check and raises before
+ # returning, so it lands here rather than at the tenancy check below.
+ # Same verdict, named the same way — a refusal must not depend on which
+ # layer happened to notice first.
+ logger.warning("owner-tier refused for '%s': registry credential mismatch: %s", caller.name, exc)
+ return f"caller '{caller.name}' does not hold citizenship in this project's registry ({exc})"
+ except Exception as exc:
+ logger.warning("owner-tier refused for '%s': registry unreadable: %s", caller.name, exc)
+ return f"the project registry could not be read ({exc}) — cannot verify ownership"
+
+ registry_id = registry_data.get("metadata", {}).get("id")
+ passport_id = caller.passport.get("citizenship", {}).get("registry_id")
+ if not registry_id:
+ return f"registry {registry_path.name} declares no metadata.id — cannot verify tenancy"
+ if not passport_id:
+ return (
+ f"caller '{caller.name}' passport has no citizenship.registry_id — "
+ "needs a registry backfill before it can hold owner-tier"
+ )
+ if passport_id != registry_id:
+ return (
+ f"caller '{caller.name}' belongs to registry {passport_id}, but this repo is "
+ f"{registry_id} — a manager of one project holds nothing in another"
+ )
+
+ entry = _registry_entry(registry_data, caller.name)
+ if entry is None:
+ return f"caller '{caller.name}' is not listed in {registry_path.name}"
+ if entry.get("owner") is not True:
+ return f"caller '{caller.name}' is listed in {registry_path.name} without owner: true"
+
+ # The registry file's own directory is the repo root by construction, which
+ # keeps path-binding anchored to the SAME registry the checks above used.
+ repo_root = registry_path.parent.resolve()
+ recorded = _recorded_home(entry, repo_root)
+ if recorded is None:
+ return f"registry entry for '{caller.name}' records no path — cannot bind authority to a location"
+ if caller.home != recorded and recorded not in caller.home.parents:
+ return (
+ f"caller '{caller.name}' presented a passport from {caller.home}, but the registry "
+ f"binds that name to {recorded} — a passport outside its recorded home proves nothing"
+ )
+
+ if registry_path.name != _AIPASS_REGISTRY_NAME and command in _AIPASS_FLOW_VERBS:
+ return (
+ f"'{command}' encodes AIPass's own dev→PR→main flow and is not translated for "
+ f"external repos yet — 'commit' and 'sync' work here today (DPLAN-0281 P2)"
+ )
+
+ return None
+
+
def verify_git_access(command: str) -> str:
"""Check if the calling branch is authorized for this git command.
- Uses GIT_ACCESS_TIERS to determine access level. Global-tier commands
- are available to all branches; owner-tier commands require the caller
- to be in the allowed_callers list.
+ Uses GIT_ACCESS_TIERS to determine access level. Global-tier commands are
+ available to all branches. Owner-tier is earned per-repo: the caller must be
+ a manager, of THIS project, listed as its owner, presenting a passport from
+ its registry-recorded home (DPLAN-0281). No branch is hardcoded — devpulse
+ holds git in AIPass through the same rule that gives any project's manager
+ git in theirs.
Returns:
The caller's branch name if authorized.
@@ -128,16 +309,32 @@ def verify_git_access(command: str) -> str:
return caller
if command in owner_tier["commands"]:
- caller = _find_caller()
- allowed = owner_tier["allowed_callers"]
- if caller not in allowed:
- msg = f"Branch '{caller}' is not authorized for '{command}'. Only {allowed} can use owner-tier commands."
+ caller_info = _resolve_caller()
+ refusal = _owner_tier_refusal(command, caller_info)
+ warn_only = os.environ.get(_AUTH_MODE_ENV, "").strip().lower() == "warn"
+ if refusal and not warn_only:
+ msg = f"Branch '{caller_info.name}' is not authorized for '{command}': {refusal}."
logger.error(msg)
raise PermissionError(msg)
+ if refusal:
+ # Rollback mode: record exactly what enforcement WOULD have refused,
+ # so the blast radius is read off the logs rather than guessed at.
+ logger.warning(
+ "git auth warn-mode: '%s' for '%s' would be denied under enforcement: %s",
+ command,
+ caller_info.name,
+ refusal,
+ )
json_handler.log_operation(
"git_access_verify",
- {"caller": caller, "command": command, "tier": "owner"},
+ {
+ "caller": caller_info.name,
+ "command": command,
+ "tier": "owner",
+ "mode": "warn" if warn_only else "enforce",
+ "would_refuse": refusal,
+ },
)
- return caller
+ return caller_info.name
raise PermissionError(f"Unknown git command: '{command}'.{_rerouted_hint(command)}")
diff --git a/src/aipass/drone/tests/conftest.py b/src/aipass/drone/tests/conftest.py
index 39be8b63..a6b98357 100644
--- a/src/aipass/drone/tests/conftest.py
+++ b/src/aipass/drone/tests/conftest.py
@@ -27,6 +27,65 @@ def temp_test_dir() -> Generator[Path, None, None]:
shutil.rmtree(test_dir)
+OWNER_REGISTRY_ID = "test-registry-0000-0000"
+
+
+def make_owner_project(
+ root: Path,
+ *,
+ branch: str = "devpulse",
+ registry_name: str = "AIPASS_REGISTRY.json",
+ citizen_class: str = "manager",
+ owner: bool = True,
+ registry_id: str = OWNER_REGISTRY_ID,
+ passport_registry_id: str | None = None,
+ branch_dir: Path | None = None,
+ record_path: str | None = None,
+) -> Path:
+ """Mint a project in which *branch* genuinely holds owner-tier, and return its home.
+
+ Owner-tier is earned from four independent facts (DPLAN-0281), so a fixture
+ that forges only a branch name no longer proves anything. This writes all
+ four — manager class, matching tenancy, owner flag, recorded path — and every
+ keyword exists so a test can break exactly ONE of them and watch the gate bite.
+
+ Args:
+ branch_dir: where the passport lives; defaults to *root*.
+ record_path: what the registry records as the branch path. Defaults to
+ the real branch_dir; pass a different value to test path-binding, or
+ a relative string to exercise external-project style registries.
+ """
+ home = branch_dir if branch_dir is not None else root
+ home.mkdir(parents=True, exist_ok=True)
+
+ registry = {
+ "metadata": {"id": registry_id, "name": "TEST-PROJECT", "version": "1.0.0"},
+ "branches": [
+ {
+ "name": branch,
+ "path": record_path if record_path is not None else str(home),
+ "email": f"@{branch}",
+ "status": "active",
+ "owner": owner,
+ }
+ ],
+ }
+ (root / registry_name).write_text(json.dumps(registry, indent=2), encoding="utf-8")
+
+ trinity = home / ".trinity"
+ trinity.mkdir(parents=True, exist_ok=True)
+ passport = {
+ "branch_info": {"branch_name": branch},
+ "identity": {"name": branch, "citizen_class": citizen_class},
+ "citizenship": {
+ "registered": True,
+ "registry_id": passport_registry_id if passport_registry_id is not None else registry_id,
+ },
+ }
+ (trinity / "passport.json").write_text(json.dumps(passport, indent=2), encoding="utf-8")
+ return home
+
+
@pytest.fixture
def sample_registry(temp_test_dir: Path) -> Path:
"""Create a sample AIPASS_REGISTRY.json for testing."""
diff --git a/src/aipass/drone/tests/test_git_access.py b/src/aipass/drone/tests/test_git_access.py
index 6357c54d..d384b3de 100644
--- a/src/aipass/drone/tests/test_git_access.py
+++ b/src/aipass/drone/tests/test_git_access.py
@@ -26,6 +26,8 @@
from aipass.drone.apps.handlers.git.checkout_handler import checkout_branch
from aipass.drone.apps.modules.git_module import handle_command
+from .conftest import OWNER_REGISTRY_ID, make_owner_project
+
# ===========================================================================
# Fixtures
@@ -34,14 +36,14 @@
@pytest.fixture()
def devpulse_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
- """Create a temp directory with a devpulse passport."""
- trinity = tmp_path / ".trinity"
- trinity.mkdir()
- passport = trinity / "passport.json"
- passport.write_text(
- json.dumps({"branch_info": {"branch_name": "devpulse"}}),
- encoding="utf-8",
- )
+ """Create a temp project in which devpulse genuinely holds owner-tier.
+
+ This fixture used to forge a branch name and nothing else, which is precisely
+ the escalation DPLAN-0281 closed — so it now mints all four facts the gate
+ checks. Tests that need one of them broken build their own via
+ make_owner_project(...).
+ """
+ make_owner_project(tmp_path)
monkeypatch.chdir(tmp_path)
return tmp_path
@@ -99,9 +101,14 @@ def test_owner_commands(self) -> None:
assert "smart-sync" in cmds
assert "fix" in cmds
- def test_owner_allowed_callers(self) -> None:
- allowed = GIT_ACCESS_TIERS["owner"]["allowed_callers"]
- assert allowed == ["devpulse"]
+ def test_owner_tier_names_no_branch(self) -> None:
+ """Owner-tier must not carry a hardcoded allowlist any more (DPLAN-0281).
+
+ A name in this table was authority-by-string. Authority is now earned per
+ repo from the caller's passport and that project's registry, which is what
+ lets a project's own manager hold git without AIPass knowing their name.
+ """
+ assert "allowed_callers" not in GIT_ACCESS_TIERS["owner"]
def test_pr_in_owner_tier(self) -> None:
cmds = GIT_ACCESS_TIERS["owner"]["commands"]
@@ -199,6 +206,324 @@ def test_unknown_verb_gets_no_hint(self, devpulse_dir: Path) -> None:
assert str(exc_info.value) == "Unknown git command: 'nonexistent'."
+class TestOwnerTierIsEarnedPerRepo:
+ """Owner-tier authorization: manager + tenancy + owner flag + path-binding.
+
+ DPLAN-0281 / F59 6.3. Each test breaks exactly ONE of the four facts and
+ proves the gate bites, so a future regression names which check it broke.
+ """
+
+ def test_manager_at_home_is_authorized(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """The happy path: all four facts hold, owner-tier is granted."""
+ make_owner_project(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ assert verify_git_access("commit") == "devpulse"
+
+ def test_no_branch_is_hardcoded(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """A project's own manager holds git even when AIPass never heard of them.
+
+ This is the whole point of the ruling — VERA in Vera-Studio authorizes by
+ the same rule that authorizes devpulse here, with no entry in any list.
+ """
+ make_owner_project(tmp_path, branch="VERA", registry_name="VERA-STUDIO_REGISTRY.json")
+ monkeypatch.chdir(tmp_path)
+ assert verify_git_access("commit") == "VERA"
+
+ def test_non_manager_denied(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Forged/insufficient class: everything else correct, class is not manager."""
+ make_owner_project(tmp_path, branch="seedgo", citizen_class="builder")
+ monkeypatch.chdir(tmp_path)
+ with pytest.raises(PermissionError, match="citizen_class"):
+ verify_git_access("commit")
+
+ def test_wrong_tenancy_denied(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """T-C: a manager of another project, holding a passport for a different registry.
+
+ Asserts the SHARED loader's wording specifically. Both this layer and the
+ explicit check below say "belongs to registry", so a loose match passed no
+ matter which one fired — and proved neither.
+
+ The AIPASS_REGISTRY pin is load-bearing: find_registry's cwd walk SKIPS
+ registries that fail the credential check, so without it the mismatched
+ fixture is passed over and resolution falls through to whatever exists
+ outside the fixture — the real AIPass registry locally (mismatch, right
+ wording, wrong reason) but nothing in a clean CI checkout (not-found, a
+ different refusal). The env pin is priority 2, ahead of the walk, so the
+ loader is forced to read THIS registry and raise its own mismatch.
+ """
+ make_owner_project(tmp_path, passport_registry_id="some-other-project-id")
+ monkeypatch.setenv("AIPASS_REGISTRY", str(tmp_path / "AIPASS_REGISTRY.json"))
+ monkeypatch.chdir(tmp_path)
+ with pytest.raises(PermissionError, match="does not hold citizenship in this project's registry"):
+ verify_git_access("commit")
+
+ def test_tenancy_rechecked_when_loader_stays_silent(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """The explicit tenancy check is the backstop, and must refuse on its own.
+
+ ``load_registry`` normally raises on a mismatch — but its credential check
+ swallows its own exceptions and returns a silent pass, so a passport it
+ fails to read reaches here unverified. Patching it to return mismatched
+ data without raising is the only way to stand in that gap: with the shared
+ layer quiet, this check alone decides, and it must still fail closed.
+
+ Pinned for the same reason as test_wrong_tenancy_denied (CI 2922a685):
+ this fixture's passport deliberately mismatches, so find_registry's cwd
+ walk skips it and get_registry_path — which is NOT patched here — would
+ otherwise resolve to whatever lives outside the fixture.
+ """
+ make_owner_project(tmp_path, passport_registry_id="some-other-project-id")
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("AIPASS_REGISTRY", str(tmp_path / "AIPASS_REGISTRY.json"))
+ silent = {
+ "metadata": {"id": OWNER_REGISTRY_ID},
+ "branches": {"devpulse": {"name": "devpulse", "path": str(tmp_path), "owner": True}},
+ }
+ with patch("aipass.drone.apps.plugins.devpulse_ops.auth.load_registry", return_value=silent):
+ with pytest.raises(PermissionError, match="belongs to registry some-other-project-id"):
+ verify_git_access("commit")
+
+ def test_missing_tenancy_id_denied(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Migration boundary (F59 sec 5, row 4): a passport predating registry_id."""
+ make_owner_project(tmp_path, passport_registry_id="")
+ monkeypatch.chdir(tmp_path)
+ with pytest.raises(PermissionError, match="no citizenship.registry_id"):
+ verify_git_access("commit")
+
+ def test_not_listed_in_registry_denied(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """A manager passport for a branch this project's registry does not carry."""
+ # Mint ghost's passport first, then overwrite the registry so it lists only
+ # devpulse — ghost's credentials survive, their registry entry does not.
+ ghost = make_owner_project(tmp_path, branch="ghost", branch_dir=tmp_path / "ghost")
+ make_owner_project(tmp_path, branch="devpulse")
+ monkeypatch.chdir(ghost)
+ with pytest.raises(PermissionError, match="not listed"):
+ verify_git_access("commit")
+
+ def test_owner_flag_false_denied(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Listed and a manager, but the registry does not mark them owner."""
+ make_owner_project(tmp_path, owner=False)
+ monkeypatch.chdir(tmp_path)
+ with pytest.raises(PermissionError, match="without owner: true"):
+ verify_git_access("commit")
+
+ def test_passport_outside_recorded_home_denied(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """T-A, the escalation that mattered: a forged passport in a directory the attacker controls.
+
+ Name, class, tenancy and owner flag are ALL correct — the registry id is
+ readable off local disk, so a same-machine attacker can copy it. Only the
+ location refuses, which is exactly why path-binding is the load-bearing check.
+ """
+ rogue = tmp_path / "tmp_workdir"
+ make_owner_project(tmp_path, branch_dir=rogue, record_path=str(tmp_path / "real_devpulse"))
+ monkeypatch.chdir(rogue)
+ with pytest.raises(PermissionError, match="outside its recorded home"):
+ verify_git_access("commit")
+
+ def test_subdirectory_of_recorded_home_allowed(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Path-binding accepts at-or-under, so working from a subdir still authorizes."""
+ make_owner_project(tmp_path)
+ nested = tmp_path / "apps" / "handlers"
+ nested.mkdir(parents=True)
+ monkeypatch.chdir(nested)
+ assert verify_git_access("commit") == "devpulse"
+
+ def test_ancestor_passport_denied_from_inside_recorded_home(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """Binding anchors to where the PASSPORT is, not where the caller stands.
+
+ The forgery sits at the repo root and the caller stands inside the real
+ manager's directory, which is empty of any passport — so the walk-up
+ reaches the forgery. Checking CWD would authorize it, because CWD really
+ is under the recorded path. Checking the passport's own home refuses:
+ standing somewhere does not make a passport found elsewhere valid there.
+ """
+ recorded = tmp_path / "devpulse"
+ recorded.mkdir()
+ make_owner_project(tmp_path, branch_dir=tmp_path, record_path=str(recorded))
+ monkeypatch.chdir(recorded)
+ with pytest.raises(PermissionError, match="outside its recorded home"):
+ verify_git_access("commit")
+
+ def test_relative_registry_path_resolves(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Registries record relative paths ('src/aipass/devpulse', 'src/vera_studio/vera')."""
+ home = tmp_path / "src" / "vera_studio" / "vera"
+ make_owner_project(
+ tmp_path,
+ branch="VERA",
+ registry_name="VERA-STUDIO_REGISTRY.json",
+ branch_dir=home,
+ record_path="src/vera_studio/vera",
+ )
+ monkeypatch.chdir(home)
+ assert verify_git_access("commit") == "VERA"
+
+ def test_dict_shaped_registry_binds(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """A registry authored as a dict skips the loader's normalization entirely.
+
+ ``_load_registry_data`` lowercases keys and absolutizes paths only for the
+ LIST shape; dicts pass through as written. So this is the shape where
+ case-insensitive lookup and relative-path resolution actually earn their
+ keep — with a list registry the loader has already done both, and a broken
+ implementation here would still pass.
+ """
+ home = tmp_path / "src" / "vera"
+ home.mkdir(parents=True)
+ (tmp_path / "VERA-STUDIO_REGISTRY.json").write_text(
+ json.dumps(
+ {
+ "metadata": {"id": OWNER_REGISTRY_ID},
+ "branches": {"VERA": {"name": "VERA", "path": "src/vera", "owner": True, "status": "active"}},
+ }
+ ),
+ encoding="utf-8",
+ )
+ (home / ".trinity").mkdir()
+ (home / ".trinity" / "passport.json").write_text(
+ json.dumps(
+ {
+ "branch_info": {"branch_name": "VERA"},
+ "identity": {"citizen_class": "manager"},
+ "citizenship": {"registry_id": OWNER_REGISTRY_ID},
+ }
+ ),
+ encoding="utf-8",
+ )
+ monkeypatch.chdir(home)
+ assert verify_git_access("commit") == "VERA"
+
+ def test_passport_under_recorded_ancestor_allowed(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Path-binding is at-OR-UNDER (F59 4.2a), not exact match.
+
+ A registry may record an ancestor of the passport's home — a small project
+ whose manager is recorded at the repo root, say. Exact-match would refuse
+ them for a reason that has nothing to do with authority.
+
+ Note this is the weaker end of the binding: the broader the recorded path,
+ the more of the tree can host a forged passport. Recording the repo root
+ degrades path-binding to repo-wide (reported to @devpulse for P2).
+ """
+ home = tmp_path / "agents" / "devpulse"
+ make_owner_project(tmp_path, branch_dir=home, record_path=str(tmp_path))
+ monkeypatch.chdir(home)
+ assert verify_git_access("commit") == "devpulse"
+
+ def test_unreadable_registry_denied(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Fresh clone / no registry: fail CLOSED, never silent-pass (F59 4.1)."""
+ make_owner_project(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("AIPASS_REGISTRY", str(tmp_path / "does_not_exist.json"))
+ with pytest.raises(PermissionError, match="registry could not be read"):
+ verify_git_access("commit")
+
+ @pytest.mark.parametrize("command", ["status", "log", "diff"])
+ def test_global_tier_never_acquires_the_gate(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, command: str
+ ) -> None:
+ """Regression (F59 6.3 #7): read-only stays open to any citizen."""
+ make_owner_project(tmp_path, branch="seedgo", citizen_class="builder", owner=False)
+ monkeypatch.chdir(tmp_path)
+ assert verify_git_access(command) == "seedgo"
+
+
+class TestExternalRepoVerbTranslation:
+ """AIPass-flow verbs must refuse honestly in an external repo, not half-run."""
+
+ @pytest.fixture()
+ def vera_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
+ home = make_owner_project(tmp_path, branch="VERA", registry_name="VERA-STUDIO_REGISTRY.json")
+ monkeypatch.chdir(home)
+ return home
+
+ @pytest.mark.parametrize("command", ["commit", "sync", "checkout", "unlock"])
+ def test_portable_verbs_work(self, vera_home: Path, command: str) -> None:
+ """P1 scope: these translate to any git repo and must work for its manager."""
+ assert verify_git_access(command) == "VERA"
+
+ @pytest.mark.parametrize("command", ["dev-pr", "pr", "merge", "tag", "fix"])
+ def test_aipass_flow_verbs_refuse_honestly(self, vera_home: Path, command: str) -> None:
+ """These assume our dev→PR→main flow; refusing beats half-running in someone's repo."""
+ with pytest.raises(PermissionError, match="not translated for external repos"):
+ verify_git_access(command)
+
+ @pytest.mark.parametrize("command", ["dev-pr", "pr", "merge", "tag", "fix"])
+ def test_same_verbs_work_in_aipass(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, command: str) -> None:
+ """The refusal is scoped to external repos — AIPass's own flow is untouched."""
+ make_owner_project(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ assert verify_git_access(command) == "devpulse"
+
+
+class TestGitAuthWarnMode:
+ """One env var is the rollback (F59 6.1): warn logs the refusal and allows."""
+
+ def test_warn_mode_allows_and_logs(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ make_owner_project(tmp_path, citizen_class="builder")
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("AIPASS_GIT_AUTH_MODE", "warn")
+ with patch("aipass.drone.apps.plugins.devpulse_ops.auth.logger") as mock_logger:
+ assert verify_git_access("commit") == "devpulse"
+ mock_logger.warning.assert_called_once()
+ assert "would be denied" in mock_logger.warning.call_args[0][0]
+
+ @pytest.mark.parametrize("value", ["enforce", "", "yes", "warn-only", "1"])
+ def test_only_the_exact_word_warn_opens_the_gate(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, value: str
+ ) -> None:
+ """Anything that isn't exactly 'warn' enforces — no near-miss opens the gate."""
+ make_owner_project(tmp_path, citizen_class="builder")
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("AIPASS_GIT_AUTH_MODE", value)
+ with pytest.raises(PermissionError):
+ verify_git_access("commit")
+
+ def test_unset_env_enforces(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """The DEFAULT is enforce (F59 6.1 decision).
+
+ Separate from the parametrized test above, which only proves that *set*
+ values other than 'warn' enforce — flipping the default would sail past it.
+ """
+ make_owner_project(tmp_path, citizen_class="builder")
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.delenv("AIPASS_GIT_AUTH_MODE", raising=False)
+ with pytest.raises(PermissionError):
+ verify_git_access("commit")
+
+ def test_warn_mode_does_not_weaken_identification(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Warn mode relaxes authorization, never identification — no passport is still no entry."""
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("AIPASS_GIT_AUTH_MODE", "warn")
+ with pytest.raises(PermissionError, match="No .trinity/passport.json"):
+ verify_git_access("commit")
+
+
+class TestRegistryIdIsNotASecret:
+ """The registry id is readable off local disk, so it cannot carry authority alone."""
+
+ def test_correct_tenancy_alone_does_not_authorize(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """F59 6.3 #1: a non-owner passport with the RIGHT registry id is still governed.
+
+ Proves the tenancy check did not become a backdoor — it narrows, it never grants.
+ """
+ make_owner_project(tmp_path, branch="devpulse")
+ rogue = tmp_path / "rogue"
+ rogue.mkdir()
+ (rogue / ".trinity").mkdir()
+ (rogue / ".trinity" / "passport.json").write_text(
+ json.dumps(
+ {
+ "branch_info": {"branch_name": "seedgo"},
+ "identity": {"citizen_class": "manager"},
+ "citizenship": {"registry_id": OWNER_REGISTRY_ID},
+ }
+ ),
+ encoding="utf-8",
+ )
+ monkeypatch.chdir(rogue)
+ with pytest.raises(PermissionError):
+ verify_git_access("commit")
+
+
class TestGitAccessLogSeverity:
"""Severity is owned by auth.py: designed refusals warn, real denials error.
diff --git a/src/aipass/drone/tests/test_git_module.py b/src/aipass/drone/tests/test_git_module.py
index f544037e..9d8712f8 100644
--- a/src/aipass/drone/tests/test_git_module.py
+++ b/src/aipass/drone/tests/test_git_module.py
@@ -41,6 +41,8 @@
)
from aipass.trigger.apps.modules import core as trigger_core
+from .conftest import make_owner_project
+
# ===========================================================================
# Fixtures
@@ -200,6 +202,20 @@ def test_finds_registry_file(self, lock_dir: Path) -> None:
root = find_repo_root()
assert root == lock_dir
+ def test_finds_external_project_registry(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Any *_REGISTRY.json marks a root, not only AIPass's own (DPLAN-0281).
+
+ External projects name theirs after themselves. Matching only the AIPass
+ filename sent them to the rev-parse fallback while registry resolution
+ (find_registry, which globs) found the real root — so the lock and the
+ registry could disagree about where the project even is.
+ """
+ (tmp_path / "VERA-STUDIO_REGISTRY.json").write_text("{}", encoding="utf-8")
+ subdir = tmp_path / "src" / "vera"
+ subdir.mkdir(parents=True)
+ monkeypatch.chdir(subdir)
+ assert find_repo_root() == tmp_path
+
def test_fallback_to_git(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Falls back to git rev-parse when no registry found."""
monkeypatch.chdir(tmp_path)
@@ -872,6 +888,45 @@ def test_pr_no_branch_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch)
assert result["exit_code"] == 1
+class TestGitModuleRealAuth:
+ """End-to-end routing with the REAL gate — nothing patched.
+
+ Every other test in this file stubs verify_git_access, so a gate that
+ authorized nobody at all would still show a green suite. These commands are
+ chosen to stop at a harmless error AFTER the auth check, so they exercise the
+ real path without touching a repo (DPLAN-0281, dispatch 05b22424).
+ """
+
+ def test_owner_reaches_the_handler(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """A genuine manager at home clears auth and lands in the handler.
+
+ The '--force' complaint is the proof: that error is raised past the gate.
+ """
+ make_owner_project(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ result = handle_command("unlock")
+ assert result["exit_code"] == 1
+ assert "--force" in result["stderr"]
+ assert "not authorized" not in result["stderr"].lower()
+
+ def test_non_manager_stopped_at_the_gate(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """The same command, one fact broken — refused before the handler runs."""
+ make_owner_project(tmp_path, citizen_class="builder")
+ monkeypatch.chdir(tmp_path)
+ result = handle_command("unlock")
+ assert result["exit_code"] == 1
+ assert "not authorized" in result["stderr"].lower()
+ assert "--force" not in result["stderr"]
+
+ def test_global_tier_needs_no_ownership(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Read-only routing still works for a citizen who owns nothing."""
+ make_owner_project(tmp_path, branch="seedgo", citizen_class="builder", owner=False)
+ monkeypatch.chdir(tmp_path)
+ result = handle_command("lock")
+ assert result["exit_code"] == 0
+ assert json.loads(result["stdout"])["locked"] is False
+
+
class TestDetectBranchDir:
"""Branch directory detection tests."""
diff --git a/src/aipass/drone/tests/test_router.py b/src/aipass/drone/tests/test_router.py
index 58a06b1f..242ded5b 100644
--- a/src/aipass/drone/tests/test_router.py
+++ b/src/aipass/drone/tests/test_router.py
@@ -235,6 +235,25 @@ def test_valid_branch_and_command(self, mock_resolve, mock_exec):
assert result.exit_code == 0
assert result.stdout == "done"
+ @patch("aipass.drone.apps.modules.router.os.environ.get", return_value=None)
+ @patch("aipass.drone.apps.modules.router.detect_caller_branch_name", return_value=None)
+ @patch("aipass.drone.apps.modules.router.execute_branch_command")
+ @patch("aipass.drone.apps.modules.router.resolve_branch")
+ def test_lost_caller_logs_unknown_not_blank(self, mock_resolve, mock_exec, _mock_detect, _mock_env):
+ """An undetected caller routes as UNKNOWN, not as a silently omitted tag.
+
+ A blank tag reads as 'not applicable' and hid the gap that surfaced one
+ second later as a BRANCH DETECTION FAILED in the target branch.
+ """
+ mock_resolve.return_value = "/fake/path/to/branch"
+ mock_exec.return_value = CommandResult(stdout="", stderr="", exit_code=0, branch="ai_mail", command="dispatch")
+
+ with patch("aipass.drone.apps.modules.router.logger") as mock_logger:
+ route_command("@ai_mail", "dispatch")
+
+ logged = mock_logger.info.call_args[0]
+ assert " [CALLER:UNKNOWN]" in logged
+
@patch("aipass.drone.apps.modules.router.resolve_branch")
def test_invalid_branch_raises_branch_not_found(self, mock_resolve):
"""route_command propagates BranchNotFoundError from resolver."""
@@ -418,6 +437,172 @@ def test_walks_up_from_subdirectory(self, temp_test_dir: Path):
assert result == "found_it"
+class TestDetectCallerFallsBackToRegistry:
+ """An unusable passport must not dead-end detection.
+
+ The old code returned None the moment a passport was found but unreadable,
+ silently skipping the registry fallback its own docstring promises — so an
+ external project with a valid registry still lost its caller identity.
+ """
+
+ @staticmethod
+ def _write_registry(root: Path, name: str) -> None:
+ (root / "PROJ_REGISTRY.json").write_text(json.dumps({"metadata": {"name": name}, "branches": []}))
+
+ def test_corrupt_passport_falls_back_to_registry(self, temp_test_dir: Path):
+ """Invalid JSON in the passport still resolves the project from the registry."""
+ trinity = temp_test_dir / ".trinity"
+ trinity.mkdir()
+ (trinity / "passport.json").write_text("{{{not valid json!!!")
+ self._write_registry(temp_test_dir, "Vera Studio")
+
+ assert detect_caller_branch_name(temp_test_dir) == "vera-studio"
+
+ def test_nameless_passport_falls_back_to_registry(self, temp_test_dir: Path):
+ """A passport that parses but names no branch is unusable, not authoritative."""
+ trinity = temp_test_dir / ".trinity"
+ trinity.mkdir()
+ (trinity / "passport.json").write_text(json.dumps({"branch_info": {}}))
+ self._write_registry(temp_test_dir, "Vera Studio")
+
+ assert detect_caller_branch_name(temp_test_dir) == "vera-studio"
+
+ def test_broken_passport_does_not_inherit_parent_identity(self, temp_test_dir: Path):
+ """The walk-up STOPS at a broken passport — climbing would misattribute identity.
+
+ A nested branch with a corrupt passport must never be reported as its parent.
+ """
+ parent_trinity = temp_test_dir / ".trinity"
+ parent_trinity.mkdir()
+ (parent_trinity / "passport.json").write_text(json.dumps({"branch_info": {"branch_name": "parent_branch"}}))
+
+ child = temp_test_dir / "child"
+ child_trinity = child / ".trinity"
+ child_trinity.mkdir(parents=True)
+ (child_trinity / "passport.json").write_text("{{{broken")
+
+ assert detect_caller_branch_name(child) != "parent_branch"
+
+ def test_total_failure_logs_cwd(self, temp_test_dir: Path):
+ """No passport and no registry logs the cwd — without it the failure is invisible.
+
+ The downstream error names the TARGET branch's directory, which sends
+ investigation to the wrong branch entirely (see @ai_mail send-fail 47f50fdb).
+ """
+ with patch("aipass.drone.apps.handlers.router_handler.logger") as mock_logger:
+ assert detect_caller_branch_name(temp_test_dir) is None
+ mock_logger.warning.assert_called_once()
+ # Compare as Paths, not reprs: on Windows the logged arg renders as
+ # WindowsPath('C:/...') while str(tmp_path) has backslashes, so a
+ # substring match fails on separator style alone.
+ assert any(
+ Path(str(arg)) == temp_test_dir
+ for arg in mock_logger.warning.call_args.args
+ if isinstance(arg, (str, Path))
+ )
+
+ def test_successful_detection_is_silent(self, temp_test_dir: Path):
+ """The happy path must not warn — noise in the logs @trigger watches."""
+ trinity = temp_test_dir / ".trinity"
+ trinity.mkdir()
+ (trinity / "passport.json").write_text(json.dumps({"branch_info": {"branch_name": "alpha"}}))
+
+ with patch("aipass.drone.apps.handlers.router_handler.logger") as mock_logger:
+ assert detect_caller_branch_name(temp_test_dir) == "alpha"
+ mock_logger.warning.assert_not_called()
+
+
+class TestNamelessRegistryFallback:
+ """A registry with no declared name must still identify its project.
+
+ AIPass's OWN registry carries only version/last_updated/total_branches/id, so
+ requiring metadata.name made the framework repo the single place this
+ fallback could never fire — and it failed silently. Callers at the AIPass
+ root were anonymous all day: six feedback messages arrived From unknown with
+ no reply path, three replies undeliverable for five hours.
+ """
+
+ @pytest.mark.parametrize(
+ ("filename", "expected"),
+ [
+ ("AIPASS_REGISTRY.json", "aipass"),
+ ("VERA-STUDIO_REGISTRY.json", "vera-studio"),
+ ("EARMARK_REGISTRY.json", "earmark"),
+ ],
+ )
+ def test_filename_identifies_project_when_metadata_is_nameless(
+ self, temp_test_dir: Path, filename: str, expected: str
+ ):
+ """The filename is the one thing every registry provably has."""
+ (temp_test_dir / filename).write_text(
+ json.dumps({"metadata": {"version": "1.0.0", "total_branches": 17, "id": "abc"}, "branches": []})
+ )
+ assert detect_caller_branch_name(temp_test_dir) == expected
+
+ def test_declared_name_beats_the_filename(self, temp_test_dir: Path):
+ """An explicit declaration outranks inference — filename is the fallback, not the rule."""
+ (temp_test_dir / "PROJ_REGISTRY.json").write_text(
+ json.dumps({"metadata": {"name": "Vera Studio"}, "branches": []})
+ )
+ assert detect_caller_branch_name(temp_test_dir) == "vera-studio"
+
+ def test_passport_still_outranks_the_registry(self, temp_test_dir: Path):
+ """Project-level attribution must never shadow a citizen standing in their branch.
+
+ This is the whole safety boundary of the fallback: it fires only when
+ nobody identifiable is home.
+ """
+ trinity = temp_test_dir / ".trinity"
+ trinity.mkdir()
+ (trinity / "passport.json").write_text(json.dumps({"branch_info": {"branch_name": "drone"}}))
+ (temp_test_dir / "AIPASS_REGISTRY.json").write_text(json.dumps({"metadata": {}, "branches": []}))
+
+ assert detect_caller_branch_name(temp_test_dir) == "drone"
+
+ def test_unreadable_registry_says_so(self, temp_test_dir: Path):
+ """Found-but-rejected must never be silent — that silence cost the diagnosis.
+
+ The old code logged nothing when a registry was found and turned down, so
+ the log showed 'no passport or registry found' while the registry was
+ sitting in the very directory named by the message.
+ """
+ (temp_test_dir / "PROJ_REGISTRY.json").write_text("{{{not json")
+ with patch("aipass.drone.apps.handlers.router_handler.logger") as mock_logger:
+ detect_caller_branch_name(temp_test_dir)
+ assert mock_logger.warning.called
+ assert any("unreadable" in str(c) for c in mock_logger.warning.call_args_list)
+
+ def test_bare_suffix_registry_is_refused_and_named(self, temp_test_dir: Path):
+ """'_REGISTRY.json' leaves nothing to derive — refuse, and SAY which file.
+
+ Asserting the warning, not just the None: the caller's truthiness check
+ already discards an empty name, so returning None proves nothing about
+ the guard. The log line is the part that only this guard can produce, and
+ it is the whole point — a registry turned down in silence is exactly the
+ failure being fixed here.
+ """
+ (temp_test_dir / "_REGISTRY.json").write_text(json.dumps({"metadata": {}, "branches": []}))
+ with patch("aipass.drone.apps.handlers.router_handler.logger") as mock_logger:
+ assert detect_caller_branch_name(temp_test_dir) is None
+ assert any("no usable project name" in str(c) for c in mock_logger.warning.call_args_list)
+
+ def test_derived_name_cannot_earn_git_authority(self, temp_test_dir: Path, monkeypatch):
+ """A project name is identity for routing, never a credential.
+
+ Filename derivation means any directory with a *_REGISTRY.json now yields
+ a caller name. Owner-tier reads passports directly and must stay unmoved
+ by that — otherwise this convenience would be an escalation path.
+ """
+ from aipass.drone.apps.plugins.devpulse_ops.auth import verify_git_access
+
+ (temp_test_dir / "AIPASS_REGISTRY.json").write_text(json.dumps({"metadata": {"id": "x"}, "branches": []}))
+ assert detect_caller_branch_name(temp_test_dir) == "aipass"
+
+ monkeypatch.chdir(temp_test_dir)
+ with pytest.raises(PermissionError):
+ verify_git_access("commit")
+
+
# ---------------------------------------------------------------------------
# AIPASS_CALLER_BRANCH env var
# ---------------------------------------------------------------------------
diff --git a/src/aipass/drone/tests/test_tag_handler.py b/src/aipass/drone/tests/test_tag_handler.py
index f02b3b3a..20e363a7 100644
--- a/src/aipass/drone/tests/test_tag_handler.py
+++ b/src/aipass/drone/tests/test_tag_handler.py
@@ -17,6 +17,8 @@
from aipass.drone.apps.modules.git_module import get_help, handle_command
+from .conftest import make_owner_project
+
_TAG_PATCH = "aipass.drone.apps.handlers.git.tag_handler.subprocess.run"
PYPROJECT_CONTENT = '[project]\nname = "aipass"\nversion = "2.6.1"\n'
@@ -73,11 +75,12 @@ def repo_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
@pytest.fixture()
def devpulse_dir(repo_dir: Path) -> Path:
- """Set up a repo_dir with a devpulse passport."""
- trinity = repo_dir / ".trinity"
- trinity.mkdir()
- passport = trinity / "passport.json"
- passport.write_text('{"branch_info": {"branch_name": "devpulse"}}', encoding="utf-8")
+ """Set up a repo_dir where devpulse genuinely holds owner-tier.
+
+ A branch-name-only passport no longer authorizes anything (DPLAN-0281) — the
+ owner has to be minted with real credentials for these handlers to be reached.
+ """
+ make_owner_project(repo_dir)
return repo_dir
diff --git a/src/aipass/flow/templates/playbook_plans/merge.md b/src/aipass/flow/templates/playbook_plans/merge.md
index bddf1a09..26e33997 100644
--- a/src/aipass/flow/templates/playbook_plans/merge.md
+++ b/src/aipass/flow/templates/playbook_plans/merge.md
@@ -78,7 +78,7 @@ just that one merge commit — **cosmetic and trivially resolved**.
## 2. Verify, commit, CHANGELOG
-- [ ] **Run the CI audit gate LOCALLY before pushing** (local == CI, S199 parity — catches red before the PR): `cd && .venv/bin/python .github/scripts/seedgo_audit.py` → expect **all** branches `>=100%`, exit 0 (the script prints the live count — don't trust a hardcoded number). Uses a relative `src/aipass` path, so run from the repo **root**, not a branch dir.
+- [ ] **Run the CI audit gate LOCALLY before pushing** (local == CI, S199 parity — catches red before the PR): `cd && .venv/bin/python .github/scripts/seedgo_audit.py` → expect **all** branches `>=100%`, exit 0 (the script prints the live count — don't trust a hardcoded number). Uses a relative `src/aipass` path, so run from the repo **root**, not a branch dir. ⚠️ **Runtime is DYNAMIC** — it audits every branch, so it grows with the system and no fixed wall-clock budget is valid (Patrick ruling 2026-08-02: a 2-min shell timeout killed a healthy run; "8 more minutes" would just be the next stale number). Run it as a background task and wait for exit — judge it by exit code, never by elapsed time.
- [ ] Update `CHANGELOG.md` — add entries under a dated section header `## [YYYY-MM-DD]` (the merge date), one section per merge. Sort into Added / Changed / Fixed.
- [ ] Commit: `drone @git commit "msg" --all` (from a branch dir, e.g. devpulse). New/untracked files (e.g. new templates) — confirm they got staged: `git ls-files ` after; `--all` may not pick up untracked.
- [ ] All commits are auto-SSH-signed via repo-level git config (key `~/.ssh/aipass_signing`, wired 2026-07-15). Nothing manual required; verify with `git log --show-signature -1` if in doubt.
diff --git a/src/aipass/flow/templates/playbook_plans/weekly_update.md b/src/aipass/flow/templates/playbook_plans/weekly_update.md
index c5d28cf1..b9ba9e2c 100644
--- a/src/aipass/flow/templates/playbook_plans/weekly_update.md
+++ b/src/aipass/flow/templates/playbook_plans/weekly_update.md
@@ -1,6 +1,6 @@
# {plan_number} - {subject} (PLAYBOOK)
-> **Create:** `drone @flow create . "Update #N — vX.Y.Z" weekly_update pplan` (template name before type)
+> **Create:** `drone @flow create . "Update #N - vX.Y.Z" weekly_update pplan` (template name before type)
**Created**: {today}
**Branch**: {location}
@@ -11,7 +11,7 @@
## What Are Playbooks?
-Playbooks (PBPLANs) are **throwaway SOP runs** — a checklist stamped from a reusable
+Playbooks (PPLANs) are **throwaway SOP runs** — a checklist stamped from a reusable
template for a recurring operation (merge, release cut, branch onboarding,
incident response). You tick steps off as you go, log what happened, then close.
@@ -26,30 +26,107 @@ tags, anything that broke) becomes a searchable trail. Costs nothing, gives hist
## Steps
*The weekly-update multi-channel SOP (Update #N). Owner: VERA. Refined from
-PPLAN-0008 / PPLAN-0011 / PPLAN-0015 runs.*
+PPLAN-0008 / 0011 / 0015 / 0017 runs.*
+
+### Ground truth first
+
+- [ ] 0. **Open `r/AIPass/new` and READ the last posted update number and date.**
+ The series number comes from the live sub — never from a plan title, a draft
+ filename, or an unticked checklist. **An empty playbook is not evidence that
+ its post never fired.** PPLAN-0017 sat with zero ticks and an empty run
+ summary while its post had been live for 7 days; trusting the plan produced
+ two posts numbered #11 and cost a delete-and-repost. Reddit titles are
+ immutable, so the number is the one mistake that cannot be fixed in place.
+ Chrome not running? Start it yourself — see **Driving Chrome** below.
+
+### Scope and draft
- [ ] 1. Scope: read AIPass CHANGELOG + `git log origin/main` since the last posted
- update. Confirm the shipped version. Only claim what's on origin/main.
+ update's window closed. Confirm the shipped version on main. Only claim what
+ is on origin/main. If the plan was stamped a while ago, re-scope to current
+ reality rather than shipping the stale window.
- [ ] 2. Draft the r/AIPass body: through-line first, receipts per claim, hyphens
- not em dashes, no banned words, durable numbers. EVERY post on EVERY
- channel includes the website: aipass.ai (Patrick directive 2026-07-27).
+ not em dashes, no banned words, durable numbers. Report losses and misses,
+ not only wins — a dev log that only reports wins reads as marketing.
+ EVERY post on EVERY channel includes the website: aipass.ai (Patrick
+ directive 2026-07-27).
Body ENDS with the series footer BEFORE first fire (bot posts cannot be
author-edited): Fresh numbers (stars w/ delta, forks, citizens, latest
release, tests, CI), Website: aipass.ai, changelog link, 'Raw dev logs
always here at r/AIPass.'
- Title: "AIPass Update #N — ...".
-- [ ] 3. Fact-check pass: every number/claim verified against the repo before posting.
+ Title: `AIPass Update #N - ...` using the number confirmed in step 0.
+- [ ] 3. Fact-check pass: every number/claim verified against the repo before
+ posting. Re-verify anything carried over from an older draft — it may sit in
+ a previous update's window and must not be presented as new.
+
+### Fire and verify, one channel at a time
+
- [ ] 4. Reddit: `python3 tools/publish_reddit_update.py --title '..' --body-file
- body.md --dry-run` → check injection → real run (posts as u/aipass-poster
- via Devvit upload + uninstall/reinstall of r/AIPass).
-- [ ] 5. Verify the Reddit post landed (Chrome MCP).
+ body.md --dry-run` → check the injection line → real run (posts as
+ u/aipass-poster via Devvit upload + uninstall/reinstall of r/AIPass).
+- [ ] 5. **Verify the Reddit post yourself** — open `r/AIPass/new` and read it back.
+ Confirm: title + number correct, body rendered, footer intact, no duplicate
+ of a prior number. This is yours, not Patrick's. If the number collided, fix
+ it NOW while the post is minutes old: `--delete `, correct the body, re-fire.
+ Content-only mistakes are a 60s `--edit ` (title stays immutable).
- [ ] 6. Bluesky: short promo via `drone @api integrations call publish_bluesky
"text"` from the AIPass project root (#618). ONE text argument only — every
argument posts verbatim, there is no --help. Verify via public.api.bsky.app
- XRPC getPostThread.
-- [ ] 7. X: Chrome MCP post, verify on profile.
+ XRPC getPostThread. Check `atproto` imports first (declared in the `[bluesky]`
+ extra since 2026-07-28, but confirm after any venv rebuild).
+- [ ] 7. X: Chrome MCP post as @AIPassSystem, verify on the profile. The aipass.ai
+ link card renders automatically — free real estate, keep the URL in.
- [ ] 8. Leave mod-approve for Patrick; log any cleanup items.
-- [ ] 9. Fill Run Summary + Listen, update .trinity, close the plan (auto-vectorizes).
+- [ ] 9. Fill Run Summary + Listen, update `.trinity`, close the plan (auto-vectorizes).
+ Post-mortem within 24h → `docs/reference/post_mortems/YYYY-MM-DD_slug.md`.
+
+---
+
+## Driving Chrome (do this yourself — do not wait for Patrick)
+
+*Cold-start tested end to end 2026-08-04: killed Chrome, confirmed `[]`, relaunched,
+reconnected, bound, read r/AIPass. Every step below is verified, not assumed.*
+
+Chrome MCP needs a running Chrome carrying the Claude extension. Launching it is
+**your** job; only the browser *selection* requires Patrick.
+
+1. Check first: `list_connected_browsers`. An empty array `[]` means **no browser is
+ running** — it does NOT mean the tooling is down. Never report Chrome as blocked
+ without running this.
+2. If empty, launch it yourself:
+ ```bash
+ nohup /usr/bin/google-chrome > /tmp/chrome.log 2>&1 &
+ ```
+ Use the **default profile** — plain `google-chrome`, never `--user-data-dir` and
+ never a Playwright/CDP launch. The default profile is the one signed into
+ @AIPassSystem, Bluesky, and Reddit; a fresh profile is signed into nothing and
+ you cannot log it in.
+3. Poll until it's up, then `list_connected_browsers` again. **Use `pgrep -x chrome`.**
+ The launcher `/usr/bin/google-chrome` is a wrapper that execs
+ `/opt/google/chrome/chrome`, so the running process is named `chrome`:
+ - `pgrep -f "google-chrome"` → false positive, it matches your own shell command
+ - `pkill -f "google-chrome"` → kills nothing, silently
+ - `pgrep -x chrome` / `pkill -x chrome` → correct
+ Measured cold start: extension reconnects ~1s after launch.
+4. **Ask Patrick which browser to bind** — the tool contract requires listing every
+ connected browser via AskUserQuestion; you may not self-pick. Then
+ `select_browser` with the chosen deviceId. The deviceId is **stable across Chrome
+ restarts**, so a choice he already made this session can be reused for the same
+ device without re-asking.
+5. `tabs_context_mcp {createIfEmpty: true}` → navigate → drive.
+
+Notes:
+- The extension can drop mid-session, often right after a click. Recovery is
+ `list_connected_browsers` + `select_browser` again — **no Claude Code restart needed.**
+- `list_connected_browsers` can briefly report a stale entry after Chrome dies. If a
+ browser is listed but calls fail, confirm with `pgrep -x chrome` before concluding
+ anything about the tooling.
+- **Chrome MCP reads reddit.com fine.** The old "all my tools refuse Reddit" note is
+ dead (superseded 2026-08-04). Verify every Reddit fire yourself.
+- Close tabs you opened when the run is done.
+- General rule this SOP was built on: when a tool "can't" do something, re-test the
+ belief before reporting a blocker. Two stale beliefs (Chrome, Reddit) blocked real
+ capability for months on nothing.
---
@@ -59,7 +136,7 @@ Fill as you go — this is the vectorized trail. Be specific: PR numbers, tags,
anything that broke and how it was handled.
- **Date:** {today}
-- **Outcome:**
+- **Outcome:** (post IDs per channel, verified how)
- **PRs / tags / commits:**
- **Issues hit:**
- **Notes for next run:**
diff --git a/src/aipass/hooks/README.md b/src/aipass/hooks/README.md
index 80dac72e..fb8dfea3 100644
--- a/src/aipass/hooks/README.md
+++ b/src/aipass/hooks/README.md
@@ -132,6 +132,23 @@ src/aipass/hooks/
6. Exit code 2 without JSON = crash (log error, continue to next hook)
7. All hook stdout concatenated and returned to platform
+## Two Log Streams
+
+Hook execution is recorded twice, at different levels of detail:
+
+| Stream | Contents | Default |
+|--------|----------|---------|
+| `logs/engine.jsonl` | Every hook — agent, exit code, timing, stderr, cwd. Source of truth for diagnostics. | Always on |
+| `system_logs/hooks_engine.log` (prax) | Warnings, errors, blocks, engine lifecycle. Per-hook narration suppressed. | Quiet |
+
+Per-hook narration ran ~3 lines per tool call, which dominated the prax stream and tripped the runaway detector during ordinary multi-agent operation. It is off by default; nothing is lost, since `engine.jsonl` carries strictly more detail.
+
+```bash
+AIPASS_HOOKS_VERBOSE_LOG=1 # restore per-hook lines in the prax stream
+```
+
+prax's `SystemLogger` exposes only `info`/`warning`/`error`, so there is no DEBUG level to demote to — the switch lives in `engine._log_detail()`. Blocks, crashes, timeouts, and trust-break banners are never suppressed.
+
## Dynamic Dispatch
Handlers are called **dynamically at runtime** — the engine uses `importlib.import_module()` + `getattr()` on the dotted handler path from `hooks.json` (e.g., `aipass.hooks.apps.handlers.prompt.identity.handle`). Handlers are never statically imported. This means static analysis tools (including seedgo's dead_code checker) cannot see that they are used. Each handler has been verified wired in `hooks.json` and confirmed firing in `engine.jsonl`.
diff --git a/src/aipass/hooks/apps/handlers/security/edit_gate.py b/src/aipass/hooks/apps/handlers/security/edit_gate.py
index d506a1d1..2580dd22 100644
--- a/src/aipass/hooks/apps/handlers/security/edit_gate.py
+++ b/src/aipass/hooks/apps/handlers/security/edit_gate.py
@@ -1,11 +1,11 @@
# =================== AIPass ====================
# Name: edit_gate.py
-# Version: 1.0.0
+# Version: 1.1.0
# Description: Cross-branch and inbox write protection (PreToolUse)
# Branch: hooks
# Layer: apps/handlers/security
# Created: 2026-05-21
-# Modified: 2026-05-21
+# Modified: 2026-08-04
# =============================================
"""Blocks unsafe edits: inbox writes, daemon confinement, cross-branch writes, diagnostics state."""
@@ -24,6 +24,22 @@
TRUSTED_CROSS_WRITERS: tuple[str, ...] = ("devpulse", "seedgo", "spawn")
_TRINITY_MEMORY_FILES = frozenset({"local.json", "observations.json"})
_NEWEST_FIRST_ARRAYS = ("sessions", "key_learnings")
+_NUMBER_KEYS = ("number", "session_number")
+
+
+def _entry_number(entry: dict) -> int | None:
+ """Read an entry's ordinal, tolerating legacy schemas.
+
+ Older .trinity files number sessions with 'session_number' rather than
+ 'number', and the rest of the fleet still honours it (@memory's rollover
+ fixtures, @daemon's latest_session). A guard that reads only 'number' locks
+ those branches out of their own memory with no way to comply.
+ """
+ for key in _NUMBER_KEYS:
+ value = entry.get(key)
+ if isinstance(value, int):
+ return value
+ return None
def _get_package_from_cwd(cwd: str) -> str:
@@ -154,6 +170,10 @@ def _check_newest_first(before: dict, after: dict) -> dict | None:
These arrays are newest-first: rollover archives the TAIL as "oldest". A new
entry appended after existing ones, or numbered <= the current max, gets
silently archived as history on the next rollover instead of kept as recent.
+
+ Ordinals are read through _entry_number, and an array where no entry carries a
+ recognized ordinal skips the monotonicity check entirely — see the comment at
+ that branch. The ordering check is schema-independent and always runs.
"""
for key in _NEWEST_FIRST_ARRAYS:
b = before.get(key)
@@ -169,8 +189,8 @@ def _check_newest_first(before: dict, after: dict) -> dict | None:
for e in b:
if not isinstance(e, dict):
continue
- number = e.get("number")
- if isinstance(number, int):
+ number = _entry_number(e)
+ if number is not None:
existing_numbers.append(number)
max_existing = max(existing_numbers) if existing_numbers else 0
@@ -187,20 +207,36 @@ def _check_newest_first(before: dict, after: dict) -> dict | None:
"sound": "edit gate",
}
+ new_numbers = [_entry_number(e) for e in new_entries if isinstance(e, dict)]
+
+ # An unnumbered array is not a newest-first violation, it is a schema this
+ # guard can't read. Blocking it would lock the branch out of its own memory
+ # with no way to comply. The ordering check above still applies.
+ if not existing_numbers and all(n is None for n in new_numbers):
+ continue
+
for entry in new_entries:
if not isinstance(entry, dict):
continue
- number = entry.get("number")
- if not isinstance(number, int) or number <= max_existing:
+ number = _entry_number(entry)
+ if number is None:
+ reason = (
+ f"{key}: new entry has no ordinal, but the existing entries are numbered "
+ f"(max {max_existing}). Number it with one of: {', '.join(_NUMBER_KEYS)} — "
+ "newest-first requires ascending numbers inserted at index 0."
+ )
+ elif number <= max_existing:
reason = (
f"{key}: new entry number ({number}) must be greater than the max existing "
f"number ({max_existing}) — newest-first requires ascending numbers inserted at index 0."
)
- return {
- "stdout": json.dumps({"decision": "block", "reason": reason}),
- "exit_code": 2,
- "sound": "edit gate",
- }
+ else:
+ continue
+ return {
+ "stdout": json.dumps({"decision": "block", "reason": reason}),
+ "exit_code": 2,
+ "sound": "edit gate",
+ }
return None
diff --git a/src/aipass/hooks/apps/modules/engine.py b/src/aipass/hooks/apps/modules/engine.py
index 0d600d6e..0d41210c 100644
--- a/src/aipass/hooks/apps/modules/engine.py
+++ b/src/aipass/hooks/apps/modules/engine.py
@@ -1,11 +1,11 @@
# =================== AIPass ====================
# Name: engine.py
-# Version: 1.1.0
+# Version: 1.2.0
# Description: Hook engine — unified dispatcher for all hook events
# Branch: hooks
# Layer: apps/modules
# Created: 2026-05-18
-# Modified: 2026-05-19
+# Modified: 2026-08-04
# =============================================
"""Hook engine — dispatches hook events to handlers, logs via prax + JSONL."""
@@ -30,6 +30,26 @@
("log", "Tail recent hook activity (last 20 entries)"),
]
+VERBOSE_LOG_ENV = "AIPASS_HOOKS_VERBOSE_LOG"
+
+
+def _log_detail(message: str, *args) -> None:
+ """Per-hook narration — DEBUG-tier detail, suppressed by default.
+
+ Every hook execution is already recorded in full (agent, exit code, timing,
+ stderr, cwd) in logs/engine.jsonl, which is the source of truth for hook
+ diagnostics. These lines are the human-readable echo of that, and at ~3 per
+ tool call they dominated system_logs/hooks_engine.log — enough to trip
+ prax's runaway detector on ordinary multi-agent operation.
+
+ prax's SystemLogger exposes only info/warning/error, so there is no DEBUG
+ level to demote to; the switch lives here instead. Set
+ AIPASS_HOOKS_VERBOSE_LOG=1 to restore them. Read per call so tests and live
+ sessions can toggle it without re-importing.
+ """
+ if os.environ.get(VERBOSE_LOG_ENV) == "1":
+ logger.info(message, *args)
+
def _run_hook(hook_cmd: str, stdin_data: str, timeout_s: int = 30) -> dict:
"""Run a single hook subprocess, capture output and timing."""
@@ -231,13 +251,14 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> tuple[str, int]:
return "", 0
outputs = []
+ ran = 0 # hooks that actually executed — outputs only counts those that wrote stdout
total_start = time.monotonic()
budget_state = None
budget_dirty = False
for hook_name, hook_def in event_hooks.items():
if not hook_def.get("enabled", True):
- logger.info("[HOOKS] %s.%s skipped (disabled)", event_type, hook_name)
+ _log_detail("[HOOKS] %s.%s skipped (disabled)", event_type, hook_name)
_log({"ts": time.time(), "event": event_type, "hook": hook_name, "action": "skipped_disabled"})
continue
@@ -285,7 +306,7 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> tuple[str, int]:
budget_dirty = True
allowed, reason = _check_budget(hook_name, budget_cfg, budget_state)
if not allowed:
- logger.info("[HOOKS] %s.%s budget: %s", event_type, hook_name, reason)
+ _log_detail("[HOOKS] %s.%s budget: %s", event_type, hook_name, reason)
_log(
{
"ts": time.time(),
@@ -328,7 +349,8 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> tuple[str, int]:
logger.info("[HOOKS] sound playback failed for timeout %s.%s: %s", event_type, hook_name, exc)
continue
- logger.info(
+ ran += 1
+ _log_detail(
"[HOOKS] %s.%s agent=%s exit=%d out=%db %dms",
event_type,
hook_name,
@@ -412,13 +434,14 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> tuple[str, int]:
_save_budget_state(budget_state, payload_session_id)
total_ms = (time.monotonic() - total_start) * 1000
- logger.info("[HOOKS] %s complete: %d hooks %dms", event_type, len(outputs), total_ms)
+ _log_detail("[HOOKS] %s complete: %d hooks %dms", event_type, ran, total_ms)
_log(
{
"ts": time.time(),
"event": event_type,
"action": "complete",
- "hooks_run": len(outputs),
+ "hooks_run": ran,
+ "hooks_with_output": len(outputs),
"total_ms": round(total_ms, 1),
}
)
diff --git a/src/aipass/hooks/tests/test_edit_gate_trinity.py b/src/aipass/hooks/tests/test_edit_gate_trinity.py
index 80ac1295..bedf25bd 100644
--- a/src/aipass/hooks/tests/test_edit_gate_trinity.py
+++ b/src/aipass/hooks/tests/test_edit_gate_trinity.py
@@ -1332,6 +1332,199 @@ def test_unrelated_field_edit_no_new_entries_allowed(self, tmp_path):
assert result["exit_code"] == 0
+class TestTrinityLegacyNumberSchema:
+ """A branch whose sessions[] use the legacy 'session_number' key must still be able
+ to write memory. The guard exists to stop newest-first violations, and a schema it
+ cannot read is not a violation (reported by VERA, Vera-Studio)."""
+
+ def test_legacy_session_number_prepend_allowed(self, tmp_path):
+ """Legacy schema, correct newest-first prepend -> allowed (was hard-blocked)."""
+ from aipass.hooks.apps.handlers.security.edit_gate import handle
+
+ file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
+ cwd = str(tmp_path / "src" / "aipass" / "hooks")
+ existing = {"sessions": [{"session_number": 5, "summary": "old"}]}
+ Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
+
+ after = {
+ "sessions": [
+ {"session_number": 6, "summary": "new"},
+ {"session_number": 5, "summary": "old"},
+ ]
+ }
+ content = json.dumps(after)
+
+ with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_WARN)):
+ result = handle(_hook_data(file_path, content, cwd=cwd))
+
+ assert result["exit_code"] == 0
+ assert result["stdout"] == ""
+
+ def test_legacy_session_number_tail_append_blocked(self, tmp_path):
+ """The ordering check is schema-independent — legacy tail append still blocks."""
+ from aipass.hooks.apps.handlers.security.edit_gate import handle
+
+ file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
+ cwd = str(tmp_path / "src" / "aipass" / "hooks")
+ existing = {"sessions": [{"session_number": 5, "summary": "old"}]}
+ Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
+
+ after = {
+ "sessions": [
+ {"session_number": 5, "summary": "old"},
+ {"session_number": 6, "summary": "new"},
+ ]
+ }
+ content = json.dumps(after)
+
+ with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_WARN)):
+ result = handle(_hook_data(file_path, content, cwd=cwd))
+
+ assert result["exit_code"] == 2
+ assert json.loads(result["stdout"])["decision"] == "block"
+
+ def test_legacy_number_not_greater_than_max_blocked(self, tmp_path):
+ """Monotonicity is enforced within the legacy schema too, not just skipped."""
+ from aipass.hooks.apps.handlers.security.edit_gate import handle
+
+ file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
+ cwd = str(tmp_path / "src" / "aipass" / "hooks")
+ existing = {"sessions": [{"session_number": 5, "summary": "old"}]}
+ Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
+
+ after = {
+ "sessions": [
+ {"session_number": 5, "summary": "dupe"},
+ {"session_number": 5, "summary": "old"},
+ ]
+ }
+ content = json.dumps(after)
+
+ with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_WARN)):
+ result = handle(_hook_data(file_path, content, cwd=cwd))
+
+ assert result["exit_code"] == 2
+ assert "must be greater than the max existing" in json.loads(result["stdout"])["reason"]
+
+ def test_number_key_wins_over_session_number(self, tmp_path):
+ """When both keys are present, 'number' is authoritative — session_number is ignored."""
+ from aipass.hooks.apps.handlers.security.edit_gate import handle
+
+ file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
+ cwd = str(tmp_path / "src" / "aipass" / "hooks")
+ # session_number 100 would block a new entry numbered 6; number 5 must win.
+ existing = {"sessions": [{"number": 5, "session_number": 100, "summary": "old"}]}
+ Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
+
+ after = {
+ "sessions": [
+ {"number": 6, "summary": "new"},
+ {"number": 5, "session_number": 100, "summary": "old"},
+ ]
+ }
+ content = json.dumps(after)
+
+ with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_WARN)):
+ result = handle(_hook_data(file_path, content, cwd=cwd))
+
+ assert result["exit_code"] == 0
+
+ def test_cross_schema_migration_allowed(self, tmp_path):
+ """Legacy existing entries, modern 'number' on the new one -> allowed."""
+ from aipass.hooks.apps.handlers.security.edit_gate import handle
+
+ file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
+ cwd = str(tmp_path / "src" / "aipass" / "hooks")
+ existing = {"sessions": [{"session_number": 5, "summary": "old"}]}
+ Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
+
+ after = {"sessions": [{"number": 6, "summary": "new"}, {"session_number": 5, "summary": "old"}]}
+ content = json.dumps(after)
+
+ with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_WARN)):
+ result = handle(_hook_data(file_path, content, cwd=cwd))
+
+ assert result["exit_code"] == 0
+
+ def test_wholly_unnumbered_array_passes_through(self, tmp_path):
+ """No recognized ordinal anywhere -> monotonicity can't be judged, so don't block."""
+ from aipass.hooks.apps.handlers.security.edit_gate import handle
+
+ file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
+ cwd = str(tmp_path / "src" / "aipass" / "hooks")
+ existing = {"sessions": [{"summary": "old"}]}
+ Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
+
+ after = {"sessions": [{"summary": "new"}, {"summary": "old"}]}
+ content = json.dumps(after)
+
+ with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_WARN)):
+ result = handle(_hook_data(file_path, content, cwd=cwd))
+
+ assert result["exit_code"] == 0
+
+ def test_unnumbered_array_still_ordering_checked(self, tmp_path):
+ """Pass-through covers the number check only — tail appends still block."""
+ from aipass.hooks.apps.handlers.security.edit_gate import handle
+
+ file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
+ cwd = str(tmp_path / "src" / "aipass" / "hooks")
+ existing = {"sessions": [{"summary": "old"}]}
+ Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
+
+ after = {"sessions": [{"summary": "old"}, {"summary": "new"}]}
+ content = json.dumps(after)
+
+ with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_WARN)):
+ result = handle(_hook_data(file_path, content, cwd=cwd))
+
+ assert result["exit_code"] == 2
+ assert "newest-first" in json.loads(result["stdout"])["reason"]
+
+ def test_unnumbered_new_entry_against_numbered_existing_blocked(self, tmp_path):
+ """Dropping the ordinal when the file has one is a real violation — block, and
+ name the accepted keys so there is a path to comply."""
+ from aipass.hooks.apps.handlers.security.edit_gate import handle
+
+ file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
+ cwd = str(tmp_path / "src" / "aipass" / "hooks")
+ existing = {"sessions": [{"number": 5, "summary": "old"}]}
+ Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
+
+ after = {"sessions": [{"summary": "new"}, {"number": 5, "summary": "old"}]}
+ content = json.dumps(after)
+
+ with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_WARN)):
+ result = handle(_hook_data(file_path, content, cwd=cwd))
+
+ assert result["exit_code"] == 2
+ reason = json.loads(result["stdout"])["reason"]
+ assert "no ordinal" in reason
+ assert "session_number" in reason
+
+ def test_key_learnings_legacy_schema_prepend_allowed(self, tmp_path):
+ """The alias applies to every newest-first array, not just sessions."""
+ from aipass.hooks.apps.handlers.security.edit_gate import handle
+
+ file_path = _make_trinity_path(tmp_path, "hooks", "local.json")
+ cwd = str(tmp_path / "src" / "aipass" / "hooks")
+ existing = {"key_learnings": [{"session_number": 5, "value": "old"}]}
+ Path(file_path).write_text(json.dumps(existing), encoding="utf-8")
+
+ after = {
+ "key_learnings": [
+ {"session_number": 6, "value": "new"},
+ {"session_number": 5, "value": "old"},
+ ]
+ }
+ content = json.dumps(after)
+
+ with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_WARN)):
+ result = handle(_hook_data(file_path, content, cwd=cwd))
+
+ assert result["exit_code"] == 0
+
+
class TestSectionCountGuard:
"""Soft count guard: warn (never block) when rolling sections exceed count cap."""
diff --git a/src/aipass/hooks/tests/test_engine.py b/src/aipass/hooks/tests/test_engine.py
index 849f9260..da937621 100644
--- a/src/aipass/hooks/tests/test_engine.py
+++ b/src/aipass/hooks/tests/test_engine.py
@@ -382,6 +382,180 @@ def test_trust_break_check_skipped_for_non_prompt_events(self, mock_logger):
mock_banner.assert_not_called()
+class TestLogVolume:
+ """Per-hook narration is suppressed in the prax stream by default.
+
+ At ~3 lines per tool call it dominated system_logs/hooks_engine.log and
+ tripped the runaway detector on ordinary multi-agent operation. engine.jsonl
+ keeps the full record either way — these tests pin that split.
+ """
+
+ CONFIG = {
+ "hooks_enabled": True,
+ "PreToolUse": {
+ "rm_gate": {"enabled": True, "command": "true", "matcher": ""},
+ "off_hook": {"enabled": False, "command": "true", "matcher": ""},
+ },
+ }
+
+ def _dispatch(self, mock_logger):
+ with (
+ patch("aipass.hooks.apps.modules.engine._log") as mock_log,
+ patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run,
+ ):
+ mock_run.return_value = {"exit_code": 0, "stdout": "", "stderr": "", "elapsed_ms": 1}
+ dispatch("PreToolUse", '{"tool_name":"Bash"}', self.CONFIG)
+ return mock_log
+
+ def test_per_hook_lines_are_silent_by_default(self, mock_logger, monkeypatch):
+ """No per-hook fire, no completion line, no skipped-disabled line."""
+ monkeypatch.delenv("AIPASS_HOOKS_VERBOSE_LOG", raising=False)
+ self._dispatch(mock_logger)
+
+ emitted = [c.args[0] for c in mock_logger.info.call_args_list if c.args]
+ assert not [m for m in emitted if "agent=%s" in m]
+ assert not [m for m in emitted if "complete:" in m]
+ assert not [m for m in emitted if "skipped (disabled)" in m]
+
+ def test_jsonl_still_records_everything(self, mock_logger, monkeypatch):
+ """Suppression is stream-only — forensics must be untouched."""
+ monkeypatch.delenv("AIPASS_HOOKS_VERBOSE_LOG", raising=False)
+ mock_log = self._dispatch(mock_logger)
+
+ actions = [c.args[0].get("action") for c in mock_log.call_args_list]
+ assert "skipped_disabled" in actions
+ assert "complete" in actions
+ assert any("hook" in c.args[0] and "exit_code" in c.args[0] for c in mock_log.call_args_list)
+
+ def test_verbose_env_restores_the_lines(self, mock_logger, monkeypatch):
+ """AIPASS_HOOKS_VERBOSE_LOG=1 is the DEBUG-level stand-in."""
+ monkeypatch.setenv("AIPASS_HOOKS_VERBOSE_LOG", "1")
+ self._dispatch(mock_logger)
+
+ emitted = [c.args[0] for c in mock_logger.info.call_args_list if c.args]
+ assert [m for m in emitted if "agent=%s" in m]
+ assert [m for m in emitted if "complete:" in m]
+
+ def test_env_must_be_exactly_one(self, mock_logger, monkeypatch):
+ """A stray truthy value must not accidentally reopen the firehose."""
+ monkeypatch.setenv("AIPASS_HOOKS_VERBOSE_LOG", "0")
+ self._dispatch(mock_logger)
+
+ assert not [c for c in mock_logger.info.call_args_list if c.args and "complete:" in c.args[0]]
+
+ def test_warnings_and_errors_are_never_suppressed(self, mock_logger, monkeypatch):
+ """Blocks stay loud at default verbosity — that is the whole point."""
+ monkeypatch.delenv("AIPASS_HOOKS_VERBOSE_LOG", raising=False)
+ config = {
+ "hooks_enabled": True,
+ "PreToolUse": {"gate": {"enabled": True, "command": "block", "matcher": ""}},
+ }
+ with (
+ patch("aipass.hooks.apps.modules.engine._log"),
+ patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run,
+ ):
+ mock_run.return_value = {
+ "exit_code": 2,
+ "stdout": json.dumps({"decision": "block"}),
+ "stderr": "",
+ "elapsed_ms": 1,
+ }
+ dispatch("PreToolUse", '{"tool_name":"Bash"}', config)
+
+ assert [c for c in mock_logger.warning.call_args_list if c.args and "BLOCKED" in c.args[0]]
+
+
+class TestCompletionCount:
+ """The 'complete: N hooks' line must count hooks that RAN, not hooks that
+ wrote stdout. Silent gates (rm_gate, git_gate) pass with empty output, so
+ counting outputs reported 'complete: 0 hooks' while gates had just run —
+ a lie that reads as 'the engine did nothing'."""
+
+ @pytest.fixture(autouse=True)
+ def _verbose(self, monkeypatch):
+ """These assert on the narration lines, so turn them back on."""
+ monkeypatch.setenv("AIPASS_HOOKS_VERBOSE_LOG", "1")
+
+ SILENT = {
+ "hooks_enabled": True,
+ "PreToolUse": {
+ "rm_gate": {"enabled": True, "command": "true", "matcher": ""},
+ "git_gate": {"enabled": True, "command": "true", "matcher": ""},
+ },
+ }
+
+ def _complete_line(self, mock_logger):
+ for call in mock_logger.info.call_args_list:
+ if call.args and "complete:" in call.args[0]:
+ return call.args
+ raise AssertionError("no completion line logged")
+
+ def test_silent_hooks_are_counted_as_run(self, mock_logger):
+ """Two gates run and pass quietly — the log must say 2, not 0."""
+ with (
+ patch("aipass.hooks.apps.modules.engine._log"),
+ patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run,
+ ):
+ mock_run.return_value = {"exit_code": 0, "stdout": "", "stderr": "", "elapsed_ms": 1}
+ dispatch("PreToolUse", '{"tool_name":"Bash"}', self.SILENT)
+
+ assert self._complete_line(mock_logger)[2] == 2
+
+ def test_jsonl_hooks_run_counts_executions_not_outputs(self, mock_logger):
+ """engine.jsonl carried the same misnomer — hooks_run was len(outputs)."""
+ with (
+ patch("aipass.hooks.apps.modules.engine._log") as mock_log,
+ patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run,
+ ):
+ mock_run.return_value = {"exit_code": 0, "stdout": "", "stderr": "", "elapsed_ms": 1}
+ dispatch("PreToolUse", '{"tool_name":"Bash"}', self.SILENT)
+
+ complete = [c.args[0] for c in mock_log.call_args_list if c.args[0].get("action") == "complete"][0]
+ assert complete["hooks_run"] == 2
+ assert complete["hooks_with_output"] == 0
+
+ def test_count_matches_the_per_hook_lines_above_it(self, mock_logger):
+ """The invariant a log reader relies on: the completion count equals the
+ number of per-hook lines printed for that dispatch."""
+ config = {
+ "hooks_enabled": True,
+ "PreToolUse": {
+ "quiet": {"enabled": True, "command": "true", "matcher": ""},
+ "loud": {"enabled": True, "command": "echo hi", "matcher": ""},
+ "off": {"enabled": False, "command": "true", "matcher": ""},
+ "unmatched": {"enabled": True, "command": "true", "matcher": "Edit"},
+ },
+ }
+ with (
+ patch("aipass.hooks.apps.modules.engine._log"),
+ patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run,
+ ):
+ mock_run.side_effect = [
+ {"exit_code": 0, "stdout": "", "stderr": "", "elapsed_ms": 1},
+ {"exit_code": 0, "stdout": "hi", "stderr": "", "elapsed_ms": 1},
+ ]
+ dispatch("PreToolUse", '{"tool_name":"Bash"}', config)
+
+ per_hook = [c for c in mock_logger.info.call_args_list if c.args and "agent=%s" in c.args[0]]
+ assert len(per_hook) == 2
+ assert self._complete_line(mock_logger)[2] == len(per_hook)
+
+ def test_timed_out_hook_is_not_counted_as_run(self, mock_logger):
+ """A timeout gets its own loud line and no per-hook line, so it must not
+ inflate the completion count."""
+ with (
+ patch("aipass.hooks.apps.modules.engine._log"),
+ patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run,
+ ):
+ mock_run.side_effect = [
+ {"exit_code": -1, "stdout": "", "stderr": "TIMEOUT", "elapsed_ms": 30000},
+ {"exit_code": 0, "stdout": "", "stderr": "", "elapsed_ms": 1},
+ ]
+ dispatch("PreToolUse", '{"tool_name":"Bash"}', self.SILENT)
+
+ assert self._complete_line(mock_logger)[2] == 1
+
+
class TestFindProjectConfig:
"""Tests for find_project_config() CWD walk."""
diff --git a/src/aipass/prax/CLOSED_PLANS.local.json b/src/aipass/prax/CLOSED_PLANS.local.json
index e2c3a51d..7cc61434 100644
--- a/src/aipass/prax/CLOSED_PLANS.local.json
+++ b/src/aipass/prax/CLOSED_PLANS.local.json
@@ -104,6 +104,13 @@
"subject": "Runaway detector rotation false-negative + relay.pid cross-boot staleness",
"date_closed": "2026-08-02",
"location": "prax"
+ },
+ {
+ "plan_id": "FPLAN-0378",
+ "type": "FPLAN",
+ "subject": "Log rotation retention: backup_count 1 to 3, dead config-template schema fix",
+ "date_closed": "2026-08-05",
+ "location": "prax"
}
],
"document_metadata": {
diff --git a/src/aipass/prax/apps/handlers/config/load.py b/src/aipass/prax/apps/handlers/config/load.py
index b94509fb..b58ff913 100755
--- a/src/aipass/prax/apps/handlers/config/load.py
+++ b/src/aipass/prax/apps/handlers/config/load.py
@@ -1,9 +1,9 @@
# =================== AIPass ====================
# Name: load.py
# Description: Load Logging Configuration Handler
-# Version: 1.0.2
+# Version: 1.1.0
# Created: 2025-11-07
-# Modified: 2026-04-14
+# Modified: 2026-08-04
# =============================================
"""
@@ -201,9 +201,26 @@ def get_module_logs_dir(module_name: Optional[str] = None) -> Path:
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
DEFAULT_LOG_LEVEL = "INFO"
-DEFAULT_SYSTEM_LOGS = {"max_lines": 1000, "backup_count": 1, "log_level": "INFO"}
-
-DEFAULT_LOCAL_LOGS = {"max_lines": 250, "backup_count": 1, "log_level": "INFO"}
+# backup_count raised 1 -> 3 on 2026-08-04. Retained history is not a fixed
+# span: it swings between backup_count x threshold (just after a roll, when the
+# live file is empty) and backup_count+1 x threshold (just before the next one).
+# At 1 backup that is 200,000-400,000 bytes, so the bump triples the guaranteed
+# floor and doubles the ceiling. Measured 2026-08-04: hooks_engine.log held
+# 45 minutes across .log.1 + .log under fleet load; only 51 of 308 system logs
+# have ever rotated at all, and just 4 of those retain under an hour, so this
+# buys the hot tail without touching the quiet majority. Cost is a ~28 MB
+# ceiling on a ~36 MB footprint.
+#
+# This does NOT rescue a true firehose — prax_event_queue.log kept 45 seconds
+# during the 07-31 event-queue flood, and 3 backups only makes that ~2 minutes.
+# Surviving that needs evidence capture at detection time, not a retention knob.
+DEFAULT_SYSTEM_LOGS = {"max_lines": 1000, "backup_count": 3, "log_level": "INFO"}
+
+DEFAULT_LOCAL_LOGS = {"max_lines": 250, "backup_count": 3, "log_level": "INFO"}
+
+# Set once per process when the config file is present but missing its
+# system_logs/local_logs sections — see load_log_config().
+_config_schema_warned: bool = False
# =============================================
# HANDLER FUNCTIONS
@@ -211,14 +228,28 @@ def get_module_logs_dir(module_name: Optional[str] = None) -> Path:
def lines_to_bytes(num_lines: int, avg_line_length: int = 200) -> int:
- """Convert number of lines to approximate bytes for log rotation
+ """Convert a line budget to a byte threshold for log rotation.
+
+ The 200-byte default is a deliberate over-estimate, not a measurement.
+ Real lines across system_logs/ average 115 bytes (median per-file 116, as
+ measured 2026-08-04 over 301 files), so ``max_lines`` behaves as a floor:
+ a 1000-line budget retains roughly 1,700 typical lines. That is the point.
+ 31 files average above 200 bytes/line and one reaches 700, and for those a
+ tighter estimate would starve the line budget rather than honour it.
+
+ So do not "correct" this to the observed average. Lowering it shrinks every
+ log's byte budget — 200,000 to 115,000 bytes for system logs — which
+ narrows the retention window instead of widening it. To retain more, raise
+ ``max_lines`` or ``backup_count``; both change bytes in the intended
+ direction.
Args:
- num_lines: Number of lines to convert
- avg_line_length: Average line length in characters (default 200)
+ num_lines: Line budget to convert.
+ avg_line_length: Bytes per line to assume. Default 200, intentionally
+ above the observed 115 so verbose logs still get their lines.
Returns:
- Approximate number of bytes
+ Byte threshold for the rotating handler.
"""
return num_lines * avg_line_length
@@ -247,39 +278,66 @@ def load_log_config() -> Dict[str, Any]:
{
"system_logs": {
"max_lines": 1000,
- "backup_count": 1,
+ "backup_count": 3,
"log_level": "INFO"
},
"local_logs": {
"max_lines": 250,
- "backup_count": 1,
+ "backup_count": 3,
"log_level": "INFO"
},
"log_format": "%(asctime)s - ...",
"date_format": "%Y-%m-%d %H:%M:%S"
}
- If config file missing or invalid, returns code defaults.
+ If config file missing or invalid, returns code defaults. A config file that
+ exists but omits the system_logs/local_logs sections also gets code defaults,
+ and warns once per process rather than falling back silently.
Example:
>>> config = load_log_config()
>>> max_lines = config['system_logs']['max_lines']
>>> print(f"System logs max lines: {max_lines}")
"""
+ global _config_schema_warned
try:
if PRAX_LOGGER_CONFIG_FILE.exists():
with open(PRAX_LOGGER_CONFIG_FILE, "r", encoding="utf-8") as f:
config = json.load(f)
+ # A config file that exists but carries neither key silently
+ # yields code defaults, so anyone reading it believes settings
+ # are live that never reach a handler. Found 2026-08-04: the
+ # file create_config_file() generates (prax_json/ is gitignored,
+ # so this is per-install, not in git) declared backup_count=5 and
+ # max_log_size_mb=10 at the top level of "config", where nothing
+ # reads them — effective retention was 1 backup of 200,000 bytes,
+ # 250x less than the file advertised. Say so instead of falling
+ # back quietly. Guarded to once per process: this runs on every
+ # logger init fleet-wide, and prax's own log is inside the
+ # directory prax watches (see DPLAN-0280).
+ section = config.get("config", {})
+ missing = [k for k in ("system_logs", "local_logs") if k not in section]
+ if missing and not _config_schema_warned:
+ _config_schema_warned = True
+ logger.warning(
+ "[config] %s has no %s section — those settings are IGNORED, "
+ "using code defaults (system: %s, local: %s)",
+ PRAX_LOGGER_CONFIG_FILE.name,
+ " or ".join(missing),
+ DEFAULT_SYSTEM_LOGS,
+ DEFAULT_LOCAL_LOGS,
+ )
+
# Extract system and local log settings
- system_logs = config.get("config", {}).get("system_logs", DEFAULT_SYSTEM_LOGS)
- local_logs = config.get("config", {}).get("local_logs", DEFAULT_LOCAL_LOGS)
+ system_logs = section.get("system_logs", DEFAULT_SYSTEM_LOGS)
+ local_logs = section.get("local_logs", DEFAULT_LOCAL_LOGS)
result = {
"system_logs": system_logs,
"local_logs": local_logs,
- "log_format": config.get("config", {}).get("log_format", LOG_FORMAT),
- "date_format": config.get("config", {}).get("date_format", DATE_FORMAT),
+ "log_format": section.get("log_format", LOG_FORMAT),
+ "date_format": section.get("date_format", DATE_FORMAT),
}
json_handler.log_operation("config_loaded", {"source": str(PRAX_LOGGER_CONFIG_FILE)})
return result
diff --git a/src/aipass/prax/apps/handlers/logging/operations.py b/src/aipass/prax/apps/handlers/logging/operations.py
index bcbf3dd3..8ddc9abf 100755
--- a/src/aipass/prax/apps/handlers/logging/operations.py
+++ b/src/aipass/prax/apps/handlers/logging/operations.py
@@ -1,9 +1,9 @@
# =================== AIPass ====================
# Name: operations.py
# Description: Logging Operations
-# Version: 1.0.0
+# Version: 1.1.0
# Created: 2025-11-10
-# Modified: 2026-03-09
+# Modified: 2026-08-04
# =============================================
"""
@@ -17,7 +17,11 @@
from typing import Dict, Optional
# Import from prax config
-from aipass.prax.apps.handlers.config.load import PRAX_JSON_DIR
+from aipass.prax.apps.handlers.config.load import (
+ DEFAULT_LOCAL_LOGS,
+ DEFAULT_SYSTEM_LOGS,
+ PRAX_JSON_DIR,
+)
from aipass.prax.apps.handlers.logging.direct import get_direct_logger
from aipass.prax.apps.handlers.json import json_handler
@@ -65,20 +69,26 @@ def log_operation(message: str, data: Optional[Dict] = None):
def create_config_file():
"""Create default config file if it doesn't exist"""
if not CONFIG_FILE.exists():
+ # Every key here must be one a reader actually looks for. The previous
+ # template emitted log_level/max_log_size_mb/backup_count/console_output/
+ # file_output/rotation_enabled/debug_prints/log_directories at this level,
+ # where nothing reads them: load_log_config() wants config.system_logs and
+ # config.local_logs, and get_debug_prints_enabled() wants
+ # debug_prints_enabled, not debug_prints. Every install generated by the
+ # old template therefore advertised 5 backups of 10 MB while the code ran
+ # 1 backup of 200,000 bytes — a 250x gap that read as configuration, and
+ # invisible in review because prax_json/ is gitignored. Sections come
+ # from load.py's constants rather than literals so the file can never
+ # drift from the defaults it is meant to mirror.
default_config = {
"module_name": MODULE_NAME,
"timestamp": datetime.now(timezone.utc).isoformat(),
"config": {
- "log_level": "INFO",
- "max_log_size_mb": 10,
- "backup_count": 5,
+ "system_logs": dict(DEFAULT_SYSTEM_LOGS),
+ "local_logs": dict(DEFAULT_LOCAL_LOGS),
"log_format": "%(asctime)s | %(name)s | %(levelname)s | %(message)s",
"date_format": "%Y-%m-%d %H:%M:%S",
- "console_output": True,
- "file_output": True,
- "rotation_enabled": True,
- "debug_prints": False,
- "log_directories": ["system_logs", "skill_logs", "error_logs"],
+ "debug_prints_enabled": False,
},
}
try:
diff --git a/src/aipass/prax/tests/test_config.py b/src/aipass/prax/tests/test_config.py
index d19ed032..4ecde518 100644
--- a/src/aipass/prax/tests/test_config.py
+++ b/src/aipass/prax/tests/test_config.py
@@ -1,9 +1,9 @@
# =================== AIPass ====================
# Name: test_config.py
# Description: Tests for prax config handlers (load + ignore_patterns)
-# Version: 1.0.0
+# Version: 1.1.0
# Created: 2026-03-29
-# Modified: 2026-03-29
+# Modified: 2026-08-04
# =============================================
"""Tests for prax config handlers — covers load.py functions
@@ -14,6 +14,7 @@
import json
import sys
from pathlib import Path
+from unittest.mock import MagicMock
# =============================================
@@ -406,6 +407,80 @@ def test_log_format_defaults(self, mock_prax_infrastructure, monkeypatch, tmp_pa
assert "asctime" in result["log_format"]
assert "%Y-%m-%d" in result["date_format"]
+ def test_defaults_keep_three_backups(self, mock_prax_infrastructure, monkeypatch, tmp_path):
+ # Retained history scales with backup_count, so this number IS the
+ # forensic window: 1 -> 3 triples the floor and doubles the ceiling.
+ # Measured 2026-08-04, hooks_engine.log held 45 min at 1 backup.
+ load_mod = _fresh_import_load(monkeypatch, tmp_path)
+ assert load_mod.DEFAULT_SYSTEM_LOGS["backup_count"] == 3
+ assert load_mod.DEFAULT_LOCAL_LOGS["backup_count"] == 3
+
+ def test_warns_when_config_file_has_no_log_sections(self, mock_prax_infrastructure, monkeypatch, tmp_path):
+ load_mod = _fresh_import_load(monkeypatch, tmp_path)
+ mock_logger = MagicMock()
+ monkeypatch.setattr(load_mod, "logger", mock_logger)
+ config_file = load_mod.PRAX_LOGGER_CONFIG_FILE
+ config_file.parent.mkdir(parents=True, exist_ok=True)
+ # The exact shape the shipped file had: retention keys one level too
+ # high, where no reader ever looks.
+ config_file.write_text(json.dumps({"config": {"backup_count": 5, "max_log_size_mb": 10}}), encoding="utf-8")
+
+ result = load_mod.load_log_config()
+
+ assert result["system_logs"] == load_mod.DEFAULT_SYSTEM_LOGS
+ assert result["local_logs"] == load_mod.DEFAULT_LOCAL_LOGS
+ mock_logger.warning.assert_called_once()
+ assert mock_logger.warning.call_args[0][2] == "system_logs or local_logs"
+
+ def test_warning_names_only_the_missing_section(self, mock_prax_infrastructure, monkeypatch, tmp_path):
+ load_mod = _fresh_import_load(monkeypatch, tmp_path)
+ mock_logger = MagicMock()
+ monkeypatch.setattr(load_mod, "logger", mock_logger)
+ config_file = load_mod.PRAX_LOGGER_CONFIG_FILE
+ config_file.parent.mkdir(parents=True, exist_ok=True)
+ config_data = {"config": {"system_logs": {"max_lines": 3000, "backup_count": 5, "log_level": "ERROR"}}}
+ config_file.write_text(json.dumps(config_data), encoding="utf-8")
+
+ load_mod.load_log_config()
+
+ mock_logger.warning.assert_called_once()
+ assert mock_logger.warning.call_args[0][2] == "local_logs"
+
+ def test_schema_warning_fires_once_per_process(self, mock_prax_infrastructure, monkeypatch, tmp_path):
+ # Every logger init fleet-wide calls this, and prax's own log sits in the
+ # directory prax watches — an unguarded warning would feed itself.
+ load_mod = _fresh_import_load(monkeypatch, tmp_path)
+ mock_logger = MagicMock()
+ monkeypatch.setattr(load_mod, "logger", mock_logger)
+ config_file = load_mod.PRAX_LOGGER_CONFIG_FILE
+ config_file.parent.mkdir(parents=True, exist_ok=True)
+ config_file.write_text(json.dumps({"config": {}}), encoding="utf-8")
+
+ for _ in range(5):
+ load_mod.load_log_config()
+
+ assert mock_logger.warning.call_count == 1
+
+ def test_no_warning_when_both_sections_present(self, mock_prax_infrastructure, monkeypatch, tmp_path):
+ load_mod = _fresh_import_load(monkeypatch, tmp_path)
+ mock_logger = MagicMock()
+ monkeypatch.setattr(load_mod, "logger", mock_logger)
+ config_file = load_mod.PRAX_LOGGER_CONFIG_FILE
+ config_file.parent.mkdir(parents=True, exist_ok=True)
+ config_data = {
+ "config": {
+ "system_logs": {"max_lines": 2000, "backup_count": 4, "log_level": "DEBUG"},
+ "local_logs": {"max_lines": 500, "backup_count": 2, "log_level": "WARNING"},
+ }
+ }
+ config_file.write_text(json.dumps(config_data), encoding="utf-8")
+
+ result = load_mod.load_log_config()
+
+ assert result["system_logs"]["backup_count"] == 4
+ assert result["local_logs"]["backup_count"] == 2
+ mock_logger.warning.assert_not_called()
+
# =============================================
# TESTS: load_ignore_patterns_from_config
diff --git a/src/aipass/prax/tests/test_logging_handlers.py b/src/aipass/prax/tests/test_logging_handlers.py
index 5cdfa65f..025be298 100644
--- a/src/aipass/prax/tests/test_logging_handlers.py
+++ b/src/aipass/prax/tests/test_logging_handlers.py
@@ -1,9 +1,9 @@
# =================== AIPass ====================
# Name: test_logging_handlers.py
# Description: Tests for prax logging handler modules
-# Version: 1.0.0
+# Version: 1.1.0
# Created: 2026-04-25
-# Modified: 2026-04-25
+# Modified: 2026-08-04
# =============================================
"""Tests for prax logging handler modules.
@@ -587,6 +587,10 @@ def test_creates_config_when_missing(self, mock_prax_infrastructure, tmp_path):
"""Creates default config file when it does not exist."""
mock_config = MagicMock()
mock_config.PRAX_JSON_DIR = tmp_path
+ # Real dicts, not MagicMock attrs — create_config_file() serialises these
+ # into the generated file, so they have to survive json.dump.
+ mock_config.DEFAULT_SYSTEM_LOGS = {"max_lines": 1000, "backup_count": 3, "log_level": "INFO"}
+ mock_config.DEFAULT_LOCAL_LOGS = {"max_lines": 250, "backup_count": 3, "log_level": "INFO"}
mock_direct = MagicMock()
mock_direct_logger = MagicMock()
@@ -611,12 +615,67 @@ def test_creates_config_when_missing(self, mock_prax_infrastructure, tmp_path):
content = json.loads(config_path.read_text(encoding="utf-8"))
assert content["module_name"] == "prax_logger"
assert "config" in content
- assert content["config"]["log_level"] == "INFO"
+ # The generated file must be readable by load_log_config(), which
+ # looks for these two sections and nothing else for retention.
+ assert content["config"]["system_logs"]["backup_count"] == 3
+ assert content["config"]["local_logs"]["max_lines"] == 250
+ assert content["config"]["debug_prints_enabled"] is False
+ # Keys no reader ever consults do not belong in a generated config.
+ for dead in ("max_log_size_mb", "backup_count", "console_output", "rotation_enabled", "debug_prints"):
+ assert dead not in content["config"], f"{dead} is written but never read"
+
+ def test_generated_config_is_readable_by_load_log_config(self, mock_prax_infrastructure, monkeypatch, tmp_path):
+ """The file prax generates must be a file prax can read.
+
+ These two halves lived apart for months: the template wrote retention
+ keys at config.* while the loader read config.system_logs/local_logs, so
+ a fresh install produced a config that governed nothing.
+ """
+ mock_config = MagicMock()
+ mock_config.PRAX_JSON_DIR = tmp_path
+ mock_config.DEFAULT_SYSTEM_LOGS = {"max_lines": 1000, "backup_count": 3, "log_level": "INFO"}
+ mock_config.DEFAULT_LOCAL_LOGS = {"max_lines": 250, "backup_count": 3, "log_level": "INFO"}
+
+ mock_direct = MagicMock()
+ mock_direct.get_direct_logger = MagicMock(return_value=MagicMock())
+
+ config_path = tmp_path / "prax_logger_config.json"
+ with patch.dict(
+ sys.modules,
+ {
+ "aipass.prax.apps.handlers.config.load": mock_config,
+ "aipass.prax.apps.handlers.logging.direct": mock_direct,
+ },
+ ):
+ sys.modules.pop("aipass.prax.apps.handlers.logging.operations", None)
+ import aipass.prax.apps.handlers.logging.operations as ops
+
+ ops.CONFIG_FILE = config_path
+ ops.create_config_file()
+
+ # Outside the patch: read it back with the real loader.
+ sys.modules.pop("aipass.prax.apps.handlers.logging.operations", None)
+ import aipass.prax.apps.handlers.config.load as load_mod
+
+ mock_logger = MagicMock()
+ monkeypatch.setattr(load_mod, "logger", mock_logger)
+ monkeypatch.setattr(load_mod, "PRAX_LOGGER_CONFIG_FILE", config_path)
+ monkeypatch.setattr(load_mod, "_config_schema_warned", False)
+
+ result = load_mod.load_log_config()
+
+ assert result["system_logs"]["backup_count"] == 3
+ assert result["local_logs"]["max_lines"] == 250
+ mock_logger.warning.assert_not_called()
def test_does_not_overwrite_existing_config(self, mock_prax_infrastructure, tmp_path):
"""Does not overwrite an existing config file."""
mock_config = MagicMock()
mock_config.PRAX_JSON_DIR = tmp_path
+ # Real dicts, not MagicMock attrs — create_config_file() serialises these
+ # into the generated file, so they have to survive json.dump.
+ mock_config.DEFAULT_SYSTEM_LOGS = {"max_lines": 1000, "backup_count": 3, "log_level": "INFO"}
+ mock_config.DEFAULT_LOCAL_LOGS = {"max_lines": 250, "backup_count": 3, "log_level": "INFO"}
mock_direct = MagicMock()
mock_direct_logger = MagicMock()
@@ -645,6 +704,10 @@ def test_handles_write_error_gracefully(self, mock_prax_infrastructure, tmp_path
"""Handles write errors without raising."""
mock_config = MagicMock()
mock_config.PRAX_JSON_DIR = tmp_path
+ # Real dicts, not MagicMock attrs — create_config_file() serialises these
+ # into the generated file, so they have to survive json.dump.
+ mock_config.DEFAULT_SYSTEM_LOGS = {"max_lines": 1000, "backup_count": 3, "log_level": "INFO"}
+ mock_config.DEFAULT_LOCAL_LOGS = {"max_lines": 250, "backup_count": 3, "log_level": "INFO"}
mock_direct = MagicMock()
mock_direct_logger = MagicMock()
diff --git a/src/aipass/seedgo/tests/test_hooks_track_e.py b/src/aipass/seedgo/tests/test_hooks_track_e.py
index c0a63134..f481f233 100644
--- a/src/aipass/seedgo/tests/test_hooks_track_e.py
+++ b/src/aipass/seedgo/tests/test_hooks_track_e.py
@@ -1,15 +1,15 @@
# =================== AIPass ====================
# Name: test_hooks_track_e.py
# Description: DPLAN-0139 Track E — single-path enforcement tests
-# Version: 1.1.0
+# Version: 1.2.0
# Created: 2026-04-21
-# Modified: 2026-06-05
+# Modified: 2026-08-04
# =============================================
"""Tests for DPLAN-0139 Track E — single-path enforcement.
Covers:
- permissions.py: TRUSTED_CROSS_WRITERS, is_trusted_caller(), identify_caller()
- - drone auth.py: ALLOWED_CALLERS derived from TRUSTED_CROSS_WRITERS
+ - drone auth.py: no name-based caller list (owner-tier is earned per-repo, DPLAN-0281)
- inbox_audit.py: handle_command routing + _scan_inbox validation
- delivery.py: deliver_to_inbox_file single-path helper
"""
@@ -116,38 +116,23 @@ def test_identify_caller_falls_back_to_identity_name(tmp_path):
# ---------------------------------------------------------------------------
-# drone auth.py — ALLOWED_CALLERS derived from permissions
+# drone auth.py — no name-based caller list (DPLAN-0281)
# ---------------------------------------------------------------------------
-def test_drone_auth_allowed_callers_matches_permissions():
- """Hook and drone must reach the same decision for the same caller."""
- from aipass.drone.apps.plugins.devpulse_ops.auth import ALLOWED_CALLERS
- from aipass.seedgo.apps.modules.permissions import TRUSTED_CROSS_WRITERS
-
- for branch in TRUSTED_CROSS_WRITERS:
- assert branch in ALLOWED_CALLERS, f"'{branch}' in TRUSTED_CROSS_WRITERS but missing from drone ALLOWED_CALLERS"
-
-
-def test_drone_auth_allowed_callers_includes_devpulse():
- """devpulse must remain in drone ALLOWED_CALLERS."""
- from aipass.drone.apps.plugins.devpulse_ops.auth import ALLOWED_CALLERS
-
- assert "devpulse" in ALLOWED_CALLERS
-
-
-def test_drone_auth_allowed_callers_includes_seedgo():
- """seedgo must be in drone ALLOWED_CALLERS."""
- from aipass.drone.apps.plugins.devpulse_ops.auth import ALLOWED_CALLERS
-
- assert "seedgo" in ALLOWED_CALLERS
-
+def test_drone_auth_has_no_hardcoded_caller_list():
+ """Owner-tier git is earned per-repo, never granted by name (DPLAN-0281).
-def test_drone_auth_allowed_callers_includes_spawn():
- """spawn must be in drone ALLOWED_CALLERS."""
- from aipass.drone.apps.plugins.devpulse_ops.auth import ALLOWED_CALLERS
+ The old ALLOWED_CALLERS constant read as though seedgo and spawn held git
+ write — they never did; verify_git_access ignored it. Its absence is
+ load-bearing: this test failing means someone reintroduced a name-based
+ gate, which is exactly the special-casing the Patrick ruling removed.
+ """
+ from aipass.drone.apps.plugins.devpulse_ops import auth
- assert "spawn" in ALLOWED_CALLERS
+ assert not hasattr(auth, "ALLOWED_CALLERS")
+ for tier in auth.GIT_ACCESS_TIERS.values():
+ assert "allowed_callers" not in tier
# ---------------------------------------------------------------------------
diff --git a/src/aipass/skills/lib/telegram/SKILL.md b/src/aipass/skills/lib/telegram/SKILL.md
index 14c5b0be..a17453d7 100644
--- a/src/aipass/skills/lib/telegram/SKILL.md
+++ b/src/aipass/skills/lib/telegram/SKILL.md
@@ -1,7 +1,7 @@
---
name: telegram
description: Multi-bot Telegram bridge — routes messages between Telegram and Claude tmux sessions
-version: 1.5.2
+version: 1.6.0
tags: [communication, bridge, telegram, bot]
requires:
pip: [telethon]
@@ -89,6 +89,23 @@ Root-privileged pieces live as reviewable repo files in `tools/suspend/`, instal
**Honest status:** `/suspend` is **retired from daily use and grounded** as of 2026-08-02 (Patrick's ruling, compass #217) — do not live-test it without him. The v1.5.0 rework worked as designed in a live soak, and Patrick still hit the wall: fixed suspend is still suspend, and real sleep is real disconnect. So the machine now stays awake 24/7 and `/lock` is the daily driver. The verb stays shipped and tested as a battery-saver, with `suspend_enabled` as the parking brake; it has still never passed a hands-off overnight soak (DPLAN-0270 test-matrix step T4). Full deployment picture: [`docs/suspend_lock_deployment.md`](docs/suspend_lock_deployment.md).
+## Slash passthrough and the /context relay
+
+An unregistered `/xyz` message must never reach tmux raw — the TUI's slash menu fuzzy-autocompletes unknown commands into unrelated registered ones. `_guard_slash_injection()` prefixes a space to anything not on the exact-match allowlist, so the TUI treats it as plain text. Two allowlists feed that one guard:
+
+- **Side-effect commands** (`DEFAULT_PASSTHROUGH_COMMANDS` — `clear`, `compact`, `prep`, `memo`; config key `passthrough_commands`) — injected as-is, fire and forget, normal pending-file flow.
+- **Informational commands** (`DEFAULT_INFORMATIONAL_COMMANDS` — `context`; config key `informational_commands`) — injected as-is, then their stdout is relayed back to the chat.
+
+**Why informational commands need their own completion path.** A CC local command produces **no assistant turn** — the caveat wrapper suppresses a reply — so the Stop hook never fires. Writing a pending file for one would leave it undelivered until `PENDING_STUCK_TIMEOUT_SECONDS` gave up: a fresh flavour of the S179 stuck-pending bug. So `_handle_informational_command()` writes **no pending file and starts no heartbeat**. It captures a transcript line-count baseline, injects, and hands off to `_relay_slash_stdout()` on a daemon thread, which polls every `SLASH_STDOUT_POLL_INTERVAL` (1s) up to `SLASH_STDOUT_TIMEOUT_SECONDS` (90s) and then either relays the panel or edits the placeholder to an honest "no output appeared" message. Either way it terminates.
+
+**Two transcript shapes, both handled.** CC writes a local command's stdout as `type=system, subtype=local_command` with the payload at the **top-level `content`** key (older builds used `message.content` — both are read), wrapped in `… `. Current CC emits `/context` *twice*: the ANSI-art TUI panel, immediately followed by an `isMeta` user entry carrying the same content as clean markdown. The twin is preferred; the search for it is bounded to `TWIN_LOOKAHEAD_ENTRIES` (3) so a later command's meta entry can't be mistaken for this one's output. If only the ANSI panel is present the relay spends exactly one extra poll waiting for the twin, then relays the escape-stripped panel rather than losing the output.
+
+**Scope guard — no surprise echo.** The relay only ever runs for a command the bot itself injected, enforced twice over: the watcher starts only from `handle_message` (a TG-inbound message), and the scan is bounded to transcript lines written *after* the injection baseline. A `/context` run at the desk or via remote control cannot reach the phone.
+
+**Formatting.** Output is markdown tables, which Telegram does not render at all, so `_format_stdout_for_telegram()` strips ANSI, escapes `&`/`<`/`>`, and wraps in `` — sent with `parse_mode="HTML"` via the optional `send_message(..., parse_mode=...)` argument. Everything else still sends as plain text with no parse mode. Chunking wraps each chunk separately, so a `` is never split across two messages.
+
+**`/cost` is deliberately not allowlisted.** It has never been run on this machine, so its transcript shape is unverified — the allowlist takes only commands whose output shape has actually been observed. Add it via the `informational_commands` config key once someone has confirmed it behaves like `/context`.
+
## Streaming replies (DPLAN-0229)
Opt-in per bot via the `stream` config key (`stream: true`, default `false`). When enabled, instead of waiting silently for the Stop hook, `_streaming_loop` tails the active Claude transcript every `STREAM_INTERVAL` (2s) and live-edits the "Processing..." placeholder message with the growing response via `editMessageText`. `_stream_edit` handles Telegram's edit-specific quirks: a 429 backs off for the given `retry_after` seconds, and a "message is not modified" 400 is treated as success (no-op edit). The pending file written for the Stop hook still carries a `"streaming": True` flag either way — streaming is a live preview layered on top of the same finalize-on-Stop-hook flow, not a replacement for it.
diff --git a/src/aipass/skills/lib/telegram/apps/handlers/base_bot.py b/src/aipass/skills/lib/telegram/apps/handlers/base_bot.py
index 5306dfeb..4c2ef304 100644
--- a/src/aipass/skills/lib/telegram/apps/handlers/base_bot.py
+++ b/src/aipass/skills/lib/telegram/apps/handlers/base_bot.py
@@ -1,7 +1,7 @@
# =================== AIPass ====================
# Name: base_bot.py
# Description: BaseBot class for Telegram multi-bot architecture
-# Version: 1.5.1
+# Version: 1.6.0
# Created: 2026-02-24
# Modified: 2026-08-02
# =============================================
@@ -130,6 +130,18 @@ def check_telethon_setup() -> tuple[bool, str]: # type: ignore[misc]
PENDING_TTL = 3600 # 1 hour
PENDING_STUCK_TIMEOUT_SECONDS = 600 # give up on an undelivered pending, don't spin "Processing..." forever
DEFAULT_PASSTHROUGH_COMMANDS = ("clear", "compact", "prep", "memo") # exact-match commands injected as-is
+# Informational passthrough: executes like the side-effect commands above, but its stdout is
+# relayed back to the chat. Local commands produce NO assistant turn, so these never reach the
+# Stop hook — they get their own completion path (_relay_slash_stdout), never a pending file.
+# /cost is deliberately absent: it has never been run on this machine, so its transcript shape
+# is unverified and the dispatch said verify, don't assume.
+DEFAULT_INFORMATIONAL_COMMANDS = ("context",)
+SLASH_STDOUT_TIMEOUT_SECONDS = 90 # give up waiting for a local command's stdout entry
+SLASH_STDOUT_POLL_INTERVAL = 1.0
+TWIN_LOOKAHEAD_ENTRIES = 3 # how far past the stdout entry its markdown twin may sit
+LOCAL_STDOUT_OPEN = ""
+LOCAL_STDOUT_CLOSE = " "
+ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
TELEGRAM_CHAR_LIMIT = 4096
RATE_LIMIT_MESSAGES = 5
RATE_LIMIT_WINDOW = 60
@@ -298,6 +310,9 @@ def __init__(
self._rate_limit_tracker: dict[int, list] = {}
self._heartbeat_thread: threading.Thread | None = None
self._heartbeat_stop = threading.Event()
+ # Most recent informational-command watcher — kept for test observability
+ # and shutdown inspection; the thread is a daemon and owns its own exit.
+ self._slash_stdout_thread: threading.Thread | None = None
self._heartbeat_gen: int = 0
# /suspend control verb state (DPLAN-0270 P5) — heartbeat mode only;
@@ -570,7 +585,13 @@ def poll_updates(self, offset: int) -> list:
logger.error("Unexpected poll error: %s", e)
return []
- def send_message(self, chat_id: int, text: str, reply_to: Optional[int] = None) -> dict | None:
+ def send_message(
+ self,
+ chat_id: int,
+ text: str,
+ reply_to: Optional[int] = None,
+ parse_mode: Optional[str] = None,
+ ) -> dict | None:
"""
Send a message via Telegram sendMessage API.
@@ -578,6 +599,9 @@ def send_message(self, chat_id: int, text: str, reply_to: Optional[int] = None)
chat_id: Target chat ID
text: Message text
reply_to: Optional message ID to reply to
+ parse_mode: Optional Telegram parse mode ("HTML"). Omitted by
+ default — agent replies are plain text and must never have
+ stray markup interpreted as formatting.
Returns:
Parsed JSON response dict (contains message_id), or None on failure
@@ -590,6 +614,8 @@ def send_message(self, chat_id: int, text: str, reply_to: Optional[int] = None)
}
if reply_to is not None:
payload["reply_to_message_id"] = reply_to
+ if parse_mode is not None:
+ payload["parse_mode"] = parse_mode
for attempt in range(3):
try:
@@ -898,19 +924,34 @@ def _dispatch_command(self, chat_id: int, parsed: tuple) -> bool:
# MESSAGE HANDLING
# =============================================
- def _passthrough_commands(self) -> set[str]:
- """Exact-match allowlist of slash commands injected as-is (config override, else default)."""
- commands = DEFAULT_PASSTHROUGH_COMMANDS
+ def _config_command_list(self, key: str, default: tuple[str, ...]) -> set[str]:
+ """Read an exact-match slash-command allowlist from bot config, falling back to *default*."""
+ commands = default
try:
from .config import load_bot_config
config = load_bot_config(self.bot_id)
- if config and isinstance(config.get("passthrough_commands"), list):
- commands = config["passthrough_commands"]
+ if config and isinstance(config.get(key), list):
+ commands = config[key]
except Exception as e:
- logger.warning("Could not read passthrough_commands, using default: %s", e)
+ logger.warning("Could not read %s, using default: %s", key, e)
return {str(c).lower().lstrip("/") for c in commands}
+ def _informational_commands(self) -> set[str]:
+ """Allowlisted commands whose stdout is relayed back to the chat (config: informational_commands)."""
+ return self._config_command_list("informational_commands", DEFAULT_INFORMATIONAL_COMMANDS)
+
+ def _passthrough_commands(self) -> set[str]:
+ """
+ Exact-match allowlist of slash commands injected as-is (config override, else default).
+
+ Union of the side-effect commands (clear/compact/prep/memo — fire and forget)
+ and the informational ones (stdout relayed back). The guard treats them
+ identically; only the post-injection handling differs.
+ """
+ side_effect = self._config_command_list("passthrough_commands", DEFAULT_PASSTHROUGH_COMMANDS)
+ return side_effect | self._informational_commands()
+
def _guard_slash_injection(self, text: str) -> str:
"""
Prevent an unregistered '/xyz' message from being injected as a raw slash command.
@@ -970,6 +1011,17 @@ def handle_message(self, chat_id: int, text: str, message: dict) -> None:
)
return
+ # Informational passthrough (/context): CC runs it as a local command,
+ # which produces no assistant turn — the Stop hook never fires, so a
+ # pending file here would sit undelivered until the stuck-timeout. This
+ # path relays the command's stdout and completes itself instead.
+ parsed = parse_command(text)
+ if parsed is not None and parsed[0] in self._informational_commands():
+ # Inject `text`, not `prompt`: a slash command is a command, not a
+ # message from a person, and a sender prefix would stop CC running it.
+ self._handle_informational_command(chat_id, parsed[0], text)
+ return
+
# Inbound reliability: clean stale pending + finalize stranded placeholder
self._finalize_superseded_pending(message_id)
@@ -2681,6 +2733,206 @@ def _extract_assistant_text(entry: dict) -> str | None:
return block.get("text", "")
return None
+ @staticmethod
+ def _extract_local_command_stdout(entry: dict) -> str | None:
+ """
+ Pull a local slash command's stdout out of a transcript entry, or None.
+
+ CC writes this in two places depending on version, so both are checked:
+ the top-level `content` of a `type=system, subtype=local_command` entry
+ (current), and `message.content` (older). The payload is wrapped in
+ ... ; the wrapper is stripped.
+ """
+ candidates = [entry.get("content")]
+ message = entry.get("message")
+ if isinstance(message, dict):
+ candidates.append(message.get("content"))
+
+ for raw in candidates:
+ if not isinstance(raw, str) or LOCAL_STDOUT_OPEN not in raw:
+ continue
+ body = raw.split(LOCAL_STDOUT_OPEN, 1)[1]
+ if LOCAL_STDOUT_CLOSE in body:
+ body = body.split(LOCAL_STDOUT_CLOSE, 1)[0]
+ return body.strip()
+ return None
+
+ @staticmethod
+ def _extract_meta_command_text(entry: dict) -> str | None:
+ """
+ Pull the clean-markdown twin of a rendered TUI panel, or None.
+
+ Current CC logs an informational command twice: the ANSI-art panel as
+ it appeared in the terminal, then an isMeta user entry carrying the
+ same content as plain markdown. The second is what we want to relay —
+ the first is box-drawing characters and colour escapes.
+ """
+ if not entry.get("isMeta"):
+ return None
+ message = entry.get("message")
+ if not isinstance(message, dict) or message.get("role") != "user":
+ return None
+ content = message.get("content")
+ if not isinstance(content, str) or not content.strip():
+ return None
+ if content.lstrip().startswith(""):
+ return None # the "do not respond to these" wrapper, never the payload
+ return content.strip()
+
+ def _scan_transcript_for_stdout(self, transcript_path: Path, from_line: int) -> tuple[str | None, bool]:
+ """
+ Scan transcript lines after *from_line* for a local command's output.
+
+ Bounded to lines written after injection, which is half the scope guard:
+ a /context run at the desk before the bot injected cannot be picked up.
+
+ Returns (payload, is_clean). Two shapes exist in the wild: the stdout
+ entry is either clean markdown, or ANSI TUI art immediately followed by
+ an isMeta twin carrying the same content as markdown. The twin is
+ preferred. is_clean is False when only the ANSI panel is present, which
+ tells the caller the twin may still be mid-write.
+ """
+ try:
+ lines = transcript_path.read_text(encoding="utf-8").splitlines()
+ except OSError as e:
+ logger.info("Could not read transcript while waiting for stdout: %s", e)
+ return None, False
+
+ stdout_payload = None
+ lookahead = 0
+ malformed = 0
+ for line in lines[from_line:]:
+ try:
+ entry = json.loads(line)
+ except json.JSONDecodeError:
+ # Expected: the scan can catch CC mid-append, leaving a partial
+ # final line. Logged once per scan so a genuinely corrupt
+ # transcript is visible without the 1s poll loop flooding.
+ malformed += 1
+ if malformed == 1:
+ logger.info("Skipping malformed transcript line while waiting for command stdout")
+ continue
+ if not isinstance(entry, dict):
+ continue
+ if stdout_payload is None:
+ stdout_payload = self._extract_local_command_stdout(entry)
+ continue
+ # The stdout entry has landed — the markdown twin, if any, is written
+ # right behind it. Bounded so a later unrelated meta entry (the next
+ # command's own preamble) can never be mistaken for this one's output.
+ lookahead += 1
+ if lookahead > TWIN_LOOKAHEAD_ENTRIES:
+ break
+ twin = self._extract_meta_command_text(entry)
+ if twin:
+ return twin, True
+
+ if stdout_payload is None:
+ return None, False
+ return stdout_payload, ANSI_ESCAPE_RE.search(stdout_payload) is None
+
+ @staticmethod
+ def _format_stdout_for_telegram(text: str) -> str:
+ """
+ Render command stdout as a Telegram HTML block.
+
+ The payload is markdown tables. Telegram renders no tables at all, so a
+ monospace block is the only format where the columns still line up.
+ ANSI escapes are stripped and the three HTML-significant characters are
+ escaped, which is what keeps parse_mode=HTML from mangling the content.
+ """
+ clean = ANSI_ESCAPE_RE.sub("", text)
+ escaped = clean.replace("&", "&").replace("<", "<").replace(">", ">")
+ return f"{escaped}"
+
+ def _send_stdout_panel(self, chat_id: int, text: str) -> None:
+ """Send command stdout to the chat as one or more monospace blocks."""
+ # Chunk the raw text, then wrap each chunk — wrapping first would let the
+ # tags be split across messages and both halves would render broken.
+ budget = TELEGRAM_CHAR_LIMIT - len("") - 64 # headroom for entity escaping
+ for chunk in self.chunk_text(ANSI_ESCAPE_RE.sub("", text), limit=budget):
+ self.send_message(chat_id, self._format_stdout_for_telegram(chunk), parse_mode="HTML")
+
+ def _relay_slash_stdout(
+ self,
+ chat_id: int,
+ cmd_name: str,
+ transcript_path: Path,
+ baseline: int,
+ processing_msg_id: Optional[int],
+ ) -> None:
+ """
+ Wait for an informational command's stdout and relay it to the chat.
+
+ This is the whole reason informational commands do not use the pending
+ file: a local command produces no assistant turn, so the Stop hook never
+ fires and a pending would sit undelivered until the stuck-timeout gave
+ up (S179's failure mode, in a new flavour). This path owns its own
+ completion instead — it either relays the output or says why it could
+ not, and it always terminates.
+ """
+ deadline = time.time() + SLASH_STDOUT_TIMEOUT_SECONDS
+ grace_used = False
+ while time.time() < deadline:
+ time.sleep(SLASH_STDOUT_POLL_INTERVAL)
+ payload, is_clean = self._scan_transcript_for_stdout(transcript_path, baseline)
+ if not payload:
+ continue
+ if not is_clean and not grace_used:
+ # Only the ANSI panel so far — its markdown twin is written a
+ # beat later. Spend exactly one extra poll on it, then relay
+ # whatever we have rather than losing the output to the timeout.
+ grace_used = True
+ continue
+ logger.info("Relaying /%s stdout to chat (%d chars)", cmd_name, len(payload))
+ if processing_msg_id:
+ self.edit_message(chat_id, processing_msg_id, f"/{cmd_name}")
+ self._send_stdout_panel(chat_id, payload)
+ return
+
+ logger.warning("/%s produced no stdout entry within %ss", cmd_name, SLASH_STDOUT_TIMEOUT_SECONDS)
+ message = f"⚠️ /{cmd_name} ran, but no output appeared within {SLASH_STDOUT_TIMEOUT_SECONDS}s."
+ if processing_msg_id:
+ self.edit_message(chat_id, processing_msg_id, message)
+ else:
+ self.send_message(chat_id, message)
+
+ def _handle_informational_command(self, chat_id: int, cmd_name: str, prompt: str) -> None:
+ """
+ Inject an allowlisted informational command and relay its stdout back.
+
+ Deliberately writes NO pending file and starts NO heartbeat — see
+ _relay_slash_stdout for why. The watcher runs on a daemon thread so the
+ poll loop keeps serving other messages while it waits.
+ """
+ transcript_path, baseline = self._resolve_active_transcript()
+ if not transcript_path:
+ logger.warning("/%s: no active transcript to watch", cmd_name)
+ self.send_message(chat_id, f"⚠️ Cannot run /{cmd_name} — no active Claude transcript found.")
+ return
+
+ processing_result = self.send_message(chat_id, PROCESSING_MSG)
+ processing_msg_id = processing_result.get("message_id") if processing_result else None
+
+ if not self.inject_message(prompt):
+ logger.error("Failed to inject /%s into tmux", cmd_name)
+ failure = f"Failed to send /{cmd_name} to the Claude session."
+ if processing_msg_id:
+ self.edit_message(chat_id, processing_msg_id, failure)
+ else:
+ self.send_message(chat_id, failure)
+ return
+
+ watcher = threading.Thread(
+ target=self._relay_slash_stdout,
+ args=(chat_id, cmd_name, Path(transcript_path), baseline, processing_msg_id),
+ daemon=True,
+ name=f"slash-stdout-{self.bot_id}",
+ )
+ watcher.start()
+ self._slash_stdout_thread = watcher
+ logger.info("Watching transcript for /%s stdout from line %d", cmd_name, baseline)
+
def _read_transcript_tail(self, n_lines: int = 50) -> str | None:
"""Read the latest assistant text response from the active transcript.
diff --git a/src/aipass/skills/lib/telegram/tests/test_slash_relay.py b/src/aipass/skills/lib/telegram/tests/test_slash_relay.py
new file mode 100644
index 00000000..6886c517
--- /dev/null
+++ b/src/aipass/skills/lib/telegram/tests/test_slash_relay.py
@@ -0,0 +1,542 @@
+# =================== AIPass ====================
+# Name: test_slash_relay.py
+# Description: Tests for relaying CC informational slash-command stdout to Telegram
+# Version: 1.0.0
+# Created: 2026-08-02
+# Modified: 2026-08-02
+# =============================================
+
+"""
+Tests for the informational slash-command relay (/context).
+
+A CC local command produces NO assistant turn — the Stop hook never fires — so
+this path deliberately writes no pending file and owns its own completion.
+That is the property most of these tests defend, alongside the parse seam and
+the scope guard.
+
+Tests cover:
+ - Parsing a local-command-stdout entry from both transcript shapes
+ - The ANSI-panel variant and its clean-markdown isMeta twin
+ - Baseline bounding + twin lookahead (scope guard: no surprise echo)
+ - Telegram formatting: ANSI stripped, HTML escaped, wrapped, chunked
+ - The allowlist: /context in, /cost out, config override honoured
+ - No-wedge completion: no pending file, relay on success, honest text on timeout
+
+All network (urllib) and process (tmux) calls are mocked. No real Telegram API,
+no real tmux, no real Claude session.
+"""
+
+import json
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from aipass.skills.lib.telegram.apps.handlers.base_bot import (
+ ANSI_ESCAPE_RE,
+ DEFAULT_INFORMATIONAL_COMMANDS,
+ SLASH_STDOUT_TIMEOUT_SECONDS,
+ BaseBot,
+)
+
+
+# =============================================
+# REAL /context PAYLOAD (verbatim excerpt)
+# =============================================
+
+# Copied verbatim from a real transcript entry written by CC (type=system,
+# subtype=local_command). Truncated in the middle of the MCP table only — the
+# wrapper, header, blank lines, two-space markdown line breaks, pipe tables and
+# trailing newline are exactly as CC emitted them.
+REAL_CONTEXT_STDOUT = """## Context Usage
+
+**Model:** claude-fable-5
+**Tokens:** 174.1k / 350k (50%)
+
+### Estimated usage by category
+
+| Category | Tokens | Percentage |
+|----------|--------|------------|
+| System prompt | 5.6k | 1.6% |
+| System tools | 22.1k | 6.3% |
+| MCP tools (deferred) | 36.3k | 10.4% |
+| Memory files | 2k | 0.6% |
+| Messages | 142.9k | 40.8% |
+| Free space | 142.4k | 40.7% |
+| Autocompact buffer | 33k | 9.4% |
+
+### MCP Tools
+
+| Tool | Server | Tokens |
+|------|--------|--------|
+| mcp__claude_ai_Gmail__create_draft | claude_ai_Gmail | 1.5k |
+| mcp__claude_ai_Gmail__get_message | claude_ai_Gmail | 827 |
+
+ """
+
+# The same command in the other shape CC produces: a rendered TUI panel with
+# colour escapes and box-drawing characters.
+REAL_ANSI_STDOUT = (
+ " \x1b[1mContext Usage\x1b[22m\n"
+ "\x1b[38;5;244m⛁ \x1b[38;5;246m⛁ ⛁ ⛁ \x1b[38;5;174m⛀ "
+ "\x1b[38;5;220m⛀ \x1b[38;5;140m⛁ ⛁\x1b[39m\n"
+ "\x1b[1mTokens:\x1b[22m 57.8k / 1m (6%)\n"
+ " "
+)
+
+# The isMeta twin CC writes immediately behind the ANSI panel.
+REAL_TWIN_MARKDOWN = "## Context Usage\n\n**Model:** claude-opus-4-8 \n**Tokens:** 57.8k / 1m (6%)\n"
+
+
+# =============================================
+# TRANSCRIPT ENTRY BUILDERS
+# =============================================
+
+
+def stdout_entry(payload: str, at_message: bool = False) -> dict:
+ """A type=system, subtype=local_command entry carrying command stdout."""
+ entry: dict = {"type": "system", "subtype": "local_command", "level": "info", "isMeta": False}
+ if at_message:
+ entry["message"] = {"role": "user", "content": payload}
+ else:
+ entry["content"] = payload
+ return entry
+
+
+def twin_entry(markdown: str) -> dict:
+ """The isMeta user entry carrying the clean-markdown twin of a TUI panel."""
+ return {"type": "user", "isMeta": True, "message": {"role": "user", "content": markdown}}
+
+
+def command_name_entry() -> dict:
+ """CC's preamble entry — same type/subtype as stdout, but no payload."""
+ return stdout_entry("/context \n ")
+
+
+def caveat_entry() -> dict:
+ """The isMeta caveat wrapper that precedes a local command. Never the payload."""
+ return twin_entry(
+ "Caveat: The messages below were generated by the user. "
+ )
+
+
+def write_transcript(path: Path, entries: list[dict]) -> None:
+ path.write_text("\n".join(json.dumps(e) for e in entries) + "\n", encoding="utf-8")
+
+
+# =============================================
+# FIXTURES
+# =============================================
+
+
+@pytest.fixture
+def bot(tmp_path):
+ """A BaseBot with pending/network/tmux seams neutralised."""
+ workdir = tmp_path / "workdir"
+ workdir.mkdir()
+ with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
+ instance = BaseBot(
+ bot_id="relay_bot",
+ bot_token="123:FAKETOKEN",
+ work_dir=workdir,
+ bot_name="Relay Bot",
+ allowed_user_ids=[111],
+ )
+ instance.pending_file = tmp_path / "bot-relay_bot.json"
+ instance.send_message = MagicMock(return_value={"message_id": 42})
+ instance.edit_message = MagicMock(return_value=True)
+ instance.inject_message = MagicMock(return_value=True)
+ instance.ensure_tmux_session = MagicMock(return_value=True)
+ return instance
+
+
+# =============================================
+# 1. PARSING THE STDOUT ENTRY
+# =============================================
+
+
+class TestExtractLocalCommandStdout:
+ """The parse seam against real CC output."""
+
+ def test_extracts_real_context_payload_from_top_level_content(self, bot):
+ result = bot._extract_local_command_stdout(stdout_entry(REAL_CONTEXT_STDOUT))
+ assert result is not None
+ assert result.startswith("## Context Usage")
+ assert "| System prompt | 5.6k | 1.6% |" in result
+
+ def test_strips_the_wrapper_tags(self, bot):
+ result = bot._extract_local_command_stdout(stdout_entry(REAL_CONTEXT_STDOUT))
+ assert "" not in result
+ assert " " not in result
+
+ def test_reads_the_older_message_content_location(self, bot):
+ result = bot._extract_local_command_stdout(stdout_entry(REAL_CONTEXT_STDOUT, at_message=True))
+ assert result is not None
+ assert result.startswith("## Context Usage")
+
+ def test_command_name_preamble_is_not_stdout(self, bot):
+ assert bot._extract_local_command_stdout(command_name_entry()) is None
+
+ def test_unrelated_entry_returns_none(self, bot):
+ assert bot._extract_local_command_stdout({"type": "assistant", "message": {"content": "hi"}}) is None
+
+ def test_missing_close_tag_still_yields_payload(self, bot):
+ """A half-written entry should degrade to its content, not to nothing."""
+ partial = "## Context Usage\n\n**Tokens:** 1k"
+ assert bot._extract_local_command_stdout(stdout_entry(partial)) == "## Context Usage\n\n**Tokens:** 1k"
+
+
+class TestExtractMetaCommandText:
+ """The clean-markdown twin of a rendered TUI panel."""
+
+ def test_extracts_twin_markdown(self, bot):
+ assert bot._extract_meta_command_text(twin_entry(REAL_TWIN_MARKDOWN)) == REAL_TWIN_MARKDOWN.strip()
+
+ def test_caveat_wrapper_is_never_the_payload(self, bot):
+ assert bot._extract_meta_command_text(caveat_entry()) is None
+
+ def test_non_meta_entry_ignored(self, bot):
+ entry = twin_entry(REAL_TWIN_MARKDOWN)
+ entry["isMeta"] = False
+ assert bot._extract_meta_command_text(entry) is None
+
+ def test_empty_content_ignored(self, bot):
+ assert bot._extract_meta_command_text(twin_entry(" ")) is None
+
+
+# =============================================
+# 2. SCANNING THE TRANSCRIPT
+# =============================================
+
+
+class TestScanTranscript:
+ """Baseline bounding, variant preference, and the lookahead bound."""
+
+ def test_finds_markdown_variant_and_reports_it_clean(self, bot, tmp_path):
+ path = tmp_path / "t.jsonl"
+ write_transcript(path, [caveat_entry(), command_name_entry(), stdout_entry(REAL_CONTEXT_STDOUT)])
+ payload, is_clean = bot._scan_transcript_for_stdout(path, 0)
+ assert is_clean is True
+ assert payload.startswith("## Context Usage")
+
+ def test_prefers_the_twin_over_the_ansi_panel(self, bot, tmp_path):
+ path = tmp_path / "t.jsonl"
+ write_transcript(path, [stdout_entry(REAL_ANSI_STDOUT), twin_entry(REAL_TWIN_MARKDOWN)])
+ payload, is_clean = bot._scan_transcript_for_stdout(path, 0)
+ assert is_clean is True
+ assert payload == REAL_TWIN_MARKDOWN.strip()
+ assert "\x1b[" not in payload
+
+ def test_ansi_panel_without_twin_is_reported_unclean(self, bot, tmp_path):
+ """The twin may still be mid-write — the caller gets to wait one beat."""
+ path = tmp_path / "t.jsonl"
+ write_transcript(path, [stdout_entry(REAL_ANSI_STDOUT)])
+ payload, is_clean = bot._scan_transcript_for_stdout(path, 0)
+ assert is_clean is False
+ assert "Context Usage" in payload
+
+ def test_output_before_the_baseline_is_invisible(self, bot, tmp_path):
+ """SCOPE GUARD: a /context run at the desk must never be picked up."""
+ path = tmp_path / "t.jsonl"
+ entries = [caveat_entry(), command_name_entry(), stdout_entry(REAL_CONTEXT_STDOUT)]
+ write_transcript(path, entries)
+ payload, is_clean = bot._scan_transcript_for_stdout(path, len(entries))
+ assert payload is None
+ assert is_clean is False
+
+ def test_twin_beyond_the_lookahead_is_not_adopted(self, bot, tmp_path):
+ """A later command's meta entry must not be mistaken for this one's output."""
+ path = tmp_path / "t.jsonl"
+ filler = {"type": "file-history-snapshot"}
+ write_transcript(
+ path,
+ [stdout_entry(REAL_ANSI_STDOUT), filler, filler, filler, filler, twin_entry("## Something Else")],
+ )
+ payload, _ = bot._scan_transcript_for_stdout(path, 0)
+ assert "Something Else" not in payload
+
+ def test_empty_transcript_yields_nothing(self, bot, tmp_path):
+ path = tmp_path / "t.jsonl"
+ path.write_text("", encoding="utf-8")
+ assert bot._scan_transcript_for_stdout(path, 0) == (None, False)
+
+ def test_malformed_lines_are_skipped(self, bot, tmp_path):
+ path = tmp_path / "t.jsonl"
+ path.write_text("not json\n" + json.dumps(stdout_entry(REAL_CONTEXT_STDOUT)) + "\n", encoding="utf-8")
+ payload, _ = bot._scan_transcript_for_stdout(path, 0)
+ assert payload.startswith("## Context Usage")
+
+ def test_unreadable_transcript_yields_nothing(self, bot, tmp_path):
+ assert bot._scan_transcript_for_stdout(tmp_path / "gone.jsonl", 0) == (None, False)
+
+
+# =============================================
+# 3. TELEGRAM FORMATTING
+# =============================================
+
+
+class TestFormatting:
+ """Markdown tables only survive Telegram inside a monospace block."""
+
+ def test_wraps_in_pre_block(self, bot):
+ out = bot._format_stdout_for_telegram("plain")
+ assert out == "plain
"
+
+ def test_strips_ansi_escapes(self, bot):
+ out = bot._format_stdout_for_telegram("\x1b[1mBold\x1b[22m")
+ assert "\x1b[" not in out
+ assert "Bold" in out
+
+ def test_escapes_html_significant_characters(self, bot):
+ out = bot._format_stdout_for_telegram("a & b c")
+ assert "&" in out and "<tag>" in out
+ # Only the wrapper's own angle brackets remain unescaped
+ assert out.count("<") == 2 and out.count(">") == 2
+
+ def test_ampersand_escaped_before_angle_brackets(self, bot):
+ """Escaping in the wrong order would double-escape into <."""
+ assert bot._format_stdout_for_telegram("<") == "<
"
+
+ def test_real_context_payload_survives_formatting(self, bot):
+ payload = REAL_CONTEXT_STDOUT.split("")[1].split(" ")[0]
+ out = bot._format_stdout_for_telegram(payload)
+ assert out.startswith("") and out.endswith("")
+ assert "| System prompt | 5.6k | 1.6% |" in out
+
+
+class TestSendStdoutPanel:
+ """Chunking must not split a across two messages."""
+
+ def test_sends_single_block_with_html_parse_mode(self, bot):
+ bot._send_stdout_panel(999, "## Context Usage")
+ assert bot.send_message.call_count == 1
+ args, kwargs = bot.send_message.call_args
+ assert args[0] == 999
+ assert args[1] == "## Context Usage
"
+ assert kwargs["parse_mode"] == "HTML"
+
+ def test_long_output_is_chunked_and_each_chunk_is_a_complete_block(self, bot):
+ bot._send_stdout_panel(999, "\n".join(f"| row {i} | value |" for i in range(600)))
+ assert bot.send_message.call_count > 1
+ for call in bot.send_message.call_args_list:
+ text = call.args[1]
+ assert text.startswith("") and text.endswith("")
+ assert len(text) <= 4096
+ assert call.kwargs["parse_mode"] == "HTML"
+
+ def test_ansi_stripped_before_chunking(self, bot):
+ bot._send_stdout_panel(999, "\x1b[1mContext Usage\x1b[22m")
+ assert "\x1b[" not in bot.send_message.call_args.args[1]
+
+
+class TestSendMessageParseMode:
+ """The new parameter must stay opt-in — agent replies are plain text."""
+
+ def test_parse_mode_omitted_by_default(self, tmp_path):
+ workdir = tmp_path / "w"
+ workdir.mkdir()
+ with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
+ plain = BaseBot(bot_id="p", bot_token="1:T", work_dir=workdir, bot_name="P", allowed_user_ids=[])
+ with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen") as mock_open:
+ mock_open.return_value.__enter__.return_value.read.return_value = b'{"ok":true,"result":{}}'
+ plain.send_message(1, "hello")
+ payload = json.loads(mock_open.call_args.args[0].data.decode())
+ assert "parse_mode" not in payload
+
+ def test_parse_mode_included_when_given(self, tmp_path):
+ workdir = tmp_path / "w"
+ workdir.mkdir()
+ with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.PENDING_DIR", tmp_path):
+ plain = BaseBot(bot_id="p", bot_token="1:T", work_dir=workdir, bot_name="P", allowed_user_ids=[])
+ with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.urlopen") as mock_open:
+ mock_open.return_value.__enter__.return_value.read.return_value = b'{"ok":true,"result":{}}'
+ plain.send_message(1, "x
", parse_mode="HTML")
+ payload = json.loads(mock_open.call_args.args[0].data.decode())
+ assert payload["parse_mode"] == "HTML"
+
+
+# =============================================
+# 4. THE ALLOWLIST
+# =============================================
+
+
+class TestAllowlist:
+ """Exact-match, config-overridable, and /cost deliberately absent."""
+
+ def test_context_is_informational_by_default(self, bot):
+ assert "context" in bot._informational_commands()
+ assert DEFAULT_INFORMATIONAL_COMMANDS == ("context",)
+
+ def test_informational_commands_pass_the_guard_unchanged(self, bot):
+ assert bot._guard_slash_injection("/context") == "/context"
+
+ def test_side_effect_commands_still_pass_the_guard(self, bot):
+ for cmd in ("/clear", "/compact", "/prep", "/memo"):
+ assert bot._guard_slash_injection(cmd) == cmd
+
+ def test_cost_is_not_allowlisted(self, bot):
+ """Its transcript shape was never observed, so it is not assumed to match."""
+ assert "cost" not in bot._passthrough_commands()
+ assert bot._guard_slash_injection("/cost") == " /cost"
+
+ def test_unknown_slash_command_still_neutralised(self, bot):
+ assert bot._guard_slash_injection("/nonsense") == " /nonsense"
+
+ def test_config_can_override_the_informational_list(self, bot):
+ with patch(
+ "aipass.skills.lib.telegram.apps.handlers.config.load_bot_config",
+ return_value={"informational_commands": ["/Cost", "context"]},
+ ):
+ assert bot._informational_commands() == {"cost", "context"}
+
+ def test_config_failure_falls_back_to_defaults(self, bot):
+ with patch(
+ "aipass.skills.lib.telegram.apps.handlers.config.load_bot_config",
+ side_effect=RuntimeError("boom"),
+ ):
+ assert bot._informational_commands() == {"context"}
+
+
+# =============================================
+# 5. NO-WEDGE COMPLETION
+# =============================================
+
+
+class TestNoWedge:
+ """A local command produces no assistant turn — it must never leave a pending."""
+
+ def test_informational_command_writes_no_pending_file(self, bot, tmp_path):
+ transcript = tmp_path / "t.jsonl"
+ write_transcript(transcript, [command_name_entry()])
+ with patch.object(bot, "_resolve_active_transcript", return_value=(str(transcript), 1)):
+ with patch.object(bot, "_relay_slash_stdout"):
+ bot.handle_message(999, "/context", {"message_id": 7})
+ assert not bot.pending_file.exists()
+
+ def test_informational_command_starts_no_heartbeat(self, bot, tmp_path):
+ transcript = tmp_path / "t.jsonl"
+ write_transcript(transcript, [command_name_entry()])
+ with patch.object(bot, "_resolve_active_transcript", return_value=(str(transcript), 1)):
+ with patch.object(bot, "_start_heartbeat") as heartbeat:
+ with patch.object(bot, "_relay_slash_stdout"):
+ bot.handle_message(999, "/context", {"message_id": 7})
+ heartbeat.assert_not_called()
+
+ def test_informational_command_is_injected_verbatim(self, bot, tmp_path):
+ """A sender prefix would stop CC from running it as a command at all."""
+ transcript = tmp_path / "t.jsonl"
+ write_transcript(transcript, [command_name_entry()])
+ with patch.object(bot, "_resolve_active_transcript", return_value=(str(transcript), 1)):
+ with patch.object(bot, "_relay_slash_stdout"):
+ bot.handle_message(999, "/context", {"message_id": 7})
+ bot.inject_message.assert_called_once_with("/context")
+
+ def test_missing_transcript_reports_honestly_and_injects_nothing(self, bot):
+ with patch.object(bot, "_resolve_active_transcript", return_value=(None, 0)):
+ bot.handle_message(999, "/context", {"message_id": 7})
+ bot.inject_message.assert_not_called()
+ assert "no active claude transcript" in bot.send_message.call_args.args[1].lower()
+
+ def test_side_effect_command_behaviour_is_unchanged(self, bot):
+ """clear/compact/prep/memo keep the pending-file flow exactly."""
+ with patch.object(bot, "_start_heartbeat"):
+ bot.handle_message(999, "/clear", {"message_id": 7})
+ assert bot.pending_file.exists()
+ bot.inject_message.assert_called_once_with("/clear")
+
+ def test_normal_message_behaviour_is_unchanged(self, bot):
+ with patch.object(bot, "_start_heartbeat"):
+ bot.handle_message(999, "hello there", {"message_id": 7})
+ assert bot.pending_file.exists()
+ bot.inject_message.assert_called_once_with("hello there")
+
+ def test_normal_message_starts_no_relay_watcher(self, bot):
+ """SCOPE GUARD: only an injected informational command may watch stdout."""
+ with patch.object(bot, "_start_heartbeat"):
+ bot.handle_message(999, "hello there", {"message_id": 7})
+ assert bot._slash_stdout_thread is None
+
+ def test_failed_injection_reports_and_starts_no_watcher(self, bot, tmp_path):
+ transcript = tmp_path / "t.jsonl"
+ write_transcript(transcript, [command_name_entry()])
+ bot.inject_message.return_value = False
+ with patch.object(bot, "_resolve_active_transcript", return_value=(str(transcript), 1)):
+ bot.handle_message(999, "/context", {"message_id": 7})
+ assert bot._slash_stdout_thread is None
+ assert not bot.pending_file.exists()
+ bot.edit_message.assert_called_once()
+ assert "failed" in bot.edit_message.call_args.args[2].lower()
+
+
+class TestRelayLoop:
+ """The relay owns its own completion: it always finishes, one way or another."""
+
+ def test_relays_the_payload_and_clears_the_placeholder(self, bot, tmp_path):
+ transcript = tmp_path / "t.jsonl"
+ write_transcript(transcript, [stdout_entry(REAL_CONTEXT_STDOUT)])
+ with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.SLASH_STDOUT_POLL_INTERVAL", 0):
+ bot._relay_slash_stdout(999, "context", transcript, 0, 42)
+ bot.edit_message.assert_called_once_with(999, 42, "/context")
+ sent = bot.send_message.call_args.args[1]
+ assert sent.startswith("") and "| System prompt | 5.6k | 1.6% |" in sent
+
+ def test_waits_one_extra_poll_for_the_markdown_twin(self, bot, tmp_path):
+ """The ANSI panel lands first; the clean twin is a beat behind it."""
+ transcript = tmp_path / "t.jsonl"
+ write_transcript(transcript, [stdout_entry(REAL_ANSI_STDOUT)])
+
+ def land_the_twin(*args, **kwargs):
+ write_transcript(transcript, [stdout_entry(REAL_ANSI_STDOUT), twin_entry(REAL_TWIN_MARKDOWN)])
+
+ with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.SLASH_STDOUT_POLL_INTERVAL", 0):
+ with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.time.sleep", side_effect=land_the_twin):
+ bot._relay_slash_stdout(999, "context", transcript, 0, 42)
+ sent = bot.send_message.call_args.args[1]
+ assert "claude-opus-4-8" in sent
+ assert "⛁" not in sent # the box-drawing panel never reached the chat
+
+ def test_relays_the_ansi_panel_rather_than_losing_it(self, bot, tmp_path):
+ """If the twin never arrives, the stripped panel still beats silence."""
+ transcript = tmp_path / "t.jsonl"
+ write_transcript(transcript, [stdout_entry(REAL_ANSI_STDOUT)])
+ with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.SLASH_STDOUT_POLL_INTERVAL", 0):
+ bot._relay_slash_stdout(999, "context", transcript, 0, 42)
+ assert "Context Usage" in bot.send_message.call_args.args[1]
+
+ def test_timeout_reports_honestly_and_terminates(self, bot, tmp_path):
+ transcript = tmp_path / "t.jsonl"
+ write_transcript(transcript, [command_name_entry()])
+ with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.SLASH_STDOUT_POLL_INTERVAL", 0):
+ with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.SLASH_STDOUT_TIMEOUT_SECONDS", 0.05):
+ bot._relay_slash_stdout(999, "context", transcript, 0, 42)
+ bot.edit_message.assert_called_once()
+ text = bot.edit_message.call_args.args[2]
+ assert "/context" in text and "no output" in text.lower()
+
+ def test_timeout_without_a_placeholder_still_reports(self, bot, tmp_path):
+ transcript = tmp_path / "t.jsonl"
+ write_transcript(transcript, [command_name_entry()])
+ with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.SLASH_STDOUT_POLL_INTERVAL", 0):
+ with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.SLASH_STDOUT_TIMEOUT_SECONDS", 0.05):
+ bot._relay_slash_stdout(999, "context", transcript, 0, None)
+ bot.edit_message.assert_not_called()
+ assert "no output" in bot.send_message.call_args.args[1].lower()
+
+ def test_timeout_is_bounded_by_the_constant(self, bot):
+ assert 0 < SLASH_STDOUT_TIMEOUT_SECONDS <= 300
+
+ def test_stale_output_before_the_baseline_never_reaches_the_chat(self, bot, tmp_path):
+ """SCOPE GUARD, end to end: a desk-run /context times out, it does not echo."""
+ transcript = tmp_path / "t.jsonl"
+ write_transcript(transcript, [stdout_entry(REAL_CONTEXT_STDOUT)])
+ with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.SLASH_STDOUT_POLL_INTERVAL", 0):
+ with patch("aipass.skills.lib.telegram.apps.handlers.base_bot.SLASH_STDOUT_TIMEOUT_SECONDS", 0.05):
+ bot._relay_slash_stdout(999, "context", transcript, 1, 42)
+ bot.send_message.assert_not_called()
+ assert "no output" in bot.edit_message.call_args.args[2].lower()
+
+
+def test_ansi_regex_matches_real_escapes():
+ """Guard the regex itself — it is the only thing standing between TUI art and the chat."""
+ assert ANSI_ESCAPE_RE.sub("", "\x1b[38;5;244m⛁\x1b[39m") == "⛁"
+ assert ANSI_ESCAPE_RE.sub("", "no escapes here") == "no escapes here"
diff --git a/src/aipass/trigger/apps/handlers/events/runaway_handler.py b/src/aipass/trigger/apps/handlers/events/runaway_handler.py
index cf259d9d..1f59911d 100644
--- a/src/aipass/trigger/apps/handlers/events/runaway_handler.py
+++ b/src/aipass/trigger/apps/handlers/events/runaway_handler.py
@@ -1,9 +1,9 @@
# =================== AIPass ====================
# Name: runaway_handler.py
# Description: Runaway log event handler with per-file cooldown gating
-# Version: 1.0.0
+# Version: 1.1.0
# Created: 2026-07-14
-# Modified: 2026-07-21
+# Modified: 2026-08-04
# =============================================
"""
@@ -20,6 +20,22 @@
- severity: "warning" or "critical"
- branch: Responsible branch name
+Severity doctrine — WARNING observes, CRITICAL wakes:
+ - WARNING (100 lines/min sustained 12 intervals) is OBSERVE-ONLY. It writes
+ the alert and the decision entry with full fidelity and records the
+ per-file cooldown, but it sends no email and wakes nobody. The 100/min
+ threshold predates routine multi-agent fleets: it fires overwhelmingly on
+ healthy chatty logs under normal load, and agents were being woken out of
+ sleep for a system behaving exactly as designed.
+ - CRITICAL (600 lines/min sustained 6 intervals) is unchanged. Email plus
+ wake_branch, bypassing a volume mute if one is set.
+
+ Accepted trade-off, ruled deliberately and not an oversight: a sustained
+ moderate leak — say ~150 lines/min for days — never climbs to CRITICAL, so
+ under observe-only nobody is ever woken for it. It lands in alerts.json and
+ in the decision log, plainly visible to anyone who looks, but nobody is
+ told. We chose a quiet record over a fleet that stops trusting the wake.
+
Gating:
- Per-file cooldown (30min default) — independent of medic circuit breaker
- VOLUME mute check (volume_muted_branches in trigger_config.json)
@@ -33,9 +49,11 @@
— a machine-eating flood is never something you asked to silence.
Every gating decision is appended to logs/runaway_suppressed.jsonl with an
-`outcome` field ("suppressed" or "delivered") so suppressed-by-design and
-delivered-by-bypass are distinguishable in the trail. Entries written before
-this field existed are all suppressions.
+`outcome` field ("suppressed", "delivered" or "observed") so suppressed-by-design,
+delivered-by-bypass and recorded-but-untold are distinguishable in the trail.
+"observed" is the observe-only WARNING outcome: we did record it, so it is not a
+suppression, and nobody was told, so it is not a delivery — it needed its own
+word. Entries written before the `outcome` field existed are all suppressions.
Alerts written to .aipass/alerts.json expire after 24h by default (same TTL
convention as medic_state.py's DEFAULT_MUTE_SECONDS) — pass forever=True to
@@ -172,7 +190,8 @@ def _write_decision_log(outcome: str, reason: str, file_path: str, branch: str)
"""Write a gating decision to the runaway decision trail.
Args:
- outcome: "suppressed" (alert dropped) or "delivered" (alert sent anyway)
+ outcome: "suppressed" (alert dropped), "delivered" (alert sent anyway)
+ or "observed" (recorded, nobody woken — observe-only WARNING)
reason: Machine-readable cause, e.g. "cooldown", "volume_muted"
file_path: Path to the runaway log file
branch: Responsible branch name
@@ -240,11 +259,15 @@ def handle_runaway_log_detected(
forever: bool = False,
**kwargs: Any,
) -> None:
- """Handle runaway_log_detected event — dispatch to responsible branch.
+ """Handle runaway_log_detected event — observe WARNING, dispatch CRITICAL.
Volume-based detection, independent of medic error_detected pipeline.
Uses per-file cooldown (30min) instead of the medic circuit breaker.
+ WARNING is observe-only: alert, decision entry and cooldown are recorded,
+ but no email is sent and no branch is woken. CRITICAL keeps the full
+ dispatch path — email, wake_branch, alert, cooldown.
+
Args:
file_path: Path to the runaway log file — REQUIRED
rate_lines_per_min: Current log rate
@@ -273,6 +296,18 @@ def handle_runaway_log_detected(
# A machine-eating flood is never something you asked to silence.
_write_decision_log("delivered", "bypass_critical", file_path, target_branch)
+ if not is_critical:
+ # Observe-only: record with full fidelity, wake nobody. The cooldown
+ # is recorded too — without it every detection interval would append
+ # another alert and the record would become its own flood.
+ _write_alert(
+ file_path, severity, target_branch, rate_lines_per_min, sustained_duration_sec, forever=forever
+ )
+ _write_decision_log("observed", "observe_only", file_path, target_branch)
+ _record_file_dispatch(file_path)
+ json_handler.log_operation("runaway_observed", {"branch": target_branch, "file": file_path})
+ return
+
if _send_email is None:
_log_warning("No email callback — cannot dispatch runaway alert")
return
diff --git a/src/aipass/trigger/apps/handlers/log_watcher.py b/src/aipass/trigger/apps/handlers/log_watcher.py
index 1d6d0031..6cbb706a 100644
--- a/src/aipass/trigger/apps/handlers/log_watcher.py
+++ b/src/aipass/trigger/apps/handlers/log_watcher.py
@@ -1,9 +1,9 @@
# =================== AIPass ====================
# Name: log_watcher.py
# Description: Branch log watcher event producer for error detection
-# Version: 2.3.0
+# Version: 2.6.0
# Created: 2026-02-02
-# Modified: 2026-02-27
+# Modified: 2026-08-04
# =============================================
"""
@@ -44,6 +44,10 @@
# Max age for log entries to be considered fresh (seconds)
STALE_ENTRY_THRESHOLD_SECONDS = 300 # 5 minutes
+# How far down the rotation chain ('.log.1' .. '.N') to look for a
+# rotated-out file. Matches prax RotatingFileHandler backup_count.
+MAX_BACKUP_CHAIN_DEPTH = 3
+
# Log filenames to exclude from watching (self-referential / dispatch feedback)
# Compared case-insensitively against Path.name (see on_modified)
EXCLUDED_LOG_FILES: Set[str] = {
@@ -165,25 +169,6 @@ def _load_seen_hashes() -> None:
_seen_error_hashes = set() # Start fresh on read failure
-def _save_seen_hashes() -> None:
- """
- Persist dedup hashes to trigger_data.json.
-
- Writes current _seen_error_hashes to disk so they survive restarts.
- Merges with existing trigger_data.json content to preserve other keys.
- """
- try:
- with json_file_lock(TRIGGER_DATA_FILE):
- data: Dict[str, Any] = {}
- if TRIGGER_DATA_FILE.exists():
- data = json.loads(TRIGGER_DATA_FILE.read_text(encoding="utf-8"))
- data["seen_error_hashes"] = list(_seen_error_hashes)
- atomic_write_json(TRIGGER_DATA_FILE, data)
- except Exception as exc:
- logger.warning("Failed to save seen hashes: %s", exc)
- return # Write failure - hashes remain in memory only
-
-
def _load_log_positions() -> Dict[str, int]:
"""
Load persisted log positions from trigger_data.json.
@@ -205,26 +190,27 @@ def _load_log_positions() -> Dict[str, int]:
return {}
-def _save_log_positions(positions: Dict[str, int]) -> None:
+def _load_log_inodes() -> Dict[str, int]:
"""
- Persist log positions to trigger_data.json.
+ Load persisted log inodes from trigger_data.json.
- Saves byte offsets for each log file so they survive restarts.
- Merges with existing trigger_data.json content to preserve other keys.
+ Parallel key to 'log_positions' (kept separate so the on-disk
+ Dict[str, int] position shape never changes). Absence means
+ "no inode known", which simply disables the rotation drain until
+ positions are recorded again.
- Args:
- positions: Dict mapping file paths to byte offsets
+ Returns:
+ Dict mapping file paths to inode numbers
"""
try:
- with json_file_lock(TRIGGER_DATA_FILE):
- data: Dict[str, Any] = {}
- if TRIGGER_DATA_FILE.exists():
- data = json.loads(TRIGGER_DATA_FILE.read_text(encoding="utf-8"))
- data["log_positions"] = positions
- atomic_write_json(TRIGGER_DATA_FILE, data)
- except Exception as exc:
- logger.warning("Failed to save log positions: %s", exc)
- return # Write failure - positions remain in memory only
+ if TRIGGER_DATA_FILE.exists():
+ data = json.loads(TRIGGER_DATA_FILE.read_text(encoding="utf-8"))
+ stored = data.get("log_inodes", {})
+ if isinstance(stored, dict):
+ return {k: int(v) for k, v in stored.items()}
+ except Exception as e:
+ logger.warning("Failed to load log inodes: %s", e)
+ return {}
def _mark_data_dirty() -> None:
@@ -250,6 +236,7 @@ def _flush_trigger_data(force: bool = False) -> None:
data = json.loads(TRIGGER_DATA_FILE.read_text(encoding="utf-8"))
if _active_watcher is not None:
data["log_positions"] = _active_watcher.log_positions
+ data["log_inodes"] = _active_watcher.log_inodes
data["seen_error_hashes"] = list(_seen_error_hashes)
atomic_write_json(TRIGGER_DATA_FILE, data)
_data_dirty = False
@@ -439,6 +426,88 @@ def __init__(self):
"""Initialize log watcher with position tracking."""
super().__init__()
self.log_positions: Dict[str, int] = {}
+ self.log_inodes: Dict[str, int] = {}
+
+ def _record_position(self, file_path: str, position: int) -> None:
+ """
+ Record byte position and inode identity for a watched file.
+
+ The inode is what later proves a shrunken file was rotated away
+ (renamed to '.log.1') rather than truncated in place.
+
+ Args:
+ file_path: Path to log file
+ position: Byte offset already processed
+ """
+ self.log_positions[file_path] = position
+ try:
+ self.log_inodes[file_path] = Path(file_path).stat().st_ino
+ except OSError as exc:
+ logger.warning("Failed to record inode for '%s': %s", file_path, exc)
+ self.log_inodes.pop(file_path, None)
+
+ def _drain_rotated_tail(self, file_path: str, last_pos: int) -> None:
+ """
+ Process the unread tail of a log file that was just rotated away.
+
+ The rotated file is located BY INODE across the backup chain
+ ('.1' .. '.MAX_BACKUP_CHAIN_DEPTH', stopping at the first
+ gap): the only file drained is the one whose CURRENT inode matches the
+ inode recorded for the live path - proof it is literally the file we
+ were reading, now renamed. No match anywhere in the chain, a backup
+ shorter than the recorded position, or an unknown inode all skip
+ silently rather than re-fire old errors.
+
+ DELIBERATE SCOPE LIMIT: when the match is found at '.2' or later, at
+ least one whole backup rotated past unseen. Those skipped backups are
+ NOT replayed - re-reading a full backup would fire a flood of events
+ for already-historical lines, which is worse than the gap. Only the
+ matched file's own tail is drained, and one warning names the file and
+ the number of skipped backups so the loss is visible in the log
+ instead of silent.
+
+ Args:
+ file_path: Path to the live log file
+ last_pos: Byte offset processed before the rotation
+ """
+ known_inode = self.log_inodes.get(file_path)
+ if not known_inode or last_pos <= 0:
+ return
+
+ try:
+ rotated: Optional[Path] = None
+ skipped = 0
+ for index in range(1, MAX_BACKUP_CHAIN_DEPTH + 1):
+ backup = Path(f"{file_path}.{index}")
+ if not backup.exists():
+ break
+ if backup.stat().st_ino == known_inode:
+ rotated = backup
+ skipped = index - 1
+ break
+ if rotated is None:
+ return
+ if skipped:
+ logger.warning(
+ "Rotated log found at '%s' - %d earlier backup(s) rotated past unread (not replayed)",
+ rotated,
+ skipped,
+ )
+ if rotated.stat().st_size <= last_pos:
+ return
+ with open(rotated, "r", encoding="utf-8", errors="ignore") as f:
+ f.seek(last_pos)
+ tail = f.read()
+ except OSError as exc:
+ logger.warning("Failed to drain rotated log for '%s': %s", file_path, exc)
+ return
+
+ if not tail.strip():
+ return
+
+ for line in tail.strip().split("\n"):
+ if line.strip():
+ self._process_log_line(line, file_path)
def _should_process(self, file_path: str) -> bool:
"""Check if a log file should be processed."""
@@ -452,12 +521,36 @@ def _should_process(self, file_path: str) -> bool:
return is_branch_log or is_system_log
def _read_new_lines(self, file_path: str) -> None:
- """Read new content from a log file and process lines."""
- current_size = Path(file_path).stat().st_size
+ """
+ Read new content from a log file and process lines.
+
+ Rotation is detected by INODE, not by size: a fresh log can already
+ have grown past the recorded offset by the time the event arrives, and
+ a size-only check would then seek into the middle of a brand new file.
+ An inode of 0 (possible on some Windows filesystems) means "unknown"
+ and falls back to the size-based check.
+
+ Args:
+ file_path: Path to log file
+ """
+ stats = Path(file_path).stat()
+ current_size = stats.st_size
last_pos = self.log_positions.get(file_path, 0)
+ known_inode = self.log_inodes.get(file_path)
+ rotated_away = bool(known_inode) and bool(stats.st_ino) and known_inode != stats.st_ino
- if current_size < last_pos:
+ if rotated_away:
+ try:
+ self._drain_rotated_tail(file_path, last_pos)
+ except Exception as exc:
+ logger.warning("Failed to drain rotated tail for '%s': %s", file_path, exc)
+ last_pos = 0
+ # Record immediately so a second event cannot drain the same tail twice
+ self._record_position(file_path, 0)
+ elif current_size < last_pos:
+ # Same file, smaller: truncated in place - no rotated tail exists
last_pos = 0
+ self._record_position(file_path, 0)
if current_size <= last_pos:
return
@@ -468,7 +561,7 @@ def _read_new_lines(self, file_path: str) -> None:
for line in new_lines.strip().split("\n"):
if line.strip():
self._process_log_line(line, file_path)
- self.log_positions[file_path] = f.tell()
+ self._record_position(file_path, f.tell())
_mark_data_dirty()
@@ -641,6 +734,8 @@ def initialize_positions(self) -> None:
"""
# Load persisted positions from disk first
persisted = _load_log_positions()
+ # Seed inodes from disk; the live stat below wins for files that exist
+ self.log_inodes.update(_load_log_inodes())
# Branch logs under aipass/*/logs/
for branch_dir in AIPASS_PKG_ROOT.iterdir():
@@ -656,9 +751,9 @@ def initialize_positions(self) -> None:
saved_pos = persisted.get(file_path, -1)
# Use persisted position if valid (not beyond current file size)
if 0 <= saved_pos <= current_size:
- self.log_positions[file_path] = saved_pos
+ self._record_position(file_path, saved_pos)
else:
- self.log_positions[file_path] = current_size
+ self._record_position(file_path, current_size)
except Exception as exc:
logger.warning("Failed to initialize position for branch log '%s': %s", log_file, exc)
continue # Skip unreadable log file
@@ -671,9 +766,9 @@ def initialize_positions(self) -> None:
current_size = log_file.stat().st_size
saved_pos = persisted.get(file_path, -1)
if 0 <= saved_pos <= current_size:
- self.log_positions[file_path] = saved_pos
+ self._record_position(file_path, saved_pos)
else:
- self.log_positions[file_path] = current_size
+ self._record_position(file_path, current_size)
except Exception as exc:
logger.warning("Failed to initialize position for system log '%s': %s", log_file, exc)
continue # Skip unreadable log file
diff --git a/src/aipass/trigger/apps/handlers/watchers/log_watcher.py b/src/aipass/trigger/apps/handlers/watchers/log_watcher.py
index 15eee030..a447bbb2 100644
--- a/src/aipass/trigger/apps/handlers/watchers/log_watcher.py
+++ b/src/aipass/trigger/apps/handlers/watchers/log_watcher.py
@@ -1,9 +1,9 @@
# =================== AIPass ====================
# Name: log_watcher.py
# Description: Centralized log file watcher for system_logs directory
-# Version: 1.0.0
+# Version: 1.2.0
# Created: 2026-01-31
-# Modified: 2026-01-31
+# Modified: 2026-08-04
# =============================================
"""
@@ -27,7 +27,7 @@
import sys
from datetime import datetime
from pathlib import Path
-from typing import Any, Dict
+from typing import Any, Dict, Optional
from aipass.prax import logger
from aipass.trigger.apps.config import TRIGGER_ROOT
@@ -39,6 +39,10 @@
# System logs directory (package-relative via config)
SYSTEM_LOGS_DIR = TRIGGER_ROOT.parent.parent.parent / "system_logs"
+# How far down the rotation chain ('.log.1' .. '.N') to look for a
+# rotated-out file. Matches prax RotatingFileHandler backup_count.
+MAX_BACKUP_CHAIN_DEPTH = 3
+
# Try to import watchdog
try:
from watchdog.observers import Observer as WatchdogObserver
@@ -190,14 +194,121 @@ def __init__(self):
"""Initialize log watcher with position tracking."""
super().__init__()
self.log_positions: Dict[str, int] = {}
+ self.log_inodes: Dict[str, int] = {}
+
+ def _record_position(self, file_path: str, position: int) -> None:
+ """
+ Record byte position and inode identity for a watched file.
+
+ The inode is what later proves a shrunken file was rotated away
+ (renamed to '.log.1') rather than truncated in place.
+
+ Args:
+ file_path: Path to log file
+ position: Byte offset already processed
+ """
+ self.log_positions[file_path] = position
+ try:
+ self.log_inodes[file_path] = Path(file_path).stat().st_ino
+ except OSError as exc:
+ logger.warning("Failed to record inode for '%s': %s", file_path, exc)
+ self.log_inodes.pop(file_path, None)
+
+ def _drain_rotated_tail(self, file_path: str, last_pos: int) -> None:
+ """
+ Process the unread tail of a log file that was just rotated away.
+
+ The rotated file is located BY INODE across the backup chain
+ ('.1' .. '.MAX_BACKUP_CHAIN_DEPTH', stopping at the first
+ gap): the only file drained is the one whose CURRENT inode matches the
+ inode recorded for the live path - proof it is literally the file we
+ were reading, now renamed. No match anywhere in the chain, a backup
+ shorter than the recorded position, or an unknown inode all skip
+ silently rather than re-fire old errors.
+
+ DELIBERATE SCOPE LIMIT: when the match is found at '.2' or later, at
+ least one whole backup rotated past unseen. Those skipped backups are
+ NOT replayed - re-reading a full backup would fire a flood of events
+ for already-historical lines, which is worse than the gap. Only the
+ matched file's own tail is drained, and one warning names the file and
+ the number of skipped backups so the loss is visible in the log
+ instead of silent.
+
+ Args:
+ file_path: Path to the live log file
+ last_pos: Byte offset processed before the rotation
+ """
+ known_inode = self.log_inodes.get(file_path)
+ if not known_inode or last_pos <= 0:
+ return
+
+ try:
+ rotated: Optional[Path] = None
+ skipped = 0
+ for index in range(1, MAX_BACKUP_CHAIN_DEPTH + 1):
+ backup = Path(f"{file_path}.{index}")
+ if not backup.exists():
+ break
+ if backup.stat().st_ino == known_inode:
+ rotated = backup
+ skipped = index - 1
+ break
+ if rotated is None:
+ return
+ if skipped:
+ logger.warning(
+ "Rotated log found at '%s' - %d earlier backup(s) rotated past unread (not replayed)",
+ rotated,
+ skipped,
+ )
+ if rotated.stat().st_size <= last_pos:
+ return
+ with open(rotated, "r", encoding="utf-8", errors="ignore") as f:
+ f.seek(last_pos)
+ tail = f.read()
+ except OSError as exc:
+ logger.warning("Failed to drain rotated log for '%s': %s", file_path, exc)
+ return
+
+ if not tail.strip():
+ return
+
+ branch = _detect_branch_from_log(file_path)
+ for line in tail.strip().split("\n"):
+ if line.strip() and not _should_skip_log(line):
+ self._process_log_line(branch, line, file_path)
def _read_new_lines(self, file_path: str) -> None:
- """Read new content from a log file and process lines."""
- current_size = Path(file_path).stat().st_size
+ """
+ Read new content from a log file and process lines.
+
+ Rotation is detected by INODE, not by size: a fresh log can already
+ have grown past the recorded offset by the time the event arrives, and
+ a size-only check would then seek into the middle of a brand new file.
+ An inode of 0 (possible on some Windows filesystems) means "unknown"
+ and falls back to the size-based check.
+
+ Args:
+ file_path: Path to log file
+ """
+ stats = Path(file_path).stat()
+ current_size = stats.st_size
last_pos = self.log_positions.get(file_path, 0)
+ known_inode = self.log_inodes.get(file_path)
+ rotated_away = bool(known_inode) and bool(stats.st_ino) and known_inode != stats.st_ino
- if current_size < last_pos:
+ if rotated_away:
+ try:
+ self._drain_rotated_tail(file_path, last_pos)
+ except Exception as exc:
+ logger.warning("Failed to drain rotated tail for '%s': %s", file_path, exc)
+ last_pos = 0
+ # Record immediately so a second event cannot drain the same tail twice
+ self._record_position(file_path, 0)
+ elif current_size < last_pos:
+ # Same file, smaller: truncated in place - no rotated tail exists
last_pos = 0
+ self._record_position(file_path, 0)
if current_size <= last_pos:
return
@@ -209,7 +320,7 @@ def _read_new_lines(self, file_path: str) -> None:
for line in new_lines.strip().split("\n"):
if line.strip() and not _should_skip_log(line):
self._process_log_line(branch, line, file_path)
- self.log_positions[file_path] = f.tell()
+ self._record_position(file_path, f.tell())
def on_modified(self, event):
"""
@@ -310,7 +421,7 @@ def initialize_positions(self) -> None:
for log_file in SYSTEM_LOGS_DIR.glob("*.log"):
try:
- self.log_positions[str(log_file)] = log_file.stat().st_size
+ self._record_position(str(log_file), log_file.stat().st_size)
except Exception as exc:
logger.warning("Failed to initialize position for '%s': %s", log_file, exc)
diff --git a/src/aipass/trigger/tests/test_log_watcher.py b/src/aipass/trigger/tests/test_log_watcher.py
index 76909cee..9b69b479 100644
--- a/src/aipass/trigger/tests/test_log_watcher.py
+++ b/src/aipass/trigger/tests/test_log_watcher.py
@@ -3,9 +3,9 @@
# =================== META ====================
# Name: test_log_watcher.py
# Description: Unit tests for branch log watcher event producer
-# Version: 1.0.0
+# Version: 1.3.0
# Created: 2026-04-03
-# Modified: 2026-04-03
+# Modified: 2026-08-04
# =============================================
import json
@@ -408,7 +408,7 @@ def test_reads_new_content(self, tmp_path):
with open(log_file, "a", encoding="utf-8") as f:
f.write(f"{now} | mod | ERROR | New failure\n")
- # Patch _save_log_positions to avoid touching the real file
+ # Patch _mark_data_dirty to avoid touching the real file
with patch.object(lw, "_mark_data_dirty"):
watcher._read_new_lines(file_path)
@@ -437,6 +437,520 @@ def test_handles_log_rotation(self, tmp_path):
assert watcher.log_positions[file_path] == log_file.stat().st_size
+# ---------------------------------------------------------------------------
+# Tests -- BranchLogWatcher rotation tail drain
+# ---------------------------------------------------------------------------
+
+
+def _rotate_log(log_file: Path) -> Path:
+ """
+ Perform a real RotatingFileHandler-style rotation.
+
+ Renames the live log to '.log.1' (inode travels with the rename)
+ and creates a fresh empty '.log'.
+
+ Args:
+ log_file: Path to the live log file
+
+ Returns:
+ Path to the rotated-out backup file
+ """
+ backup = Path(f"{log_file}.1")
+ if backup.exists():
+ backup.unlink()
+ log_file.rename(backup)
+ log_file.write_text("", encoding="utf-8")
+ return backup
+
+
+def _rotate_chain(log_file: Path, backup_count: int = 3) -> Path:
+ """
+ Perform a real RotatingFileHandler rotation including backup shifting.
+
+ Shifts '.log.N' -> '.log.N+1' (dropping the oldest), renames
+ the live log to '.log.1', then creates a fresh empty '.log'.
+ Inodes travel with the renames exactly as they do in production.
+
+ Args:
+ log_file: Path to the live log file
+ backup_count: Number of backups kept (prax uses 3)
+
+ Returns:
+ Path to the newest backup file ('.log.1')
+ """
+ for index in range(backup_count - 1, 0, -1):
+ source = Path(f"{log_file}.{index}")
+ target = Path(f"{log_file}.{index + 1}")
+ if source.exists():
+ if target.exists():
+ target.unlink()
+ source.rename(target)
+ backup = Path(f"{log_file}.1")
+ if backup.exists():
+ backup.unlink()
+ log_file.rename(backup)
+ log_file.write_text("", encoding="utf-8")
+ return backup
+
+
+def _processed_lines(mock_proc) -> list:
+ """Extract the log-line argument from every _process_log_line call."""
+ return [call.args[0] for call in mock_proc.call_args_list]
+
+
+def _error_line(message: str) -> str:
+ """Build a fresh-timestamped ERROR line in Prax format."""
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
+ return f"{now} | mod | ERROR | {message}\n"
+
+
+class TestRotationDrain:
+ """Tests for draining the unread tail of a rotated-out branch log."""
+
+ def test_rotation_drains_unread_tail(self, tmp_path):
+ """Lines written between last position and rotation ARE processed."""
+ lw = _import_log_watcher()
+ watcher = lw.BranchLogWatcher()
+
+ log_file = tmp_path / "core.log"
+ # Bulk of the file is already processed (mirrors a log near its size cap)
+ log_file.write_text(_error_line("already seen") * 20, encoding="utf-8")
+ file_path = str(log_file)
+ watcher._record_position(file_path, log_file.stat().st_size)
+
+ # Unread tail: written after last position, before rotation
+ with open(log_file, "a", encoding="utf-8") as f:
+ f.write(_error_line("missed one"))
+ f.write(_error_line("missed two"))
+
+ _rotate_log(log_file)
+ log_file.write_text(_error_line("after rotation"), encoding="utf-8")
+
+ with patch.object(lw, "_mark_data_dirty"):
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert any("missed one" in line for line in lines)
+ assert any("missed two" in line for line in lines)
+ assert any("after rotation" in line for line in lines)
+ assert not any("already seen" in line for line in lines)
+ assert all(call.args[1] == file_path for call in mock_proc.call_args_list)
+
+ def test_stale_backup_inode_mismatch_not_reprocessed(self, tmp_path):
+ """A stale '.log.1' from an earlier rotation is never re-read."""
+ lw = _import_log_watcher()
+ watcher = lw.BranchLogWatcher()
+
+ log_file = tmp_path / "core.log"
+ log_file.write_text(_error_line("ancient"), encoding="utf-8")
+ # Earlier rotation happened before we started tracking the live file
+ _rotate_log(log_file)
+
+ log_file.write_text(_error_line("live line one"), encoding="utf-8")
+ file_path = str(log_file)
+ watcher._record_position(file_path, log_file.stat().st_size)
+
+ # Live file shrinks but the stale backup still sits there
+ log_file.write_text(_error_line("fresh"), encoding="utf-8")
+ watcher.log_positions[file_path] = 9999
+
+ with patch.object(lw, "_mark_data_dirty"):
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert not any("ancient" in line for line in lines)
+ assert any("fresh" in line for line in lines)
+
+ def test_missing_backup_file_still_reads_new_file(self, tmp_path):
+ """No '.log.1' present: no crash, the new file is still read."""
+ lw = _import_log_watcher()
+ watcher = lw.BranchLogWatcher()
+
+ log_file = tmp_path / "core.log"
+ log_file.write_text(_error_line("plenty of old content in here"), encoding="utf-8")
+ file_path = str(log_file)
+ watcher._record_position(file_path, log_file.stat().st_size)
+
+ log_file.write_text(_error_line("tiny"), encoding="utf-8")
+
+ with patch.object(lw, "_mark_data_dirty"):
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert len(lines) == 1
+ assert "tiny" in lines[0]
+ assert watcher.log_positions[file_path] == log_file.stat().st_size
+
+ def test_in_place_truncation_no_duplicate_processing(self, tmp_path):
+ """Truncation in place (same inode) never re-fires the old content."""
+ lw = _import_log_watcher()
+ watcher = lw.BranchLogWatcher()
+
+ log_file = tmp_path / "core.log"
+ file_path = str(log_file)
+ old_content = _error_line("first pass line with plenty of length")
+ log_file.write_text(old_content, encoding="utf-8")
+
+ # A stale backup exists holding a copy of the same text, different inode
+ Path(f"{file_path}.1").write_text(old_content, encoding="utf-8")
+
+ watcher._record_position(file_path, log_file.stat().st_size)
+
+ # Truncate in place - inode is unchanged
+ inode_before = log_file.stat().st_ino
+ with open(log_file, "w", encoding="utf-8") as f:
+ f.write(_error_line("short"))
+ assert log_file.stat().st_ino == inode_before
+
+ with patch.object(lw, "_mark_data_dirty"):
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert len(lines) == 1
+ assert "short" in lines[0]
+
+ def test_normal_append_never_drains(self, tmp_path):
+ """No-overreach guard: an ordinary append never touches the drain path.
+
+ This test passes with and without the fix - it exists to prove the
+ drain does not run on the normal (non-shrink) path.
+ """
+ lw = _import_log_watcher()
+ watcher = lw.BranchLogWatcher()
+
+ log_file = tmp_path / "core.log"
+ log_file.write_text(_error_line("first"), encoding="utf-8")
+ file_path = str(log_file)
+ watcher.log_positions[file_path] = log_file.stat().st_size
+
+ # A backup with other content exists but must be ignored
+ Path(f"{file_path}.1").write_text(_error_line("backup only"), encoding="utf-8")
+
+ with open(log_file, "a", encoding="utf-8") as f:
+ f.write(_error_line("appended"))
+
+ with patch.object(lw, "_mark_data_dirty"):
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert len(lines) == 1
+ assert "appended" in lines[0]
+
+ def test_repeat_shrink_event_does_not_drain_twice(self, tmp_path):
+ """Two events after one rotation drain the tail exactly once."""
+ lw = _import_log_watcher()
+ watcher = lw.BranchLogWatcher()
+
+ log_file = tmp_path / "core.log"
+ log_file.write_text("header\n", encoding="utf-8")
+ file_path = str(log_file)
+ watcher._record_position(file_path, log_file.stat().st_size)
+
+ with open(log_file, "a", encoding="utf-8") as f:
+ f.write(_error_line("tail line"))
+
+ # Rotate, leaving the new live file EMPTY (nothing written yet)
+ _rotate_log(log_file)
+
+ with patch.object(lw, "_mark_data_dirty"):
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert len([line for line in lines if "tail line" in line]) == 1
+
+ def test_unknown_inode_skips_drain(self, tmp_path):
+ """Position recorded without an inode (old state) disables the drain.
+
+ No-overreach guard: passes with and without the fix - it proves an
+ unknown inode degrades to exactly the pre-fix behaviour.
+ """
+ lw = _import_log_watcher()
+ watcher = lw.BranchLogWatcher()
+
+ log_file = tmp_path / "core.log"
+ log_file.write_text(_error_line("tail line that is fairly long"), encoding="utf-8")
+ file_path = str(log_file)
+ # Position only - exactly what older on-disk state restores
+ watcher.log_positions[file_path] = log_file.stat().st_size
+
+ _rotate_log(log_file)
+ log_file.write_text(_error_line("new"), encoding="utf-8")
+
+ with patch.object(lw, "_mark_data_dirty"):
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert len(lines) == 1
+ assert "new" in lines[0]
+
+ def test_rotated_file_smaller_than_position_skipped(self, tmp_path):
+ """A backup shorter than the recorded position is not drained."""
+ lw = _import_log_watcher()
+ watcher = lw.BranchLogWatcher()
+
+ log_file = tmp_path / "core.log"
+ log_file.write_text(_error_line("content"), encoding="utf-8")
+ file_path = str(log_file)
+ watcher._record_position(file_path, log_file.stat().st_size)
+ watcher.log_positions[file_path] = 9999
+
+ _rotate_log(log_file)
+ log_file.write_text(_error_line("new"), encoding="utf-8")
+
+ with patch.object(lw, "_mark_data_dirty"):
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert len(lines) == 1
+ assert "new" in lines[0]
+
+ def test_drain_failure_does_not_block_new_file(self, tmp_path):
+ """An exception inside the drain never prevents reading the new file."""
+ lw = _import_log_watcher()
+ watcher = lw.BranchLogWatcher()
+
+ log_file = tmp_path / "core.log"
+ log_file.write_text(_error_line("old content that is long enough"), encoding="utf-8")
+ file_path = str(log_file)
+ watcher._record_position(file_path, log_file.stat().st_size)
+
+ _rotate_log(log_file)
+ log_file.write_text(_error_line("new"), encoding="utf-8")
+
+ with patch.object(lw, "_mark_data_dirty"):
+ with patch.object(watcher, "_drain_rotated_tail", side_effect=RuntimeError("boom")):
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert len(lines) == 1
+ assert "new" in lines[0]
+
+ def test_record_position_tracks_inode(self, tmp_path):
+ """_record_position stores the current inode alongside the offset."""
+ lw = _import_log_watcher()
+ watcher = lw.BranchLogWatcher()
+
+ log_file = tmp_path / "core.log"
+ log_file.write_text("data\n", encoding="utf-8")
+ file_path = str(log_file)
+
+ watcher._record_position(file_path, 4)
+
+ assert watcher.log_positions[file_path] == 4
+ assert watcher.log_inodes[file_path] == log_file.stat().st_ino
+
+
+# ---------------------------------------------------------------------------
+# Tests -- BranchLogWatcher inode-based rotation detection
+# ---------------------------------------------------------------------------
+
+
+def _fresh_lines(count: int) -> list:
+ """Build a list of distinct full ERROR lines for the post-rotation file."""
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
+ return [f"{now} | mod | ERROR | after rotation {i:03d}" for i in range(count)]
+
+
+class TestInodeRotationDetection:
+ """Tests for rotation detected by inode and located across the backup chain."""
+
+ def test_rotation_detected_when_new_file_already_grew(self, tmp_path):
+ """A fresh log already past the old offset is still detected as rotated.
+
+ The size-only check missed this entirely and seeked into the middle of
+ the brand new file, yielding a partial line.
+ """
+ lw = _import_log_watcher()
+ watcher = lw.BranchLogWatcher()
+
+ log_file = tmp_path / "core.log"
+ log_file.write_text(_error_line("already seen") * 20, encoding="utf-8")
+ file_path = str(log_file)
+ watcher._record_position(file_path, log_file.stat().st_size)
+ recorded_pos = watcher.log_positions[file_path]
+
+ with open(log_file, "a", encoding="utf-8") as f:
+ f.write(_error_line("missed one"))
+
+ _rotate_chain(log_file)
+
+ # New file is LARGER than the recorded offset - there is no shrink to see
+ fresh = _fresh_lines(40)
+ log_file.write_text("\n".join(fresh) + "\n", encoding="utf-8")
+ assert log_file.stat().st_size > recorded_pos
+
+ with patch.object(lw, "_mark_data_dirty"):
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert any("missed one" in line for line in lines)
+ # Read from position 0: every fresh line whole, none dropped, none partial
+ assert [line for line in lines if "after rotation" in line] == fresh
+ assert not any("already seen" in line for line in lines)
+ assert watcher.log_positions[file_path] == log_file.stat().st_size
+
+ def test_rotated_file_found_at_second_backup(self, tmp_path):
+ """Two rotations: the tail is drained from '.log.2' and a warning is emitted."""
+ lw = _import_log_watcher()
+ watcher = lw.BranchLogWatcher()
+
+ log_file = tmp_path / "core.log"
+ log_file.write_text("header line\n", encoding="utf-8")
+ file_path = str(log_file)
+ watcher._record_position(file_path, log_file.stat().st_size)
+
+ with open(log_file, "a", encoding="utf-8") as f:
+ f.write(_error_line("tail from two rotations ago"))
+
+ tracked_inode = log_file.stat().st_ino
+ _rotate_chain(log_file) # tracked file -> .log.1
+ _rotate_chain(log_file) # tracked file -> .log.2
+ assert Path(f"{file_path}.2").stat().st_ino == tracked_inode
+
+ log_file.write_text(_error_line("brand new"), encoding="utf-8")
+
+ with patch.object(lw, "_mark_data_dirty"):
+ with patch.object(lw, "logger") as mock_logger:
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert any("tail from two rotations ago" in line for line in lines)
+ assert any("brand new" in line for line in lines)
+
+ warnings = [str(call.args) for call in mock_logger.warning.call_args_list]
+ assert any("not replayed" in warning and ".log.2" in warning for warning in warnings)
+
+ def test_tracked_inode_beyond_chain_reads_new_file_from_zero(self, tmp_path):
+ """Inode matching nothing within the chain: nothing drained, new file read whole."""
+ lw = _import_log_watcher()
+ watcher = lw.BranchLogWatcher()
+
+ log_file = tmp_path / "core.log"
+ log_file.write_text("header\n", encoding="utf-8")
+ file_path = str(log_file)
+ watcher._record_position(file_path, log_file.stat().st_size)
+ tracked_inode = log_file.stat().st_ino
+
+ with open(log_file, "a", encoding="utf-8") as f:
+ f.write(_error_line("lost tail"))
+
+ # Four rotations push the tracked file to '.log.4' - past the depth cap
+ for _ in range(4):
+ _rotate_chain(log_file, backup_count=5)
+ assert Path(f"{file_path}.4").stat().st_ino == tracked_inode
+
+ fresh = _fresh_lines(40)
+ log_file.write_text("\n".join(fresh) + "\n", encoding="utf-8")
+
+ with patch.object(lw, "_mark_data_dirty"):
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert not any("lost tail" in line for line in lines)
+ assert lines == fresh
+ assert watcher.log_positions[file_path] == log_file.stat().st_size
+
+ def test_in_place_truncation_with_backup_chain_no_drain(self, tmp_path):
+ """No-overreach guard: same inode plus a full backup chain never drains.
+
+ Passes with and without the fix - it proves walking the chain did not
+ turn an in-place truncation into a false rotation.
+ """
+ lw = _import_log_watcher()
+ watcher = lw.BranchLogWatcher()
+
+ log_file = tmp_path / "core.log"
+ file_path = str(log_file)
+ for name in ("chain three", "chain two", "chain one"):
+ log_file.write_text(_error_line(name), encoding="utf-8")
+ _rotate_chain(log_file)
+
+ log_file.write_text(_error_line("live content line"), encoding="utf-8")
+ watcher._record_position(file_path, log_file.stat().st_size)
+
+ inode_before = log_file.stat().st_ino
+ with open(log_file, "w", encoding="utf-8") as f:
+ f.write(_error_line("short"))
+ assert log_file.stat().st_ino == inode_before
+
+ with patch.object(lw, "_mark_data_dirty"):
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert len(lines) == 1
+ assert "short" in lines[0]
+
+ def test_normal_append_with_backup_chain_never_drains(self, tmp_path):
+ """No-overreach guard: an append with backups present stays on the normal path.
+
+ Passes with and without the fix - it proves the chain walk only runs
+ when the inode actually changed.
+ """
+ lw = _import_log_watcher()
+ watcher = lw.BranchLogWatcher()
+
+ log_file = tmp_path / "core.log"
+ file_path = str(log_file)
+ for name in ("chain three", "chain two", "chain one"):
+ log_file.write_text(_error_line(name), encoding="utf-8")
+ _rotate_chain(log_file)
+
+ log_file.write_text(_error_line("first"), encoding="utf-8")
+ watcher._record_position(file_path, log_file.stat().st_size)
+
+ with open(log_file, "a", encoding="utf-8") as f:
+ f.write(_error_line("appended"))
+
+ with patch.object(lw, "_mark_data_dirty"):
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert len(lines) == 1
+ assert "appended" in lines[0]
+
+ def test_zero_inode_falls_back_to_size_check(self, tmp_path):
+ """No-overreach guard: a recorded inode of 0 is treated as unknown.
+
+ Passes with and without the fix - some Windows filesystems report
+ st_ino 0, which must never be trusted as a rotation signal.
+ """
+ lw = _import_log_watcher()
+ watcher = lw.BranchLogWatcher()
+
+ log_file = tmp_path / "core.log"
+ log_file.write_text(_error_line("tail line that is fairly long"), encoding="utf-8")
+ file_path = str(log_file)
+ watcher.log_positions[file_path] = log_file.stat().st_size
+ watcher.log_inodes[file_path] = 0
+
+ _rotate_chain(log_file)
+ log_file.write_text(_error_line("new"), encoding="utf-8")
+
+ with patch.object(lw, "_mark_data_dirty"):
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert len(lines) == 1
+ assert "new" in lines[0]
+ assert watcher.log_positions[file_path] == log_file.stat().st_size
+
+
# ---------------------------------------------------------------------------
# Tests -- start / stop / is_active / get_status
# ---------------------------------------------------------------------------
@@ -666,49 +1180,6 @@ def test_handles_missing_key(self, tmp_path):
assert lw._seen_error_hashes == set()
-# ---------------------------------------------------------------------------
-# Tests -- _save_seen_hashes
-# ---------------------------------------------------------------------------
-
-
-class TestSaveSeenHashes:
- """Tests for _save_seen_hashes persistence."""
-
- def test_saves_to_new_file(self, tmp_path):
- """Creates trigger_data.json when it does not exist yet."""
- lw = _import_log_watcher()
- data_file = tmp_path / "trigger_data.json"
- lw.TRIGGER_DATA_FILE = data_file
- lw._seen_error_hashes = {"hash1", "hash2"}
- lw._save_seen_hashes()
- written = json.loads(data_file.read_text(encoding="utf-8"))
- assert set(written["seen_error_hashes"]) == {"hash1", "hash2"}
-
- def test_merges_with_existing_data(self, tmp_path):
- """Preserves other keys already in trigger_data.json."""
- lw = _import_log_watcher()
- data_file = tmp_path / "trigger_data.json"
- data_file.write_text(
- json.dumps({"log_positions": {"/a.log": 100}}),
- encoding="utf-8",
- )
- lw.TRIGGER_DATA_FILE = data_file
- lw._seen_error_hashes = {"x"}
- lw._save_seen_hashes()
- written = json.loads(data_file.read_text(encoding="utf-8"))
- assert written["log_positions"] == {"/a.log": 100}
- assert written["seen_error_hashes"] == ["x"]
-
- def test_handles_write_error(self, tmp_path):
- """Write failure logs warning but does not raise."""
- lw = _import_log_watcher()
- bad_path = tmp_path / "nope" / "nope" / "trigger_data.json"
- lw.TRIGGER_DATA_FILE = bad_path
- lw._seen_error_hashes = {"z"}
- with patch.object(lw, "atomic_write_json", side_effect=PermissionError("denied")):
- lw._save_seen_hashes()
-
-
# ---------------------------------------------------------------------------
# Tests -- _load_log_positions
# ---------------------------------------------------------------------------
@@ -769,42 +1240,147 @@ def test_coerces_values_to_int(self, tmp_path):
# ---------------------------------------------------------------------------
-# Tests -- _save_log_positions
+# Tests -- log_inodes persistence (parallel key, backward compatible)
# ---------------------------------------------------------------------------
-class TestSaveLogPositions:
- """Tests for _save_log_positions persistence."""
+class TestLogInodesPersistence:
+ """Tests for the 'log_inodes' key alongside 'log_positions'."""
+
+ def test_flush_and_load_round_trip(self, tmp_path):
+ """Positions and inodes round-trip through trigger_data.json."""
+ lw = _import_log_watcher()
+ data_file = tmp_path / "trigger_data.json"
+ lw.TRIGGER_DATA_FILE = data_file
+ lw._active_watcher = MagicMock()
+ lw._active_watcher.log_positions = {"/a.log": 50}
+ lw._active_watcher.log_inodes = {"/a.log": 4242}
+ lw._seen_error_hashes = set()
+
+ lw._flush_trigger_data(force=True)
+
+ assert lw._load_log_positions() == {"/a.log": 50}
+ assert lw._load_log_inodes() == {"/a.log": 4242}
- def test_saves_positions_to_new_file(self, tmp_path):
- """Creates file with log_positions key."""
+ def test_position_shape_unchanged(self, tmp_path):
+ """log_positions stays a plain Dict[str, int] - inodes live elsewhere."""
lw = _import_log_watcher()
data_file = tmp_path / "trigger_data.json"
lw.TRIGGER_DATA_FILE = data_file
- lw._save_log_positions({"/a.log": 50})
+ lw._active_watcher = MagicMock()
+ lw._active_watcher.log_positions = {"/a.log": 50}
+ lw._active_watcher.log_inodes = {"/a.log": 4242}
+ lw._seen_error_hashes = set()
+
+ lw._flush_trigger_data(force=True)
+
written = json.loads(data_file.read_text(encoding="utf-8"))
assert written["log_positions"] == {"/a.log": 50}
+ assert written["log_inodes"] == {"/a.log": 4242}
- def test_merges_with_existing_data(self, tmp_path):
- """Preserves other keys in trigger_data.json."""
+ def test_old_format_state_loads_without_error(self, tmp_path):
+ """State written before this change (no log_inodes) still loads."""
lw = _import_log_watcher()
data_file = tmp_path / "trigger_data.json"
data_file.write_text(
- json.dumps({"seen_error_hashes": ["abc"]}),
+ json.dumps({"log_positions": {"/a.log": 12}, "seen_error_hashes": ["abc"]}),
encoding="utf-8",
)
lw.TRIGGER_DATA_FILE = data_file
- lw._save_log_positions({"/b.log": 77})
+
+ assert lw._load_log_positions() == {"/a.log": 12}
+ assert lw._load_log_inodes() == {}
+
+ def test_flush_without_watcher_leaves_position_state_untouched(self, tmp_path):
+ """A flush with no active watcher does not wipe on-disk position state.
+
+ With no watcher there is no in-memory position state to write, so
+ log_positions/log_inodes already on disk must survive the hash-only
+ flush rather than being clobbered with empty dicts.
+ """
+ lw = _import_log_watcher()
+ data_file = tmp_path / "trigger_data.json"
+ data_file.write_text(
+ json.dumps({"log_positions": {"/a.log": 50}, "log_inodes": {"/a.log": 7}}),
+ encoding="utf-8",
+ )
+ lw.TRIGGER_DATA_FILE = data_file
+ lw._active_watcher = None
+ lw._seen_error_hashes = {"h1"}
+
+ lw._flush_trigger_data(force=True)
+
written = json.loads(data_file.read_text(encoding="utf-8"))
- assert written["seen_error_hashes"] == ["abc"]
- assert written["log_positions"] == {"/b.log": 77}
+ assert written["log_positions"] == {"/a.log": 50}
+ assert written["log_inodes"] == {"/a.log": 7}
+ assert written["seen_error_hashes"] == ["h1"]
- def test_handles_write_error(self, tmp_path):
- """Write failure logs warning but does not raise."""
+ def test_load_returns_empty_for_missing_file(self, tmp_path):
+ """Missing trigger_data.json yields an empty inode map."""
lw = _import_log_watcher()
- lw.TRIGGER_DATA_FILE = tmp_path / "trigger_data.json"
- with patch.object(lw, "atomic_write_json", side_effect=OSError("disk full")):
- lw._save_log_positions({"/c.log": 10})
+ lw.TRIGGER_DATA_FILE = tmp_path / "nope.json"
+ assert lw._load_log_inodes() == {}
+
+ def test_load_returns_empty_when_not_dict(self, tmp_path):
+ """Non-dict log_inodes value is ignored."""
+ lw = _import_log_watcher()
+ data_file = tmp_path / "trigger_data.json"
+ data_file.write_text(json.dumps({"log_inodes": "nope"}), encoding="utf-8")
+ lw.TRIGGER_DATA_FILE = data_file
+ assert lw._load_log_inodes() == {}
+
+ def test_flush_persists_inodes(self, tmp_path):
+ """_flush_trigger_data writes the watcher's inodes to disk."""
+ lw = _import_log_watcher()
+ data_file = tmp_path / "trigger_data.json"
+ lw.TRIGGER_DATA_FILE = data_file
+ lw._data_dirty = True
+ lw._active_watcher = MagicMock()
+ lw._active_watcher.log_positions = {"/x.log": 42}
+ lw._active_watcher.log_inodes = {"/x.log": 909}
+ lw._seen_error_hashes = set()
+
+ lw._flush_trigger_data(force=True)
+
+ assert lw._load_log_inodes() == {"/x.log": 909}
+
+ def test_initialize_positions_records_inodes(self, tmp_path):
+ """initialize_positions records an inode for every tracked file."""
+ lw = _import_log_watcher()
+ lw.AIPASS_PKG_ROOT = tmp_path / "aipass"
+ lw.SYSTEM_LOGS_DIR = tmp_path / "system_logs"
+ lw._load_log_positions = MagicMock(return_value={})
+ branch_logs = tmp_path / "aipass" / "flow" / "logs"
+ branch_logs.mkdir(parents=True)
+ log_file = branch_logs / "core.log"
+ log_file.write_text("line1\nline2\n")
+
+ watcher = lw.BranchLogWatcher()
+ watcher.initialize_positions()
+
+ assert watcher.log_inodes[str(log_file)] == log_file.stat().st_ino
+
+ def test_initialize_positions_with_old_state(self, tmp_path):
+ """Old-format persisted state (no log_inodes) initializes cleanly."""
+ lw = _import_log_watcher()
+ data_file = tmp_path / "trigger_data.json"
+ lw.TRIGGER_DATA_FILE = data_file
+ lw.AIPASS_PKG_ROOT = tmp_path / "aipass"
+ lw.SYSTEM_LOGS_DIR = tmp_path / "system_logs"
+ branch_logs = tmp_path / "aipass" / "flow" / "logs"
+ branch_logs.mkdir(parents=True)
+ log_file = branch_logs / "core.log"
+ log_file.write_text("line1\nline2\n")
+ data_file.write_text(
+ json.dumps({"log_positions": {str(log_file): 6}}),
+ encoding="utf-8",
+ )
+
+ watcher = lw.BranchLogWatcher()
+ watcher.initialize_positions()
+
+ assert watcher.log_positions[str(log_file)] == 6
+ assert watcher.log_inodes[str(log_file)] == log_file.stat().st_ino
# ---------------------------------------------------------------------------
@@ -824,6 +1400,7 @@ def test_rapid_events_coalesce_into_one_write(self, tmp_path):
lw._data_dirty = False
lw._active_watcher = MagicMock()
lw._active_watcher.log_positions = {"/a.log": 100}
+ lw._active_watcher.log_inodes = {"/a.log": 111}
write_count = 0
real_write = lw.atomic_write_json
@@ -848,6 +1425,7 @@ def test_flush_writes_both_positions_and_hashes(self, tmp_path):
lw._data_dirty = True
lw._active_watcher = MagicMock()
lw._active_watcher.log_positions = {"/x.log": 42}
+ lw._active_watcher.log_inodes = {"/x.log": 4242}
lw._seen_error_hashes = {"hash1", "hash2"}
lw._flush_trigger_data(force=True)
@@ -856,6 +1434,45 @@ def test_flush_writes_both_positions_and_hashes(self, tmp_path):
assert data["log_positions"] == {"/x.log": 42}
assert set(data["seen_error_hashes"]) == {"hash1", "hash2"}
+ def test_flush_merges_with_existing_data(self, tmp_path):
+ """Unrelated keys already in trigger_data.json survive a flush."""
+ lw = _import_log_watcher()
+ data_file = tmp_path / "trigger_data.json"
+ data_file.write_text(
+ json.dumps({"unrelated_key": {"keep": "me"}}),
+ encoding="utf-8",
+ )
+ lw.TRIGGER_DATA_FILE = data_file
+ lw._data_dirty = True
+ lw._active_watcher = MagicMock()
+ lw._active_watcher.log_positions = {"/b.log": 77}
+ lw._active_watcher.log_inodes = {"/b.log": 7777}
+ lw._seen_error_hashes = {"x"}
+
+ lw._flush_trigger_data(force=True)
+
+ written = json.loads(data_file.read_text(encoding="utf-8"))
+ assert written["unrelated_key"] == {"keep": "me"}
+ assert written["log_positions"] == {"/b.log": 77}
+ assert written["seen_error_hashes"] == ["x"]
+
+ def test_flush_handles_write_error(self, tmp_path):
+ """Write failure logs a warning but does not raise."""
+ lw = _import_log_watcher()
+ lw.TRIGGER_DATA_FILE = tmp_path / "trigger_data.json"
+ lw._data_dirty = True
+ lw._active_watcher = MagicMock()
+ lw._active_watcher.log_positions = {"/c.log": 10}
+ lw._active_watcher.log_inodes = {"/c.log": 1010}
+ lw._seen_error_hashes = {"z"}
+
+ with patch.object(lw, "logger") as mock_logger:
+ with patch.object(lw, "atomic_write_json", side_effect=PermissionError("denied")):
+ lw._flush_trigger_data(force=True)
+
+ warnings = [str(call.args) for call in mock_logger.warning.call_args_list]
+ assert any("Failed to flush trigger_data.json" in warning for warning in warnings)
+
def test_restart_survival(self, tmp_path):
"""Flushed data survives reload — positions and hashes intact."""
lw = _import_log_watcher()
@@ -863,6 +1480,7 @@ def test_restart_survival(self, tmp_path):
lw.TRIGGER_DATA_FILE = data_file
lw._active_watcher = MagicMock()
lw._active_watcher.log_positions = {"/srv.log": 999}
+ lw._active_watcher.log_inodes = {"/srv.log": 777}
lw._seen_error_hashes = {"abc", "def"}
lw._data_dirty = True
@@ -882,6 +1500,7 @@ def test_force_flush_writes_even_when_not_dirty(self, tmp_path):
lw._data_dirty = False
lw._active_watcher = MagicMock()
lw._active_watcher.log_positions = {"/f.log": 10}
+ lw._active_watcher.log_inodes = {"/f.log": 1010}
lw._seen_error_hashes = set()
lw._flush_trigger_data(force=True)
diff --git a/src/aipass/trigger/tests/test_runaway_handler.py b/src/aipass/trigger/tests/test_runaway_handler.py
index 2bfffdf1..3b3357dd 100644
--- a/src/aipass/trigger/tests/test_runaway_handler.py
+++ b/src/aipass/trigger/tests/test_runaway_handler.py
@@ -84,8 +84,9 @@ def test_second_call_within_cooldown_suppressed(self) -> None:
mod.handle_runaway_log_detected(
file_path="/var/log/test.log",
branch="flow",
- rate_lines_per_min=500,
+ rate_lines_per_min=5000,
sustained_duration_sec=60,
+ severity="critical",
)
assert send.call_count == 1
@@ -93,8 +94,9 @@ def test_second_call_within_cooldown_suppressed(self) -> None:
mod.handle_runaway_log_detected(
file_path="/var/log/test.log",
branch="flow",
- rate_lines_per_min=500,
+ rate_lines_per_min=5000,
sustained_duration_sec=120,
+ severity="critical",
)
assert send.call_count == 1
@@ -116,8 +118,9 @@ def test_dispatches_again_after_cooldown_expires(self, mock_time: MagicMock) ->
mod.handle_runaway_log_detected(
file_path="/var/log/test.log",
branch="flow",
- rate_lines_per_min=500,
+ rate_lines_per_min=5000,
sustained_duration_sec=60,
+ severity="critical",
)
assert send.call_count == 1
@@ -126,8 +129,9 @@ def test_dispatches_again_after_cooldown_expires(self, mock_time: MagicMock) ->
mod.handle_runaway_log_detected(
file_path="/var/log/test.log",
branch="flow",
- rate_lines_per_min=500,
+ rate_lines_per_min=5000,
sustained_duration_sec=120,
+ severity="critical",
)
assert send.call_count == 2
@@ -162,8 +166,9 @@ def test_content_muted_branch_still_delivers(self, tmp_path: Path) -> None:
mod.handle_runaway_log_detected(
file_path="/var/log/test.log",
branch="flow",
- rate_lines_per_min=500,
+ rate_lines_per_min=5000,
sustained_duration_sec=60,
+ severity="critical",
)
send.assert_called_once()
assert send.call_args.kwargs["to_branch"] == "@flow"
@@ -192,8 +197,9 @@ def test_expired_volume_mute_delivers(self, tmp_path: Path) -> None:
mod.handle_runaway_log_detected(
file_path="/var/log/test.log",
branch="flow",
- rate_lines_per_min=500,
+ rate_lines_per_min=5000,
sustained_duration_sec=60,
+ severity="critical",
)
send.assert_called_once()
@@ -229,7 +235,12 @@ def test_critical_bypass_is_case_insensitive(self, tmp_path: Path) -> None:
send.assert_called_once()
def test_warning_still_respects_volume_mute(self, tmp_path: Path) -> None:
- """Non-critical runaways still honour an explicit volume mute."""
+ """Non-critical runaways still honour an explicit volume mute.
+
+ Observe-only made the no-email half of this trivially true, so the
+ assertion that carries weight now is that a volume-muted WARNING is
+ dropped entirely — it does not even reach the observe-only record.
+ """
send = _setup_happy_path()
_write_config(tmp_path, {"volume_muted_branches": ["flow"]})
@@ -242,6 +253,7 @@ def test_warning_still_respects_volume_mute(self, tmp_path: Path) -> None:
severity="warning",
)
send.assert_not_called()
+ assert not (tmp_path / "alerts.json").exists()
# ---------------------------------------------------------------------------
@@ -259,8 +271,9 @@ def test_unknown_branch_dispatches_to_prax(self) -> None:
mod.handle_runaway_log_detected(
file_path="/var/log/test.log",
branch="UNKNOWN",
- rate_lines_per_min=500,
+ rate_lines_per_min=5000,
sustained_duration_sec=60,
+ severity="critical",
)
send.assert_called_once()
assert send.call_args[1]["to_branch"] == "@prax"
@@ -281,8 +294,9 @@ def test_none_branch_dispatches_to_prax(self) -> None:
mod.handle_runaway_log_detected(
file_path="/var/log/test.log",
branch=None,
- rate_lines_per_min=500,
+ rate_lines_per_min=5000,
sustained_duration_sec=60,
+ severity="critical",
)
send.assert_called_once()
assert send.call_args[1]["to_branch"] == "@prax"
@@ -294,7 +308,11 @@ def test_none_branch_dispatches_to_prax(self) -> None:
class TestNoEmailCallback:
- """Handler logs warning and returns when _send_email is None."""
+ """Handler logs warning and returns when _send_email is None.
+
+ Only the CRITICAL path needs a callback — observe-only WARNINGs never
+ reach this guard (see TestObserveOnlyWarning).
+ """
def test_logs_warning_no_dispatch(self) -> None:
"""Logs warning via _append_jsonl when no callback set."""
@@ -302,8 +320,9 @@ def test_logs_warning_no_dispatch(self) -> None:
mod.handle_runaway_log_detected(
file_path="/var/log/test.log",
branch="flow",
- rate_lines_per_min=500,
+ rate_lines_per_min=5000,
sustained_duration_sec=60,
+ severity="critical",
)
calls = mod._append_jsonl.call_args_list # type: ignore[union-attr]
@@ -485,8 +504,9 @@ def test_returns_early_no_alert_no_cooldown(self, tmp_path: Path) -> None:
mod.handle_runaway_log_detected(
file_path=file_path,
branch="flow",
- rate_lines_per_min=500,
+ rate_lines_per_min=5000,
sustained_duration_sec=60,
+ severity="critical",
)
send.assert_called_once()
@@ -611,3 +631,316 @@ def test_overwrites_previous_callback(self) -> None:
mod.set_send_email_callback(first)
mod.set_send_email_callback(second)
assert mod._send_email is second
+
+
+# ---------------------------------------------------------------------------
+# 14. Observe-only WARNING — records with full fidelity, wakes nobody
+# ---------------------------------------------------------------------------
+
+
+def _wake_mock() -> MagicMock:
+ """Return the mocked wake_branch installed by the autouse fixture."""
+ from aipass.ai_mail.apps.handlers.dispatch.wake import wake_branch
+
+ return wake_branch # type: ignore[return-value]
+
+
+def _read_alerts(tmp_path: Path) -> list:
+ """Read the alert entries written to the redirected alerts.json."""
+ alerts_file = tmp_path / "alerts.json"
+ if not alerts_file.exists():
+ return []
+ return json.loads(alerts_file.read_text(encoding="utf-8")).get("alerts", [])
+
+
+class TestObserveOnlyWarning:
+ """WARNING tier is observe-only: full record, no email, no wake.
+
+ The 100 lines/min threshold predates routine multi-agent fleets and fires
+ on healthy chatty logs, so a WARNING must never pull an agent out of sleep.
+ """
+
+ def test_warning_never_emails_and_never_wakes(self) -> None:
+ """A WARNING sends no email and wakes no branch."""
+ send = _setup_happy_path()
+
+ mod.handle_runaway_log_detected(
+ file_path="/var/log/test.log",
+ branch="flow",
+ rate_lines_per_min=150,
+ sustained_duration_sec=720,
+ severity="warning",
+ )
+
+ send.assert_not_called()
+ _wake_mock().assert_not_called()
+
+ def test_warning_default_severity_never_wakes(self) -> None:
+ """Severity defaults to 'warning' — the default path wakes nobody either."""
+ send = _setup_happy_path()
+
+ mod.handle_runaway_log_detected(
+ file_path="/var/log/test.log",
+ branch="flow",
+ rate_lines_per_min=150,
+ sustained_duration_sec=720,
+ )
+
+ send.assert_not_called()
+ _wake_mock().assert_not_called()
+
+ def test_warning_writes_alert_with_full_fidelity(self, tmp_path: Path) -> None:
+ """The durable record keeps file, severity, branch, rate and duration.
+
+ Asserts the no-wake half too: writing the alert alone is what the old
+ dispatch path did as well, so only the pair pins observe-only.
+ """
+ send = _setup_happy_path()
+
+ mod.handle_runaway_log_detected(
+ file_path="/var/log/chatty.log",
+ branch="flow",
+ rate_lines_per_min=150,
+ sustained_duration_sec=720,
+ severity="warning",
+ )
+
+ alerts = _read_alerts(tmp_path)
+ assert len(alerts) == 1
+ alert = alerts[0]
+ assert alert["severity"] == "warning"
+ assert alert["source"] == "prax"
+ assert "chatty.log" in alert["title"]
+ assert "/var/log/chatty.log" in alert["body"]
+ assert "150 lines/min" in alert["body"]
+ assert "720s" in alert["body"]
+ assert "flow" in alert["body"]
+
+ send.assert_not_called()
+ _wake_mock().assert_not_called()
+
+ def test_warning_writes_observed_decision_entry(self) -> None:
+ """Decision trail records outcome='observed', reason='observe_only'."""
+ _setup_happy_path()
+
+ mod.handle_runaway_log_detected(
+ file_path="/var/log/test.log",
+ branch="flow",
+ rate_lines_per_min=150,
+ sustained_duration_sec=720,
+ severity="warning",
+ )
+
+ entries = _decision_entries("observe_only")
+ assert len(entries) == 1
+ assert entries[0]["outcome"] == "observed"
+ assert entries[0]["branch"] == "flow"
+ assert entries[0]["file"] == "/var/log/test.log"
+ # Not a suppression (we recorded it) and not a delivery (nobody was told)
+ assert entries[0]["outcome"] not in {"suppressed", "delivered"}
+
+ def test_warning_records_file_cooldown(self, tmp_path: Path) -> None:
+ """Observe-only still books the cooldown — the record must not flood itself."""
+ _setup_happy_path()
+ file_path = "/var/log/test.log"
+
+ mod.handle_runaway_log_detected(
+ file_path=file_path,
+ branch="flow",
+ rate_lines_per_min=150,
+ sustained_duration_sec=720,
+ severity="warning",
+ )
+ assert file_path in mod._file_cooldowns
+ assert len(_read_alerts(tmp_path)) == 1
+
+ # Second detection interval for the same file is gated by the cooldown
+ mod.handle_runaway_log_detected(
+ file_path=file_path,
+ branch="flow",
+ rate_lines_per_min=150,
+ sustained_duration_sec=780,
+ severity="warning",
+ )
+ assert len(_read_alerts(tmp_path)) == 1
+ assert len(_decision_entries("cooldown")) == 1
+ assert len(_decision_entries("observe_only")) == 1
+
+ def test_warning_records_fully_without_email_callback(self, tmp_path: Path) -> None:
+ """REGRESSION: no email callback must not silence the observe-only record.
+
+ The callback guard predates the split and used to return before any
+ record was written; a WARNING no longer sends, so it must not care.
+ """
+ # _send_email stays None (no set_send_email_callback call)
+ file_path = "/var/log/test.log"
+
+ mod.handle_runaway_log_detected(
+ file_path=file_path,
+ branch="flow",
+ rate_lines_per_min=150,
+ sustained_duration_sec=720,
+ severity="warning",
+ )
+
+ assert len(_read_alerts(tmp_path)) == 1
+ assert len(_decision_entries("observe_only")) == 1
+ assert file_path in mod._file_cooldowns
+ _wake_mock().assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# 15. CRITICAL tier — NO-OVERREACH GUARDS (pass before and after the split)
+#
+# These deliberately pass against both the old and new handler: their whole
+# job is to prove the observe-only split did not touch the CRITICAL path.
+# ---------------------------------------------------------------------------
+
+
+class TestCriticalUnchanged:
+ """NO-OVERREACH: CRITICAL keeps email + wake exactly as before the split."""
+
+ def test_critical_emails_and_wakes(self, tmp_path: Path) -> None:
+ """NO-OVERREACH: CRITICAL sends the email and wakes the branch."""
+ send = _setup_happy_path()
+
+ mod.handle_runaway_log_detected(
+ file_path="/var/log/test.log",
+ branch="flow",
+ rate_lines_per_min=5000,
+ sustained_duration_sec=60,
+ severity="critical",
+ )
+
+ send.assert_called_once()
+ assert send.call_args.kwargs["to_branch"] == "@flow"
+ _wake_mock().assert_called_once_with("@flow", fresh=False, sender="@trigger")
+ assert len(_read_alerts(tmp_path)) == 1
+ assert "/var/log/test.log" in mod._file_cooldowns
+
+ def test_critical_bypasses_volume_mute_and_still_wakes(self, tmp_path: Path) -> None:
+ """NO-OVERREACH: a volume mute still does not stop a CRITICAL wake."""
+ send = _setup_happy_path()
+ _write_config(tmp_path, {"volume_muted_branches": ["flow"]})
+
+ mod.handle_runaway_log_detected(
+ file_path="/var/log/test.log",
+ branch="flow",
+ rate_lines_per_min=5000,
+ sustained_duration_sec=60,
+ severity="critical",
+ )
+
+ send.assert_called_once()
+ _wake_mock().assert_called_once_with("@flow", fresh=False, sender="@trigger")
+ assert _decision_entries("bypass_critical")[0]["outcome"] == "delivered"
+
+ def test_critical_send_failure_logs_and_records_nothing(self, tmp_path: Path) -> None:
+ """NO-OVERREACH: sent=False still logs, returns, and records no dispatch."""
+ send = MagicMock(return_value=False)
+ mod.set_send_email_callback(send)
+ file_path = "/var/log/test.log"
+
+ mod.handle_runaway_log_detected(
+ file_path=file_path,
+ branch="flow",
+ rate_lines_per_min=5000,
+ sustained_duration_sec=60,
+ severity="critical",
+ )
+
+ send.assert_called_once()
+ _wake_mock().assert_not_called()
+ assert not _read_alerts(tmp_path)
+ assert file_path not in mod._file_cooldowns
+ assert not _decision_entries("observe_only")
+
+ calls = mod._append_jsonl.call_args_list # type: ignore[union-attr]
+ warnings = [c[0][1] for c in calls if isinstance(c[0][1], dict) and c[0][1].get("level") == "WARNING"]
+ assert any("Email delivery failed" in w["msg"] for w in warnings)
+
+ def test_critical_unknown_branch_still_wakes_prax(self) -> None:
+ """NO-OVERREACH: the @prax fallback recipient still gets woken."""
+ send = _setup_happy_path()
+
+ mod.handle_runaway_log_detected(
+ file_path="/var/log/test.log",
+ branch="UNKNOWN",
+ rate_lines_per_min=5000,
+ sustained_duration_sec=60,
+ severity="critical",
+ )
+
+ assert send.call_args.kwargs["to_branch"] == "@prax"
+ _wake_mock().assert_called_once_with("@prax", fresh=False, sender="@trigger")
+
+
+# ---------------------------------------------------------------------------
+# 16. Operation log naming — never log a dispatch that did not happen
+# ---------------------------------------------------------------------------
+
+
+class TestOperationLogNaming:
+ """The two tiers log distinct operation names."""
+
+ def _operations(self, log_mock: MagicMock) -> list[str]:
+ """Extract operation names from a patched log_operation mock."""
+ return [c[0][0] for c in log_mock.call_args_list]
+
+ def test_warning_logs_runaway_observed(self) -> None:
+ """A WARNING logs 'runaway_observed' — never a dispatch that did not happen."""
+ _setup_happy_path()
+
+ with patch.object(mod.json_handler, "log_operation") as log_op:
+ mod.handle_runaway_log_detected(
+ file_path="/var/log/test.log",
+ branch="flow",
+ rate_lines_per_min=150,
+ sustained_duration_sec=720,
+ severity="warning",
+ )
+
+ ops = self._operations(log_op)
+ assert ops == ["runaway_observed"]
+ assert "runaway_dispatch_sent" not in ops
+
+ def test_critical_logs_runaway_dispatch_sent(self) -> None:
+ """NO-OVERREACH: a CRITICAL still logs 'runaway_dispatch_sent'."""
+ _setup_happy_path()
+
+ with patch.object(mod.json_handler, "log_operation") as log_op:
+ mod.handle_runaway_log_detected(
+ file_path="/var/log/test.log",
+ branch="flow",
+ rate_lines_per_min=5000,
+ sustained_duration_sec=60,
+ severity="critical",
+ )
+
+ ops = self._operations(log_op)
+ assert ops == ["runaway_dispatch_sent"]
+ assert "runaway_observed" not in ops
+
+ def test_tier_operation_names_differ(self) -> None:
+ """The same file across both tiers produces two different operation names."""
+ _setup_happy_path()
+
+ with patch.object(mod.json_handler, "log_operation") as log_op:
+ mod.handle_runaway_log_detected(
+ file_path="/var/log/warn.log",
+ branch="flow",
+ rate_lines_per_min=150,
+ sustained_duration_sec=720,
+ severity="warning",
+ )
+ mod.handle_runaway_log_detected(
+ file_path="/var/log/crit.log",
+ branch="flow",
+ rate_lines_per_min=5000,
+ sustained_duration_sec=60,
+ severity="critical",
+ )
+
+ ops = self._operations(log_op)
+ assert len(set(ops)) == 2
+ assert ops == ["runaway_observed", "runaway_dispatch_sent"]
diff --git a/src/aipass/trigger/tests/test_watchers_log_watcher.py b/src/aipass/trigger/tests/test_watchers_log_watcher.py
index 50adbde6..dc679e05 100644
--- a/src/aipass/trigger/tests/test_watchers_log_watcher.py
+++ b/src/aipass/trigger/tests/test_watchers_log_watcher.py
@@ -3,9 +3,9 @@
# =================== META ====================
# Name: test_watchers_log_watcher.py
# Description: Unit tests for centralized system_logs log watcher
-# Version: 1.0.0
+# Version: 1.2.0
# Created: 2026-04-03
-# Modified: 2026-04-03
+# Modified: 2026-08-04
# =============================================
import sys
@@ -320,6 +320,517 @@ def test_no_change_no_read(self, tmp_path):
mock_proc.assert_not_called()
+# ---------------------------------------------------------------------------
+# Tests -- LogFileWatcher rotation tail drain
+# ---------------------------------------------------------------------------
+
+
+def _rotate_log(log_file: Path) -> Path:
+ """
+ Perform a real RotatingFileHandler-style rotation.
+
+ Renames the live log to '.log.1' (inode travels with the rename)
+ and creates a fresh empty '.log'.
+
+ Args:
+ log_file: Path to the live log file
+
+ Returns:
+ Path to the rotated-out backup file
+ """
+ backup = Path(f"{log_file}.1")
+ if backup.exists():
+ backup.unlink()
+ log_file.rename(backup)
+ log_file.write_text("", encoding="utf-8")
+ return backup
+
+
+def _rotate_chain(log_file: Path, backup_count: int = 3) -> Path:
+ """
+ Perform a real RotatingFileHandler rotation including backup shifting.
+
+ Shifts '.log.N' -> '.log.N+1' (dropping the oldest), renames
+ the live log to '.log.1', then creates a fresh empty '.log'.
+ Inodes travel with the renames exactly as they do in production.
+
+ Args:
+ log_file: Path to the live log file
+ backup_count: Number of backups kept (prax uses 3)
+
+ Returns:
+ Path to the newest backup file ('.log.1')
+ """
+ for index in range(backup_count - 1, 0, -1):
+ source = Path(f"{log_file}.{index}")
+ target = Path(f"{log_file}.{index + 1}")
+ if source.exists():
+ if target.exists():
+ target.unlink()
+ source.rename(target)
+ backup = Path(f"{log_file}.1")
+ if backup.exists():
+ backup.unlink()
+ log_file.rename(backup)
+ log_file.write_text("", encoding="utf-8")
+ return backup
+
+
+def _processed_lines(mock_proc) -> list:
+ """Extract the log-line argument from every _process_log_line call."""
+ return [call.args[1] for call in mock_proc.call_args_list]
+
+
+class TestLogFileWatcherRotationDrain:
+ """Tests for draining the unread tail of a rotated-out log file."""
+
+ def test_rotation_drains_unread_tail(self, tmp_path):
+ """Lines written between last position and rotation ARE processed."""
+ wlw = _import_watchers_lw()
+ watcher = wlw.LogFileWatcher()
+
+ log_file = tmp_path / "prax_core.log"
+ # Bulk of the file is already processed (mirrors a log near its size cap)
+ log_file.write_text("2026-08-04 | mod | ERROR | already seen\n" * 20, encoding="utf-8")
+ file_path = str(log_file)
+ watcher._record_position(file_path, log_file.stat().st_size)
+
+ # Unread tail: written after last position, before rotation
+ with open(log_file, "a", encoding="utf-8") as f:
+ f.write("2026-08-04 | mod | ERROR | missed one\n")
+ f.write("2026-08-04 | mod | ERROR | missed two\n")
+
+ _rotate_log(log_file)
+ log_file.write_text("2026-08-04 | mod | ERROR | after rotation\n", encoding="utf-8")
+
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert any("missed one" in line for line in lines)
+ assert any("missed two" in line for line in lines)
+ assert any("after rotation" in line for line in lines)
+ assert not any("already seen" in line for line in lines)
+
+ def test_drain_uses_branch_and_noise_filter(self, tmp_path):
+ """Drained lines get the same branch arg and _should_skip_log filter."""
+ wlw = _import_watchers_lw()
+ watcher = wlw.LogFileWatcher()
+
+ log_file = tmp_path / "prax_core.log"
+ log_file.write_text("header\n", encoding="utf-8")
+ file_path = str(log_file)
+ watcher._record_position(file_path, log_file.stat().st_size)
+
+ with open(log_file, "a", encoding="utf-8") as f:
+ f.write("2026-08-04 | mod | INFO | Module initialized\n")
+ f.write("2026-08-04 | mod | ERROR | real tail failure\n")
+
+ _rotate_log(log_file)
+
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert any("real tail failure" in line for line in lines)
+ assert not any("Module initialized" in line for line in lines)
+ assert all(call.args[0] == "PRAX" for call in mock_proc.call_args_list)
+ assert all(call.args[2] == file_path for call in mock_proc.call_args_list)
+
+ def test_stale_backup_inode_mismatch_not_reprocessed(self, tmp_path):
+ """A stale '.log.1' from an earlier rotation is never re-read."""
+ wlw = _import_watchers_lw()
+ watcher = wlw.LogFileWatcher()
+
+ log_file = tmp_path / "prax_core.log"
+ log_file.write_text("old rotation content\n2026-08-04 | mod | ERROR | ancient\n", encoding="utf-8")
+ # Earlier rotation happened before we started tracking the live file
+ _rotate_log(log_file)
+
+ # Now the live file is a DIFFERENT inode with its own content
+ log_file.write_text("2026-08-04 | mod | ERROR | live line one\n", encoding="utf-8")
+ file_path = str(log_file)
+ watcher._record_position(file_path, log_file.stat().st_size)
+
+ # Live file is truncated (shrinks) but the stale backup still sits there
+ log_file.write_text("2026-08-04 | mod | ERROR | fresh\n", encoding="utf-8")
+ watcher.log_positions[file_path] = 9999
+
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert not any("ancient" in line for line in lines)
+ assert any("fresh" in line for line in lines)
+
+ def test_missing_backup_file_still_reads_new_file(self, tmp_path):
+ """No '.log.1' present: no crash, the new file is still read."""
+ wlw = _import_watchers_lw()
+ watcher = wlw.LogFileWatcher()
+
+ log_file = tmp_path / "prax_core.log"
+ log_file.write_text("2026-08-04 | mod | ERROR | plenty of old content here\n", encoding="utf-8")
+ file_path = str(log_file)
+ watcher._record_position(file_path, log_file.stat().st_size)
+
+ # Shrink with no backup file at all
+ log_file.write_text("2026-08-04 | mod | ERROR | tiny\n", encoding="utf-8")
+
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert lines == ["2026-08-04 | mod | ERROR | tiny"]
+ assert watcher.log_positions[file_path] == log_file.stat().st_size
+
+ def test_in_place_truncation_no_duplicate_processing(self, tmp_path):
+ """Truncation in place (same inode) never re-fires the old content."""
+ wlw = _import_watchers_lw()
+ watcher = wlw.LogFileWatcher()
+
+ log_file = tmp_path / "prax_core.log"
+ file_path = str(log_file)
+ log_file.write_text("2026-08-04 | mod | ERROR | first pass line\n", encoding="utf-8")
+
+ # A stale backup exists holding a copy of the same text, different inode
+ backup = Path(f"{file_path}.1")
+ backup.write_text("2026-08-04 | mod | ERROR | first pass line\n", encoding="utf-8")
+
+ watcher._record_position(file_path, log_file.stat().st_size)
+
+ # Truncate in place - inode is unchanged
+ inode_before = log_file.stat().st_ino
+ with open(log_file, "w", encoding="utf-8") as f:
+ f.write("2026-08-04 | mod | ERROR | short\n")
+ assert log_file.stat().st_ino == inode_before
+
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert lines == ["2026-08-04 | mod | ERROR | short"]
+
+ def test_normal_append_never_drains(self, tmp_path):
+ """No-overreach guard: an ordinary append never touches the drain path.
+
+ This test passes with and without the fix - it exists to prove the
+ drain does not run on the normal (non-shrink) path.
+ """
+ wlw = _import_watchers_lw()
+ watcher = wlw.LogFileWatcher()
+
+ log_file = tmp_path / "prax_core.log"
+ log_file.write_text("2026-08-04 | mod | ERROR | first\n", encoding="utf-8")
+ file_path = str(log_file)
+ watcher.log_positions[file_path] = log_file.stat().st_size
+
+ # A backup with the SAME content exists but must be ignored
+ backup = Path(f"{file_path}.1")
+ backup.write_text("2026-08-04 | mod | ERROR | backup only\n", encoding="utf-8")
+
+ with open(log_file, "a", encoding="utf-8") as f:
+ f.write("2026-08-04 | mod | ERROR | appended\n")
+
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert lines == ["2026-08-04 | mod | ERROR | appended"]
+
+ def test_repeat_shrink_event_does_not_drain_twice(self, tmp_path):
+ """Two events after one rotation drain the tail exactly once."""
+ wlw = _import_watchers_lw()
+ watcher = wlw.LogFileWatcher()
+
+ log_file = tmp_path / "prax_core.log"
+ log_file.write_text("header\n", encoding="utf-8")
+ file_path = str(log_file)
+ watcher._record_position(file_path, log_file.stat().st_size)
+
+ with open(log_file, "a", encoding="utf-8") as f:
+ f.write("2026-08-04 | mod | ERROR | tail line\n")
+
+ # Rotate, leaving the new live file EMPTY (nothing written yet)
+ _rotate_log(log_file)
+
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert lines.count("2026-08-04 | mod | ERROR | tail line") == 1
+
+ def test_unknown_inode_skips_drain(self, tmp_path):
+ """Position recorded without an inode (old state) disables the drain.
+
+ No-overreach guard: passes with and without the fix - it proves an
+ unknown inode degrades to exactly the pre-fix behaviour.
+ """
+ wlw = _import_watchers_lw()
+ watcher = wlw.LogFileWatcher()
+
+ log_file = tmp_path / "prax_core.log"
+ log_file.write_text("2026-08-04 | mod | ERROR | tail line\n", encoding="utf-8")
+ file_path = str(log_file)
+ # Position only - exactly what an older in-memory/on-disk state holds
+ watcher.log_positions[file_path] = log_file.stat().st_size
+
+ _rotate_log(log_file)
+ log_file.write_text("2026-08-04 | mod | ERROR | new\n", encoding="utf-8")
+
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert lines == ["2026-08-04 | mod | ERROR | new"]
+
+ def test_rotated_file_smaller_than_position_skipped(self, tmp_path):
+ """A backup shorter than the recorded position is not drained."""
+ wlw = _import_watchers_lw()
+ watcher = wlw.LogFileWatcher()
+
+ log_file = tmp_path / "prax_core.log"
+ log_file.write_text("2026-08-04 | mod | ERROR | content\n", encoding="utf-8")
+ file_path = str(log_file)
+ watcher._record_position(file_path, log_file.stat().st_size)
+ # Pretend we were further along than the file ever got
+ watcher.log_positions[file_path] = 9999
+
+ _rotate_log(log_file)
+ log_file.write_text("2026-08-04 | mod | ERROR | new\n", encoding="utf-8")
+
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert lines == ["2026-08-04 | mod | ERROR | new"]
+
+ def test_drain_failure_does_not_block_new_file(self, tmp_path):
+ """An exception inside the drain never prevents reading the new file."""
+ wlw = _import_watchers_lw()
+ watcher = wlw.LogFileWatcher()
+
+ log_file = tmp_path / "prax_core.log"
+ log_file.write_text("2026-08-04 | mod | ERROR | old content here\n", encoding="utf-8")
+ file_path = str(log_file)
+ watcher._record_position(file_path, log_file.stat().st_size)
+
+ _rotate_log(log_file)
+ log_file.write_text("2026-08-04 | mod | ERROR | new\n", encoding="utf-8")
+
+ with patch.object(watcher, "_drain_rotated_tail", side_effect=RuntimeError("boom")):
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert lines == ["2026-08-04 | mod | ERROR | new"]
+
+ def test_record_position_tracks_inode(self, tmp_path):
+ """_record_position stores the current inode alongside the offset."""
+ wlw = _import_watchers_lw()
+ watcher = wlw.LogFileWatcher()
+
+ log_file = tmp_path / "prax_core.log"
+ log_file.write_text("data\n", encoding="utf-8")
+ file_path = str(log_file)
+
+ watcher._record_position(file_path, 4)
+
+ assert watcher.log_positions[file_path] == 4
+ assert watcher.log_inodes[file_path] == log_file.stat().st_ino
+
+
+# ---------------------------------------------------------------------------
+# Tests -- LogFileWatcher inode-based rotation detection
+# ---------------------------------------------------------------------------
+
+
+def _fresh_lines(count: int) -> list:
+ """Build a list of distinct full ERROR lines for the post-rotation file."""
+ return [f"2026-08-04 | mod | ERROR | after rotation {i:03d}" for i in range(count)]
+
+
+class TestLogFileWatcherInodeRotation:
+ """Tests for rotation detected by inode and located across the backup chain."""
+
+ def test_rotation_detected_when_new_file_already_grew(self, tmp_path):
+ """A fresh log already past the old offset is still detected as rotated.
+
+ The size-only check missed this entirely and seeked into the middle of
+ the brand new file, yielding a partial line.
+ """
+ wlw = _import_watchers_lw()
+ watcher = wlw.LogFileWatcher()
+
+ log_file = tmp_path / "prax_core.log"
+ log_file.write_text("2026-08-04 | mod | ERROR | already seen\n" * 20, encoding="utf-8")
+ file_path = str(log_file)
+ watcher._record_position(file_path, log_file.stat().st_size)
+ recorded_pos = watcher.log_positions[file_path]
+
+ with open(log_file, "a", encoding="utf-8") as f:
+ f.write("2026-08-04 | mod | ERROR | missed one\n")
+
+ _rotate_chain(log_file)
+
+ # New file is LARGER than the recorded offset - there is no shrink to see
+ fresh = _fresh_lines(40)
+ log_file.write_text("\n".join(fresh) + "\n", encoding="utf-8")
+ assert log_file.stat().st_size > recorded_pos
+
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert any("missed one" in line for line in lines)
+ # Read from position 0: every fresh line whole, none dropped, none partial
+ assert [line for line in lines if "after rotation" in line] == fresh
+ assert not any("already seen" in line for line in lines)
+ assert watcher.log_positions[file_path] == log_file.stat().st_size
+
+ def test_rotated_file_found_at_second_backup(self, tmp_path):
+ """Two rotations: the tail is drained from '.log.2' and a warning is emitted."""
+ wlw = _import_watchers_lw()
+ watcher = wlw.LogFileWatcher()
+
+ log_file = tmp_path / "prax_core.log"
+ log_file.write_text("header line\n", encoding="utf-8")
+ file_path = str(log_file)
+ watcher._record_position(file_path, log_file.stat().st_size)
+
+ with open(log_file, "a", encoding="utf-8") as f:
+ f.write("2026-08-04 | mod | ERROR | tail from two rotations ago\n")
+
+ tracked_inode = log_file.stat().st_ino
+ _rotate_chain(log_file) # tracked file -> .log.1
+ _rotate_chain(log_file) # tracked file -> .log.2
+ assert Path(f"{file_path}.2").stat().st_ino == tracked_inode
+
+ log_file.write_text("2026-08-04 | mod | ERROR | brand new\n", encoding="utf-8")
+
+ with patch.object(wlw, "logger") as mock_logger:
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert any("tail from two rotations ago" in line for line in lines)
+ assert any("brand new" in line for line in lines)
+
+ warnings = [str(call.args) for call in mock_logger.warning.call_args_list]
+ assert any("not replayed" in warning and ".log.2" in warning for warning in warnings)
+
+ def test_tracked_inode_beyond_chain_reads_new_file_from_zero(self, tmp_path):
+ """Inode matching nothing within the chain: nothing drained, new file read whole."""
+ wlw = _import_watchers_lw()
+ watcher = wlw.LogFileWatcher()
+
+ log_file = tmp_path / "prax_core.log"
+ log_file.write_text("header\n", encoding="utf-8")
+ file_path = str(log_file)
+ watcher._record_position(file_path, log_file.stat().st_size)
+ tracked_inode = log_file.stat().st_ino
+
+ with open(log_file, "a", encoding="utf-8") as f:
+ f.write("2026-08-04 | mod | ERROR | lost tail\n")
+
+ # Four rotations push the tracked file to '.log.4' - past the depth cap
+ for _ in range(4):
+ _rotate_chain(log_file, backup_count=5)
+ assert Path(f"{file_path}.4").stat().st_ino == tracked_inode
+
+ fresh = _fresh_lines(40)
+ log_file.write_text("\n".join(fresh) + "\n", encoding="utf-8")
+
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert not any("lost tail" in line for line in lines)
+ assert lines == fresh
+ assert watcher.log_positions[file_path] == log_file.stat().st_size
+
+ def test_in_place_truncation_with_backup_chain_no_drain(self, tmp_path):
+ """No-overreach guard: same inode plus a full backup chain never drains.
+
+ Passes with and without the fix - it proves walking the chain did not
+ turn an in-place truncation into a false rotation.
+ """
+ wlw = _import_watchers_lw()
+ watcher = wlw.LogFileWatcher()
+
+ log_file = tmp_path / "prax_core.log"
+ file_path = str(log_file)
+ for name in ("chain three", "chain two", "chain one"):
+ log_file.write_text(f"2026-08-04 | mod | ERROR | {name}\n", encoding="utf-8")
+ _rotate_chain(log_file)
+
+ log_file.write_text("2026-08-04 | mod | ERROR | live content line\n", encoding="utf-8")
+ watcher._record_position(file_path, log_file.stat().st_size)
+
+ inode_before = log_file.stat().st_ino
+ with open(log_file, "w", encoding="utf-8") as f:
+ f.write("2026-08-04 | mod | ERROR | short\n")
+ assert log_file.stat().st_ino == inode_before
+
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert lines == ["2026-08-04 | mod | ERROR | short"]
+
+ def test_normal_append_with_backup_chain_never_drains(self, tmp_path):
+ """No-overreach guard: an append with backups present stays on the normal path.
+
+ Passes with and without the fix - it proves the chain walk only runs
+ when the inode actually changed.
+ """
+ wlw = _import_watchers_lw()
+ watcher = wlw.LogFileWatcher()
+
+ log_file = tmp_path / "prax_core.log"
+ file_path = str(log_file)
+ for name in ("chain three", "chain two", "chain one"):
+ log_file.write_text(f"2026-08-04 | mod | ERROR | {name}\n", encoding="utf-8")
+ _rotate_chain(log_file)
+
+ log_file.write_text("2026-08-04 | mod | ERROR | first\n", encoding="utf-8")
+ watcher._record_position(file_path, log_file.stat().st_size)
+
+ with open(log_file, "a", encoding="utf-8") as f:
+ f.write("2026-08-04 | mod | ERROR | appended\n")
+
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert lines == ["2026-08-04 | mod | ERROR | appended"]
+
+ def test_zero_inode_falls_back_to_size_check(self, tmp_path):
+ """No-overreach guard: a recorded inode of 0 is treated as unknown.
+
+ Passes with and without the fix - some Windows filesystems report
+ st_ino 0, which must never be trusted as a rotation signal.
+ """
+ wlw = _import_watchers_lw()
+ watcher = wlw.LogFileWatcher()
+
+ log_file = tmp_path / "prax_core.log"
+ log_file.write_text("2026-08-04 | mod | ERROR | tail line that is long\n", encoding="utf-8")
+ file_path = str(log_file)
+ watcher.log_positions[file_path] = log_file.stat().st_size
+ watcher.log_inodes[file_path] = 0
+
+ _rotate_chain(log_file)
+ log_file.write_text("2026-08-04 | mod | ERROR | new\n", encoding="utf-8")
+
+ with patch.object(watcher, "_process_log_line") as mock_proc:
+ watcher._read_new_lines(file_path)
+
+ lines = _processed_lines(mock_proc)
+ assert lines == ["2026-08-04 | mod | ERROR | new"]
+ assert watcher.log_positions[file_path] == log_file.stat().st_size
+
+
# ---------------------------------------------------------------------------
# Tests -- start / stop / is_active
# ---------------------------------------------------------------------------
@@ -440,6 +951,18 @@ def test_initializes_to_eof(self, tmp_path):
watcher.initialize_positions()
assert watcher.log_positions[str(log_file)] == log_file.stat().st_size
+ def test_initializes_inodes(self, tmp_path):
+ """Inodes are recorded alongside positions at startup."""
+ wlw = _import_watchers_lw()
+ sys_logs = tmp_path / "system_logs"
+ sys_logs.mkdir()
+ wlw.SYSTEM_LOGS_DIR = sys_logs
+ log_file = sys_logs / "app.log"
+ log_file.write_text("test data\n")
+ watcher = wlw.LogFileWatcher()
+ watcher.initialize_positions()
+ assert watcher.log_inodes[str(log_file)] == log_file.stat().st_ino
+
def test_handles_missing_dir(self, tmp_path):
wlw = _import_watchers_lw()
wlw.SYSTEM_LOGS_DIR = tmp_path / "nonexistent"