[Sprint 1] MAILBOX-LIST UDS verb + non-root mailbox_list#178
Conversation
Move the MCP `mailbox_list` tool off direct `/etc/aimx/config.toml` reads and onto a new `AIMX/1 MAILBOX-LIST` verb. The daemon resolves the caller via `SO_PEERCRED`, filters mailboxes by ownership, and returns a JSON array with absolute `inbox_path` / `sent_path`, unread / total / sent counts, and a `registered` flag. - New `Request::MailboxList` variant in the codec; the parser rejects any header (Owner:, Content-Length:, etc.) so a future caller cannot smuggle an owner hint past the SO_PEERCRED filter. - New `JsonAckResponse` shape (OK + Content-Length + body) for verbs that ship a JSON payload. - New `mailbox_list_handler` reads from the in-memory `Arc<Config>` snapshot (no locks) and answers with the JSON body. Root sees every mailbox (including catchall and stray on-disk dirs); non-root callers see only the mailboxes whose owner resolves to their uid. - MCP `mailbox_list` becomes a thin UDS client returning the JSON body verbatim. Daemon-not-running surfaces the canonical "aimx daemon not running. Start with 'sudo systemctl start aimx'" message that `email_send` and `hook_create` already emit. - Integration tests cover both happy path (daemon running, JSON shape with paths and counts) and missing-socket fallback. - Primer + `references/mcp-tools.md` lead with "read .md files directly when you don't need a mutation" and document the new JSON return shape with a worked next-step example.
uzyn
left a comment
There was a problem hiding this comment.
Sprint 1 Review — MAILBOX-LIST UDS verb + non-root mailbox_list
Sprint Goal Assessment
Sprint 1's goal is met. The MCP mailbox_list tool no longer touches /etc/aimx/config.toml; it ships an AIMX/1 MAILBOX-LIST frame and the daemon answers with a SO_PEERCRED-filtered JSON array. Non-root agents enumerate their own mailboxes without sudo. Codec discipline (no headers on MAILBOX-LIST) and the new JsonAckResponse shape are clean and well-tested. Build, clippy, fmt, and the new unit + integration tests all pass locally.
Acceptance Criteria Checklist
[S1-1] Add MAILBOX-LIST UDS verb to the AIMX/1 codec
-
Request::MailboxListvariant with no payload —src/send_protocol.rs:182. -
parse_request_framerecognizesMAILBOX-LIST—src/send_protocol.rs:402. - Round-trip + header-rejection unit tests cover
Owner:andContent-Length:—mailbox_list_codec_tests, all 5 tests pass. - No daemon handler in this story — handler lands in S1-2.
-
cargo test/cargo clippy --all-targets -- -D warnings/cargo fmt -- --checkclean.
[S1-2] Daemon-side handler — JSON response with SO_PEERCRED owner filter
-
handle_mailbox_listinsrc/mailbox_list_handler.rs(the sprint plan explicitly allowed a new file). - Per-row struct serializes the FR-A3 fields exactly:
name, inbox_path, sent_path, total, unread, sent_count, registered. - Root sees all; non-root uses
mailbox::caller_ownswith stray-dir uid fallback viainbox_owner_uid. - Stray dirs visible to root and to the user whose uid owns the inbox dir.
- Empty result is the JSON literal
[](verified bystranger_sees_empty_array). - Dispatch in
src/serve.rs:861writes the JSON ack response. - Four unit tests cover root, owner, stranger, and on-disk count paths.
- Lints clean.
[S1-3] MCP mailbox_list becomes a thin UDS client
-
mailbox_listno longer callsself.load_configorself.authorize_mailbox— it goes throughsubmit_mailbox_list_via_daemon. - Daemon-not-running message matches
email_send/hook_createexactly. -
list_mailboxes_with_unreaddeleted fromsrc/mcp.rs. - New integration test
mcp_mailbox_list_returns_caller_ownedspawnsaimx serve+aimx mcpin a temp data dir and asserts the JSON shape. - Old text-format assertion rewritten; new test exists for the missing-socket path (
mcp_mailbox_list_without_daemon_reports_missing_socket). - Lints clean.
[S1-4] Primer FR-F1 lead + mcp-tools.md mailbox_list row rewrite
-
agents/common/aimx-primer.md"Two access surfaces" leads with the exact FR-F1 phrasing verbatim. -
agents/common/references/mcp-tools.mdmailbox_listrow rewritten with the JSON field table, an example response, and a "next step" pointing the agent at<inbox_path>/<id>.md. - No reference to
aimx mailboxes listintroduced in the primer. -
cargo buildrebuilds bundles cleanly. - Manual smoke (
aimx agents setup claude --print) — not explicitly evidenced in the PR description, but the bundle is built from the sameinclude_str!/include_dir!source so the rebuild covers it.
Test Coverage
Coverage is solid. The codec layer has 5 round-trip + rejection tests, the handler has 4 unit tests across the visibility matrix, and there are 2 integration tests that exercise the real binary end-to-end (with-daemon and without-daemon paths). Two minor gaps worth flagging — both Non-blocker:
- Stray on-disk dir visibility for non-root caller is unit-tested only implicitly. The handler's stray-dir branch (
is_visible_tofalling through toinbox_owner_uid) is not exercised by a dedicated test. Thenon_root_owner_sees_only_owned_mailboxestest seeds dirs but the mailboxes are registered, so the fallback path never fires. Adding a test that creates a stray inbox dir (no config entry) chowned to the caller would pin FR-A3's "stray dirs appear withregistered: false" guarantee for non-root callers. is_marked_readcheap-parser divergence fromcount_with_unread. The handler uses a line-scan that recognizesread = trueliterally;mailbox::count_with_unread(used byaimx mailboxes show) does a full TOML decode. The two will agree on every well-formed frontmatter aimx writes today, but a hand-editedread = "true"(string) would be reported as unread by the new handler and as read by the show command. No test pins this — agree-by-construction withcount_with_unreadwould be a useful invariant test.
Potential Bugs
None of the issues below are blockers — Sprint 1 is internally consistent and the code paths are correct on every input the handler can encounter from aimx-written frontmatter. Calling them out as Non-blocker for the implementer's awareness.
- Cheap
read = …parser is permissive about quoted values.src/mailbox_list_handler.rs:295-298parsesread = "true"(string) asfalsebecause it compares the trimmed value against the literal"true"(without quotes). aimx never writes a quoted bool, so this is unreachable from the daemon's own writers, but it is a divergence from the full TOML decoder used elsewhere. Non-blocker — no production frontmatter would hit this; raised for consistency withcount_with_unread. splitn(3, "+++")breaks on a+++token inside the frontmatter body. Same file; same function. The TOML frontmatter contract forbids+++inside the block, but the cheap parser is more brittle than the TOML decoder. Non-blocker, same reason.is_visible_tofor catchall. The PRD wording is "non-root callers never see the catchall — preserves today's behavior" but the sprint plan and the implementation both treat catchall identically to other mailboxes (visible to root or to whoever owns it viaaimx-catchall). The implementation matches the sprint plan and matches today'scaller_ownssemantics, so this is correct — flagging only because the PRD reads slightly more restrictive at first glance. Not a bug.
Security Issues
None observed. The MAILBOX-LIST codec rejects Owner: and Content-Length: headers on the request frame, so a forged client cannot smuggle an owner hint past the SO_PEERCRED filter. caller.uid is sourced from the kernel-validated peer credential. No user-supplied input feeds into a path or shell.
Code Quality
submit_mailbox_list_via_daemonreusesis_socket_missing(which catchesNotFound | ConnectionRefused | PermissionDenied) while older sibling helpers (submit_via_daemon,submit_mark_via_daemon) still hand-checkErrorKind::NotFoundonly. The new code is the better pattern; consider migrating the older helpers in a follow-up so all daemon-not-running surfaces behave identically when the socket exists but is unreachable. Non-blocker, follow-up.decode_mailbox_list_responsereimplements basicAIMX/1header parsing (Content-Length read, ERR split). The pattern is broadly the same asparse_responsebut specialised for the JSON-bodied OK frame. Acceptable for the first JSON ack consumer; if Sprint 2'semail_listships another JSON ack reader, a sharedparse_json_ack_responsehelper would prevent drift.JsonAckResponse::Errwriter is byte-identical toAckResponse::Err. The unit test pins this, which is good. As long as the contract holds, clients can use one ERR parser for all ack-style verbs.
Alignment with PRD
Aligned. FR-A1 through FR-A6 and FR-F1/F2 are all addressed in this sprint. The other FRs (B/C/D/F3/F4) belong to later sprints and are correctly out of scope here.
Human Review Comments
No human comments on the PR; no linked issue.
Summary and Recommended Actions
- Overall verdict: Ready to merge.
- Blockers: None.
- Non-blockers (worth a follow-up but not a re-spin):
- Add a unit test that exercises the stray-dir visibility branch for a non-root caller (seed a stray inbox dir with the caller's uid; assert it appears with
registered: false). - Add an invariant test that the cheap
is_marked_readparser agrees with the full-TOML decoder on every shape aimx itself emits (or replace the cheap parser with the full decoder if perf is not load-bearing here). - Migrate
submit_via_daemonandsubmit_mark_via_daemonto useis_socket_missingso all daemon-not-running surfaces behave identically.
- Add a unit test that exercises the stray-dir visibility branch for a non-root caller (seed a stray inbox dir with the caller's uid; assert it appears with
- Nice-to-haves: Factor the JSON-ack response decoder into a shared helper before Sprint 2 adds a second consumer in
email_list.
[Sprint 1] MAILBOX-LIST UDS verb + non-root mailbox_list
Move mailbox_list off the root-owned config.toml: add an
AIMX/1 MAILBOX-LIST verb the daemon answers with a JSON array
filtered by SO_PEERCRED, and turn the MCP tool into a thin UDS
client. New JsonAckResponse shape; codec rejects Owner: /
Content-Length: headers on MAILBOX-LIST so a forged owner hint
cannot defeat peer-cred filtering. Primer + mcp-tools.md lead
with "read .md files directly when you don't need a mutation"
and document the new JSON shape with a worked next-step example.
Summary
AIMX/1 MAILBOX-LISTUDS verb. The daemon resolves the caller viaSO_PEERCREDand answers with a JSON array of mailboxes the uid owns; root sees everything.mailbox_list_handlerreads from the in-memoryArc<Config>snapshot and returns rows of{ name, inbox_path, sent_path, total, unread, sent_count, registered }. Empty result is[].mailbox_listbecomes a thin UDS client. It no longer reads/etc/aimx/config.tomland no longer runs its own authz pre-flight, so non-root agents can enumerate their own mailboxes withoutsudo.aimx daemon not running. Start with 'sudo systemctl start aimx'message.MAILBOX-LISTrejects any header (includingOwner:andContent-Length:) so a forged owner header cannot defeat theSO_PEERCREDfilter.JsonAckResponseshape for verbs that ship a JSON body alongside the bare-ack response variant.references/mcp-tools.mdnow lead with "read.mdfiles directly when you don't need a mutation"; themailbox_listrow documents the new JSON shape with a worked next-step example.Test plan
cargo test(959 tests) — greencargo clippy --all-targets -- -D warnings— cleancargo fmt -- --check— cleancargo build/clippy/fmt— cleanmailbox_list_handler::testscover root, owner, stranger, and on-disk count visibility paths.MAILBOX-LIST, rejectOwner:andContent-Length:headers, and pin the JSON-ack writer wire shape.aimx serve+aimx mcpand parses the JSON response frommailbox_list. A second integration test asserts the canonical missing-socket message when the daemon is absent.🤖 Generated with Claude Code