diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index fa4bc34..0ba73fa 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -16,6 +16,9 @@ python -m pip install -e ".[dev,mcp]" python -m pytest -q ``` +Tests live in `tests/`. Run only the MCP wire tests with +`python -m pytest -q tests/test_mcp*.py`. + Tests isolate the default and named-bag directories as well as `POSTBAG_LEDGER`, and use fake doors. They must never scan the user's bags or reach a real session. Core tests use the standard library and pytest. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd18be4..9e45737 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,7 @@ jobs: - name: Install MCP and test dependencies run: python -m pip install -e '.[dev,mcp]' - name: Run MCP subprocess wire tests - run: python -m pytest -q test_mcp*.py + run: python -m pytest -q tests/test_mcp*.py - name: Check MCP entry point run: postbag-mcp --version @@ -82,7 +82,9 @@ jobs: import tarfile with tarfile.open(next(Path("dist").glob("*.tar.gz"))) as archive: packaged = {name.split("/", 1)[1] for name in archive.getnames() if "/" in name} - missing = {path.name for path in Path(".").glob("test_*.py")} - packaged + tests = {path.as_posix() for path in Path("tests").glob("test_*.py")} + assert tests, "No test modules found under tests/" + missing = tests - packaged assert not missing, f"Source archive is missing test modules: {sorted(missing)}" PYTESTARCHIVE - name: Install wheel and source archive in fresh environments @@ -115,7 +117,7 @@ jobs: wheels=(dist/*.whl) "$RUNNER_TEMP/wheel-mcp/bin/python" -m pip install "${wheels[0]}[mcp]" pytest mkdir "$RUNNER_TEMP/wheel-mcp-tests" - cp test_mcp*.py "$RUNNER_TEMP/wheel-mcp-tests/" + cp tests/test_mcp*.py "$RUNNER_TEMP/wheel-mcp-tests/" cd "$RUNNER_TEMP/wheel-mcp-tests" "$RUNNER_TEMP/wheel-mcp/bin/postbag-mcp" --version "$RUNNER_TEMP/wheel-mcp/bin/python" -I -c 'import pathlib, sys, postbag_mcp; assert pathlib.Path(postbag_mcp.__file__).is_relative_to(sys.prefix)' diff --git a/CHANGELOG.md b/CHANGELOG.md index 585f800..e65cb5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to postbag are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/). +## [2.2.1] - 2026-10-02 + +Documentation and repository layout release. No runtime change beyond the +version number. + +### Changed +- The README is a short product page: install and connect for both hosts, + the two-prompt setup, the five tools, and what to know before use. Release + evidence and migration notes stay in this file, `docs/mcp.md` and + `docs/native-compatibility.md`. +- The demo shows scripted calls to two real `postbag mcp` servers, one with + a Codex identity and one with a Claude Code identity, through fake native + doors, including the envelope each door receives. Its source is + `docs/demo_mcp.py`. The CLI demo environment script is removed. +- Tests live in `tests/`. CI, the installed-wheel check and the source + distribution follow the new paths. + ## [2.2.0] - 2026-10-01 The new `postbag mcp` launcher lets MCP installers run Postbag by its @@ -386,7 +403,8 @@ Four commits from "bridge" to "postbag" on the day the idea was born: the ledger became the only state, `open` became human-only, and every refusal learned to say stop. -[Unreleased]: https://github.com/parasxos/postbag/compare/v2.2.0...HEAD +[Unreleased]: https://github.com/parasxos/postbag/compare/v2.2.1...HEAD +[2.2.1]: https://github.com/parasxos/postbag/releases/tag/v2.2.1 [2.2.0]: https://github.com/parasxos/postbag/releases/tag/v2.2.0 [2.1.0]: https://github.com/parasxos/postbag/releases/tag/v2.1.0 [2.0.0]: https://github.com/parasxos/postbag/releases/tag/v2.0.0 diff --git a/Dockerfile b/Dockerfile index 17d7edf..fb679fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,6 @@ # letters through a host session's native door, which a container does not have, # so native delivery from this image is unsupported and unverified. FROM python:3.12-slim -ARG POSTBAG_VERSION=2.2.0 +ARG POSTBAG_VERSION=2.2.1 RUN pip install --no-cache-dir "postbag[mcp]==${POSTBAG_VERSION}" ENTRYPOINT ["postbag-mcp"] diff --git a/MANIFEST.in b/MANIFEST.in index 3cac073..b25ccee 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,14 +1,7 @@ include postbag include CONCEPT.md CHANGELOG.md .github/SECURITY.md .github/CONTRIBUTING.md -include test_postbag.py test_hardening.py test_transports.py -include test_names.py test_presentation.py -include test_bags.py test_bag_context.py -include test_inventory.py -include test_inventory_terminal.py -include test_resume.py -include test_mcp*.py test_review_core.py test_core_2.py -include test_leave.py -include docs/demo.tape docs/demo-env.sh docs/readme-research.md +recursive-include tests test_*.py +include docs/demo.tape docs/demo_mcp.py docs/readme-research.md include docs/native-compatibility.md include docs/mcp.md include server.json llms-install.md diff --git a/README.md b/README.md index b1f56fe..1c9fcf0 100644 --- a/README.md +++ b/README.md @@ -1,249 +1,113 @@
-Postbag logo +Postbag logo # postbag **Two agents, one bag of letters.** -Let two existing Claude Code or Codex sessions review each other's work, -split a task, or exchange a second opinion. They run on the same machine, -receive letters through their native inboxes and share one recorded history. +Your Claude Code and Codex sessions write to each other. Ask one to review +the other's diff, split a task, or get a second opinion, without copying +text between windows. [![ci](https://github.com/parasxos/postbag/actions/workflows/ci.yml/badge.svg)](https://github.com/parasxos/postbag/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/postbag)](https://pypi.org/project/postbag/) -[![MCP tools](https://img.shields.io/badge/MCP_tools-5-blue)](https://github.com/parasxos/postbag/blob/v2.2.0/docs/mcp.md) -[![Platforms](https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey)](#install) -[![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/parasxos/postbag/blob/v2.2.0/LICENSE) - -```sh -pipx install 'postbag[mcp]' -``` +[![postbag MCP server](https://glama.ai/mcp/servers/parasxos/postbag/badges/score.svg)](https://glama.ai/mcp/servers/parasxos/postbag) +[![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/parasxos/postbag/blob/v2.2.1/LICENSE)
-Five MCP tools and a CLI. Postbag adds no delivery daemon, polling, hooks or -remote relay. It carries text and records submitted letters. The agents and their -hosts decide when to reply and when to stop. Code and other work products -stay in your repository. +![Scripted demo: a Codex identity and a Claude Code identity call two postbag MCP servers to join a bag, exchange a review, mark the last letter final, leave and read the bag](https://raw.githubusercontent.com/parasxos/postbag/v2.2.1/docs/assets/demo.gif) -![Two peers join, exchange a review, mark a letter final, leave and read the bag](https://raw.githubusercontent.com/parasxos/postbag/v2.2.0/docs/assets/demo.gif) +Scripted calls to two real postbag MCP servers, with fake native doors. +[Demo source](https://github.com/parasxos/postbag/blob/v2.2.1/docs/demo_mcp.py). -*Real CLI commands with fake inboxes and a temporary home. This is a local -demonstration, not a recording of live agents. -[Demo source](https://github.com/parasxos/postbag/blob/v2.2.0/docs/demo.tape).* +## What it does -See [agent installation instructions](https://github.com/parasxos/postbag/blob/v2.2.0/llms-install.md). +- **Native delivery.** A letter arrives in the recipient as a new user turn, + through its own input: Claude Code's inbox or `codex queue`. postbag adds + no delivery daemon, polling or hooks. +- **One shared record.** Every join, letter and leave goes into one + append-only ledger per bag, which both agents and you can read. +- **Set up by the agents.** Joining a bag creates it. Nothing to run in a + separate terminal. +- **The session is the sender.** Identity comes from the host, never from + tool arguments. ## Install ```sh pipx install 'postbag[mcp]' -postbag --version -postbag-mcp --version ``` -Python 3.10 or later, on macOS or Linux. Use `pipx install postbag` for the -CLI alone, which uses only the standard library. Upgrade both peers' CLI and -MCP installations together and reconnect their MCP servers. Existing bags -need no migration. Once a bag contains a leave record, all its readers need -2.1 or later. See [migration details](https://github.com/parasxos/postbag/blob/v2.2.0/docs/mcp.md#upgrade-existing-sessions). - -Each vendor in use brings its own door. A Claude Code session exports -`CLAUDE_CODE_MESSAGING_SOCKET` and `CLAUDE_CODE_MESSAGING_TOKEN` to the -commands it runs. A Codex session exports `CODEX_THREAD_ID` (older versions -use `CODEX_SESSION_ID`) and has a -`codex` binary with the `queue` command (0.149 or later). Set `POSTBAG_CODEX` -if it is not in the ChatGPT app or on `PATH`. Two Claude sessions need no -Codex binary, two Codex sessions no Claude socket. - -### MCP tools - -The optional MCP interface lets agents call `postbag_join`, `postbag_leave`, `postbag_send`, -`postbag_read`, and `postbag_bags` directly. Sender identity comes from the -host. The ledger and native delivery are the same as the CLI. Joining creates -a missing bag. There is no letter budget or human-only command. +Python 3.10 or later, on macOS or Linux. Then register the server once in +each host. -Install the optional tools with: +**Claude Code** ```sh -pipx install 'postbag[mcp]' -``` - -Or let [uv](https://docs.astral.sh/uv/getting-started/installation/) run the -pinned package in an isolated environment: - -```sh -uvx --with 'postbag[mcp]==2.2.0' postbag@2.2.0 mcp +claude mcp add --transport stdio --scope user postbag -- "$(command -v postbag-mcp)" ``` -This starts a stdio MCP server for a host to manage. It waits for protocol -input, rather than opening an interactive terminal prompt. `postbag mcp` -and `postbag-mcp` serve the same five tools. The launcher takes no `--bag` -or operational arguments. Each tool call selects its own bag. - -Register the absolute path to `postbag-mcp` as a local stdio MCP -server in each host. [Setup, upgrades, and compatibility](https://github.com/parasxos/postbag/blob/v2.2.0/docs/mcp.md). -The MCP SDK is required only for this interface. - -The supported peers are existing Claude Code and Codex sessions. A generic -MCP client can inspect bags, but registering and sending require a supported -host's native session identity. Install Postbag on the same machine as both -sessions. A directory's Docker check can inspect the tool catalog without -providing access to those sessions. - -The installed 2.2 candidate `c56d92d` passed native acceptance through -`postbag mcp` on macOS with Claude Code 2.1.287 and Codex 0.159.2. Five -letters were observed at their recipients, including replies in both -directions. Leaving blocked a later send, deliberate rejoining restored -delivery, and no reply followed a final letter during a 30.0992-second -observation window. See [launcher evidence and limits](https://github.com/parasxos/postbag/blob/v2.2.0/docs/native-compatibility.md). - -The installed 2.0 candidate `8b87b4f` passed native acceptance on macOS with -Claude Code 2.1.286 and Codex 0.158.0-alpha.2.1. The check observed two-way -receipt, no Postbag reply during 30.0668 seconds after a final letter, and a later -deliberately initiated ordinary round trip. This observation does not -guarantee that other model conversations will stop. See -[candidate evidence and limits](https://github.com/parasxos/postbag/blob/v2.1.0/docs/native-compatibility.md). - -Historical 1.x native delivery checks ran on macOS with Claude Code 2.1.285 and -Codex 0.157.1 / desktop 0.158.0-alpha.2.1, including the installed 1.3.0 MCP -package, default server discovery, two-way receipt and spent-budget refusal. -The 1.4 checks are also retained in [native compatibility](https://github.com/parasxos/postbag/blob/v2.1.0/docs/native-compatibility.md). -Native Linux delivery is unverified. Windows is unsupported. - -## Quick start - -1. Open two sessions on the same machine. Ask each to join under a name: - `postbag --bag default join claude ada` and `postbag --bag default join claude bob`, - or `postbag --bag default join codex bob` for Codex. Same-vendor pairs need distinct names. - The first valid join creates the bag. -2. Ask ada to send the first letter: - - ```sh - postbag --bag default send @bob "Review my last commit. Reply with the top three findings." - ``` - - If accepted, bob receives: "Letter 1 from @ada to @bob via postbag - (bag default).", the body, and the - reply instructions: use `postbag_send` if MCP tools are available, or - `postbag --bag default send @ada -` with the reply on stdin. The footer asks - for replies that advance the task and discourages courtesy acknowledgements - and unsolicited delivery checks. -3. Read the bag from anywhere with `postbag --bag default read`. Its first - line names the bag, its registered peers and its recorded letter count. -4. To send a letter that asks for no reply, use - `postbag --bag default send --final @bob "The review is complete."`. - Its footer says not to reply to that letter, even if its body asks for one. - This is guidance to the recipient. It does not close the bag or prevent a - later deliberately initiated send. -5. To stop participating in this bag, ask the session to run - `postbag --bag default leave`, or call `postbag_leave` with `bag="default"`. - This releases its name without ending the session or deleting history. - Its door can no longer send or be addressed in that bag until it joins again. - Queued letters and a send already holding the bag lock can still arrive. - Rejoin only when you deliberately ask the session to resume. - -If you intend to resume participation after a restart, rejoin the same bag -under the same name. A reply reaches -whoever holds the name when it runs, and a displaced door's next `send` refuses. - -## Bags - -A bag is one ledger, and it has a name. `default` is `~/.postbag/ledger.jsonl`. -For a second conversation, each session joins another bag with the -same flag, `postbag --bag acceptance join claude ada` and -`postbag --bag acceptance join claude bob`, and ada sends with -`postbag --bag acceptance send @bob "..."`. - -`--bag` goes before the verb and takes a name, kept in -`~/.postbag/bags/.jsonl`, or an absolute path of printable characters. -`join` creates a missing default, named or path-selected bag. `send`, `leave` -and `read` refuse a missing bag without creating files or directories. A join -refused for its arguments or identity also creates nothing. Once creation -starts, an I/O failure can leave a directory or partial file for inspection. -Outputs identify their bags, and every command inside a -letter or a refusal carries `--bag`, `--bag default` included, so a reply -lands where the letter came from whatever the recipient's shell has set. -Without `--bag`, `POSTBAG_LEDGER` selects a ledger by path. - -Run `postbag bags` for a count of paths found, recorded letter counts, last letter -times and registered names with vendors. It lists default, named and selected -custom bags. Unselected external paths are omitted. Busy or unreadable bags -are unavailable. The summary counts bags with letters, bags without letters, -and unavailable bags. Registered names do not imply live sessions. -Terminals adapt to width and sort bags by last letter, with brief local times. -Pipes keep the plain table and full ISO timestamps. - -`postbag bags --resume` adds Claude resume commands for conversations recorded -at `join`, with one reminder for missing IDs. Rejoin after `/clear` or switching -conversations. Resume needs saved history and opens a new process, not the old terminal. - -## How it works - -`join` writes the session's door into the ledger under a name: Claude Code's -messaging socket and token, or Codex's thread id. `send` holds a file lock -while it knocks on that door and appends the letter. Completed sends get -distinct record numbers. MCP calls refuse a busy ledger without waiting. -Letter numbers count all recorded letters in the bag. -Record numbers also count joins, leaves and historical `open` rows. CLI `read N` -returns the last N records. MCP `before` and `next_before` address immutable -record numbers. Old `open` rows keep their -recorded limits as history and no longer control sending. -Before using `leave`, upgrade all readers of the bag to 2.1 or later and -reconnect their MCP servers. Once a leave is recorded, 2.0 readers refuse -that bag. Older records need no migration. Deleting leave rows is not a -repair because it would restore withdrawn registrations. -Two sessions are the supported use, three or more is experimental. -A bag is one ledger, the only state. No delivery daemon, -polling, hooks, or bag index. The optional MCP process is started by its host -and uses the same CLI operations in isolated workers. -[CONCEPT.md](https://github.com/parasxos/postbag/blob/v2.2.0/CONCEPT.md) is the specification. - -## Tests - -For a full development test run, install both test dependencies and the MCP extra: +**Codex**, in `~/.codex/config.toml`, with the path that `command -v postbag-mcp` prints: -```sh -python -m pip install -e '.[dev,mcp]' -python -m pytest -q +```toml +[mcp_servers.postbag] +command = "/Users/you/.local/bin/postbag-mcp" +tool_timeout_sec = 60 ``` -Without the `mcp` extra, the wire tests are skipped. Tests use private fixtures -and fake native doors. See [Contributing](https://github.com/parasxos/postbag/blob/v2.2.0/.github/CONTRIBUTING.md). - -## Security and limits - -- A ledger holds every Claude session token and every letter in its bag. - New files use `0600` and new state directories `0700`. Existing file modes - are preserved. Mutations refuse files that grant group or other access or - lack owner read and write permissions. `read` and - `bags` hide door credentials. Keep raw files out of git and logs. -- An accepted letter is a user turn. Trust both sessions with the task. - postbag itself sends nothing off the machine. Vendor sessions forward - the letter to their model services like any prompt. -- A name is an address, not authentication, and so is a bag. Sender routing - uses the vendor's session variables or trusted host metadata. Another - process running as the same OS user can supply those fields. -- Claude's inbound policy may hold or refuse a letter, including in - bypass-permissions sessions. CLI sends need permission to write the ledger - and contact the recipient. MCP tools run with the server process permissions, - outside the command sandbox. Use host tool approvals for per-letter consent. -- Leaving removes a registration in one bag. It does not revoke the native - inbox or registrations in other bags. Claude subagents sharing an inbox - share a peer, so one subagent leaving withdraws the parent's name too. -- Postbag has no letter limit or rate limit. The footer and `--final` are - model instructions, not protection against loops or prompt injection. - Closing a Codex client does not revoke its saved thread's queue. Making a - ledger unwritable prevents new write opens. Mutations also check the mode - after taking the lock, but an operation past that check may finish. Read - access can remain, and queued letters are not recalled. MCP body, page and - result depth caps remain, as do native timeouts. -- "Delivered" means submitted through the door, not accepted or read. postbag - does not wait for delivery notices or retry. A crash before recording leaves - a submitted letter in doubt. Timeouts and failed native commands can also - leave submission uncertain. Check both the bag and the recipient before - sending again. An absent ledger record is not proof of failed delivery. - -postbag is a small bridge for two existing sessions. [Tools that do more](https://github.com/parasxos/postbag/blob/v2.2.0/docs/readme-research.md) · [Concept](https://github.com/parasxos/postbag/blob/v2.2.0/CONCEPT.md) · [Security](https://github.com/parasxos/postbag/blob/v2.2.0/.github/SECURITY.md) · [Changelog](https://github.com/parasxos/postbag/blob/v2.2.0/CHANGELOG.md) · [Contributing](https://github.com/parasxos/postbag/blob/v2.2.0/.github/CONTRIBUTING.md) · [MIT](https://github.com/parasxos/postbag/blob/v2.2.0/LICENSE) +Reconnect or restart each host's MCP server, then check that the five +postbag tools are listed, for example with `/mcp` in Claude Code. +Codex delivery uses the `codex` binary's `queue` command, 0.149 or later. +Set `POSTBAG_CODEX` if it is neither on `PATH` nor in the ChatGPT app. +To run without installing, as listed in the official MCP registry: +`uvx --with 'postbag[mcp]==2.2.1' postbag@2.2.1 mcp`. +Agents can follow [llms-install.md](https://github.com/parasxos/postbag/blob/v2.2.1/llms-install.md). + +## Use it + +Open a Codex session and a Claude Code session in the same repository. + +1. In Codex: *"Join the postbag bag review as bob and wait for letters."* +2. In Claude Code: *"Join bag review as ada and ask bob to review my last commit."* + +Each letter ends with how to reply, so neither agent needs instructions. +Watch the conversation with `postbag --bag review read`. When the work is +done, ada can send a final letter, which asks for no reply, and either side +can leave the bag. + +## The five tools + +| Tool | What it does | +|---|---| +| `postbag_join` | Registers this session under a name in a bag, creating the bag if needed. | +| `postbag_send` | Sends a letter to a name in the bag. `final: true` asks for no reply. | +| `postbag_read` | Pages through the bag's history. | +| `postbag_bags` | Lists local bags with their letter counts and registered names. | +| `postbag_leave` | Withdraws this session from the bag. History stays. | + +The CLI has the same verbs: `postbag join`, `send`, `read`, `bags` and `leave`. + +## Good to know + +- **No brake.** postbag sets no letter limit. Your hosts' tool approvals and + the agents' judgement decide how long a conversation runs. +- **A letter is a user turn.** Connect only sessions you trust with the + task. The ledger holds each Claude session's inbox token. It is created + with mode `0600`, so keep it out of git and logs. +- **Same machine, same version.** Two sessions are the supported use. After + an upgrade, upgrade both and reconnect both MCP servers. +- **Restarted session.** If a session comes back with a new identity and + should keep corresponding, ask it to join the bag again under the same name. +- **Submitted, not read.** A send returns once the recipient's door took the + letter, not when the agent read it. + +[Concept](https://github.com/parasxos/postbag/blob/v2.2.1/CONCEPT.md) · +[MCP setup and limits](https://github.com/parasxos/postbag/blob/v2.2.1/docs/mcp.md) · +[Security](https://github.com/parasxos/postbag/blob/v2.2.1/.github/SECURITY.md) · +[Changelog](https://github.com/parasxos/postbag/blob/v2.2.1/CHANGELOG.md) · +[Contributing](https://github.com/parasxos/postbag/blob/v2.2.1/.github/CONTRIBUTING.md) · +[Tools that do more](https://github.com/parasxos/postbag/blob/v2.2.1/docs/readme-research.md) · +[MIT](https://github.com/parasxos/postbag/blob/v2.2.1/LICENSE) diff --git a/docs/assets/demo.gif b/docs/assets/demo.gif index 55f4707..3b4fe40 100644 Binary files a/docs/assets/demo.gif and b/docs/assets/demo.gif differ diff --git a/docs/demo-env.sh b/docs/demo-env.sh deleted file mode 100644 index eb76116..0000000 --- a/docs/demo-env.sh +++ /dev/null @@ -1,25 +0,0 @@ -# Fake doors for the README demo. Source this in a disposable shell, as vhs -# provides, then run postbag. It replaces HOME and unsets the session -# variables, so do not source it in an interactive shell you keep using. -# Nothing here touches a real session or your own bags: HOME is a temporary -# directory, so the default bag lives there, and each Claude door is a -# throwaway socket that accepts a connection and drops it. -unset CLAUDE_CODE_MESSAGING_SOCKET CLAUDE_CODE_MESSAGING_TOKEN CLAUDE_CODE_SESSION_ID CODEX_SESSION_ID CODEX_THREAD_ID POSTBAG_LEDGER -PS1='$ ' -PATH="$PWD:$PATH" # run the checkout being documented -DEMO=$(mktemp -d) -trap 'kill $LISTENERS 2>/dev/null; rm -rf "$DEMO"' EXIT -export HOME="$DEMO" -LISTENERS= -for door in ada bob; do -python3 - "$DEMO/$door.sock" <<'PY' & -import socket, sys -s = socket.socket(socket.AF_UNIX); s.bind(sys.argv[1]); s.listen() -while True: - c, _ = s.accept(); c.recv(65536); c.close() -PY -LISTENERS="$LISTENERS $!" -done -sleep 0.3 -in-ada() { CLAUDE_CODE_MESSAGING_SOCKET="$DEMO/ada.sock" CLAUDE_CODE_MESSAGING_TOKEN=demo-ada "$@"; } -in-bob() { CLAUDE_CODE_MESSAGING_SOCKET="$DEMO/bob.sock" CLAUDE_CODE_MESSAGING_TOKEN=demo-bob "$@"; } diff --git a/docs/demo.tape b/docs/demo.tape index 3758267..97edc29 100644 --- a/docs/demo.tape +++ b/docs/demo.tape @@ -1,30 +1,16 @@ # vhs docs/demo.tape (https://github.com/charmbracelet/vhs) +# Real postbag MCP tool calls between a Claude Code identity and a Codex +# identity, through fake native doors. See docs/demo_mcp.py. Output docs/assets/demo.gif Set Shell bash -Set FontSize 18 -Set Width 1180 -Set Height 700 -Set Padding 18 +Set FontSize 16 +Set Width 1260 +Set Height 800 +Set Padding 20 Set Theme "Catppuccin Mocha" -Set TypingSpeed 40ms Hide -Type "source docs/demo-env.sh; clear" +Type "clear; ${PYTHON:-python3} docs/demo_mcp.py" Enter -Sleep 1.5s Show - -Type "# postbag demo: real commands against fake doors, no live session" Enter Sleep 800ms - -Type "# in the first Claude Code session" Enter -Type "in-ada postbag join claude ada" Sleep 300ms Enter Sleep 900ms -Type "# in the second Claude Code session" Enter -Type "in-bob postbag join claude bob" Sleep 300ms Enter Sleep 900ms -Type "# ada writes, bob answers, ada marks the last letter final" Enter -Type `in-ada postbag send @bob "Review parser.py, top three findings please."` Sleep 300ms Enter Sleep 1400ms -Type `in-bob postbag send @ada "Empty input, swallowed date errors, no field bound."` Sleep 300ms Enter Sleep 1400ms -Type `in-ada postbag send --final @bob "Fixed all three, merging."` Sleep 300ms Enter Sleep 1400ms -Type "# bob withdraws from the bag" Enter -Type "in-bob postbag leave" Sleep 300ms Enter Sleep 900ms -Type "postbag read" Sleep 300ms Enter -Sleep 4s +Sleep 36s diff --git a/docs/demo_mcp.py b/docs/demo_mcp.py new file mode 100644 index 0000000..e16eef7 --- /dev/null +++ b/docs/demo_mcp.py @@ -0,0 +1,243 @@ +"""Render the README demo: scripted calls to two real postbag MCP servers. + +One `postbag mcp` server runs with a Claude Code identity (ada), the other +with a Codex identity (bob). Their native doors are fakes: a throwaway Unix +socket stands in for Claude Code's inbox and a small script stands in for +`codex queue`, so the demo shows the exact envelope each door receives. +No model authors these calls, and nothing touches a real session or your +own bags. + + vhs docs/demo.tape # records docs/assets/demo.gif + python docs/demo_mcp.py # the same transcript in your terminal +""" +import json +import os +import pathlib +import queue +import socket +import subprocess +import sys +import tempfile +import threading +import time + +ROOT = pathlib.Path(__file__).resolve().parents[1] +THREAD = "019a0000-0000-7000-8000-00000000b0b0" # bob's Codex thread, fixed for a stable recording +DEADLINE = 30 + +ADA, BOB, DIM, BOLD, OK, END = "\033[38;5;215m", "\033[38;5;114m", "\033[2m", "\033[1m", "\033[38;5;151m", "\033[0m" + + +class Server: + """One agent's postbag MCP server, spoken to with plain JSON-RPC lines.""" + + def __init__(self, env, errors, meta=None): + self.meta = meta or {} + self.next_id = 1 + self.errors = errors + self.lines = queue.Queue() + with errors.open("w") as sink: + self.process = subprocess.Popen([sys.executable, str(ROOT / "postbag.py"), "mcp"], env=env, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=sink, text=True) + # A reader thread hands over complete lines, so each request waits on one deadline in total. + self.reader = threading.Thread(target=self.read_lines, daemon=True) + self.reader.start() + try: + self.request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "postbag-demo", "version": "1"}}) + self.write({"jsonrpc": "2.0", "method": "notifications/initialized"}) + except BaseException: + self.close() + sys.stderr.write(errors.read_text()) + raise + + def read_lines(self): + for line in self.process.stdout: + self.lines.put(line) + self.lines.put(None) + + def write(self, message): + self.process.stdin.write(json.dumps(message) + "\n") + self.process.stdin.flush() + + def request(self, method, params): + ident, self.next_id = self.next_id, self.next_id + 1 + self.write({"jsonrpc": "2.0", "id": ident, "method": method, "params": params}) + deadline = time.monotonic() + DEADLINE + while True: + try: + line = self.lines.get(timeout=max(0.0, deadline - time.monotonic())) + except queue.Empty: + raise TimeoutError(f"no answer to {method} within {DEADLINE} s") from None + if line is None: + raise RuntimeError(f"the server exited during {method}") + message = json.loads(line) + if message.get("id") == ident: + return message["result"] + + def call(self, tool, **arguments): + result = self.request("tools/call", {"name": tool, "arguments": arguments, "_meta": self.meta}) + outcome = result["structuredContent"] + # Only calls that really succeeded are shown; anything else stops the recording. + if result.get("isError") or outcome["ok"] is not True: + raise RuntimeError(f"{tool} failed: {outcome}") + if tool == "postbag_send" and outcome["submission_state"] != "submitted": + raise RuntimeError(f"{tool} was not submitted: {outcome}") + return outcome + + def close(self): + try: + self.process.stdin.close() + except OSError: + pass # the child already exited and closed its end + try: + self.process.wait(timeout=DEADLINE) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait() + self.reader.join(timeout=DEADLINE) + self.process.stdout.close() + + +class Doors: + """Fake native doors that keep what each recipient would be handed.""" + + def __init__(self, temp): + self.to_ada, self.to_bob = [], [] + self.socket_path = str(temp / "ada.sock") + self.queue_log = temp / "bob.jsonl" + self.queue = temp / "codex" + self.queue.write_text( + f"#!{sys.executable}\nimport json, sys\n" + "args = sys.argv[1:]\n" + f"with open({str(self.queue_log)!r}, 'a') as out:\n" + " out.write(json.dumps(args[args.index('--message') + 1]) + '\\n')\n") + self.queue.chmod(0o700) + listener = socket.socket(socket.AF_UNIX) + listener.bind(self.socket_path) + listener.listen() + threading.Thread(target=self.inbox, args=(listener,), daemon=True).start() + + def inbox(self, listener): + while True: + connection, _ = listener.accept() + with connection: + data = b"" + while chunk := connection.recv(65536): + data += chunk + for line in data.decode().splitlines(): + message = json.loads(line) + if message.get("type") == "user": + self.to_ada.append(message["message"]["content"]) + + def wait(self, inbox, count): + for _ in range(DEADLINE * 20): + if inbox is self.to_bob and self.queue_log.exists(): + self.to_bob[:] = [json.loads(line) for line in self.queue_log.read_text().splitlines()] + if len(inbox) >= count: + return inbox[count - 1] + time.sleep(0.05) + raise TimeoutError("a submitted letter never reached its fake door") + + +def pause(seconds): + sys.stdout.flush() + time.sleep(seconds) + + +def show_call(who, colour, tool, **arguments): + shown = " ".join(f"{key}={json.dumps(value)}" for key, value in arguments.items()) + sys.stdout.write(f"{colour}{who:<19}{END}") + for char in f"{BOLD}{tool}{END} {shown}": + sys.stdout.write(char) + sys.stdout.flush() + time.sleep(0.012) + sys.stdout.write("\n") + + +def show_result(text): + print(f"{' ' * 19}{OK}✓ {text}{END}") + + +def show_envelope(who, colour, envelope, keep): + print(f"{colour}envelope captured at {who}'s door:{END}") + lines = envelope.splitlines() + for line in lines[:keep]: + line = line if len(line) <= 92 else line[:91] + "…" + print(f"{DIM} │ {line}{END}" if line else f"{DIM} │{END}") + if len(lines) > keep: + print(f"{DIM} │ …{END}") + + +def first_sentence(outcome): + return outcome["message"].split(". ")[0].rstrip(".") + + +def transcript(ada, bob, doors): + print("\033[2J\033[H", end="") + print(f"{DIM}postbag demo: scripted calls to two real postbag MCP servers, one with a Codex identity") + print(f"(bob) and one with a Claude Code identity (ada). The native doors are fakes.{END}\n") + pause(1.2) + # The recipient joins first, so the first letter never meets an unregistered name. + show_call("bob Codex", BOB, "postbag_join", bag="review", name="bob") + show_result(first_sentence(bob.call("postbag_join", bag="review", name="bob"))) + pause(0.8) + show_call("ada Claude Code", ADA, "postbag_join", bag="review", name="ada") + show_result(first_sentence(ada.call("postbag_join", bag="review", name="ada"))) + pause(0.8) + ask = "Review parser.py, top three findings please." + show_call("ada Claude Code", ADA, "postbag_send", bag="review", to="bob", body=ask) + show_result(first_sentence(ada.call("postbag_send", bag="review", to="bob", body=ask))) + pause(0.6) + show_envelope("bob", BOB, doors.wait(doors.to_bob, 1), keep=6) + pause(2.4) + answer = "Empty input, swallowed date errors, no field bound." + show_call("bob Codex", BOB, "postbag_send", bag="review", to="ada", body=answer) + show_result(first_sentence(bob.call("postbag_send", bag="review", to="ada", body=answer))) + pause(0.6) + show_envelope("ada", ADA, doors.wait(doors.to_ada, 1), keep=3) + pause(1.8) + done = "Fixed all three, merging." + show_call("ada Claude Code", ADA, "postbag_send", bag="review", to="bob", final=True, body=done) + show_result(first_sentence(ada.call("postbag_send", bag="review", to="bob", final=True, body=done))) + pause(0.6) + show_envelope("bob", BOB, doors.wait(doors.to_bob, 2), keep=5) + pause(2.4) + show_call("bob Codex", BOB, "postbag_leave", bag="review") + show_result(first_sentence(bob.call("postbag_leave", bag="review"))) + pause(0.8) + show_call("ada Claude Code", ADA, "postbag_read", bag="review", limit=3) + state = ada.call("postbag_read", bag="review", limit=3)["data"] + peers = ", ".join(f"@{peer['name']} ({peer['vendor']})" for peer in state["peers"]) + show_result(f"{state['letters']} letters, registered now: {peers}") + pause(4) + + +def main(): + with tempfile.TemporaryDirectory(prefix="postbag-demo-") as temp: + temp = pathlib.Path(temp) + home = temp / "home" + home.mkdir() + doors = Doors(temp) + base = {"HOME": str(home), "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + "POSTBAG_CODEX": str(doors.queue), "PYTHONPATH": str(ROOT)} + servers = [] + try: + bob = Server(base, temp / "bob.stderr", meta={"threadId": THREAD}) + servers.append(bob) + ada = Server({**base, "CLAUDECODE": "1", "CLAUDE_CODE_MESSAGING_SOCKET": doors.socket_path, + "CLAUDE_CODE_MESSAGING_TOKEN": "demo-token"}, temp / "ada.stderr") + servers.append(ada) + transcript(ada, bob, doors) + except BaseException: + for server in servers: + sys.stderr.write(server.errors.read_text()) + raise + finally: + for server in servers: + server.close() + + +if __name__ == "__main__": + main() diff --git a/docs/mcp.md b/docs/mcp.md index 0c499c9..9907736 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -55,14 +55,14 @@ installation. The MCP extra and command select the same version: ```toml [mcp_servers.postbag] command = "/absolute/path/to/uvx" -args = ["--with", "postbag[mcp]==2.2.0", "postbag@2.2.0", "mcp"] +args = ["--with", "postbag[mcp]==2.2.1", "postbag@2.2.1", "mcp"] tool_timeout_sec = 60 ``` For Claude Code: ```sh -claude mcp add --transport stdio --scope user postbag -- /absolute/path/to/uvx --with 'postbag[mcp]==2.2.0' postbag@2.2.0 mcp +claude mcp add --transport stdio --scope user postbag -- /absolute/path/to/uvx --with 'postbag[mcp]==2.2.1' postbag@2.2.1 mcp ``` Generic stdio configuration uses the same command and arguments: @@ -72,7 +72,7 @@ Generic stdio configuration uses the same command and arguments: "mcpServers": { "postbag": { "command": "/absolute/path/to/uvx", - "args": ["--with", "postbag[mcp]==2.2.0", "postbag@2.2.0", "mcp"] + "args": ["--with", "postbag[mcp]==2.2.1", "postbag@2.2.1", "mcp"] } } } @@ -419,7 +419,7 @@ with private HOME and `ResourceWarning` as an error. See the provenance, interru first attempt and native runtime versions in [native compatibility](native-compatibility.md). -The `test_mcp*.py` files use the real SDK and stdio subprocesses with isolated +The `tests/test_mcp*.py` files use the real SDK and stdio subprocesses with isolated homes, fake native executables, and private Unix sockets. The frozen 2.1 source at `6489151` passed **665 tests** with private HOME and `ResourceWarning` as an error. Installed-wheel runs outside the source diff --git a/docs/native-compatibility.md b/docs/native-compatibility.md index bc0bd1a..ef4b3cc 100644 --- a/docs/native-compatibility.md +++ b/docs/native-compatibility.md @@ -1,5 +1,53 @@ # Native compatibility checks +## 2 October 2026: installed 2.2.1 release candidate + +A clean wheel from `87777fc2c24f97cd4fdbbf77734a3065491930ef` passed the +native gate on its first execution. Both hosts ran the installed `postbag mcp` +launcher. The package and runtime reported `2.2.1`. The core and adapter +loaded from the isolated venv, matched the reviewed source and wheel, and +remained unchanged throughout the run. Separate checks found identical +five-tool catalogs through `postbag mcp` and `postbag-mcp` with current and +legacy SDK clients. Each returned an empty private inventory. + +The run used macOS 27.0.1, build 26A434, Python 3.14.6 and MCP SDK 2.2.0. +Codex CLI **0.159.0-alpha.12.1** reported model `gpt-6.1-sol`. Claude Code +**2.1.287** reported `claude-opus-5-5`. The unwrapped native `codex queue` +used default discovery in a private `CODEX_HOME`, without a `--remote` +override, and reached a disposable persisted thread. The user's personal +daemon and native Linux delivery were not tested. + +| Check | Observed result | +|---|---| +| Ordinary delivery | The initial letter and reply were observed by both recipient sessions. | +| Final letter | After receipt and turn completion, 30.1091 seconds elapsed with no recipient send attempt or ledger change. Both hosts stayed alive. | +| Leave and refusal | The recipient left through MCP. The next send refused with `not_submitted` and the departure time, preserving the ledger bytes. | +| Deliberate rejoin | An explicit later harness instruction caused the recipient to join again. A later ordinary letter and reply were both received. | +| Accounting | Ten model-authored tool calls produced three joins, one leave and five submitted letters, with one additional send attempt refused. | +| Cleanup | Both clients and the private server exited 0, without forced shutdown. Native endpoints closed and the private authentication alias was removed. | + +Receipt checks correlated tool arguments, outcomes, ledger records and +completed recipient turns. Claude's local stdout receipt markers were +harness instrumentation, not Postbag replies. The final observation is +evidence for this run, not a guarantee that every model obeys the footer. + +The command sandbox reported read-only access, disabled network access and +`approvalPolicy: never`. Claude was launched with `--permission-mode manual` +and reported `permissionMode: default`. The preauthorized fixture tools +required no human prompt. `crossSessionInbound` was unset with empty Claude +setting sources. Native own-child classification was unobserved. No personal +configuration or installation changed during the gate. Claude used its +existing login and could write its usual caches or telemetry. + +Candidate provenance: + +```text +wheel 76cdd1df87c5980cff0218bb6eb54a1f82147c293cd01ee4c08f70aacee1f4d2 +postbag.py 59a23112e1c6f88f9e39721f423b9d4950e519bbff15f9c0bbb004ae070fd6d2 +postbag_mcp.py fe354cca37c92c6ce1e5ceaba71a37f0218542834124b1b31ee7528b7f4113c8 +harness 81d2c29cf3f957f023933eca47f4e3146f53e1ce38a84401be255775431aaa2c +``` + ## 1 October 2026: installed 2.2.0 launcher candidate A clean wheel from `c56d92d7ec2d4fb79a39b206db2bb0af8615d5be` passed the diff --git a/llms-install.md b/llms-install.md index da54997..940d287 100644 --- a/llms-install.md +++ b/llms-install.md @@ -35,7 +35,7 @@ The `postbag mcp` launcher and uvx examples below require 2.2.0 or later. ``` Alternatively, use uvx as the stdio command, with arguments - `["--with", "postbag[mcp]==2.2.0", "postbag@2.2.0", "mcp"]`. + `["--with", "postbag[mcp]==2.2.1", "postbag@2.2.1", "mcp"]`. Keep both version pins equal. This does not put the CLI on shell `PATH`. Do not register both launch methods under different names. @@ -80,5 +80,5 @@ an inbox share one peer, so a subagent can withdraw the parent's registration. - Keep raw ledgers out of logs and repositories. They contain letter bodies and native endpoint credentials. Use the redacted read and inventory tools. -See [MCP setup and outcomes](https://github.com/parasxos/postbag/blob/v2.2.0/docs/mcp.md) and -[native compatibility evidence](https://github.com/parasxos/postbag/blob/v2.2.0/docs/native-compatibility.md). +See [MCP setup and outcomes](https://github.com/parasxos/postbag/blob/v2.2.1/docs/mcp.md) and +[native compatibility evidence](https://github.com/parasxos/postbag/blob/v2.2.1/docs/native-compatibility.md). diff --git a/postbag.py b/postbag.py index e430b26..13bf883 100644 --- a/postbag.py +++ b/postbag.py @@ -25,7 +25,7 @@ from datetime import datetime from pathlib import Path -__version__ = "2.2.0" +__version__ = "2.2.1" PEERS = {"claude", "codex"} # supported vendors; registered peer names come from the ledger NAME = re.compile(r"[a-z][a-z0-9-]{0,15}") diff --git a/pyproject.toml b/pyproject.toml index c9001db..3ed06de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,4 +50,5 @@ py-modules = ["postbag", "postbag_mcp"] version = {attr = "postbag.__version__"} [tool.pytest.ini_options] -testpaths = ["."] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/server.json b/server.json index 36647fd..c2051ef 100644 --- a/server.json +++ b/server.json @@ -6,12 +6,12 @@ "url": "https://github.com/parasxos/postbag", "source": "github" }, - "version": "2.2.0", + "version": "2.2.1", "packages": [ { "registryType": "pypi", "identifier": "postbag", - "version": "2.2.0", + "version": "2.2.1", "transport": { "type": "stdio" }, @@ -20,7 +20,7 @@ { "type": "named", "name": "--with", - "value": "postbag[mcp]==2.2.0", + "value": "postbag[mcp]==2.2.1", "description": "The MCP server is the optional mcp extra of the same version." } ], diff --git a/test_bag_context.py b/tests/test_bag_context.py similarity index 100% rename from test_bag_context.py rename to tests/test_bag_context.py diff --git a/test_bags.py b/tests/test_bags.py similarity index 98% rename from test_bags.py rename to tests/test_bags.py index ea5f18f..d8d3dd9 100644 --- a/test_bags.py +++ b/tests/test_bags.py @@ -1,5 +1,6 @@ """Bag selection and scoped commands through an isolated public CLI.""" +import importlib.util import json import os from pathlib import Path @@ -12,7 +13,9 @@ import pytest -SCRIPT = Path(__file__).resolve().with_name("postbag.py") +# Children run the postbag module this process selected: the checkout's in the source +# layout, the installed one otherwise. Never a path derived from this test file. +SCRIPT = Path(importlib.util.find_spec("postbag").origin).resolve() THREADS = {"ada": "fake-bag-thread-ada", "bob": "fake-bag-thread-bob"} diff --git a/test_core_2.py b/tests/test_core_2.py similarity index 100% rename from test_core_2.py rename to tests/test_core_2.py diff --git a/test_hardening.py b/tests/test_hardening.py similarity index 98% rename from test_hardening.py rename to tests/test_hardening.py index a9b7512..b47156d 100644 --- a/test_hardening.py +++ b/tests/test_hardening.py @@ -1,6 +1,7 @@ """Regression tests using private ledgers and fake vendor doors only.""" import fcntl +import importlib.util import json import os from pathlib import Path @@ -15,8 +16,9 @@ import pytest -ROOT = Path(__file__).resolve().parent -SCRIPT = ROOT / ("postbag.py" if (ROOT / "postbag.py").exists() else "postbag") +# Children run the postbag module this process selected: the checkout's in the source +# layout, the installed one otherwise. Never a path derived from this test file. +SCRIPT = Path(importlib.util.find_spec("postbag").origin).resolve() SESSION_VARS = { "claude": { "CLAUDE_CODE_MESSAGING_SOCKET": "/tmp/postbag-test-unused.sock", diff --git a/test_inventory.py b/tests/test_inventory.py similarity index 98% rename from test_inventory.py rename to tests/test_inventory.py index 33ad256..9ee029e 100644 --- a/test_inventory.py +++ b/tests/test_inventory.py @@ -2,6 +2,7 @@ import errno import fcntl +import importlib.util import json import os from pathlib import Path @@ -14,7 +15,9 @@ import pytest -SCRIPT = Path(__file__).resolve().with_name("postbag.py") +# Children run the postbag module this process selected: the checkout's in the source +# layout, the installed one otherwise. Never a path derived from this test file. +SCRIPT = Path(importlib.util.find_spec("postbag").origin).resolve() PRIVATE_BODY = "INVENTORY-PRIVATE-LETTER-BODY" PRIVATE_SOCKET = "/tmp/inventory-private-never-used.sock" PRIVATE_TOKEN = "inventory-private-fake-token" diff --git a/test_inventory_terminal.py b/tests/test_inventory_terminal.py similarity index 100% rename from test_inventory_terminal.py rename to tests/test_inventory_terminal.py diff --git a/test_leave.py b/tests/test_leave.py similarity index 100% rename from test_leave.py rename to tests/test_leave.py diff --git a/test_mcp.py b/tests/test_mcp.py similarity index 99% rename from test_mcp.py rename to tests/test_mcp.py index 9c1abd9..9719660 100644 --- a/test_mcp.py +++ b/tests/test_mcp.py @@ -26,8 +26,10 @@ pytest.skip("MCP wire checks require mcp 2.x", allow_module_level=True) -ROOT = Path(__file__).resolve().parent SERVER_SCRIPT = Path(importlib.util.find_spec("postbag_mcp").origin) +# Children import from the directory the server module was selected from: the checkout root +# in the source layout, site-packages in the installed layout. Never this test directory. +ROOT = SERVER_SCRIPT.resolve().parent THREAD_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" THREAD_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" STARTUP_THREAD = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" diff --git a/test_mcp_adversarial.py b/tests/test_mcp_adversarial.py similarity index 98% rename from test_mcp_adversarial.py rename to tests/test_mcp_adversarial.py index 549bb34..278dfa9 100644 --- a/test_mcp_adversarial.py +++ b/tests/test_mcp_adversarial.py @@ -16,11 +16,12 @@ import pytest HERE = Path(__file__).resolve().parent -if (HERE / "postbag.py").exists(): - # Source layout: test the checkout beside this file, not some other import of the same name. +SOURCE = HERE.parent +if (SOURCE / "postbag.py").exists(): + # Source layout: test the checkout above tests/, not some other import of the same name. spec = importlib.util.find_spec("postbag_mcp") - if spec is None or Path(spec.origin).resolve() != HERE / "postbag_mcp.py": - sys.path.insert(0, str(HERE)) + if spec is None or Path(spec.origin).resolve() != SOURCE / "postbag_mcp.py": + sys.path.insert(0, str(SOURCE)) for stale in ("postbag_mcp", "postbag", "test_mcp"): sys.modules.pop(stale, None) # Installed layout (only test_mcp*.py copied elsewhere): use the installed package as found. diff --git a/test_mcp_launcher.py b/tests/test_mcp_launcher.py similarity index 100% rename from test_mcp_launcher.py rename to tests/test_mcp_launcher.py diff --git a/test_mcp_leave.py b/tests/test_mcp_leave.py similarity index 98% rename from test_mcp_leave.py rename to tests/test_mcp_leave.py index 0fb60de..3d6549b 100644 --- a/test_mcp_leave.py +++ b/tests/test_mcp_leave.py @@ -11,10 +11,11 @@ HERE = Path(__file__).resolve().parent +SOURCE = HERE.parent MODULES = ("postbag", "postbag_mcp") -SOURCE_LAYOUT = all((HERE / f"{module}.py").exists() for module in MODULES) -if SOURCE_LAYOUT and Path(importlib.util.find_spec("postbag_mcp").origin).resolve() != HERE / "postbag_mcp.py": - sys.path.insert(0, str(HERE)) +SOURCE_LAYOUT = all((SOURCE / f"{module}.py").exists() for module in MODULES) +if SOURCE_LAYOUT and Path(importlib.util.find_spec("postbag_mcp").origin).resolve() != SOURCE / "postbag_mcp.py": + sys.path.insert(0, str(SOURCE)) for stale in (*MODULES, "test_mcp"): sys.modules.pop(stale, None) @@ -61,7 +62,7 @@ def read_recovery(response, *, bag="default", submission_state=None): def test_leave_server_module_uses_source_or_installed_layout(): origins = {module: Path(importlib.util.find_spec(module).origin).resolve() for module in MODULES} if SOURCE_LAYOUT: - assert origins == {module: HERE / f"{module}.py" for module in MODULES} + assert origins == {module: SOURCE / f"{module}.py" for module in MODULES} else: assert origins["postbag"].parent == origins["postbag_mcp"].parent assert Path(sys.prefix).resolve() in origins["postbag_mcp"].parents diff --git a/test_mcp_leave_faults.py b/tests/test_mcp_leave_faults.py similarity index 100% rename from test_mcp_leave_faults.py rename to tests/test_mcp_leave_faults.py diff --git a/test_mcp_protocol.py b/tests/test_mcp_protocol.py similarity index 100% rename from test_mcp_protocol.py rename to tests/test_mcp_protocol.py diff --git a/test_mcp_review.py b/tests/test_mcp_review.py similarity index 98% rename from test_mcp_review.py rename to tests/test_mcp_review.py index 41da3a3..8062651 100644 --- a/test_mcp_review.py +++ b/tests/test_mcp_review.py @@ -19,12 +19,13 @@ import pytest HERE = Path(__file__).resolve().parent +SOURCE = HERE.parent MODULES = ("postbag", "postbag_mcp") -# Source layout: the modules sit beside this file and must be the ones under test. +# Source layout: the modules sit one directory above tests/ and must be the ones under test. # Installed layout (CI copies only test_mcp*.py elsewhere): both come from the environment. -SOURCE_LAYOUT = all((HERE / f"{module}.py").exists() for module in MODULES) -if SOURCE_LAYOUT and Path(importlib.util.find_spec("postbag_mcp").origin).resolve() != HERE / "postbag_mcp.py": - sys.path.insert(0, str(HERE)) +SOURCE_LAYOUT = all((SOURCE / f"{module}.py").exists() for module in MODULES) +if SOURCE_LAYOUT and Path(importlib.util.find_spec("postbag_mcp").origin).resolve() != SOURCE / "postbag_mcp.py": + sys.path.insert(0, str(SOURCE)) for stale in (*MODULES, "test_mcp"): sys.modules.pop(stale, None) @@ -40,7 +41,7 @@ def test_server_and_ledger_modules_come_from_one_layout(): """The server under test and its ledger module must come from the same place.""" origins = {module: Path(importlib.util.find_spec(module).origin).resolve() for module in MODULES} if SOURCE_LAYOUT: - assert origins == {module: HERE / f"{module}.py" for module in MODULES} + assert origins == {module: SOURCE / f"{module}.py" for module in MODULES} else: assert origins["postbag"].parent == origins["postbag_mcp"].parent, origins assert HERE not in origins["postbag_mcp"].parents and origins["postbag_mcp"].parent != HERE diff --git a/test_mcp_worker_contract.py b/tests/test_mcp_worker_contract.py similarity index 100% rename from test_mcp_worker_contract.py rename to tests/test_mcp_worker_contract.py diff --git a/test_names.py b/tests/test_names.py similarity index 100% rename from test_names.py rename to tests/test_names.py diff --git a/test_postbag.py b/tests/test_postbag.py similarity index 100% rename from test_postbag.py rename to tests/test_postbag.py diff --git a/test_presentation.py b/tests/test_presentation.py similarity index 100% rename from test_presentation.py rename to tests/test_presentation.py diff --git a/test_resume.py b/tests/test_resume.py similarity index 100% rename from test_resume.py rename to tests/test_resume.py diff --git a/test_review_core.py b/tests/test_review_core.py similarity index 100% rename from test_review_core.py rename to tests/test_review_core.py diff --git a/test_transports.py b/tests/test_transports.py similarity index 100% rename from test_transports.py rename to tests/test_transports.py