Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
29f42df
fix(backup): a help-gate test was doing LIVE OAuth on every suite run…
AIOSAI Aug 30, 2026
d7776f6
feat(daemon+memory): one fleet definition, one public door - daemon c…
AIOSAI Aug 30, 2026
7c55560
fix(drone): rm survives deleting the directory you stand in - trigger…
AIOSAI Aug 30, 2026
e3ff384
fix(seedgo+daemon): the lane's first fleet morning banked - the impor…
AIOSAI Aug 30, 2026
63243aa
feat(fleet): the declared-roots anchor + drone's one-cwd rule - FPLAN…
AIOSAI Aug 30, 2026
32db831
feat(fleet): the tier hardens itself in one evening - hermeticity, th…
AIOSAI Aug 31, 2026
c9d0327
feat(daemon+ai_mail+hooks+memory): VERA WOKE - the first external cit…
AIOSAI Aug 31, 2026
8aefa69
fix(fleet): one test universe - the night CI-red-per-train stopped be…
AIOSAI Aug 31, 2026
ef02978
fix(fleet): round two - the first round's pins caught the species on …
AIOSAI Aug 31, 2026
ebb8075
fix(fleet): Windows joins the one universe - the case-fold registry d…
AIOSAI Aug 31, 2026
28ee90d
fix(fleet): round four - the import-time dead-cwd defect dies in all …
AIOSAI Aug 31, 2026
8550ed1
fix(fleet): round five - the round-4 pins met the real Windows platfo…
AIOSAI Aug 31, 2026
c82c3d3
fix(fleet): round six - the interpreter is part of the platform: the …
AIOSAI Sep 1, 2026
68ab513
fix(fleet): round seven - an instrument must not import behaviour it …
AIOSAI Sep 1, 2026
9bd2618
fix(fleet): round eight - the assumption moves up a level every time …
AIOSAI Sep 1, 2026
84175b8
fix(fleet): round nine - the instruments learned to say where they ca…
AIOSAI Sep 1, 2026
c5b6e17
fix(spawn+seedgo): round 10 - the assumption moves into the assertion…
AIOSAI Sep 1, 2026
5bfd5b6
fix(devpulse+seedgo): round 11 - the fixture's first catch, the captu…
AIOSAI Sep 1, 2026
5dee751
fix(seedgo+backup): round 12 - the last pre-ruling pair: the directio…
AIOSAI Sep 1, 2026
31a60fc
fix(seedgo+backup): round-12 board answered with a skip list, not a r…
AIOSAI Sep 1, 2026
cd03892
chore(release): v2.8.1 - the green-board train (PPLAN-0047, PR#750). …
AIOSAI Sep 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion .aipass/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
"pre_edit_gate": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.security.edit_gate.handle",
"matcher": "Edit|MultiEdit|Write|NotebookEdit"
"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"
},
"git_gate": {
"enabled": true,
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ build/

# AIPass runtime state (local to each installation)
AIPASS_REGISTRY.json
AIPASS_ROOTS.json
.trinity/
.watchdog/
.ai_mail.local/
Expand Down
570 changes: 570 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

49 changes: 43 additions & 6 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,53 @@


def _points_into_repo(mod: types.ModuleType) -> bool:
"""True if mod's *JSON_DIR* constant currently resolves inside this repo."""
"""True if mod's next log write would land inside this repo.

Seam-adopted handlers (prax's 2026-08-30 contract) expose
``_current_json_dir()`` — the same resolver their own write consults, per
call, honouring both a monkeypatched ``JSON_DIR`` and the
``AIPASS_TEST_LOG_DIR`` redirect. When it exists, ask IT: re-deriving the
answer here from constants is a second implementation, and it already
misfired once — the seam's ``_IMPORT_TIME_JSON_DIR`` fixed point is
env-independent BY CONTRACT (always the real dir), so a substring scan
read every seam-adopted module as unpatched and skipped writes their
tests were asserting on (CI-only: this conftest loads only on repo-root
runs, which is why 12 log_operation tests were green per-branch and red
in CI on 2026-08-31).

Handlers without the seam keep the constant scan, with one narrowing:
underscore-private names are anchors, not the live dir, and are ignored.
A public *JSON_DIR* patched outside the repo means the test controls the
write; all-inside means production state, skip.
"""
resolver = getattr(mod, "_current_json_dir", None)
if callable(resolver):
try:
target = Path(str(resolver())).resolve()
except Exception as exc: # a broken resolver must fail safe: skip the write
# Lazy prax import: every wrapped module already imported prax
# itself, and importing it at conftest top would start the logger
# for every collection this guard exists to keep quiet.
from aipass.prax import logger

logger.warning(
"conftest guard: %s._current_json_dir raised %r — failing safe, write skipped",
mod.__name__,
exc,
)
return True
return target.is_relative_to(_REPO_ROOT)
verdict = False
for name, val in vars(mod).items():
if name.startswith("_"):
continue
if "JSON_DIR" not in name or not isinstance(val, (str, Path)):
continue
try:
Path(str(val)).resolve().relative_to(_REPO_ROOT)
return True
except ValueError:
if Path(str(val)).resolve().is_relative_to(_REPO_ROOT):
verdict = True
else:
return False
return False
return verdict


def _guarded(mod: types.ModuleType, real):
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "aipass"
version = "2.8.0"
version = "2.8.1"
description = "A local multi-agent framework where your AI agents keep their memory, work together, and never ask you to re-explain context"
readme = "README.md"
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion src/aipass/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
git clone + ./setup.sh — https://github.com/AIOSAI/AIPass
"""

__version__ = "2.8.0"
__version__ = "2.8.1"
164 changes: 162 additions & 2 deletions src/aipass/ai_mail/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
**Purpose:** Inter-agent messaging for AIPass. File-based email system that lets agents send, receive, and process messages using `@branch` addresses. No SMTP, no external services — just JSON files and symbolic routing.
**Module:** `aipass.ai_mail`
**Created:** 2025-11-08
**Last Updated:** 2026-08-27
**Last Updated:** 2026-08-31

---

**Status:** Operational | **Seedgo:** 100% | **Tests:** 1350 pass across 46 files (1332 + 4 live-hygiene skips on a fresh checkout — 2 in `test_live_mailbox_hygiene.py`, 2 in `test_live_contacts_hygiene.py`) | **Battle Tested:** S62
**Status:** Operational | **Seedgo:** 100% | **Tests:** 1446 pass across 49 files, 0 skipped, both rootdirs (on a fresh checkout 4 live-hygiene tests skip instead — 2 in `test_live_mailbox_hygiene.py`, 2 in `test_live_contacts_hygiene.py`) | **Battle Tested:** S62

## Quick Start

Expand Down Expand Up @@ -558,6 +558,63 @@ status, ok = wake_branch("@devpulse", custom_message=prompt, sender="@daemon", s
- **Reading the outcome** — `status.find_step("scheduled")` is present only for the
headless lane; the interactive spawn names its tmux session in the `spawn` step.

### Unattended Wakes: Permissions, Model, and Marking

Three rulings from Patrick on 2026-08-30, all after @vera's first external daemon
wake. The fire itself worked; the **lane** failed three ways around it.

**1 — Always bypass permissions.** *"always bypass permissions always, claude alone
will nvr work."* @vera launched as a bare `claude`, sat in default permission mode,
and had Bash **denied** mid-playbook with nobody present to approve it — she
improvised around it with WebFetch, which is not a thing an unattended agent should
ever have to do. The headless lane had carried `--permission-mode bypassPermissions`
for months; the interactive manager lane had not. It does now, unconditionally:
every route into that spawn comes from `@daemon`, so *attachable* was never the same
thing as *attended*.

**2 — Fable is managers-only.** *"managers are fable thats it, only manager run
fable."* `resolve_wake_model(citizen_class, requested)` is the one site that decides:

| Target | Requested model | Runs on |
|---|---|---|
| `citizen_class: manager` | anything, or nothing | **`fable`** — overridden, and the override is logged |
| anyone else | `fable` / `claude-fable-5` / `FABLE` | `DEFAULT_MODEL`, with a warning — the wake still happens |
| anyone else | anything else, or nothing | unchanged: the `wake.model` field, else `DEFAULT_MODEL` |

- **One read, one source.** `citizen_class` comes from the passport the manager gate
already opens. An unreadable passport arrives as `""` — not a manager — the same
direction `is_manager()` refuses to fail in; inventing a manager would silently
move an ordinary branch onto Fable.
- **Substring, not equality.** The CLI takes both `fable` and `claude-fable-5`, so a
check comparing to the bare alias would let the full id walk straight past the
non-manager half of the rule.
- **The lane names its model.** @vera reached Fable by *CLI default* — the right
answer with no decision behind it. Both spawn lanes now state the model they mean.

**3 — Daemon sessions are marked.** Patrick killed @vera's live session mid-run: it
was not in the dispatch register (the manager-interactive lane bypasses it) and
`daemon-vera-192848` read as his own leftover tmux. Two markings:

- **The session name**, `AIPASS-DAEMON-WAKE-<branch>-<HHMMSS>` — guaranteed, because
tmux either creates the session under that name or `new-session` already failed.
Loud on purpose: it is read by a person deciding whether to kill a window.
- **A tmux user option**, `@aipass_daemon_wake`, carrying branch/sender/start time —
the queryable half (`tmux show-options -v -t <session> @aipass_daemon_wake`), so a
tool need not string-match a prefix. Set **before** the agent starts, because a
window is killable from the moment it exists. Best-effort: a failure is a `warn`
step on the `mark` label and the wake proceeds — refusing to start real work over
a cosmetic label is the worse trade, but the thin marking is said out loud.

**Not the dispatch register, and the reason is the register's own contract.**
`open_dispatch()` takes `expected_seconds` from the lane's real timeout, and the
interactive lane has no monitor and no timeout. Every entry it wrote would stay
outstanding forever and go overdue against a number invented here — turning crash
detection into a wall of false alarms. Marking a window and tracking a promised
dispatch are two questions; only one of them has a monitor to close it. The
**headless** lane registers and is closed by `dispatch_monitor`, which is why
routing scheduled manager wakes through it (`scheduled=True`) answers marking and
supervision together.

### Admin Lane (`admin=True`)

Patrick's ruling (DPLAN-0288): @devpulse — and only @devpulse — holds an admin
Expand Down Expand Up @@ -880,6 +937,109 @@ External projects (outside the AIPass repo) can send to AIPass branches. On deli

The contacts system (`contacts.py`) maintains an address book at `.ai_mail.local/contacts.json`, auto-registering branches on every send/receive. This enables fast sender detection for known branches without CWD walking or registry lookups.

### Waking a citizen outside this repo — the external tier

`wake.resolve_branch()` checks four sources, in strict precedence. **Local always wins:** the first three all resolve inside AIPass home, and only the fourth leaves it.

| # | Source | Gated by |
|---|---|---|
| 1 | `AIPASS_REGISTRY.json` — core branches | — |
| 2 | The caller's project registry, via `AIPASS_CALLER_CWD` | — |
| 3 | The `projects/*` sweep — the cross-project bridge | verified admin only |
| 4 | The declared-roots **external tier** | — (declaration is the credential) |

Step 4 consumes @memory's public gateway, `aipass.memory.apps.modules.fleet.external_branches()`, which reads the machine anchor `AIPASS_ROOTS.json` at AIPass home. Nothing here re-reads that file — one anchor, one reader. No anchor means no external roots, which is the ordinary state of a fresh clone, and resolution is then byte-identical to what it was before the tier existed.

There is no admin gate on step 4: @daemon fires scheduled wakes unverified, and the anchor is a machine-managed file Patrick blessed, so an external root is already an authorised destination. The admin sweep keeps its position *above* the tier — moving it below would let a sibling repo shadow a citizen living in our own `projects/`.

**Collisions break by declaration order, and are logged anyway.** When two declared roots claim one address, the first-*declared* root wins — the fleet ruling's own tie-break — and an error line names every losing claimant. This was a known gap for one day: `declared_roots()` returned `sorted(found)`, so the winner was alphabetical-by-resolved-path and the tie-break the ruling names could not reach this door. Re-reading the anchor here to recover it would have been a second reader of the file the gateway exists to own, so the collision was made loud and the disagreement raised with @memory instead — who dropped the sort (`registry_scope` 4.1.0, 2026-08-30). The error line stays: a tie-break being correct does not make a collision expected.

## The Import Guard Needs No Filesystem

`apps/handlers/__init__.py` runs a branch-access check at import time. It used to
open with `inspect.stack()`, which builds a FrameInfo per frame and reaches
`getsourcefile() -> getmodule() -> os.path.realpath()`. On Windows
`ntpath.realpath` calls `os.getcwd()` unconditionally in its opening lines —
before checking whether the path is even absolute — at a call site inside
`getmodule` that is **not** wrapped in a try. So importing any handler in this
package needed a readable cwd on Windows, and a disconnected share killed the
import of a package whose only job at that moment was to compare a name.
(@spawn's find, 2026-08-31; 16 branches carried it.)

It walks frames with `sys._getframe` now — `co_filename` is already a string in
memory — and uses `linecache` for the import line. Every `Path.resolve()` is
guarded with a raw-spelling fallback.

**Why it hid on Linux, and what the pins deny.** `posixpath.realpath` does not
call `getcwd` for an absolute path, so the POSIX equivalent raises earlier inside
`getabsfile()` where `inspect` catches it. Denying `os.getcwd` on Linux proves
nothing here — measured both ways. `test_handlers_guard_import.py` denies
`os.path.realpath`, the call the defect actually makes, and was red against the
pre-fix guard on this machine.

**A second `inspect.stack()` was deleted outright.** It looked for
`<string>`/`<stdin>` and then returned either way — a second copy of the cwd
dependency in service of a branch that could not change the answer. A discarded
result does not stop being a crash site for being discarded.

**Known, pre-existing, not fixed here.** `apps/__init__.py` does
`from . import handlers`, so importing `aipass.ai_mail.apps.handlers` imports the
parent package first, which imports handlers itself — the guard therefore sees an
ai_mail file as the caller and allows, and the module is cached before any
external importer is ever seen. Verified identical before and after this change,
so it is not a regression from it. Reported rather than swept: closing it is a
security-behaviour change that deserves its own round.

## Registry Globs Are Re-Checked in Python

`pathlib` delegates glob matching to the filesystem, so on Windows and default
macOS `*_REGISTRY.json` **also matches** `*_registry.json`. This repo is full of
bait — 237 lowercase files on this machine when the sweep ran:
`drone_command_registry.json` sits directly beside drone's tree, every branch
carries `.spawn/.template_registry.json` (pathlib `*` matches dotfiles, unlike the
`glob` module), and @flow keeps ten `flow_json/*_registry.json` plan counters.
Found on `ef029782`'s windows-setup leg, root-caused by @drone, swept fleet-wide.

**Every registry walk in this branch goes through `paths.registries_in()`.** The
glob still does the walking — only the filesystem knows where files are — but it
is not trusted with the *answer*: the name is compared again in Python with
`str.endswith(REGISTRY_SUFFIX)`, where case means what it says. Refusals are
logged, so on Windows there is a record that the filesystem returned something the
pattern never asked for.

**Suffix, never the stem.** External projects name registries after themselves —
`Vera-Studio_REGISTRY.json`, `vera_studio_REGISTRY.json`, `feel_good_app_REGISTRY.json`.
A filter keyed on the stem would delete real citizens in order to fix this bug, so
all three spellings are pinned as must-survive.

| Site | What it decides | Reached via |
|---|---|---|
| `paths.find_project_root` | which project this is, for the delivery fence | walk up |
| `users/branch_detection._find_caller_registry` | which registry names the caller | walk up |
| `email/reply._validate_reply_path` | may a reply leave toward this inbox | ancestors |
| `registry/read.resident_registry_paths` | the resident roster | `projects/*/` |
| `registry/read.get_project_tree_branches` | the verified-admin bridge roster | `projects/*/` |
| `registry/read.get_caller_project_branches` | the caller's citizens | walk up |

The last three were **not** on the sweep's list of four — found by sweeping the
tree rather than working the list, and all three decide *which citizens exist*.

**What the defect actually did here, measured rather than assumed.** The brief
said mail would land as the wrong citizen. That needs a decoy carrying a
`branches` key, and **zero of the 237 lowercase files on this machine have one** —
so the identity swap is reachable but not currently armed. What *was* live: the
walk-up sites return the **first** registry they meet and stop, so a counter file
ends the walk and a genuine external caller resolves to nothing; and
`find_project_root` returned `src/aipass/drone` as a "project root", which changes
the cross-project fence's answer with no `branches` key needed at all. Both
reproduced against the real tree before the fix and dead after it.

**The ban is structural.** `test_registry_case_sweep.py` AST-walks `apps/` and
fails on any `.glob()`/`.rglob()` reaching for a registry pattern outside the one
reader — catching a **named constant** as well as a literal, because the
literal-only version reported my own `resident_registry_paths` site clean while it
still held the defect.

## Architecture

Follows the standard AIPass 3-layer pattern:
Expand Down
Loading
Loading