From b6764f25d5a8906e9d1b80032bdf1e14a64695a0 Mon Sep 17 00:00:00 2001 From: Sean Robbins <251665174+coldlogicAI@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:58:44 -0400 Subject: [PATCH 1/3] feat: add durable packet inbox --- AGENTS.md | 5 +- CHANGELOG.md | 15 + README.md | 34 +- docs/README.md | 1 + .../v5/3notch-v5-durable-inbox-plan.md | 77 ++ docs/active-plans/v5/v5-implementation-log.md | 14 + docs/guides/cross-repo-packets.md | 2 +- docs/guides/cross-tool-handoff.md | 8 +- docs/guides/durable-inbox.md | 126 ++ docs/guides/mcp-setup.md | 10 + docs/reference/privacy.md | 7 +- docs/reference/security-story.md | 5 +- package-lock.json | 1137 +++++------------ package.json | 7 +- src/cli/commands/inbox.ts | 214 ++++ src/cli/commands/onboard.ts | 12 +- src/cli/mcp-instructions.ts | 14 +- src/cli/program.ts | 4 + src/cli/prompts/shared.ts | 12 + src/core/archive-service.ts | 104 +- src/core/inbox-service.ts | 662 ++++++++++ src/core/local-inbox-adapter.ts | 689 ++++++++++ src/core/schema-service.ts | 6 + src/mcp/server.ts | 199 ++- src/schemas/audit.schema.json | 10 +- src/schemas/config.schema.json | 6 +- src/schemas/inbox-config.schema.json | 14 + src/schemas/inbox-delivery.schema.json | 38 + src/schemas/mcp-tools.schema.json | 47 + src/types/inbox.ts | 45 + src/types/records.ts | 11 + tests/cli/inbox.test.ts | 169 +++ tests/cli/onboard.test.ts | 11 + tests/cli/prompt.test.ts | 7 +- tests/e2e/durable-inbox-smoke.test.ts | 152 +++ tests/helpers/store-fixtures.ts | 4 + tests/mcp/inbox-tools.test.ts | 192 +++ tests/mcp/server-start.test.ts | 5 + tests/schema/inbox-schemas.test.ts | 86 ++ tests/schema/mcp-tool-schemas.test.ts | 17 + tests/unit/archive-service.test.ts | 54 +- tests/unit/inbox-service.test.ts | 322 +++++ tests/unit/local-inbox-adapter.test.ts | 227 ++++ tests/unit/no-deferred-commands.test.ts | 3 +- 44 files changed, 3901 insertions(+), 883 deletions(-) create mode 100644 docs/active-plans/v5/3notch-v5-durable-inbox-plan.md create mode 100644 docs/active-plans/v5/v5-implementation-log.md create mode 100644 docs/guides/durable-inbox.md create mode 100644 src/cli/commands/inbox.ts create mode 100644 src/core/inbox-service.ts create mode 100644 src/core/local-inbox-adapter.ts create mode 100644 src/schemas/inbox-config.schema.json create mode 100644 src/schemas/inbox-delivery.schema.json create mode 100644 src/types/inbox.ts create mode 100644 tests/cli/inbox.test.ts create mode 100644 tests/e2e/durable-inbox-smoke.test.ts create mode 100644 tests/mcp/inbox-tools.test.ts create mode 100644 tests/schema/inbox-schemas.test.ts create mode 100644 tests/unit/inbox-service.test.ts create mode 100644 tests/unit/local-inbox-adapter.test.ts diff --git a/AGENTS.md b/AGENTS.md index 85c517a..6f0be8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,8 @@ CLI commands: - `notch brief`, `notch brief create`, `notch brief list`, `notch brief show ` - `notch packet create`, `notch packet import `, `notch packet import -`, `notch packet list`, `notch packet show `, `notch packet preview ` - `notch packet pack `, `notch packet unpack ` +- `notch inbox init`, `notch inbox list`, `notch inbox pull `, `notch inbox ack ` +- `notch send --to ` - `notch seed from ` - `notch prompt --client ` - `notch scan ` @@ -51,13 +53,13 @@ MCP tools: - `create_seed_packet`, `import_seed_packet` - `create_mark`, `create_reply`, `check_store` - `get_status`, `run_doctor` +- `inbox_init`, `send_packet`, `list_inbox`, `pull_inbox_packet`, `ack_inbox_delivery` ## Deferred (do not implement) These are deliberately out of scope. Same-repo same-tool continuity is solved by CLAUDE.md, native tool memory, and `git commit`. 3Notch's wedge is cross-boundary transport. - `notch pass`, MCP `create_pass`/`get_latest_pass`/`get_recent_passes` -- `notch send` (sugar for create + import; the two-step is fine) - `notch decision *`, MCP `record_decision`/`get_decisions` - `notch question *`, MCP `add_open_question`/`get_open_questions` - `notch conflict *`, MCP `create_conflict`/`list_conflicts`/`resolve_conflict` @@ -70,6 +72,7 @@ A regression-guard test (`tests/unit/no-deferred-commands.test.ts`) prevents acc - When the user presents a product idea or asks "is this the right path?", discuss and pressure-test it before changing specs, plans, or repo files. - Treat cross-repo packets, cross-tool handoff, and private context seeding as the core product loops. - No telemetry, cloud sync, hosted service, dashboard, vector database, or SQLite/native DB. +- Durable inbox remains a dependency-free local filesystem spool. Do not turn it into chat, a daemon, hosted identity, background sync, or model invocation. - Do not build hidden chat or project scraping. MCP tools write selected or summarized context the user or agent explicitly supplies during a session. - Preserve human-readable `.notch/` source files as the source of truth. - Use `.notch/outbox/` for created packets, `.notch/inbox/` for imported packets, and ignored `.notch/private/` for user preferences, workflow conventions, and seed packets. diff --git a/CHANGELOG.md b/CHANGELOG.md index 70f32f9..0dbe659 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [0.6.0] - 2026-08-04 + +### Added +- Explicit local durable inbox commands: `notch inbox init/list/pull/ack` and `notch send` for asynchronous packed-packet delivery between separately configured stores. +- Matching vendor-neutral MCP tools for Claude, Codex, Grok, Cursor, and other stdio MCP clients, with structured output schemas and safety annotations. +- Retained delivery state, deterministic retry identity, redacted lifecycle audit events, and an end-to-end request/reply test across two clean stores. + +### Security +- Durable delivery fails closed on unregistered recipients, traversal-shaped addresses, symlinks, malformed metadata, conflicting retries, changed hashes, private/seed packets, secret-bearing content, and configured size limits. +- Archive reads now cap compressed bytes, decompressed bytes, and entry count, and reject invalid tar checksums, numeric fields, padding, duplicate paths, and trailing data. +- Refreshed dependencies and pinned `tsup` to a patched esbuild line; `npm audit` reports zero known vulnerabilities. + +### Changed +- Packed packet import remains the authority for received content; durable inbox adds delivery around the existing packet, artifact, secret-scan, relationship, and review model without a daemon, database, account, or hosted relay. + ## [0.5.0] - 2026-07-19 ### Added diff --git a/README.md b/README.md index 37decbb..8fef3e3 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,26 @@ notch packet pack notch packet unpack .notchpkt ``` +### Async agents or repos — use durable inbox + +When two agents cannot rely on one live chat or shared store, point both at one explicit local mailbox root: + +```bash +# Run once in each store with a unique name and the same root +notch inbox init --name review-agent --root /shared/3notch-mailbox + +# Pack and send from the source +notch packet pack +notch send .notchpkt --to local:review-agent + +# Verify and import at the receiver +notch inbox list +notch inbox pull --import +notch inbox ack +``` + +The local address is a routing label, not authenticated identity. Deliveries are hashed, validated, scanned, retained after ack, and traced locally. See [Durable inbox](docs/guides/durable-inbox.md). + ### Browser-only chats (fallback) Web chats that can't reach local MCP use a clipboard bridge: @@ -113,7 +133,7 @@ Checkpoints and packets are plain Markdown files with structured frontmatter. Yo 3. You preview the checkpoint before another tool relies on it. 4. The next session, tool, or repo reads and resumes from it. -Targeting fields (`--to-agent`, `--to-repo`) are routing metadata, not delivery controls — your existing transport moves the bytes. +Targeting fields (`--to-agent`, `--to-repo`) remain intent metadata. Manual transports move archives directly; durable inbox uses a separate registered `local:` address as its delivery control. ## Commands @@ -124,6 +144,8 @@ notch packet import import a packet into .notch/inbox/ notch packet preview show what an agent will read notch packet pack produce a .notchpkt archive notch packet unpack import a .notchpkt archive +notch inbox init/list/pull/ack configure and process durable deliveries +notch send --to
send a packed project handoff notch packet list / show list / inspect packets notch reply typed reply to a packet notch mark self-addressed private capture @@ -141,8 +163,8 @@ notch mcp serve local stdio MCP server `notch mcp serve` exposes these tools over local stdio: -- Read-only: `get_brief`, `list_briefs`, `get_targeted_brief`, `get_packet`, `list_packets`, `get_status`, `check_store`, `run_doctor` -- Write: `create_brief`, `create_packet`, `create_mark`, `create_reply`, `create_seed_packet`, `import_packet`, `import_seed_packet` +- Read-only: `get_brief`, `list_briefs`, `get_targeted_brief`, `get_packet`, `list_packets`, `list_inbox`, `get_status`, `check_store`, `run_doctor` +- Write: `create_brief`, `create_packet`, `create_mark`, `create_reply`, `create_seed_packet`, `import_packet`, `import_seed_packet`, `inbox_init`, `send_packet`, `pull_inbox_packet`, `ack_inbox_delivery` Private records under `.notch/private/` are hidden unless the server starts with `--include-private`. See [docs/guides/mcp-setup.md](docs/guides/mcp-setup.md) for client-specific setup. @@ -154,6 +176,7 @@ Full index: [docs/README.md](docs/README.md). | --- | --- | | Move context between repos | [docs/guides/cross-repo-packets.md](docs/guides/cross-repo-packets.md) | | Move context between tools | [docs/guides/cross-tool-handoff.md](docs/guides/cross-tool-handoff.md) | +| Deliver packets asynchronously | [docs/guides/durable-inbox.md](docs/guides/durable-inbox.md) | | Carry preferences into a new repo | [docs/guides/private-context-seeding.md](docs/guides/private-context-seeding.md) | | Brief an agent on scoped work | [docs/guides/targeted-brief-workflow.md](docs/guides/targeted-brief-workflow.md) | | Set up continuation checkpoints | [docs/guides/continuation-checkpoints.md](docs/guides/continuation-checkpoints.md) | @@ -170,7 +193,7 @@ Full index: [docs/README.md](docs/README.md). - No telemetry. - No vector database or native database dependency. - No arbitrary shell execution through MCP. -- Your existing tools move bytes; 3Notch validates, scans, and stores them. +- Manual transports or the explicit local durable inbox move bytes; 3Notch validates, scans, hashes, and stores them. A regression-guard test (`tests/unit/no-deferred-commands.test.ts`) prevents accidental scope creep. @@ -185,7 +208,8 @@ A regression-guard test (`tests/unit/no-deferred-commands.test.ts`) prevents acc - **Claude Code → web-chat sharing** (reverse of the current bridge). **Moving packets between machines and people** -- **Cross-machine transport recipes** for Tailscale, iCloud, Syncthing, git, scp/rsync — opinionated adapter docs. +- **Hosted authenticated transport** that preserves the durable-inbox CLI/MCP contract without putting accounts or network listeners in the local core. +- **Cross-machine transport recipes** for Tailscale, iCloud, Syncthing, git, scp/rsync — opinionated adapter docs and honest sync guarantees. - **Cross-user / teammate workflows** — conventions for handing packets through whatever channel a team already uses. **Distribution polish** diff --git a/docs/README.md b/docs/README.md index b7ccf13..d117561 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ Task-shaped how-tos for the main flows. - [Cross-repo packets](guides/cross-repo-packets.md) — create, preview, import; `--file` vs `--ref`; pack/unpack. - [Cross-tool handoff](guides/cross-tool-handoff.md) — same-store, cross-repo, cross-machine, and web-chat shapes. +- [Durable inbox](guides/durable-inbox.md) — asynchronous packet delivery through an explicit local mailbox root. - [Private context seeding](guides/private-context-seeding.md) — carrying reviewed preferences into a new repo. - [Targeted brief workflow](guides/targeted-brief-workflow.md) — scoped task briefs vs cross-boundary packets. - [MCP setup](guides/mcp-setup.md) — server flags, available tools, agent instruction packs. diff --git a/docs/active-plans/v5/3notch-v5-durable-inbox-plan.md b/docs/active-plans/v5/3notch-v5-durable-inbox-plan.md new file mode 100644 index 0000000..7595b50 --- /dev/null +++ b/docs/active-plans/v5/3notch-v5-durable-inbox-plan.md @@ -0,0 +1,77 @@ +# 3Notch V5 — Durable Inbox + +Status: implementation complete; ready for review +Branch: `agent/2026-08-04-durable-inbox` +Base: `a9006ca` + +## Goal + +Let two separately configured 3Notch stores exchange immutable `.notchpkt` files asynchronously through one explicit local mailbox root. Preserve the existing packet, import, reply, privacy, audit, and MCP model. + +## Product Contract + +```text +notch inbox init --name --root +notch send --to local: +notch inbox list [--all] +notch inbox pull [--import] [--as-reviewed] +notch inbox ack +``` + +Matching MCP tools: + +- `inbox_init` +- `send_packet` +- `list_inbox` +- `pull_inbox_packet` +- `ack_inbox_delivery` + +The CLI and MCP return the same small structured result fields. Tool descriptions and server instructions explain the pack → send → list → pull/import → ack sequence. + +## Simplicity Boundary + +V5 is a filesystem spool around existing packets. It is not chat, sync, identity, hosting, agent invocation, or a database. It adds no daemon or dependency. The local adapter does not interpret packet content. The service layer reuses existing packet validation before delivery and import. + +The CLI/MCP schemas are the stable boundary for a later authenticated Streamable HTTP adapter or plugin. That future layer can serve Claude, Codex, Grok, and other MCP clients without vendor branches in the inbox core. + +## Storage + +Each store keeps ignored machine-local config at `.notch/inbox-config.json`: + +```json +{ + "schemaVersion": "1.0.0", + "transport": "local", + "name": "review-agent", + "address": "local:review-agent", + "root": "/explicit/mailbox/root" +} +``` + +The root contains recipient markers and immutable delivery directories. Each delivery contains validated metadata, exact archive bytes, and a SHA-256 sidecar. Delivery state is `pending`, `pulled`, `acked`, or `rejected`; ack never deletes bytes. + +Delivery IDs are deterministic from the packet ID. Same ID + same archive hash is an idempotent retry. Same ID + different bytes is a conflict. + +## Security And Durability + +- Require an explicit root and a registered recipient; typo addresses fail. +- Reject unsafe names, traversal, symlinks inside the managed tree, non-regular files, malformed metadata, archive bombs/oversize input, hash mismatch, private packets, seed packets, secret-bearing packets, and invalid packet/artifact schemas. +- Validate and scan before the archive enters the shared root; validate the byte hash again before pull/import. +- Stage a complete delivery beside its destination and rename once; never expose partial delivery directories. +- Serialize recipient mutations using the existing short-lived filesystem lock and clean failed temp state. +- Keep address labels honest: local labels are not authenticated identities, encryption, or authorization. +- Append local redacted audit events for init, send, pull/import, ack, rejection, and failure. Never log packet content, credentials, or payment/auth data. + +## Verification + +- Unit: config/address/metadata/state validation, adapter CRUD, idempotent and conflicting retries, corruption, unsafe paths/symlinks, limits, private/seed/secret rejection, cleanup, audit events, and concurrency. +- CLI: useful human output, JSON parity, missing-config guidance, full state loop. +- MCP: schemas, annotations, read-only enforcement, structured results, full tool loop. +- E2E: two clean repos exchange request and reply, preserve `replyTo`, pass `notch check`, reject corruption/conflicts, and produce valid traces. +- Package: install packed tarball into a clean temp prefix and repeat the request/reply loop with built CLI. +- Repository: lint, type-check, build, all tests, e2e, no-secret diff, dependency audit review, clean focused commit. + +## Non-goals + +- Hosted relay, remote MCP transport, OAuth, accounts, keys, signatures, encryption, team ACLs, retention/delete, background polling, guaranteed sync-folder semantics, or model-to-model invocation. +- Changes to packet meaning, relationship semantics, `notch check`, private MCP visibility, or Stoa commercial logic. diff --git a/docs/active-plans/v5/v5-implementation-log.md b/docs/active-plans/v5/v5-implementation-log.md new file mode 100644 index 0000000..0c47a4e --- /dev/null +++ b/docs/active-plans/v5/v5-implementation-log.md @@ -0,0 +1,14 @@ +# V5 Implementation Log + +## 2026-08-04 + +- Activated the durable-inbox plan on isolated branch `agent/2026-08-04-durable-inbox` from upstream `a9006ca` after confirming PRs #1-#3 are merged. +- Reviewed the proposed CLI against current packet/import/MCP/store-lock behavior and corrected the missing transport-root contract. +- Kept V5 as a dependency-free local spool: explicit root, registered local address, deterministic delivery identity, sender-side preflight, hash-before-import, retained acknowledgements, vendor-neutral MCP schemas, and redacted local lifecycle audit events. +- Baseline lint, type-check, build, and 22 e2e tests passed. The current dependency lock reports 11 npm advisories; remediation will be tested separately from inbox behavior. +- Implemented the dependency-free local adapter and service: ignored config, safe local addresses, registered recipients, deterministic delivery IDs, atomic staging, recipient serialization, strict metadata schemas, retained states, hash-before-pull, pre-send/pre-import archive/schema/artifact/secret validation, private/seed blocking, decompression and size caps, and redacted lifecycle audit events. +- Added the CLI (`send`, `inbox init/list/pull/ack`) and five matching MCP tools with shared structured results, focused descriptions, output schemas, write/read annotations, and concise cross-model workflow instructions. +- Core verification passes: lint, type-check, and 16 focused adapter/service tests including 20 simultaneous sends, 10 simultaneous import pulls, retry idempotency, conflicting bytes, corruption/rejection, unsafe addresses, symlinks, malformed stores, secret/private blocking, archive bounds, retained ack history, and audit traces. +- Hardened the archive boundary for untrusted delivery: reject oversized files before reading, bound decompression and entry count, verify tar header checksums and numeric fields, and reject malformed padding, duplicate paths, unsafe entries, missing end markers, and trailing data. +- Refreshed the lockfile without forced major upgrades. The source graph and clean installed tarball both report zero npm advisories; a scoped `tsup` override keeps esbuild on the patched `0.28.1` line. +- Final verification passed after a clean `npm ci`: lint, type-check, build, 234 tests across 78 files, and 23 e2e tests across 11 files. The packed `@3notch/cli@0.6.0` tarball installed into an empty prefix and completed a real request → import → typed reply → import → ack loop with lineage preserved and both stores passing `notch check`. diff --git a/docs/guides/cross-repo-packets.md b/docs/guides/cross-repo-packets.md index 72ed27f..493159c 100644 --- a/docs/guides/cross-repo-packets.md +++ b/docs/guides/cross-repo-packets.md @@ -17,7 +17,7 @@ notch packet create \ The packet is written to `.notch/outbox/`. When `--file` is used, it lands as a folder containing `packet.md`, `manifest.json`, and copied bytes under `artifacts/`, preserving each file's project-relative path. Without `--file`, it lands as a single `.md` file. -Recipient fields (`--to-agent`, `--to-person`, `--to-repo`) label intent. They do not authenticate or deliver anything — local filesystems and your own transport (scp, rsync, iCloud, Tailscale, email, git) move the bytes. +Recipient fields (`--to-agent`, `--to-person`, `--to-repo`) label intent. They do not authenticate or deliver anything. Manual transports move the bytes directly; [durable inbox](durable-inbox.md) uses a separately registered `local:` address. ## File vs Reference diff --git a/docs/guides/cross-tool-handoff.md b/docs/guides/cross-tool-handoff.md index 4bb870c..e2a1036 100644 --- a/docs/guides/cross-tool-handoff.md +++ b/docs/guides/cross-tool-handoff.md @@ -58,6 +58,12 @@ notch packet unpack .notchpkt `.notchpkt` is a gzipped tar with deterministic entry ordering. Hashes are re-verified at unpack against the manifest before anything lands in the inbox. +## Async Agents Through A Local Mailbox + +When the source and receiver operate at different times, configure one explicit durable-inbox root and exchange registered `local:` addresses. The sender packs once; the receiver lists, verifies/imports, and acknowledges later. The same tools are available over MCP, so the two sides may use different model clients. + +See [durable-inbox.md](durable-inbox.md). This flow is local filesystem delivery, not authenticated identity, hosted chat, or guaranteed network sync. + ## Web-Chat (no local MCP) For Claude.ai and other browser chats that can't reach a local MCP server, use the bridge prompt and stdin import: @@ -74,6 +80,6 @@ See [../prompts/web-chat-to-project.md](../prompts/web-chat-to-project.md) for t - The source must hand over the context it wants stored. 3Notch does not derive it for you. - Source links and exclusions belong in the packet, not as out-of-band conversation. -- Same-store handoff is fastest. Cross-repo and cross-machine require an explicit import. +- Same-store handoff is fastest. Manual cross-repo/cross-machine movement and durable inbox both require an explicit validated import. - Inbox packets are immutable. To change context, author a successor with `--supersedes` or a typed reply with `notch reply`. - Private seed records (`.notch/private/`) are hidden from MCP unless the server is started with `--include-private`. diff --git a/docs/guides/durable-inbox.md b/docs/guides/durable-inbox.md new file mode 100644 index 0000000..3706f29 --- /dev/null +++ b/docs/guides/durable-inbox.md @@ -0,0 +1,126 @@ +# Durable Inbox + +Durable inbox is a local filesystem spool for asynchronous `.notchpkt` handoff. Use it when two agents or repos cannot rely on one live chat or one shared `.notch/` store, but can intentionally access the same local or mounted mailbox root. + +It adds delivery, not chat. Packets remain the unit of context, existing import validation remains authoritative, and acknowledgements retain the original archive. + +## Configure Both Sides + +Choose one explicit mailbox root. Each store registers its own address: + +```bash +# In the sender repo +notch inbox init --name build-agent --root /shared/3notch-mailbox + +# In the receiver repo +notch inbox init --name review-agent --root /shared/3notch-mailbox +``` + +The commands print `local:build-agent` and `local:review-agent`. Configuration lives in ignored `.notch/inbox-config.json`; the absolute root is machine-local and is not added to packet records. + +Every sender should have an address so a reply has a clear return destination. A name is a safe routing label, not authenticated identity. + +## Send + +Create and pack an ordinary project handoff, then send the archive: + +```bash +notch packet create \ + --title "Review auth change" \ + --summary "Auth change is ready for an independent review." \ + --to-agent review-agent \ + --file src/auth.ts:source \ + --next-steps "Review src/auth.ts and reply with findings." + +notch packet pack +notch send .notchpkt --to local:review-agent +``` + +Before a shared-mailbox write, 3Notch checks that the source is a regular `.notchpkt`, caps compressed and unpacked work, validates packet and artifact schemas, verifies artifact hashes, scans packet text and text-like artifacts, and blocks private or seed packets. The recipient must already be registered; a typo does not create a new mailbox. + +Retrying the same packet ID and exact bytes is harmless and returns the existing delivery. Reusing a packet ID with different archive bytes fails. + +## Receive + +List pending deliveries: + +```bash +notch inbox list +``` + +Verify without importing: + +```bash +notch inbox pull +``` + +Verify and import through normal 3Notch validation: + +```bash +notch inbox pull --import +notch packet preview --inbox +notch check +``` + +After review, acknowledge it: + +```bash +notch inbox ack +``` + +Ack never deletes packet bytes. `notch inbox list` shows pending only; `notch inbox list --all` includes pulled, acknowledged, and rejected history. + +## Reply + +Reply to the imported packet normally, then pack and send the reply to the original sender's address: + +```bash +notch reply \ + --type confirmation \ + --summary "Review complete." \ + --next-steps "Continue from the confirmed handoff." + +notch packet pack +notch send .notchpkt --to local:build-agent +``` + +The existing `replyTo` relationship survives delivery and import. + +## MCP And Cross-Model Use + +The local stdio MCP server exposes the same workflow: + +- `inbox_init` +- `send_packet` +- `list_inbox` +- `pull_inbox_packet` +- `ack_inbox_delivery` + +This is the neutral boundary for Claude, Codex, Grok, Cursor, ChatGPT, or any other MCP client that can run the 3Notch stdio server. Tools return small structured results with delivery ID, packet ID, byte hash, state, path, optional imported packet ID, and next action. + +`send_packet` is annotated as an externally visible write. List is read-only. Pull and ack mutate state; ack is non-destructive. Client approval and `.notch/config.json` write-tool policy still apply. + +A future remote adapter can expose the same contracts over authenticated MCP Streamable HTTP. It does not need to add model-to-model calls, hosted identity, or vendor branches to this local core. + +## Local Traces + +Lifecycle events append to ignored `.notch/logs/audit.jsonl`: + +- `inbox-init` +- `inbox-send` +- `inbox-pull` +- `inbox-ack` +- `inbox-reject` + +Events include routing labels, delivery and packet IDs, SHA-256, byte count, state, actor/source tool, and error code when relevant. They do not include packet content or credentials. + +## Honest Limits + +- The local adapter does not authenticate addresses, authorize users, sign senders, or encrypt archives. +- Anyone with filesystem access to the mailbox root may be able to read or change it. Use OS permissions and disk encryption appropriate to the data. +- SHA-256 detects changed bytes; it does not prove who authored them. +- One-host locking and atomic renames are tested. Dropbox, iCloud, Syncthing, network mounts, and similar products have their own conflict and availability behavior; V1 does not claim distributed locks or guaranteed delivery across them. +- There is no daemon, polling loop, notification service, account, hosted relay, retention deletion, or remote network listener. +- Private and seed packets are intentionally blocked from this shared-mailbox flow. + +If no config exists, run `notch inbox init`. If a delivery is rejected, use `notch inbox list --all`, preserve the evidence, and ask the sender to retry from the original archive rather than editing mailbox files. diff --git a/docs/guides/mcp-setup.md b/docs/guides/mcp-setup.md index ea0079d..aea6277 100644 --- a/docs/guides/mcp-setup.md +++ b/docs/guides/mcp-setup.md @@ -13,6 +13,7 @@ notch onboard --yes --mcp claude-code notch onboard --yes --mcp claude-desktop notch onboard --yes --mcp codex notch onboard --yes --mcp cursor +notch onboard --yes --mcp grok ``` For opt-in Claude Code continuation checkpoints, configure MCP and hooks together: @@ -25,6 +26,8 @@ This safely merges command hooks into `.claude/settings.local.json`. See [Contin `notch onboard --mcp claude-code` writes a project-local `.mcp.json`. `notch onboard --mcp claude-desktop` updates `~/Library/Application Support/Claude/claude_desktop_config.json` with a backup. Other clients print copy-paste snippets. +For Grok CLI, 3Notch prints the project-scoped `grok mcp add` command and a `grok mcp doctor` verification command. Grok can also reuse a compatible project `.mcp.json`; 3Notch does not duplicate that file when it already exists. + ## Server Flags ```bash @@ -41,6 +44,7 @@ Read-only: - `get_brief`, `list_briefs`, `get_targeted_brief` - `get_packet`, `list_packets` - `get_status`, `check_store`, `run_doctor` +- `list_inbox` — list pending or retained durable deliveries Write tools (require client tool-permission grant): @@ -50,6 +54,12 @@ Write tools (require client tool-permission grant): - `create_reply` — typed packet reply - `create_seed_packet` - `import_packet`, `import_seed_packet` +- `inbox_init` — register an explicit local mailbox root and address +- `send_packet` — send an already-packed project packet to a registered `local:` address +- `pull_inbox_packet` — verify and optionally import a delivery +- `ack_inbox_delivery` — acknowledge without deleting retained bytes + +Durable inbox tools use the same schemas and structured results in every MCP client. See [Durable inbox](durable-inbox.md) for the pack → send → list → pull/import → ack flow, local audit events, and security limits. There are no shell-execution, chat-scraping, remote-connector, or background-collection tools. Any tool name not in this list does not exist. diff --git a/docs/reference/privacy.md b/docs/reference/privacy.md index 3985045..b642ade 100644 --- a/docs/reference/privacy.md +++ b/docs/reference/privacy.md @@ -2,7 +2,7 @@ 3Notch is local-first, explicit, and file-based. -Local-first does not mean same-repo only. It means there is no hosted relay, background receiver, account system, cloud inbox, telemetry, or remote connector. Cross-repo and cross-machine movement happens through local files and the user's existing transport (scp, rsync, iCloud, Tailscale, email, git). Cross-tool handoff happens through local MCP calls the user or agent explicitly initiates. +Local-first does not mean same-repo only. It means there is no hosted relay, background receiver, account system, cloud inbox, telemetry, or remote connector. Cross-repo and cross-machine movement happens through explicit local files, the user's existing transport, or an intentionally configured durable mailbox root. Cross-tool handoff happens through local MCP calls the user or agent explicitly initiates. ## What 3Notch Does @@ -12,6 +12,7 @@ Local-first does not mean same-repo only. It means there is no hosted relay, bac - Writes private seed context to `.notch/private/` (gitignored by default). - Hides `.notch/index/` and `.notch/logs/` as derived output (gitignored). - Hides private records from MCP unless the server is started with `--include-private`. +- Stores durable-inbox configuration in ignored `.notch/inbox-config.json`, validates project packets before delivery, and retains acknowledged archives under the chosen mailbox root. - Scans writes for configured patterns, JWT-like strings, private-key markers, and high-entropy token-like strings. - Scans the bytes of text-like artifacts (`.md`, `.html`, source files, etc.) before they are copied into a packet bundle. - When explicitly configured, consumes documented Claude hook fields (`task_*`, `compact_summary`, or `last_assistant_message`) and Git metadata to create continuation fallbacks. @@ -19,7 +20,7 @@ Local-first does not mean same-repo only. It means there is no hosted relay, bac ## What 3Notch Does Not Do - It does not verify another person or device. -- It does not deliver packets to a remote recipient. +- It does not host or deliver packets to an internet recipient. The optional durable inbox writes only to the explicit local or mounted root the user configures. - It does not accept packets from a network listener. - It does not scrape hidden chat databases, browser state, or project history. - It does not open the `transcript_path` supplied with Claude hook events. @@ -41,6 +42,7 @@ You receive a packet only when you explicitly import one: ```bash notch packet import /path/to/packet.md notch packet unpack /path/to/.notchpkt +notch inbox pull --import ``` Review unknown packets with `notch packet preview ` before relying on them. @@ -52,3 +54,4 @@ Review unknown packets with `notch packet preview ` before relying on them. - The scanner is a guardrail, not a proof that content is safe. - Project-sensitivity continuation packets are Git-visible under `.notch/outbox/`; private sensitivity routes them to ignored `.notch/private/outbox/`. - 3Notch is not a policy engine, DLP system, or audit platform. +- Durable inbox blocks private/seed packets and does not provide encryption, authenticated identity, team authorization, or guaranteed sync-folder delivery. diff --git a/docs/reference/security-story.md b/docs/reference/security-story.md index e478140..38c25de 100644 --- a/docs/reference/security-story.md +++ b/docs/reference/security-story.md @@ -29,7 +29,7 @@ The scanner intentionally blocks some benign documentation prose. When that happ ## Audit -Every successful write, blocked write, and skipped artifact scan appends a line to `.notch/logs/audit.jsonl`. The log is local and gitignored, but it gives evidence that a record was created, imported, blocked, or skipped — with timestamps, actor resolution, and source-tool attribution. +Every successful write, blocked write, and skipped artifact scan appends a line to `.notch/logs/audit.jsonl`. Durable inbox also traces init, send, pull/import, ack, rejection, and failure using IDs, hashes, routing labels, byte counts, states, and error codes—not packet content. The log is local and gitignored. ## Review @@ -48,6 +48,7 @@ Received packets are ground truth. - A single-file packet's markdown content is content-hash-checked before any overwrite is permitted. - A packet folder is the immutable unit: `packet.md`, `manifest.json`, and every file under `artifacts/` must continue to match the SHA-256 values recorded in the packet's frontmatter and manifest. - Hash mismatches at import raise `NOTCH_ARTIFACT_HASH_MISMATCH` and abort before any inbox state is written. +- Durable delivery verifies the archive SHA-256 before pull/import. Mismatch marks the retained delivery rejected and aborts before packet import. When context changes, author a successor packet with `--supersedes ` or a typed reply with `notch reply`. Do not mutate an imported packet. @@ -59,4 +60,6 @@ Continuation checkpoints follow the same rule: each stream is an immutable `supe - 3Notch does not encrypt records at rest. Use OS disk encryption and keep `.notch/private/` gitignored. - The scanner is a guardrail, not a proof that content is safe. - Bundle integrity is verified by SHA-256 hashing, not signing. Anyone with write access to a `.notchpkt` archive can produce a valid-looking bundle. Hashing proves bytes did not change in transit; it does not prove who authored them. +- Durable inbox addresses are local labels, not authenticated identities or access control. Anyone with filesystem access to the mailbox root may be able to read or change deliveries. +- Local atomic rename and one-host serialization do not provide distributed locks or guaranteed delivery semantics for sync products and network filesystems. - 3Notch is not a policy engine, DLP system, hosted audit platform, or remote sync service. diff --git a/package-lock.json b/package-lock.json index 918b76f..a4f1668 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@3notch/cli", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@3notch/cli", - "version": "0.5.0", + "version": "0.6.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", @@ -32,44 +32,10 @@ "node": ">=20" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -84,9 +50,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -101,9 +67,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -118,9 +84,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -135,9 +101,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -152,9 +118,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -169,9 +135,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -186,9 +152,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -203,9 +169,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -220,9 +186,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -237,9 +203,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -254,9 +220,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -271,9 +237,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -288,9 +254,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -305,9 +271,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -322,9 +288,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -339,9 +305,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -356,9 +322,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -373,9 +339,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -390,9 +356,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -407,9 +373,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -424,9 +390,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -441,9 +407,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -458,9 +424,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -475,9 +441,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -492,9 +458,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -637,12 +603,12 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz", + "integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==", "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" @@ -754,12 +720,12 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -793,29 +759,10 @@ } } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@oxc-project/types": { - "version": "0.132.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", - "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", "dev": true, "license": "MIT", "funding": { @@ -823,9 +770,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", - "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz", + "integrity": "sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==", "cpu": [ "arm64" ], @@ -840,9 +787,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", - "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.2.tgz", + "integrity": "sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==", "cpu": [ "arm64" ], @@ -857,9 +804,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", - "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.2.tgz", + "integrity": "sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==", "cpu": [ "x64" ], @@ -874,9 +821,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", - "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.2.tgz", + "integrity": "sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==", "cpu": [ "x64" ], @@ -891,9 +838,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", - "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.2.tgz", + "integrity": "sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==", "cpu": [ "arm" ], @@ -908,9 +855,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", - "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.2.tgz", + "integrity": "sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==", "cpu": [ "arm64" ], @@ -928,9 +875,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", - "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.2.tgz", + "integrity": "sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==", "cpu": [ "arm64" ], @@ -948,9 +895,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", - "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.2.tgz", + "integrity": "sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==", "cpu": [ "ppc64" ], @@ -968,9 +915,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", - "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.2.tgz", + "integrity": "sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==", "cpu": [ "s390x" ], @@ -988,9 +935,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", - "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.2.tgz", + "integrity": "sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==", "cpu": [ "x64" ], @@ -1008,9 +955,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", - "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.2.tgz", + "integrity": "sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==", "cpu": [ "x64" ], @@ -1028,9 +975,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", - "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.2.tgz", + "integrity": "sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==", "cpu": [ "arm64" ], @@ -1044,29 +991,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", - "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", - "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.2.tgz", + "integrity": "sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==", "cpu": [ "arm64" ], @@ -1081,9 +1009,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", - "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.2.tgz", + "integrity": "sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==", "cpu": [ "x64" ], @@ -1500,17 +1428,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2009,20 +1926,20 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -2032,17 +1949,30 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/bundle-require": { @@ -2349,9 +2279,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2362,32 +2292,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escape-html": { @@ -2742,9 +2672,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -3003,9 +2933,9 @@ } }, "node_modules/hono": { - "version": "4.12.22", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.22.tgz", - "integrity": "sha512-7fvVPbB92zNRsQke+uiRGwtTuef0tB2Dg4hWxYfFNvkQhIltWoyi0ONReM5LWA+jJWS3nfT5lTq+qbsIpX0IQw==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz", + "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -3074,9 +3004,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" @@ -3155,9 +3085,9 @@ } }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -3227,9 +3157,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -3243,23 +3173,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -3278,9 +3208,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -3299,9 +3229,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -3320,9 +3250,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -3341,9 +3271,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -3362,9 +3292,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], @@ -3386,9 +3316,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], @@ -3410,9 +3340,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], @@ -3434,9 +3364,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], @@ -3458,9 +3388,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -3479,9 +3409,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -3658,9 +3588,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "dev": true, "funding": [ { @@ -3848,9 +3778,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -3892,9 +3822,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "dev": true, "funding": [ { @@ -3912,7 +3842,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4069,13 +3999,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", - "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.2.tgz", + "integrity": "sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.132.0", + "@oxc-project/types": "=0.142.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -4085,21 +4015,20 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.2", - "@rolldown/binding-darwin-arm64": "1.0.2", - "@rolldown/binding-darwin-x64": "1.0.2", - "@rolldown/binding-freebsd-x64": "1.0.2", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", - "@rolldown/binding-linux-arm64-gnu": "1.0.2", - "@rolldown/binding-linux-arm64-musl": "1.0.2", - "@rolldown/binding-linux-ppc64-gnu": "1.0.2", - "@rolldown/binding-linux-s390x-gnu": "1.0.2", - "@rolldown/binding-linux-x64-gnu": "1.0.2", - "@rolldown/binding-linux-x64-musl": "1.0.2", - "@rolldown/binding-openharmony-arm64": "1.0.2", - "@rolldown/binding-wasm32-wasi": "1.0.2", - "@rolldown/binding-win32-arm64-msvc": "1.0.2", - "@rolldown/binding-win32-x64-msvc": "1.0.2" + "@rolldown/binding-android-arm64": "1.2.2", + "@rolldown/binding-darwin-arm64": "1.2.2", + "@rolldown/binding-darwin-x64": "1.2.2", + "@rolldown/binding-freebsd-x64": "1.2.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.2", + "@rolldown/binding-linux-arm64-gnu": "1.2.2", + "@rolldown/binding-linux-arm64-musl": "1.2.2", + "@rolldown/binding-linux-ppc64-gnu": "1.2.2", + "@rolldown/binding-linux-s390x-gnu": "1.2.2", + "@rolldown/binding-linux-x64-gnu": "1.2.2", + "@rolldown/binding-linux-x64-musl": "1.2.2", + "@rolldown/binding-openharmony-arm64": "1.2.2", + "@rolldown/binding-win32-arm64-msvc": "1.2.2", + "@rolldown/binding-win32-x64-msvc": "1.2.2" } }, "node_modules/rollup": { @@ -4482,9 +4411,9 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -4547,14 +4476,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, "node_modules/tsup": { "version": "8.5.1", "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", @@ -4627,490 +4548,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -5236,17 +4673,17 @@ } }, "node_modules/vite": { - "version": "8.0.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", - "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.2", - "tinyglobby": "^0.2.16" + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -5262,7 +4699,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/package.json b/package.json index 42df72c..66a52b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@3notch/cli", - "version": "0.5.0", + "version": "0.6.0", "description": "Save the working state your AI tools won't. Local checkpoints and portable packets across tools, repos, and machines.", "type": "module", "license": "MIT", @@ -68,5 +68,10 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.59.4", "vitest": "^4.1.7" + }, + "overrides": { + "tsup": { + "esbuild": "^0.28.1" + } } } diff --git a/src/cli/commands/inbox.ts b/src/cli/commands/inbox.ts new file mode 100644 index 0000000..b9e97d3 --- /dev/null +++ b/src/cli/commands/inbox.ts @@ -0,0 +1,214 @@ +import type { Command } from 'commander'; + +import { getCliContext } from '../context.js'; +import { printInfo, printJson } from '../output.js'; +import { loadConfig } from '../../core/config-service.js'; +import { + ackInboxDelivery, + initializeDurableInbox, + listInboxDeliveries, + pullInboxDelivery, + sendInboxPacket, +} from '../../core/inbox-service.js'; +import { NotchException } from '../../types/errors.js'; + +type InboxInitOptions = { + name?: string; + root?: string; +}; + +type InboxListOptions = { + all?: boolean; +}; + +type InboxPullOptions = { + asReviewed?: boolean; + import?: boolean; +}; + +type SendOptions = { + to?: string; +}; + +export function registerInboxCommand(program: Command): void { + program + .command('send') + .description('send a packed packet through the configured durable inbox') + .argument('', 'regular .notchpkt file created by notch packet pack') + .requiredOption('--to
', 'registered local recipient address') + .action(async (packetPath: string, options: SendOptions, command: Command) => { + const context = getCliContext(command); + const loaded = await loadCurrentConfig(context); + const result = await sendInboxPacket(loaded, { + packetPath, + to: required(options.to, '--to'), + }, actorOptions(context)); + + if (context.output.json) { + printJson(result); + return; + } + + printInfo( + result.idempotent + ? `Already delivered packet ${result.packetId} as ${result.deliveryId}` + : `Sent packet ${result.packetId} as ${result.deliveryId}`, + context.output, + ); + printInfo(`To: ${options.to}`, context.output); + printInfo(`SHA-256: ${result.packetHash}`, context.output); + printInfo(`Next: ${result.nextAction}`, context.output); + }); + + const inbox = program.command('inbox').description('configure and process durable packet deliveries'); + + inbox + .command('init') + .description('register this store in an explicit local mailbox root') + .requiredOption('--name ', 'local recipient name') + .requiredOption('--root ', 'local or shared mailbox root') + .action(async (options: InboxInitOptions, command: Command) => { + const context = getCliContext(command); + const loaded = await loadCurrentConfig(context); + const result = await initializeDurableInbox(loaded, { + name: required(options.name, '--name'), + root: required(options.root, '--root'), + }, actorOptions(context)); + + if (context.output.json) { + printJson(result); + return; + } + + printInfo( + result.alreadyInitialized + ? `Durable inbox already ready at ${result.address}` + : `Durable inbox ready at ${result.address}`, + context.output, + ); + printInfo(`Mailbox: ${result.root}`, context.output); + printInfo(`Next: ${result.nextAction}`, context.output); + }); + + inbox + .command('list') + .description('list pending durable deliveries') + .option('--all', 'include pulled, acknowledged, and rejected deliveries') + .action(async (options: InboxListOptions, command: Command) => { + const context = getCliContext(command); + const loaded = await loadCurrentConfig(context); + const result = await listInboxDeliveries(loaded, { includeAll: Boolean(options.all) }); + + if (context.output.json) { + printJson(result); + return; + } + + if (result.deliveries.length === 0) { + printInfo(`Inbox empty for ${result.address}`, context.output); + printInfo(`Next: ${result.nextAction}`, context.output); + return; + } + + for (const delivery of result.deliveries) { + printInfo([ + delivery.deliveryId, + delivery.state, + delivery.from, + delivery.packetId, + `${delivery.bytes} bytes`, + delivery.createdAt, + ].join('\t'), context.output); + } + + printInfo(`Next: ${result.nextAction}`, context.output); + }); + + inbox + .command('pull') + .description('verify a delivery and optionally import it into this store') + .argument('') + .option('--import', 'import through normal 3Notch validation after byte verification') + .option('--as-reviewed', 'mark an imported packet as reviewed') + .action(async (deliveryId: string, options: InboxPullOptions, command: Command) => { + if (options.asReviewed && !options.import) { + throw new NotchException({ + code: 'NOTCH_INBOX_IMPORT_REQUIRED', + message: '--as-reviewed requires --import.', + recovery: 'Add --import, or remove --as-reviewed to verify without importing.', + severity: 'error', + exitCode: 1, + }); + } + + const context = getCliContext(command); + const loaded = await loadCurrentConfig(context); + const result = await pullInboxDelivery(loaded, { + deliveryId, + import: Boolean(options.import), + asReviewed: Boolean(options.asReviewed), + }, actorOptions(context)); + + if (context.output.json) { + printJson(result); + return; + } + + printInfo( + result.importedPacketId + ? `Imported packet ${result.importedPacketId} from ${result.deliveryId}` + : `Verified delivery ${result.deliveryId}`, + context.output, + ); + printInfo(result.packetPath, context.output); + printInfo(`SHA-256: ${result.packetHash}`, context.output); + printInfo(`Next: ${result.nextAction}`, context.output); + }); + + inbox + .command('ack') + .description('acknowledge a processed delivery without deleting it') + .argument('') + .action(async (deliveryId: string, _options: object, command: Command) => { + const context = getCliContext(command); + const loaded = await loadCurrentConfig(context); + const result = await ackInboxDelivery(loaded, { deliveryId }, actorOptions(context)); + + if (context.output.json) { + printJson(result); + return; + } + + printInfo(`Acknowledged delivery ${result.deliveryId}`, context.output); + printInfo('Packet bytes retained for audit.', context.output); + }); +} + +function actorOptions(context: ReturnType) { + return { + ...(context.actor ? { actor: context.actor } : {}), + ...(context.agent ? { agent: context.agent } : {}), + ...(context.sourceTool ? { sourceTool: context.sourceTool } : {}), + }; +} + +async function loadCurrentConfig(context: ReturnType) { + return await loadConfig({ + ...(context.cwd ? { cwd: context.cwd } : {}), + ...(context.store ? { store: context.store } : {}), + }); +} + +function required(value: string | undefined, flag: string): string { + if (!value) { + throw new NotchException({ + code: 'NOTCH_OPTION_REQUIRED', + message: `Missing required option ${flag}.`, + recovery: `Re-run with ${flag} .`, + severity: 'error', + exitCode: 1, + }); + } + + return value; +} diff --git a/src/cli/commands/onboard.ts b/src/cli/commands/onboard.ts index e57572c..01ffca2 100644 --- a/src/cli/commands/onboard.ts +++ b/src/cli/commands/onboard.ts @@ -412,6 +412,10 @@ function defaultConfig(projectName: string, projectRoot: string): NotchConfig { 'create_reply', 'create_seed_packet', 'import_seed_packet', + 'inbox_init', + 'send_packet', + 'pull_inbox_packet', + 'ack_inbox_delivery', ], }, }; @@ -477,7 +481,7 @@ This project uses 3Notch for explicit, reviewable context handoffs between AI to 2. Use \`notch packet list\`, \`notch packet show \`, and \`notch packet preview \` to inspect existing handoffs. 3. When the user asks you to hand context to another repo, person, or tool, create a packet with \`notch packet create\`. 4. Use \`--file \` when the receiver needs copied bytes. Copied files keep their project-relative path under \`artifacts/\`. If you are unsure, omit \`:purpose\`; the default is \`asset\`. Valid artifact purposes are \`asset\`, \`source\`, \`reference\`, and \`output\`; common labels like \`favicon\`, \`icon\`, \`logo\`, and \`image\` are accepted as \`asset\`. Use \`--ref \` only when the receiver shares the same filesystem path. -5. Use \`notch packet pack \` and \`notch packet unpack \` when a packet needs to move between machines. +5. Use \`notch packet pack \` and \`notch packet unpack \` for manual cross-machine movement. Use \`notch inbox init\`, \`notch send\`, \`notch inbox pull --import\`, and \`notch inbox ack\` only when the user intentionally configures a shared local mailbox root. 6. Run \`notch check\` after imports or relationship-heavy changes. Run \`notch doctor\` when the store seems unhealthy. ## Continuation Checkpoints @@ -490,7 +494,7 @@ Claude Code checkpoints are opt-in through \`notch onboard --mcp claude-code --c - Do not scrape hidden chat history or private project files. - Do not expose \`.notch/private/\` unless the user intentionally enables private context. - Do not edit imported inbox packets in place. Create a successor packet or a typed reply instead. -- 3Notch does not send packets over a network. The user chooses how packet files move. +- Durable inbox writes only to an explicit local filesystem root. It does not run a network listener or authenticate recipient labels. ## Useful Commands @@ -499,6 +503,10 @@ notch packet create --title "Handoff" --summary "..." --to-agent codex --file pa notch packet preview notch packet pack notch packet unpack .notchpkt +notch inbox init --name review-agent --root /shared/3notch-mailbox +notch send .notchpkt --to local:review-agent +notch inbox pull --import +notch inbox ack notch check notch doctor \`\`\` diff --git a/src/cli/mcp-instructions.ts b/src/cli/mcp-instructions.ts index ab4b5d9..db281a1 100644 --- a/src/cli/mcp-instructions.ts +++ b/src/cli/mcp-instructions.ts @@ -1,7 +1,7 @@ import os from 'node:os'; import path from 'node:path'; -export type McpConfigClient = 'claude-code' | 'claude-desktop' | 'codex' | 'cursor' | 'chatgpt-desktop'; +export type McpConfigClient = 'claude-code' | 'claude-desktop' | 'codex' | 'cursor' | 'grok' | 'chatgpt-desktop'; export type McpServerDefinition = { args: string[]; @@ -79,6 +79,18 @@ ${jsonSnippet(server)} Private seed packets stay hidden unless you intentionally add --include-private to args.`; + case 'grok': + return `${clientHeading('Grok')} +Project config: ${path.join(projectRoot, '.grok', 'config.toml')} + +Add the local stdio server: +grok mcp add --scope project 3notch -- ${serverCommand} + +Verify it: +grok mcp doctor 3notch + +Grok can also read a project .mcp.json created for another compatible client. Private seed packets stay hidden unless you intentionally add --include-private.`; + case 'chatgpt-desktop': return `${clientHeading('ChatGPT Desktop')} Config file: ${chatGptDesktopConfigPath()} diff --git a/src/cli/program.ts b/src/cli/program.ts index 0fc8c15..357ba5f 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -5,6 +5,7 @@ import { registerBriefCommand } from './commands/brief.js'; import { registerCheckCommand } from './commands/check.js'; import { registerDoctorCommand } from './commands/doctor.js'; import { registerHookCommand } from './commands/hook.js'; +import { registerInboxCommand } from './commands/inbox.js'; import { registerMarkCommand } from './commands/mark.js'; import { registerMcpCommand } from './commands/mcp.js'; import { registerOnboardCommand } from './commands/onboard.js'; @@ -41,6 +42,8 @@ Examples: $ notch packet create --title "Auth handoff" --to-agent codex --summary "..." --file src/auth.ts --next-steps "Review auth flow." $ notch packet pack $ notch packet unpack .notchpkt + $ notch inbox init --name review-agent --root /shared/3notch-mailbox + $ notch send .notchpkt --to local:review-agent $ notch scan README.md $ notch seed from ../old-project --review $ notch mcp serve @@ -53,6 +56,7 @@ Examples: registerReplyCommand(program); registerBriefCommand(program); registerPacketCommand(program); + registerInboxCommand(program); registerSeedCommand(program); registerStatusCommand(program); registerCheckCommand(program); diff --git a/src/cli/prompts/shared.ts b/src/cli/prompts/shared.ts index 9e8aa06..84c0304 100644 --- a/src/cli/prompts/shared.ts +++ b/src/cli/prompts/shared.ts @@ -18,6 +18,11 @@ export const v1McpToolNames = [ 'get_packet', 'create_seed_packet', 'import_seed_packet', + 'inbox_init', + 'send_packet', + 'list_inbox', + 'pull_inbox_packet', + 'ack_inbox_delivery', 'create_mark', 'create_reply', 'check_store', @@ -60,6 +65,13 @@ Use status checks when helpful: - Call get_status for a store summary. - Call run_doctor for diagnostics. +Use durable inbox for asynchronous handoff after both stores have intentionally configured the same local mailbox root: +- Call send_packet only for a packed project handoff the user wants delivered to a registered local: address. Never use it for private or seed packets. +- Call list_inbox to discover pending deliveries. +- Call pull_inbox_packet without import to verify first, or with import=true to run normal 3Notch validation and import. +- Call ack_inbox_delivery only after review, successful import, or an intentional skip. Acknowledgement retains packet bytes. +- Treat local addresses as labels, not authenticated identities, and never claim the local adapter is encrypted or network-guaranteed. + If .notch/config.json enables continuation checkpoints: - Follow the configured prompt or auto mode only at the listed semantic triggers; do not change that policy unless the user explicitly asks. - A continuation is an ordinary handoff packet targeted to next-agent, tagged continuation, stream-, and source-agent. diff --git a/src/core/archive-service.ts b/src/core/archive-service.ts index e85b053..85be40f 100644 --- a/src/core/archive-service.ts +++ b/src/core/archive-service.ts @@ -13,6 +13,15 @@ export type ArchiveEntry = { const blockSize = 512; const zeroBlock = Buffer.alloc(blockSize); +export const DEFAULT_MAX_ARCHIVE_BYTES = 200 * 1024 * 1024; +export const DEFAULT_MAX_UNPACKED_ARCHIVE_BYTES = 208 * 1024 * 1024; +export const DEFAULT_MAX_ARCHIVE_ENTRIES = 4_096; + +export type ArchiveReadOptions = { + maxArchiveBytes?: number; + maxEntries?: number; + maxUnpackedBytes?: number; +}; export async function createPacketArchive(input: { markdownPath: string; rootPath: string }): Promise { const entries = await packetArchiveEntries(input); @@ -25,14 +34,17 @@ export async function createPacketArchive(input: { markdownPath: string; rootPat return gzipSync(tar, { level: 9 }); } -export async function unpackPacketArchiveToTemp(archive: Buffer): Promise<{ cleanup: () => Promise; packetFolderPath: string }> { +export async function unpackPacketArchiveToTemp( + archive: Buffer, + options: ArchiveReadOptions = {}, +): Promise<{ cleanup: () => Promise; packetFolderPath: string }> { const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'notch-unpack-')); const packetFolderPath = path.join(tempRoot, 'packet'); try { await mkdir(packetFolderPath, { recursive: true }); - for (const entry of readArchiveEntries(archive)) { + for (const entry of readArchiveEntries(archive, options)) { assertSafeArchiveEntryPath(entry.path); const outputPath = path.join(packetFolderPath, entry.path); await mkdir(path.dirname(outputPath), { recursive: true }); @@ -52,26 +64,45 @@ export async function unpackPacketArchiveToTemp(archive: Buffer): Promise<{ clea } } -export function readArchiveEntries(archive: Buffer): ArchiveEntry[] { +export function readArchiveEntries(archive: Buffer, options: ArchiveReadOptions = {}): ArchiveEntry[] { + const maxArchiveBytes = options.maxArchiveBytes ?? DEFAULT_MAX_ARCHIVE_BYTES; + const maxEntries = options.maxEntries ?? DEFAULT_MAX_ARCHIVE_ENTRIES; + const maxUnpackedBytes = options.maxUnpackedBytes ?? DEFAULT_MAX_UNPACKED_ARCHIVE_BYTES; + + if (archive.byteLength > maxArchiveBytes) { + throw archiveTooLarge(archive.byteLength, maxArchiveBytes); + } + let tar: Buffer; try { - tar = gunzipSync(archive); - } catch { + tar = gunzipSync(archive, { maxOutputLength: maxUnpackedBytes }); + } catch (error) { + if (error instanceof RangeError || (error instanceof Error && 'code' in error && error.code === 'ERR_BUFFER_TOO_LARGE')) { + throw archiveTooLarge(maxUnpackedBytes + 1, maxUnpackedBytes); + } throw unsafeArchive('Archive is not a readable gzipped tar stream.'); } const entries: ArchiveEntry[] = []; + const entryPaths = new Set(); let offset = 0; + let sawEndMarker = false; while (offset + blockSize <= tar.length) { const header = tar.subarray(offset, offset + blockSize); offset += blockSize; if (header.every((byte) => byte === 0)) { + if (!tar.subarray(offset).every((byte) => byte === 0)) { + throw unsafeArchive('Archive contains data after its end marker.'); + } + + sawEndMarker = true; break; } + assertTarHeaderChecksum(header); const typeflag = header.toString('utf8', 156, 157); if (typeflag !== '0' && typeflag !== '\0') { @@ -84,15 +115,38 @@ export function readArchiveEntries(archive: Buffer): ArchiveEntry[] { const entryPath = prefix ? `${prefix}/${name}` : name; assertSafeArchiveEntryPath(entryPath); + if (entryPaths.has(entryPath)) { + throw unsafeArchive(`Archive contains a duplicate entry: ${entryPath}`); + } + if (offset + size > tar.length) { throw unsafeArchive('Archive entry is truncated.'); } + const nextOffset = offset + size + paddingLength(size); + + if (nextOffset > tar.length) { + throw unsafeArchive('Archive entry padding is truncated.'); + } + + if (!tar.subarray(offset + size, nextOffset).every((byte) => byte === 0)) { + throw unsafeArchive('Archive entry padding is invalid.'); + } + entries.push({ path: entryPath, content: Buffer.from(tar.subarray(offset, offset + size)), }); - offset += size + paddingLength(size); + entryPaths.add(entryPath); + + if (entries.length > maxEntries) { + throw archiveTooLarge(entries.length, maxEntries, 'entries'); + } + offset = nextOffset; + } + + if (!sawEndMarker) { + throw unsafeArchive('Archive is missing its end marker.'); } if (!entries.some((entry) => entry.path === 'packet.md')) { @@ -102,6 +156,16 @@ export function readArchiveEntries(archive: Buffer): ArchiveEntry[] { return entries.sort((left, right) => left.path.localeCompare(right.path)); } +function archiveTooLarge(actual: number, maximum: number, unit = 'bytes'): NotchException { + return new NotchException({ + code: 'NOTCH_ARCHIVE_TOO_LARGE', + message: `Archive exceeds the safe ${unit} limit (${actual} > ${maximum}).`, + recovery: 'Create a smaller packet or raise the configured packet limit only after reviewing the files.', + severity: 'error', + exitCode: 5, + }); +} + async function packetArchiveEntries(input: { markdownPath: string; rootPath: string }): Promise { const rootStat = await stat(input.rootPath); @@ -199,7 +263,33 @@ function writeOctal(header: Buffer, value: number, offset: number, length: numbe function parseOctal(value: string): number { const trimmed = value.replace(/\0.*$/, '').trim(); - return trimmed.length === 0 ? 0 : Number.parseInt(trimmed, 8); + + if (trimmed.length === 0) { + return 0; + } + + if (!/^[0-7]+$/u.test(trimmed)) { + throw unsafeArchive('Archive contains an invalid numeric header field.'); + } + + const parsed = Number.parseInt(trimmed, 8); + + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw unsafeArchive('Archive contains an unsafe numeric header field.'); + } + + return parsed; +} + +function assertTarHeaderChecksum(header: Buffer): void { + const recorded = parseOctal(header.toString('utf8', 148, 156)); + const checksumHeader = Buffer.from(header); + checksumHeader.fill(0x20, 148, 156); + const calculated = checksumHeader.reduce((sum, byte) => sum + byte, 0); + + if (recorded !== calculated) { + throw unsafeArchive('Archive header checksum is invalid.'); + } } function headerString(header: Buffer, start: number, length: number): string { diff --git a/src/core/inbox-service.ts b/src/core/inbox-service.ts new file mode 100644 index 0000000..2f4e790 --- /dev/null +++ b/src/core/inbox-service.ts @@ -0,0 +1,662 @@ +import { lstat, readFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { + DEFAULT_MAX_ARCHIVE_BYTES, + DEFAULT_MAX_UNPACKED_ARCHIVE_BYTES, + unpackPacketArchiveToTemp, +} from './archive-service.js'; +import { + DEFAULT_MAX_ARTIFACT_BYTES, + DEFAULT_MAX_PACKET_BYTES, + sha256, + verifyPacketFolderArtifacts, +} from './artifact-service.js'; +import { appendAuditEntry } from './audit-service.js'; +import { resolveActor } from './actor-service.js'; +import { + deliveryIdForPacketId, + initializeInboxConfig, + loadInboxConfig, + LocalInboxAdapter, + parseLocalInboxAddress, +} from './local-inbox-adapter.js'; +import { parseAndValidateRecord } from './record-parser.js'; +import { + assertNoSecretsInArtifactWithAudit, + assertNoSecretsWithAudit, +} from './secret-scan-service.js'; +import { importPacketFolder } from './transfer-service.js'; +import { errorToNotchError, NotchException } from '../types/errors.js'; +import type { LoadedConfig } from './config-service.js'; +import type { InboxConfig, InboxDelivery, InboxResult } from '../types/inbox.js'; +import type { NotchPacket, SourceTool } from '../types/records.js'; + +const archiveOverheadBytes = 8 * 1024 * 1024; + +export type InboxActorOptions = { + actor?: string; + agent?: string; + mcp?: boolean; + sourceTool?: SourceTool['name']; +}; + +export type InboxInitResult = { + ok: true; + address: string; + root: string; + transport: 'local'; + alreadyInitialized: boolean; + nextAction: string; +}; + +export type InboxListResult = { + ok: true; + address: string; + deliveries: InboxDelivery[]; + nextAction: string; +}; + +export type InboxSendResult = InboxResult & { + idempotent: boolean; +}; + +export async function initializeDurableInbox( + context: LoadedConfig, + input: { name: string; root: string }, + options: InboxActorOptions = {}, +): Promise { + const author = inboxActor(context, options); + + try { + const initialized = await initializeInboxConfig(context, input); + await appendAuditEntry(context.paths.logs, { + schemaVersion: '1.0.0', + at: new Date().toISOString(), + operation: 'inbox-init', + result: 'success', + actor: author.actor, + actorNameResolution: author.actorNameResolution, + actorTypeResolution: author.actorTypeResolution, + sourceTool: author.sourceTool, + to: initialized.config.address, + }); + + return { + ok: true, + address: initialized.config.address, + root: initialized.config.root, + transport: 'local', + alreadyInitialized: initialized.alreadyInitialized, + nextAction: `Share ${initialized.config.address} with a sender configured on the same mailbox root.`, + }; + } catch (error) { + await auditFailure(context, author, 'inbox-init', error); + throw error; + } +} + +export async function sendInboxPacket( + context: LoadedConfig, + input: { packetPath: string; to: string }, + options: InboxActorOptions = {}, +): Promise { + const author = inboxActor(context, options); + let config: InboxConfig | undefined; + let inspected: InspectedPacket | undefined; + + try { + config = await loadInboxConfig(context); + parseLocalInboxAddress(input.to); + inspected = await inspectPacketArchive(context, input.packetPath, author); + const deliveryId = deliveryIdForPacketId(inspected.packet.id); + const now = new Date().toISOString(); + const delivery: InboxDelivery = { + schemaVersion: '1.0.0', + deliveryId, + transport: 'local', + from: config.address, + to: input.to, + packetId: inspected.packet.id, + packetHash: inspected.packetHash, + bytes: inspected.bytes, + state: 'pending', + createdAt: now, + updatedAt: now, + }; + const stored = await new LocalInboxAdapter(config.root).putDelivery(input.to, { + delivery, + packet: inspected.archive, + }); + + await appendAuditEntry(context.paths.logs, { + schemaVersion: '1.0.0', + at: new Date().toISOString(), + operation: 'inbox-send', + result: 'success', + actor: author.actor, + actorNameResolution: author.actorNameResolution, + actorTypeResolution: author.actorTypeResolution, + sourceTool: author.sourceTool, + recordType: 'packet', + recordId: stored.delivery.packetId, + deliveryId: stored.delivery.deliveryId, + deliveryState: stored.delivery.state, + packetHash: stored.delivery.packetHash, + from: stored.delivery.from, + to: stored.delivery.to, + bytes: stored.delivery.bytes, + ...(stored.idempotent ? { reason: 'idempotent-retry' } : {}), + }); + + return { + ok: true, + deliveryId: stored.delivery.deliveryId, + packetId: stored.delivery.packetId, + packetHash: stored.delivery.packetHash, + state: stored.delivery.state, + packetPath: stored.packetPath, + idempotent: stored.idempotent, + nextAction: `Ask ${stored.delivery.to} to run notch inbox list, then pull and import ${stored.delivery.deliveryId}.`, + }; + } catch (error) { + await auditFailure(context, author, 'inbox-send', error, { + ...(config ? { from: config.address } : {}), + to: input.to, + ...(inspected ? { + recordId: inspected.packet.id, + packetHash: inspected.packetHash, + bytes: inspected.bytes, + } : {}), + }); + throw error; + } +} + +export async function listInboxDeliveries( + context: LoadedConfig, + input: { includeAll?: boolean } = {}, +): Promise { + const config = await loadInboxConfig(context); + const deliveries = await new LocalInboxAdapter(config.root).listDeliveries(config.address, input); + + return { + ok: true, + address: config.address, + deliveries, + nextAction: deliveries.length > 0 + ? `Pull a delivery with notch inbox pull ${deliveries[0]?.deliveryId ?? ''} --import.` + : 'No matching deliveries. Wait for a sender or run with --all to include processed deliveries.', + }; +} + +export async function pullInboxDelivery( + context: LoadedConfig, + input: { deliveryId: string; import?: boolean; asReviewed?: boolean }, + options: InboxActorOptions = {}, +): Promise { + const author = inboxActor(context, options); + const config = await loadInboxConfig(context); + const limits = archiveLimits(context); + + try { + const result = await new LocalInboxAdapter(config.root).withDelivery( + config.address, + input.deliveryId, + { maxBytes: limits.maxArchiveBytes }, + async (locked) => { + if (locked.delivery.state === 'rejected') { + throw invalidState(locked.delivery, 'Rejected deliveries cannot be pulled.'); + } + + if (locked.delivery.state === 'acked' && !locked.delivery.importedPacketId) { + throw invalidState(locked.delivery, 'This delivery was acknowledged without import.'); + } + + let inspected: InspectedPacket; + + try { + inspected = await inspectPacketArchiveBytes( + context, + locked.packet, + path.basename(locked.packetPath), + author, + locked.delivery.packetId, + ); + } catch (error) { + const notchError = errorToNotchError(error); + + if (isRejectedDeliveryError(notchError.code)) { + const now = new Date().toISOString(); + await locked.update({ + state: 'rejected', + rejectedAt: now, + errorCode: notchError.code, + }); + } + + throw error; + } + + if (inspected.packetHash !== locked.delivery.packetHash) { + throw new NotchException({ + code: 'NOTCH_INBOX_HASH_MISMATCH', + message: 'Validated archive hash does not match delivery metadata.', + recovery: 'Do not import this delivery. Ask the sender to retry from the original packet.', + severity: 'error', + exitCode: 5, + }); + } + + if (input.import && !locked.delivery.importedPacketId) { + const unpacked = await unpackPacketArchiveToTemp(locked.packet, limits); + + try { + const imported = await importPacketFolder(context, unpacked.packetFolderPath, { + ...(options.actor ? { actor: options.actor } : {}), + ...(options.agent ? { agent: options.agent } : {}), + ...(input.asReviewed ? { asReviewed: true } : {}), + ...(options.mcp ? { mcp: true } : {}), + sourceTool: options.sourceTool ?? (options.mcp ? 'notch-mcp' : 'notch-cli'), + }); + const now = new Date().toISOString(); + const updated = await locked.update({ + state: locked.delivery.state === 'acked' ? 'acked' : 'pulled', + pulledAt: locked.delivery.pulledAt ?? now, + importedAt: now, + importedPacketId: imported.packet.id, + }); + + return inboxResult(updated, locked.packetPath, 'Acknowledge the imported delivery after reviewing the packet.'); + } finally { + await unpacked.cleanup(); + } + } + + const now = new Date().toISOString(); + const updated = locked.delivery.state === 'pending' + ? await locked.update({ state: 'pulled', pulledAt: now }) + : locked.delivery; + + return inboxResult( + updated, + locked.packetPath, + updated.importedPacketId + ? 'This delivery is already imported. Acknowledge it after review.' + : `Inspect ${locked.packetPath}, then pull again with --import or acknowledge it as intentionally skipped.`, + ); + }, + ); + + await appendAuditEntry(context.paths.logs, { + schemaVersion: '1.0.0', + at: new Date().toISOString(), + operation: 'inbox-pull', + result: 'success', + actor: author.actor, + actorNameResolution: author.actorNameResolution, + actorTypeResolution: author.actorTypeResolution, + sourceTool: author.sourceTool, + recordType: 'packet', + recordId: result.packetId, + deliveryId: result.deliveryId, + deliveryState: result.state, + packetHash: result.packetHash, + to: config.address, + ...(result.importedPacketId ? { reason: 'imported', importedFrom: result.deliveryId } : { reason: 'verified-only' }), + }); + + return result; + } catch (error) { + const notchError = errorToNotchError(error); + const rejected = isRejectedDeliveryError(notchError.code); + await auditFailure(context, author, rejected ? 'inbox-reject' : 'inbox-pull', error, { + deliveryId: input.deliveryId, + ...(rejected ? { deliveryState: 'rejected' as const } : {}), + to: config.address, + }); + throw error; + } +} + +export async function ackInboxDelivery( + context: LoadedConfig, + input: { deliveryId: string }, + options: InboxActorOptions = {}, +): Promise { + const author = inboxActor(context, options); + const config = await loadInboxConfig(context); + const limits = archiveLimits(context); + + try { + const result = await new LocalInboxAdapter(config.root).withDelivery( + config.address, + input.deliveryId, + { maxBytes: limits.maxArchiveBytes }, + async (locked) => { + if (locked.delivery.state === 'rejected') { + throw invalidState(locked.delivery, 'Rejected deliveries cannot be acknowledged.'); + } + + const updated = locked.delivery.state === 'acked' + ? locked.delivery + : await locked.update({ state: 'acked', ackedAt: new Date().toISOString() }); + return inboxResult(updated, locked.packetPath, 'Delivery retained for audit; no further action is required.'); + }, + ); + + await appendAuditEntry(context.paths.logs, { + schemaVersion: '1.0.0', + at: new Date().toISOString(), + operation: 'inbox-ack', + result: 'success', + actor: author.actor, + actorNameResolution: author.actorNameResolution, + actorTypeResolution: author.actorTypeResolution, + sourceTool: author.sourceTool, + recordType: 'packet', + recordId: result.packetId, + deliveryId: result.deliveryId, + deliveryState: result.state, + packetHash: result.packetHash, + to: config.address, + }); + + return result; + } catch (error) { + await auditFailure(context, author, 'inbox-ack', error, { + deliveryId: input.deliveryId, + to: config.address, + }); + throw error; + } +} + +type InboxActor = ReturnType; + +type InspectedPacket = { + archive: Buffer; + bytes: number; + packet: NotchPacket; + packetHash: string; +}; + +function inboxActor(context: LoadedConfig, options: InboxActorOptions): InboxActor { + return resolveActor({ + ...(options.actor ? { actor: options.actor } : {}), + ...(options.agent ? { agent: options.agent } : {}), + cwd: context.projectRoot, + ...(options.mcp ? { mcp: true } : {}), + sourceTool: options.sourceTool ?? (options.mcp ? 'notch-mcp' : 'notch-cli'), + }); +} + +async function inspectPacketArchive( + context: LoadedConfig, + packetPath: string, + author: InboxActor, +): Promise { + const absolutePath = path.isAbsolute(packetPath) + ? packetPath + : path.resolve(context.projectRoot, packetPath); + const packetStat = await lstat(absolutePath); + + if (packetStat.isSymbolicLink()) { + throw new NotchException({ + code: 'NOTCH_SYMLINK_REJECTED', + message: `Durable inbox does not send symlinked packet files: ${packetPath}`, + path: packetPath, + recovery: 'Pass the real .notchpkt file created by notch packet pack.', + severity: 'error', + exitCode: 5, + }); + } + + if (!packetStat.isFile() || path.extname(absolutePath) !== '.notchpkt') { + throw new NotchException({ + code: 'NOTCH_INBOX_PACKET_INVALID', + message: 'Durable inbox sends regular .notchpkt files only.', + path: packetPath, + recovery: 'Run notch packet pack , then send the resulting .notchpkt file.', + severity: 'error', + exitCode: 1, + }); + } + + const limits = archiveLimits(context); + + if (packetStat.size > limits.maxArchiveBytes) { + throw new NotchException({ + code: 'NOTCH_ARCHIVE_TOO_LARGE', + message: `Inbox archive exceeds the configured packet limit (${packetStat.size} > ${limits.maxArchiveBytes}).`, + path: packetPath, + recovery: 'Create a smaller packet or raise the limit only after reviewing the source.', + severity: 'error', + exitCode: 5, + }); + } + + const archive = await readFile(absolutePath); + return await inspectPacketArchiveBytes(context, archive, path.basename(absolutePath), author); +} + +async function inspectPacketArchiveBytes( + context: LoadedConfig, + archive: Buffer, + sourceName: string, + author: InboxActor, + expectedPacketId?: string, +): Promise { + const limits = archiveLimits(context); + const unpacked = await unpackPacketArchiveToTemp(archive, limits); + + try { + const markdownPath = path.join(unpacked.packetFolderPath, 'packet.md'); + const markdown = await readFile(markdownPath, 'utf8'); + const parsed = parseAndValidateRecord(markdown, sourceName); + + if (!parsed.ok) { + const first = parsed.errors[0]; + throw new NotchException({ + code: first?.code ?? 'NOTCH_RECORD_INVALID', + message: first?.message ?? 'Inbox packet is invalid.', + path: sourceName, + recovery: 'Recreate the packet with the current 3Notch CLI.', + severity: 'error', + exitCode: 3, + }); + } + + const packet = parsed.data; + + if (expectedPacketId && packet.id !== expectedPacketId) { + throw new NotchException({ + code: 'NOTCH_INBOX_DELIVERY_CONFLICT', + message: `Delivery metadata names ${expectedPacketId}, but the archive contains ${packet.id}.`, + recovery: 'Reject this delivery and ask the sender to retry from the original archive.', + severity: 'error', + exitCode: 5, + }); + } + + if (packet.purpose === 'seed' || packet.sensitivity === 'private') { + throw new NotchException({ + code: 'NOTCH_INBOX_PRIVATE_PACKET_BLOCKED', + message: 'Durable inbox V1 does not place private or seed packets in a shared mailbox.', + recovery: 'Use an ordinary project handoff packet, or transfer private packets through an explicitly trusted channel.', + severity: 'error', + exitCode: 5, + }); + } + + await verifyPacketFolderArtifacts(unpacked.packetFolderPath, packet); + assertPacketArtifactLimits(context, packet); + await assertNoSecretsWithAudit(markdown, context.config, { + actor: author.actor, + actorNameResolution: author.actorNameResolution, + actorTypeResolution: author.actorTypeResolution, + field: 'durable inbox packet markdown', + logsDir: context.paths.logs, + path: sourceName, + recordId: packet.id, + recordType: 'packet', + sourceTool: author.sourceTool, + }); + + for (const artifact of packet.artifacts ?? []) { + await assertNoSecretsInArtifactWithAudit( + await readFile(path.join(unpacked.packetFolderPath, artifact.path)), + context.config, + { + actor: author.actor, + actorNameResolution: author.actorNameResolution, + actorTypeResolution: author.actorTypeResolution, + logsDir: context.paths.logs, + path: artifact.path, + recordId: packet.id, + recordType: 'packet', + sourcePath: artifact.path, + sourceTool: author.sourceTool, + }, + ); + } + + return { + archive, + bytes: archive.byteLength, + packet, + packetHash: sha256(archive), + }; + } finally { + await unpacked.cleanup(); + } +} + +function assertPacketArtifactLimits(context: LoadedConfig, packet: NotchPacket): void { + const maxArtifactBytes = context.config.artifacts?.maxArtifactBytes ?? DEFAULT_MAX_ARTIFACT_BYTES; + const maxPacketBytes = context.config.artifacts?.maxPacketBytes ?? DEFAULT_MAX_PACKET_BYTES; + let total = 0; + + for (const artifact of packet.artifacts ?? []) { + if (artifact.bytes >= maxArtifactBytes) { + throw packetTooLarge(artifact.bytes, maxArtifactBytes, artifact.path); + } + + total += artifact.bytes; + } + + if (total >= maxPacketBytes) { + throw packetTooLarge(total, maxPacketBytes, packet.id); + } +} + +function archiveLimits(context: LoadedConfig): { + maxArchiveBytes: number; + maxEntries: number; + maxUnpackedBytes: number; +} { + const packetLimit = context.config.artifacts?.maxPacketBytes ?? DEFAULT_MAX_PACKET_BYTES; + return { + maxArchiveBytes: Math.min(DEFAULT_MAX_ARCHIVE_BYTES, packetLimit + archiveOverheadBytes), + maxEntries: 4_096, + maxUnpackedBytes: Math.min(DEFAULT_MAX_UNPACKED_ARCHIVE_BYTES, packetLimit + archiveOverheadBytes), + }; +} + +function packetTooLarge(actual: number, maximum: number, packetPath: string): NotchException { + return new NotchException({ + code: 'NOTCH_PACKET_TOO_LARGE', + message: `Inbox packet exceeds its configured size limit (${actual} >= ${maximum}).`, + path: packetPath, + recovery: 'Create a smaller packet or raise the limit only after reviewing the files.', + severity: 'error', + exitCode: 5, + }); +} + +function invalidState(delivery: InboxDelivery, message: string): NotchException { + return new NotchException({ + code: 'NOTCH_INBOX_STATE_INVALID', + message, + path: delivery.deliveryId, + recovery: 'Run notch inbox list --all to inspect the delivery state.', + severity: 'error', + exitCode: 6, + }); +} + +function inboxResult(delivery: InboxDelivery, packetPath: string, nextAction: string): InboxResult { + return { + ok: true, + deliveryId: delivery.deliveryId, + packetId: delivery.packetId, + packetHash: delivery.packetHash, + state: delivery.state, + packetPath, + ...(delivery.importedPacketId ? { importedPacketId: delivery.importedPacketId } : {}), + nextAction, + }; +} + +async function auditFailure( + context: LoadedConfig, + author: InboxActor, + operation: 'inbox-ack' | 'inbox-init' | 'inbox-pull' | 'inbox-reject' | 'inbox-send', + error: unknown, + fields: { + recordId?: string; + deliveryId?: string; + deliveryState?: InboxDelivery['state']; + packetHash?: string; + from?: string; + to?: string; + bytes?: number; + } = {}, +): Promise { + const notchError = errorToNotchError(error); + await appendAuditEntry(context.paths.logs, { + schemaVersion: '1.0.0', + at: new Date().toISOString(), + operation, + result: operation === 'inbox-reject' ? 'blocked' : 'failed', + actor: author.actor, + actorNameResolution: author.actorNameResolution, + actorTypeResolution: author.actorTypeResolution, + sourceTool: author.sourceTool, + ...(fields.recordId ? { recordType: 'packet', recordId: fields.recordId } : {}), + ...(fields.deliveryId && /^delivery_[a-f0-9]{24}$/u.test(fields.deliveryId) ? { deliveryId: fields.deliveryId } : {}), + ...(fields.deliveryState ? { deliveryState: fields.deliveryState } : {}), + ...(fields.packetHash ? { packetHash: fields.packetHash } : {}), + ...(validLocalAddress(fields.from) ? { from: fields.from } : {}), + ...(validLocalAddress(fields.to) ? { to: fields.to } : {}), + ...(fields.bytes ? { bytes: fields.bytes } : {}), + errorCode: notchError.code, + }); +} + +function isRejectedDeliveryError(code: string): boolean { + return [ + 'NOTCH_ARCHIVE_TOO_LARGE', + 'NOTCH_ARCHIVE_UNSAFE', + 'NOTCH_INBOX_DELIVERY_CONFLICT', + 'NOTCH_INBOX_DELIVERY_INVALID', + 'NOTCH_INBOX_HASH_MISMATCH', + 'NOTCH_INBOX_PRIVATE_PACKET_BLOCKED', + 'NOTCH_PACKET_TOO_LARGE', + 'NOTCH_RECORD_INVALID', + 'NOTCH_SECRET_DETECTED', + ].includes(code); +} + +function validLocalAddress(value: string | undefined): value is string { + if (!value) { + return false; + } + + try { + parseLocalInboxAddress(value); + return true; + } catch { + return false; + } +} diff --git a/src/core/local-inbox-adapter.ts b/src/core/local-inbox-adapter.ts new file mode 100644 index 0000000..a02faf0 --- /dev/null +++ b/src/core/local-inbox-adapter.ts @@ -0,0 +1,689 @@ +import { randomUUID } from 'node:crypto'; +import { chmod, lstat, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { sha256 } from './artifact-service.js'; +import { schemaService } from './schema-service.js'; +import { atomicWriteFile } from './store-service.js'; +import { withStoreWriteLock } from './store-lock-service.js'; +import { toSlug } from './id-service.js'; +import { NotchException } from '../types/errors.js'; +import type { LoadedConfig } from './config-service.js'; +import type { InboxConfig, InboxDelivery, InboxDeliveryState } from '../types/inbox.js'; + +const inboxConfigFilename = 'inbox-config.json'; +const addressPattern = /^local:([a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?)$/; +const deliveryIdPattern = /^delivery_[a-f0-9]{24}$/; +const hashPattern = /^[a-f0-9]{64}$/; + +export type PutLocalDeliveryInput = { + delivery: InboxDelivery; + packet: Buffer; +}; + +export type PutLocalDeliveryResult = { + delivery: InboxDelivery; + idempotent: boolean; + packetPath: string; +}; + +export type LockedLocalDelivery = { + delivery: InboxDelivery; + packet: Buffer; + packetPath: string; + update: (changes: { + state: InboxDeliveryState; + pulledAt?: string; + ackedAt?: string; + rejectedAt?: string; + importedAt?: string; + importedPacketId?: string; + errorCode?: string; + }) => Promise; +}; + +export interface InboxTransportAdapter { + initializeRecipient: (address: string) => Promise; + putDelivery: (address: string, input: PutLocalDeliveryInput) => Promise; + listDeliveries: (address: string, options?: { includeAll?: boolean }) => Promise; + withDelivery: ( + address: string, + deliveryId: string, + options: { maxBytes: number }, + operation: (delivery: LockedLocalDelivery) => Promise, + ) => Promise; +} + +export function inboxConfigPath(context: LoadedConfig): string { + return path.join(context.storePath, inboxConfigFilename); +} + +export function normalizeInboxName(value: string): string { + const name = toSlug(value) + .slice(0, 64) + .replace(/[-._]+$/u, ''); + + if (!name || !/^[a-z0-9]/u.test(name)) { + throw new NotchException({ + code: 'NOTCH_INBOX_ADDRESS_INVALID', + message: `Inbox name cannot form a safe local address: ${value}`, + recovery: 'Use letters, numbers, dots, underscores, or hyphens.', + severity: 'error', + exitCode: 1, + }); + } + + return name; +} + +export function localInboxAddress(name: string): string { + return `local:${normalizeInboxName(name)}`; +} + +export function parseLocalInboxAddress(address: string): string { + const match = addressPattern.exec(address); + + if (!match?.[1]) { + throw new NotchException({ + code: 'NOTCH_INBOX_ADDRESS_INVALID', + message: `Invalid local inbox address: ${address}`, + recovery: 'Use the exact address printed by notch inbox init, such as local:review-agent.', + severity: 'error', + exitCode: 1, + }); + } + + return match[1]; +} + +export function deliveryIdForPacketId(packetId: string): string { + return `delivery_${sha256(packetId).slice(0, 24)}`; +} + +export async function loadInboxConfig(context: LoadedConfig): Promise { + const configPath = inboxConfigPath(context); + const configStat = await lstat(configPath).catch(() => undefined); + + if (!configStat) { + throw new NotchException({ + code: 'NOTCH_INBOX_NOT_CONFIGURED', + message: 'This 3Notch store has no durable inbox configuration.', + path: configPath, + recovery: 'Run notch inbox init --name --root .', + severity: 'error', + exitCode: 2, + }); + } + + if (configStat.isSymbolicLink()) { + throw symlinkError(configPath); + } + + if (!configStat.isFile()) { + throw invalidConfig(configPath, 'Inbox config must be a regular file.'); + } + + let parsed: unknown; + + try { + parsed = JSON.parse(await readFile(configPath, 'utf8')) as unknown; + } catch (error) { + throw invalidConfig(configPath, error instanceof Error ? error.message : 'Inbox config is not readable JSON.'); + } + + const validation = schemaService.validate('inboxConfig', parsed, configPath); + + if (!validation.ok) { + throw invalidConfig(configPath, validation.errors[0]?.message ?? 'Inbox config is invalid.'); + } + + const config = validation.data; + + if (!path.isAbsolute(config.root) || localInboxAddress(config.name) !== config.address) { + throw invalidConfig(configPath, 'Inbox config root must be absolute and its name/address must agree.'); + } + + return config; +} + +export async function initializeInboxConfig( + context: LoadedConfig, + input: { name: string; root: string }, +): Promise<{ config: InboxConfig; alreadyInitialized: boolean }> { + const name = normalizeInboxName(input.name); + const root = path.resolve(context.projectRoot, input.root); + const config: InboxConfig = { + schemaVersion: '1.0.0', + transport: 'local', + name, + address: `local:${name}`, + root, + }; + const configPath = inboxConfigPath(context); + const existing = await lstat(configPath).catch(() => undefined); + + if (existing) { + const current = await loadInboxConfig(context); + + if (JSON.stringify(current) !== JSON.stringify(config)) { + throw new NotchException({ + code: 'NOTCH_INBOX_ALREADY_CONFIGURED', + message: `Durable inbox is already configured as ${current.address}.`, + path: configPath, + recovery: 'Use the existing address, or remove inbox-config.json intentionally before configuring a different mailbox.', + severity: 'error', + exitCode: 6, + }); + } + + await new LocalInboxAdapter(root).initializeRecipient(config.address); + return { config, alreadyInitialized: true }; + } + + await new LocalInboxAdapter(root).initializeRecipient(config.address); + await atomicWriteFile(configPath, `${JSON.stringify(config, null, 2)}\n`); + await chmod(configPath, 0o600).catch(() => undefined); + await ensureIgnoredConfig(context.storePath); + return { config, alreadyInitialized: false }; +} + +export class LocalInboxAdapter implements InboxTransportAdapter { + readonly root: string; + + constructor(root: string) { + this.root = path.resolve(root); + } + + async initializeRecipient(address: string): Promise { + const recipient = parseLocalInboxAddress(address); + await this.ensureRoot(); + const recipientsPath = this.recipientsPath(); + const recipientPath = path.join(recipientsPath, recipient); + await ensureChildDirectory(recipientsPath, recipientPath); + await ensureChildDirectory(recipientPath, path.join(recipientPath, 'deliveries')); + } + + async putDelivery(address: string, input: PutLocalDeliveryInput): Promise { + this.assertDeliveryId(input.delivery.deliveryId); + this.validateDelivery(input.delivery); + + if (input.delivery.to !== address) { + throw new NotchException({ + code: 'NOTCH_INBOX_DELIVERY_CONFLICT', + message: `Delivery target ${input.delivery.to} does not match recipient ${address}.`, + recovery: 'Use the same registered address in delivery metadata and adapter routing.', + severity: 'error', + exitCode: 6, + }); + } + + if (input.packet.byteLength !== input.delivery.bytes || sha256(input.packet) !== input.delivery.packetHash) { + throw new NotchException({ + code: 'NOTCH_INBOX_HASH_MISMATCH', + message: 'Delivery metadata does not match the packet bytes.', + recovery: 'Re-pack the packet and retry. Do not edit .notchpkt bytes in place.', + severity: 'error', + exitCode: 5, + }); + } + + const recipientPath = await this.assertRecipient(address); + + return await withStoreWriteLock(recipientPath, async () => { + const deliveriesPath = path.join(recipientPath, 'deliveries'); + const deliveryPath = path.join(deliveriesPath, input.delivery.deliveryId); + const existing = await lstat(deliveryPath).catch(() => undefined); + + if (existing) { + return await this.resolveExistingDelivery(deliveryPath, input.delivery); + } + + const temporaryPath = path.join( + deliveriesPath, + `.${input.delivery.deliveryId}.${process.pid}.${randomUUID()}.tmp`, + ); + + try { + await mkdir(temporaryPath, { mode: 0o700 }); + await writeFile(path.join(temporaryPath, 'packet.notchpkt'), input.packet, { flag: 'wx', mode: 0o600 }); + await writeFile(path.join(temporaryPath, 'packet.sha256'), `${input.delivery.packetHash}\n`, { flag: 'wx', mode: 0o600 }); + await writeFile( + path.join(temporaryPath, 'delivery.json'), + `${JSON.stringify(input.delivery, null, 2)}\n`, + { flag: 'wx', mode: 0o600 }, + ); + await rename(temporaryPath, deliveryPath); + } catch (error) { + await rm(temporaryPath, { force: true, recursive: true }); + + if (await lstat(deliveryPath).catch(() => undefined)) { + return await this.resolveExistingDelivery(deliveryPath, input.delivery); + } + + throw error; + } + + return { + delivery: input.delivery, + idempotent: false, + packetPath: path.join(deliveryPath, 'packet.notchpkt'), + }; + }); + } + + async listDeliveries(address: string, options: { includeAll?: boolean } = {}): Promise { + const recipientPath = await this.assertRecipient(address); + const deliveriesPath = path.join(recipientPath, 'deliveries'); + const entries = await readdir(deliveriesPath, { withFileTypes: true }); + const deliveries: InboxDelivery[] = []; + + for (const entry of entries) { + if (entry.name.startsWith('.')) { + continue; + } + + if (!entry.isDirectory() || !deliveryIdPattern.test(entry.name)) { + throw malformedDelivery(path.join(deliveriesPath, entry.name), 'Unexpected entry in the delivery store.'); + } + + const delivery = await this.readDeliveryMetadata(path.join(deliveriesPath, entry.name)); + + if (options.includeAll || delivery.state === 'pending') { + deliveries.push(delivery); + } + } + + return deliveries.sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + } + + async withDelivery( + address: string, + deliveryId: string, + options: { maxBytes: number }, + operation: (delivery: LockedLocalDelivery) => Promise, + ): Promise { + this.assertDeliveryId(deliveryId); + const recipientPath = await this.assertRecipient(address); + + return await withStoreWriteLock(recipientPath, async () => { + const deliveryPath = path.join(recipientPath, 'deliveries', deliveryId); + let delivery = await this.readDeliveryMetadata(deliveryPath); + let packet: Buffer; + + try { + packet = await this.readVerifiedPacket(deliveryPath, delivery, options.maxBytes); + } catch (error) { + const now = new Date().toISOString(); + delivery = await this.writeDeliveryMetadata(deliveryPath, { + ...delivery, + state: 'rejected', + rejectedAt: delivery.rejectedAt ?? now, + updatedAt: now, + errorCode: error instanceof NotchException ? error.notchError.code : 'NOTCH_INBOX_READ_FAILED', + }); + throw error; + } + + return await operation({ + delivery, + packet, + packetPath: path.join(deliveryPath, 'packet.notchpkt'), + update: async (changes) => { + const now = new Date().toISOString(); + const next: InboxDelivery = { + ...delivery, + state: changes.state, + updatedAt: now, + ...(changes.pulledAt ? { pulledAt: changes.pulledAt } : {}), + ...(changes.ackedAt ? { ackedAt: changes.ackedAt } : {}), + ...(changes.rejectedAt ? { rejectedAt: changes.rejectedAt } : {}), + ...(changes.importedAt ? { importedAt: changes.importedAt } : {}), + ...(changes.importedPacketId ? { importedPacketId: changes.importedPacketId } : {}), + ...(changes.errorCode ? { errorCode: changes.errorCode } : {}), + }; + delivery = await this.writeDeliveryMetadata(deliveryPath, next); + return delivery; + }, + }); + }); + } + + private async ensureRoot(): Promise { + const parent = path.dirname(this.root); + const parentStat = await lstat(parent).catch(() => undefined); + + if (!parentStat?.isDirectory()) { + await mkdir(parent, { recursive: true }); + } + + await ensureDirectory(this.root); + await ensureChildDirectory(this.root, this.recipientsPath()); + } + + private recipientsPath(): string { + return path.join(this.root, 'recipients'); + } + + private async assertRecipient(address: string): Promise { + const recipient = parseLocalInboxAddress(address); + await this.ensureRoot(); + const recipientPath = path.join(this.recipientsPath(), recipient); + const recipientStat = await lstat(recipientPath).catch(() => undefined); + + if (!recipientStat) { + throw new NotchException({ + code: 'NOTCH_INBOX_RECIPIENT_NOT_FOUND', + message: `Recipient is not registered in this mailbox: ${address}`, + path: recipientPath, + recovery: 'Ask the receiver to run notch inbox init with this mailbox root, then use the printed address.', + severity: 'error', + exitCode: 2, + }); + } + + assertDirectoryStat(recipientPath, recipientStat); + const deliveriesPath = path.join(recipientPath, 'deliveries'); + const deliveriesStat = await lstat(deliveriesPath).catch(() => undefined); + + if (!deliveriesStat) { + throw malformedDelivery(deliveriesPath, 'Recipient delivery directory is missing.'); + } + + assertDirectoryStat(deliveriesPath, deliveriesStat); + return recipientPath; + } + + private async resolveExistingDelivery( + deliveryPath: string, + expected: InboxDelivery, + ): Promise { + const existing = await this.readDeliveryMetadata(deliveryPath); + + if ( + existing.deliveryId !== expected.deliveryId + || existing.packetId !== expected.packetId + || existing.packetHash !== expected.packetHash + || existing.bytes !== expected.bytes + || existing.from !== expected.from + || existing.to !== expected.to + ) { + throw new NotchException({ + code: 'NOTCH_INBOX_DELIVERY_CONFLICT', + message: `Delivery ${expected.deliveryId} already exists with different packet metadata.`, + path: deliveryPath, + recovery: 'Do not reuse a packet ID for different bytes. Create a new packet or restore the original archive.', + severity: 'error', + exitCode: 6, + }); + } + + await this.readVerifiedPacket(deliveryPath, existing, Number.MAX_SAFE_INTEGER); + return { + delivery: existing, + idempotent: true, + packetPath: path.join(deliveryPath, 'packet.notchpkt'), + }; + } + + private async readDeliveryMetadata(deliveryPath: string): Promise { + const deliveryStat = await lstat(deliveryPath).catch(() => undefined); + + if (!deliveryStat) { + throw new NotchException({ + code: 'NOTCH_INBOX_DELIVERY_NOT_FOUND', + message: `Inbox delivery was not found: ${path.basename(deliveryPath)}`, + recovery: 'Run notch inbox list --all and use an existing delivery ID.', + severity: 'error', + exitCode: 2, + }); + } + + assertDirectoryStat(deliveryPath, deliveryStat); + const metadataPath = path.join(deliveryPath, 'delivery.json'); + const metadataStat = await lstat(metadataPath).catch(() => undefined); + + if (!metadataStat?.isFile() || metadataStat.isSymbolicLink()) { + throw malformedDelivery(metadataPath, 'Delivery metadata must be a regular file.'); + } + + let parsed: unknown; + + try { + parsed = JSON.parse(await readFile(metadataPath, 'utf8')) as unknown; + } catch (error) { + throw malformedDelivery(metadataPath, error instanceof Error ? error.message : 'Delivery metadata is invalid JSON.'); + } + + const validation = schemaService.validate('inboxDelivery', parsed, metadataPath); + + if (!validation.ok) { + throw malformedDelivery(metadataPath, validation.errors[0]?.message ?? 'Delivery metadata is invalid.'); + } + + if (validation.data.deliveryId !== path.basename(deliveryPath)) { + throw malformedDelivery(metadataPath, 'Delivery ID does not match its directory.'); + } + + this.assertDeliveryInvariants(validation.data, metadataPath); + return validation.data; + } + + private async readVerifiedPacket(deliveryPath: string, delivery: InboxDelivery, maxBytes: number): Promise { + const packetPath = path.join(deliveryPath, 'packet.notchpkt'); + const hashPath = path.join(deliveryPath, 'packet.sha256'); + const packetStat = await lstat(packetPath).catch(() => undefined); + const hashStat = await lstat(hashPath).catch(() => undefined); + + if (!packetStat?.isFile() || packetStat.isSymbolicLink()) { + throw malformedDelivery(packetPath, 'Delivery packet must be a regular file.'); + } + + if (!hashStat?.isFile() || hashStat.isSymbolicLink()) { + throw malformedDelivery(hashPath, 'Delivery hash must be a regular file.'); + } + + if (packetStat.size > maxBytes) { + throw new NotchException({ + code: 'NOTCH_ARCHIVE_TOO_LARGE', + message: `Delivery archive exceeds the configured packet limit (${packetStat.size} > ${maxBytes}).`, + path: packetPath, + recovery: 'Reject the delivery or raise the packet limit only after reviewing the source.', + severity: 'error', + exitCode: 5, + }); + } + + const sidecar = (await readFile(hashPath, 'utf8')).trim(); + + if (!hashPattern.test(sidecar) || sidecar !== delivery.packetHash) { + throw hashMismatch(packetPath); + } + + const packet = await readFile(packetPath); + + if (packet.byteLength !== delivery.bytes || sha256(packet) !== delivery.packetHash) { + throw hashMismatch(packetPath); + } + + return packet; + } + + private async writeDeliveryMetadata(deliveryPath: string, delivery: InboxDelivery): Promise { + this.validateDelivery(delivery); + await atomicWriteFile(path.join(deliveryPath, 'delivery.json'), `${JSON.stringify(delivery, null, 2)}\n`); + await chmod(path.join(deliveryPath, 'delivery.json'), 0o600).catch(() => undefined); + return delivery; + } + + private validateDelivery(delivery: InboxDelivery): void { + const validation = schemaService.validate('inboxDelivery', delivery); + + if (!validation.ok) { + throw malformedDelivery(delivery.deliveryId, validation.errors[0]?.message ?? 'Delivery metadata is invalid.'); + } + + this.assertDeliveryInvariants(validation.data, delivery.deliveryId); + } + + private assertDeliveryInvariants(delivery: InboxDelivery, metadataPath: string): void { + if (delivery.deliveryId !== deliveryIdForPacketId(delivery.packetId)) { + throw malformedDelivery(metadataPath, 'Delivery ID does not match its packet ID.'); + } + + if (Date.parse(delivery.updatedAt) < Date.parse(delivery.createdAt)) { + throw malformedDelivery(metadataPath, 'Delivery update time precedes its creation time.'); + } + + if (delivery.state === 'pending' && ( + delivery.pulledAt + || delivery.ackedAt + || delivery.rejectedAt + || delivery.importedAt + || delivery.importedPacketId + || delivery.errorCode + )) { + throw malformedDelivery(metadataPath, 'Pending delivery contains completed lifecycle fields.'); + } + + if (delivery.state === 'pulled' && ( + !delivery.pulledAt + || delivery.ackedAt + || delivery.rejectedAt + || delivery.errorCode + )) { + throw malformedDelivery(metadataPath, 'Pulled delivery has inconsistent lifecycle fields.'); + } + + if (delivery.state === 'acked' && (!delivery.ackedAt || delivery.rejectedAt || delivery.errorCode)) { + throw malformedDelivery(metadataPath, 'Acknowledged delivery has inconsistent lifecycle fields.'); + } + + if (delivery.state === 'rejected' && (!delivery.rejectedAt || !delivery.errorCode)) { + throw malformedDelivery(metadataPath, 'Rejected delivery must record its time and error code.'); + } + + if (Boolean(delivery.importedAt) !== Boolean(delivery.importedPacketId)) { + throw malformedDelivery(metadataPath, 'Imported delivery fields must be recorded together.'); + } + + if (delivery.importedPacketId && (!delivery.pulledAt || delivery.importedPacketId !== delivery.packetId)) { + throw malformedDelivery(metadataPath, 'Imported delivery does not match the pulled packet.'); + } + } + + private assertDeliveryId(deliveryId: string): void { + if (!deliveryIdPattern.test(deliveryId)) { + throw new NotchException({ + code: 'NOTCH_INBOX_DELIVERY_ID_INVALID', + message: `Invalid delivery ID: ${deliveryId}`, + recovery: 'Copy a delivery ID exactly from notch inbox list.', + severity: 'error', + exitCode: 1, + }); + } + } +} + +async function ensureIgnoredConfig(storePath: string): Promise { + const gitignorePath = path.join(storePath, '.gitignore'); + const existing = await readFile(gitignorePath, 'utf8').catch(() => ''); + const lines = existing.split(/\r?\n/u); + + if (lines.some((line) => line.trim() === inboxConfigFilename)) { + return; + } + + const prefix = existing.trimEnd(); + await atomicWriteFile(gitignorePath, `${prefix ? `${prefix}\n` : ''}${inboxConfigFilename}\n`); +} + +async function ensureDirectory(directory: string): Promise { + const existing = await lstat(directory).catch(() => undefined); + + if (!existing) { + await mkdir(directory, { mode: 0o700 }); + return; + } + + assertDirectoryStat(directory, existing); +} + +async function ensureChildDirectory(parent: string, child: string): Promise { + const relative = path.relative(parent, child); + + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + throw new NotchException({ + code: 'NOTCH_PATH_OUTSIDE_STORE', + message: `Inbox path escapes its managed root: ${child}`, + path: child, + recovery: 'Use the mailbox root and address printed by notch inbox init.', + severity: 'error', + exitCode: 5, + }); + } + + const parentStat = await lstat(parent); + assertDirectoryStat(parent, parentStat); + const existing = await lstat(child).catch(() => undefined); + + if (!existing) { + await mkdir(child, { mode: 0o700 }); + return; + } + + assertDirectoryStat(child, existing); +} + +function assertDirectoryStat(directory: string, stat: Awaited>): void { + if (stat.isSymbolicLink()) { + throw symlinkError(directory); + } + + if (!stat.isDirectory()) { + throw malformedDelivery(directory, 'Managed inbox path must be a directory.'); + } +} + +function invalidConfig(configPath: string, message: string): NotchException { + return new NotchException({ + code: 'NOTCH_INBOX_CONFIG_INVALID', + message, + path: configPath, + recovery: 'Fix or remove .notch/inbox-config.json, then run notch inbox init again.', + severity: 'error', + exitCode: 3, + }); +} + +function malformedDelivery(filePath: string, message: string): NotchException { + return new NotchException({ + code: 'NOTCH_INBOX_DELIVERY_INVALID', + message, + path: filePath, + recovery: 'Do not edit mailbox files manually. Restore the delivery from the sender or move the damaged directory aside.', + severity: 'error', + exitCode: 5, + }); +} + +function hashMismatch(packetPath: string): NotchException { + return new NotchException({ + code: 'NOTCH_INBOX_HASH_MISMATCH', + message: 'Delivery packet bytes do not match the recorded SHA-256 hash.', + path: packetPath, + recovery: 'Do not import this delivery. Ask the sender to retry from the original packet.', + severity: 'error', + exitCode: 5, + }); +} + +function symlinkError(filePath: string): NotchException { + return new NotchException({ + code: 'NOTCH_SYMLINK_REJECTED', + message: `Durable inbox does not follow symlinks in its managed path: ${filePath}`, + path: filePath, + recovery: 'Use real directories and regular files for the mailbox root and deliveries.', + severity: 'error', + exitCode: 5, + }); +} diff --git a/src/core/schema-service.ts b/src/core/schema-service.ts index 72f9609..c7d1d67 100644 --- a/src/core/schema-service.ts +++ b/src/core/schema-service.ts @@ -3,6 +3,8 @@ import { Ajv2020, type ErrorObject, type ValidateFunction } from 'ajv/dist/2020. import auditSchema from '../schemas/audit.schema.json' with { type: 'json' }; import briefSchema from '../schemas/brief.schema.json' with { type: 'json' }; import configSchema from '../schemas/config.schema.json' with { type: 'json' }; +import inboxConfigSchema from '../schemas/inbox-config.schema.json' with { type: 'json' }; +import inboxDeliverySchema from '../schemas/inbox-delivery.schema.json' with { type: 'json' }; import mcpToolsSchema from '../schemas/mcp-tools.schema.json' with { type: 'json' }; import packetSchema from '../schemas/packet.schema.json' with { type: 'json' }; import projectBriefSchema from '../schemas/project-brief.schema.json' with { type: 'json' }; @@ -14,6 +16,8 @@ export const SCHEMA_IDS = { audit: 'https://3notch.dev/schemas/audit.schema.json', brief: 'https://3notch.dev/schemas/brief.schema.json', config: 'https://3notch.dev/schemas/config.schema.json', + inboxConfig: 'https://3notch.dev/schemas/inbox-config.schema.json', + inboxDelivery: 'https://3notch.dev/schemas/inbox-delivery.schema.json', mcpTools: 'https://3notch.dev/schemas/mcp-tools.schema.json', packet: 'https://3notch.dev/schemas/packet.schema.json', projectBrief: 'https://3notch.dev/schemas/project-brief.schema.json', @@ -31,6 +35,8 @@ const schemas = { audit: auditSchema, brief: briefSchema, config: configSchema, + inboxConfig: inboxConfigSchema, + inboxDelivery: inboxDeliverySchema, mcpTools: mcpToolsSchema, packet: packetSchema, projectBrief: projectBriefSchema, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 1cee345..ffb0d8d 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -10,6 +10,7 @@ import { createTargetedBrief, getProjectBrief, getTargetedBrief, listTargetedBri import { checkStore } from '../core/check-service.js'; import { loadConfig } from '../core/config-service.js'; import { runDoctor } from '../core/doctor-service.js'; +import { ackInboxDelivery, initializeDurableInbox, listInboxDeliveries, pullInboxDelivery, sendInboxPacket } from '../core/inbox-service.js'; import { createMark, createPacket, createReply, getPacket, listPackets } from '../core/packet-service.js'; import { createSeedPacket, importSeedPacket } from '../core/seed-service.js'; import { getStatus } from '../core/status-service.js'; @@ -33,7 +34,10 @@ export type NotchMcpServerOptions = { type ToolDefinition = { description: string; name: keyof typeof mcpToolInputSchemas; + openWorld?: boolean; + outputSchema?: Record; readOnly: boolean; + title?: string; }; const toolDefinitions: ToolDefinition[] = [ @@ -49,6 +53,43 @@ const toolDefinitions: ToolDefinition[] = [ { name: 'get_packet', description: 'Read a packet by ID or slug.', readOnly: true }, { name: 'create_seed_packet', description: 'Create a private seed packet from explicit input.', readOnly: false }, { name: 'import_seed_packet', description: 'Import an explicit private seed packet file.', readOnly: false }, + { + name: 'inbox_init', + title: 'Initialize durable inbox', + description: 'Use only after the user chooses an explicit local mailbox root and address for async cross-agent packet delivery. This writes local configuration and registers the address; it does not create authenticated identity.', + readOnly: false, + openWorld: true, + outputSchema: inboxInitOutputSchema(), + }, + { + name: 'send_packet', + title: 'Send packed packet', + description: 'Use after notch packet pack when the user wants an async handoff to a registered local: address in another repo or model client. This is an externally visible write to the configured mailbox and refuses private or seed packets.', + readOnly: false, + openWorld: true, + outputSchema: inboxActionOutputSchema(true), + }, + { + name: 'list_inbox', + title: 'List durable inbox', + description: 'List pending async packet deliveries for this store. Use includeAll only when reviewing pulled, acknowledged, or rejected history.', + readOnly: true, + outputSchema: inboxListOutputSchema(), + }, + { + name: 'pull_inbox_packet', + title: 'Pull inbox packet', + description: 'Verify an async delivery by SHA-256 and existing packet/security checks; set import to true to import it through normal 3Notch validation. Import does not acknowledge or delete the retained delivery.', + readOnly: false, + outputSchema: inboxActionOutputSchema(false), + }, + { + name: 'ack_inbox_delivery', + title: 'Acknowledge inbox delivery', + description: 'Mark a reviewed, imported, or intentionally skipped delivery as acknowledged. Packet bytes remain retained for audit and this tool never deletes them.', + readOnly: false, + outputSchema: inboxActionOutputSchema(false), + }, { name: 'get_status', description: 'Return 3Notch project status.', readOnly: true }, { name: 'check_store', description: 'Return deterministic corpus integrity findings.', readOnly: true }, { name: 'run_doctor', description: 'Run 3Notch store diagnostics.', readOnly: true }, @@ -62,23 +103,25 @@ export function createNotchMcpServer(options: NotchMcpServerOptions = {}): Serve { name: '3notch', version: VERSION }, { capabilities: { tools: {} }, - instructions: '3Notch exposes local, explicit, reviewable context packet tools. It does not scrape chat history or execute shell commands.', + instructions: '3Notch exposes local, explicit, reviewable packets. For async handoff: create and pack a project packet, send_packet to a registered local: address, list_inbox at the receiver, pull_inbox_packet with import=true, review, then ack_inbox_delivery. Durable inbox labels are not authenticated identities; private/seed packets are blocked. 3Notch does not scrape chats, invoke other models, run shells, host a relay, or delete acknowledged packets.', }, ); server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: toolDefinitions.map((tool) => ({ name: tool.name, + ...(tool.title ? { title: tool.title } : {}), description: tool.description, inputSchema: getMcpToolInputSchema(tool.name) as { type: 'object'; properties?: Record; required?: string[]; }, + ...(tool.outputSchema ? { outputSchema: tool.outputSchema } : {}), annotations: { readOnlyHint: tool.readOnly, destructiveHint: false, - openWorldHint: false, + openWorldHint: tool.openWorld ?? false, }, })), })); @@ -284,6 +327,59 @@ async function executeTool( sourceTool: 'notch-mcp', }) as unknown as Record; } + case 'inbox_init': { + return await initializeDurableInbox(context, { + name: requiredString(args.name, 'name'), + root: assertAbsoluteMcpPath(requiredString(args.root, 'root'), 'root'), + }, { + actor: mcpActorName(args, options), + mcp: true, + sourceTool: 'notch-mcp', + }) as unknown as Record; + } + case 'send_packet': { + return await sendInboxPacket(context, { + packetPath: await assertSafeMcpPacketPath(requiredString(args.packetPath, 'packetPath')), + to: requiredString(args.to, 'to'), + }, { + actor: mcpActorName(args, options), + mcp: true, + sourceTool: 'notch-mcp', + }) as unknown as Record; + } + case 'list_inbox': { + return await listInboxDeliveries(context, { includeAll: Boolean(args.includeAll) }) as unknown as Record; + } + case 'pull_inbox_packet': { + if (Boolean(args.asReviewed) && !args.import) { + throw new NotchException({ + code: 'NOTCH_INBOX_IMPORT_REQUIRED', + message: 'asReviewed requires import=true.', + recovery: 'Set import=true, or omit asReviewed to verify without importing.', + severity: 'error', + exitCode: 1, + }); + } + + return await pullInboxDelivery(context, { + deliveryId: requiredString(args.deliveryId, 'deliveryId'), + import: Boolean(args.import), + asReviewed: Boolean(args.asReviewed), + }, { + actor: mcpActorName(args, options), + mcp: true, + sourceTool: 'notch-mcp', + }) as unknown as Record; + } + case 'ack_inbox_delivery': { + return await ackInboxDelivery(context, { + deliveryId: requiredString(args.deliveryId, 'deliveryId'), + }, { + actor: mcpActorName(args, options), + mcp: true, + sourceTool: 'notch-mcp', + }) as unknown as Record; + } case 'get_status': { return await getStatus(context) as unknown as Record; } @@ -379,3 +475,102 @@ async function assertSafeMcpPacketPath(packetPath: string): Promise { return packetPath; } + +function assertAbsoluteMcpPath(inputPath: string, field: string): string { + if (!path.isAbsolute(inputPath)) { + throw new NotchException({ + code: 'NOTCH_MCP_PATH_INVALID', + message: `MCP ${field} requires an absolute path.`, + path: inputPath, + recovery: 'Ask the user to choose an explicit absolute local path.', + severity: 'error', + exitCode: 4, + }); + } + + return inputPath; +} + +function inboxActionOutputSchema(includeIdempotent: boolean): Record { + return { + type: 'object', + additionalProperties: false, + required: [ + 'ok', + 'deliveryId', + 'packetId', + 'packetHash', + 'state', + 'packetPath', + ...(includeIdempotent ? ['idempotent'] : []), + 'nextAction', + ], + properties: { + ok: { const: true }, + deliveryId: { type: 'string', pattern: '^delivery_[a-f0-9]{24}$' }, + packetId: { type: 'string', minLength: 1 }, + packetHash: { type: 'string', pattern: '^[a-f0-9]{64}$' }, + state: { enum: ['pending', 'pulled', 'acked', 'rejected'] }, + packetPath: { type: 'string', minLength: 1 }, + importedPacketId: { type: 'string', minLength: 1 }, + ...(includeIdempotent ? { idempotent: { type: 'boolean' } } : {}), + nextAction: { type: 'string', minLength: 1 }, + }, + }; +} + +function inboxInitOutputSchema(): Record { + return { + type: 'object', + additionalProperties: false, + required: ['ok', 'address', 'root', 'transport', 'alreadyInitialized', 'nextAction'], + properties: { + ok: { const: true }, + address: { type: 'string', pattern: '^local:' }, + root: { type: 'string', minLength: 1 }, + transport: { const: 'local' }, + alreadyInitialized: { type: 'boolean' }, + nextAction: { type: 'string', minLength: 1 }, + }, + }; +} + +function inboxListOutputSchema(): Record { + return { + type: 'object', + additionalProperties: false, + required: ['ok', 'address', 'deliveries', 'nextAction'], + properties: { + ok: { const: true }, + address: { type: 'string', pattern: '^local:' }, + deliveries: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['schemaVersion', 'deliveryId', 'transport', 'from', 'to', 'packetId', 'packetHash', 'bytes', 'state', 'createdAt', 'updatedAt'], + properties: { + schemaVersion: { const: '1.0.0' }, + deliveryId: { type: 'string' }, + transport: { const: 'local' }, + from: { type: 'string' }, + to: { type: 'string' }, + packetId: { type: 'string' }, + packetHash: { type: 'string' }, + bytes: { type: 'integer' }, + state: { enum: ['pending', 'pulled', 'acked', 'rejected'] }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + pulledAt: { type: 'string' }, + ackedAt: { type: 'string' }, + rejectedAt: { type: 'string' }, + importedAt: { type: 'string' }, + importedPacketId: { type: 'string' }, + errorCode: { type: 'string' }, + }, + }, + }, + nextAction: { type: 'string', minLength: 1 }, + }, + }; +} diff --git a/src/schemas/audit.schema.json b/src/schemas/audit.schema.json index 9becba9..3b26685 100644 --- a/src/schemas/audit.schema.json +++ b/src/schemas/audit.schema.json @@ -16,7 +16,7 @@ "properties": { "schemaVersion": { "$ref": "https://3notch.dev/schemas/shared.schema.json#/$defs/schemaVersion" }, "at": { "$ref": "https://3notch.dev/schemas/shared.schema.json#/$defs/timestamp" }, - "operation": { "enum": ["create", "import", "rebuild-index", "scan-skip", "secret-blocked", "validation-failed"] }, + "operation": { "enum": ["create", "import", "inbox-ack", "inbox-init", "inbox-pull", "inbox-reject", "inbox-send", "rebuild-index", "scan-skip", "secret-blocked", "validation-failed"] }, "result": { "enum": ["success", "blocked", "failed"] }, "actor": { "$ref": "https://3notch.dev/schemas/shared.schema.json#/$defs/actor" }, "actorNameResolution": { "enum": ["cli-flag", "git-config", "environment", "unknown", "mcp-client"] }, @@ -29,6 +29,12 @@ "path": { "type": "string", "minLength": 1 }, "reason": { "type": "string", "minLength": 1 }, "supersedes": { "$ref": "https://3notch.dev/schemas/shared.schema.json#/$defs/recordId" }, - "errorCode": { "type": "string", "minLength": 1 } + "errorCode": { "type": "string", "minLength": 1 }, + "deliveryId": { "type": "string", "pattern": "^delivery_[a-f0-9]{24}$" }, + "deliveryState": { "enum": ["pending", "pulled", "acked", "rejected"] }, + "packetHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "from": { "type": "string", "pattern": "^local:[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$" }, + "to": { "type": "string", "pattern": "^local:[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$" }, + "bytes": { "type": "integer", "minimum": 1 } } } diff --git a/src/schemas/config.schema.json b/src/schemas/config.schema.json index c7d5d50..9f7e038 100644 --- a/src/schemas/config.schema.json +++ b/src/schemas/config.schema.json @@ -72,7 +72,11 @@ "create_mark", "create_reply", "create_seed_packet", - "import_seed_packet" + "import_seed_packet", + "inbox_init", + "send_packet", + "pull_inbox_packet", + "ack_inbox_delivery" ] }, "uniqueItems": true diff --git a/src/schemas/inbox-config.schema.json b/src/schemas/inbox-config.schema.json new file mode 100644 index 0000000..a5f091c --- /dev/null +++ b/src/schemas/inbox-config.schema.json @@ -0,0 +1,14 @@ +{ + "$id": "https://3notch.dev/schemas/inbox-config.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "transport", "name", "address", "root"], + "properties": { + "schemaVersion": { "const": "1.0.0" }, + "transport": { "const": "local" }, + "name": { "type": "string", "pattern": "^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$" }, + "address": { "type": "string", "pattern": "^local:[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$" }, + "root": { "type": "string", "minLength": 1 } + } +} diff --git a/src/schemas/inbox-delivery.schema.json b/src/schemas/inbox-delivery.schema.json new file mode 100644 index 0000000..8269951 --- /dev/null +++ b/src/schemas/inbox-delivery.schema.json @@ -0,0 +1,38 @@ +{ + "$id": "https://3notch.dev/schemas/inbox-delivery.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "deliveryId", + "transport", + "from", + "to", + "packetId", + "packetHash", + "bytes", + "state", + "createdAt", + "updatedAt" + ], + "properties": { + "schemaVersion": { "const": "1.0.0" }, + "deliveryId": { "type": "string", "pattern": "^delivery_[a-f0-9]{24}$" }, + "transport": { "const": "local" }, + "from": { "type": "string", "pattern": "^local:[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$" }, + "to": { "type": "string", "pattern": "^local:[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$" }, + "packetId": { "type": "string", "minLength": 1, "maxLength": 256 }, + "packetHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "bytes": { "type": "integer", "minimum": 1 }, + "state": { "enum": ["pending", "pulled", "acked", "rejected"] }, + "createdAt": { "$ref": "https://3notch.dev/schemas/shared.schema.json#/$defs/timestamp" }, + "updatedAt": { "$ref": "https://3notch.dev/schemas/shared.schema.json#/$defs/timestamp" }, + "pulledAt": { "$ref": "https://3notch.dev/schemas/shared.schema.json#/$defs/timestamp" }, + "ackedAt": { "$ref": "https://3notch.dev/schemas/shared.schema.json#/$defs/timestamp" }, + "rejectedAt": { "$ref": "https://3notch.dev/schemas/shared.schema.json#/$defs/timestamp" }, + "importedAt": { "$ref": "https://3notch.dev/schemas/shared.schema.json#/$defs/timestamp" }, + "importedPacketId": { "type": "string", "minLength": 1, "maxLength": 256 }, + "errorCode": { "type": "string", "minLength": 1, "maxLength": 128 } + } +} diff --git a/src/schemas/mcp-tools.schema.json b/src/schemas/mcp-tools.schema.json index 81cbf0d..a050a6f 100644 --- a/src/schemas/mcp-tools.schema.json +++ b/src/schemas/mcp-tools.schema.json @@ -207,6 +207,53 @@ "asReviewed": { "type": "boolean" } } }, + "inbox_init": { + "type": "object", + "additionalProperties": false, + "required": ["name", "root"], + "properties": { + "actorName": { "type": "string", "minLength": 1 }, + "name": { "type": "string", "minLength": 1, "maxLength": 128 }, + "root": { "type": "string", "minLength": 1 } + } + }, + "send_packet": { + "type": "object", + "additionalProperties": false, + "required": ["packetPath", "to"], + "properties": { + "actorName": { "type": "string", "minLength": 1 }, + "packetPath": { "type": "string", "minLength": 1 }, + "to": { "type": "string", "pattern": "^local:[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$" } + } + }, + "list_inbox": { + "type": "object", + "additionalProperties": false, + "properties": { + "includeAll": { "type": "boolean" } + } + }, + "pull_inbox_packet": { + "type": "object", + "additionalProperties": false, + "required": ["deliveryId"], + "properties": { + "actorName": { "type": "string", "minLength": 1 }, + "deliveryId": { "type": "string", "pattern": "^delivery_[a-f0-9]{24}$" }, + "import": { "type": "boolean" }, + "asReviewed": { "type": "boolean" } + } + }, + "ack_inbox_delivery": { + "type": "object", + "additionalProperties": false, + "required": ["deliveryId"], + "properties": { + "actorName": { "type": "string", "minLength": 1 }, + "deliveryId": { "type": "string", "pattern": "^delivery_[a-f0-9]{24}$" } + } + }, "get_status": { "type": "object", "additionalProperties": false, diff --git a/src/types/inbox.ts b/src/types/inbox.ts new file mode 100644 index 0000000..d5d2af5 --- /dev/null +++ b/src/types/inbox.ts @@ -0,0 +1,45 @@ +export type InboxTransport = 'local'; +export type InboxDeliveryState = 'pending' | 'pulled' | 'acked' | 'rejected'; + +export type InboxConfig = { + schemaVersion: '1.0.0'; + transport: InboxTransport; + name: string; + address: string; + root: string; +}; + +export type InboxDelivery = { + schemaVersion: '1.0.0'; + deliveryId: string; + transport: InboxTransport; + from: string; + to: string; + packetId: string; + packetHash: string; + bytes: number; + state: InboxDeliveryState; + createdAt: string; + updatedAt: string; + pulledAt?: string; + ackedAt?: string; + rejectedAt?: string; + importedAt?: string; + importedPacketId?: string; + errorCode?: string; +}; + +export type InboxDeliverySummary = InboxDelivery & { + packetPath?: string; +}; + +export type InboxResult = { + ok: true; + deliveryId: string; + packetId: string; + packetHash: string; + state: InboxDeliveryState; + packetPath: string; + importedPacketId?: string; + nextAction: string; +}; diff --git a/src/types/records.ts b/src/types/records.ts index 182ba17..71c328d 100644 --- a/src/types/records.ts +++ b/src/types/records.ts @@ -21,6 +21,11 @@ export type TransferStatus = 'draft' | 'outbox' | 'imported' | 'archived'; export type ReplyType = 'question' | 'clarification' | 'counter-decision' | 'objection' | 'confirmation'; export type AuditOperation = | 'create' + | 'inbox-ack' + | 'inbox-init' + | 'inbox-pull' + | 'inbox-reject' + | 'inbox-send' | 'import' | 'rebuild-index' | 'scan-skip' @@ -229,6 +234,12 @@ export type AuditEntry = { reason?: string; supersedes?: string; errorCode?: string; + deliveryId?: string; + deliveryState?: 'pending' | 'pulled' | 'acked' | 'rejected'; + packetHash?: string; + from?: string; + to?: string; + bytes?: number; }; export type ProjectStatusSummary = { diff --git a/tests/cli/inbox.test.ts b/tests/cli/inbox.test.ts new file mode 100644 index 0000000..05654ee --- /dev/null +++ b/tests/cli/inbox.test.ts @@ -0,0 +1,169 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { runCli } from '../helpers/run-cli.js'; +import { withTempProject } from '../helpers/temp-project.js'; + +describe('notch durable inbox CLI', () => { + it('moves a packed packet through init, send, list, pull/import, check, and ack with useful output', async () => { + await withTempProject({}, async (sender) => { + await withTempProject({}, async (receiver) => { + await onboard(sender.path, 'sender'); + await onboard(receiver.path, 'receiver'); + const mailboxRoot = path.join(sender.path, 'mailbox'); + const senderInit = await runCli([ + '--json', 'inbox', 'init', '--name', 'Sender Agent', '--root', mailboxRoot, + ], { cwd: sender.path }); + const receiverInit = await runCli([ + 'inbox', 'init', '--name', 'Receiver Agent', '--root', mailboxRoot, + ], { cwd: receiver.path }); + + expect(senderInit.exitCode).toBe(0); + expect(JSON.parse(senderInit.stdout)).toMatchObject({ + ok: true, + address: 'local:sender-agent', + root: mailboxRoot, + alreadyInitialized: false, + }); + expect(receiverInit.exitCode).toBe(0); + expect(receiverInit.stdout).toContain('Durable inbox ready at local:receiver-agent'); + expect(receiverInit.stdout).toContain(`Mailbox: ${mailboxRoot}`); + + const packed = await createPackedPacket(sender.path, receiver.path, 'CLI handoff'); + const send = await runCli([ + '--json', 'send', packed.archivePath, '--to', 'local:receiver-agent', + ], { cwd: sender.path }); + const sent = JSON.parse(send.stdout) as { + deliveryId: string; + packetHash: string; + packetId: string; + }; + + expect(send.exitCode).toBe(0); + expect(sent).toMatchObject({ + packetId: packed.packetId, + packetHash: expect.stringMatching(/^[a-f0-9]{64}$/u), + }); + + const humanList = await runCli(['inbox', 'list'], { cwd: receiver.path }); + const list = await runCli(['--json', 'inbox', 'list'], { cwd: receiver.path }); + expect(humanList.stdout).toContain(`${sent.deliveryId}\tpending\tlocal:sender-agent`); + expect(JSON.parse(list.stdout)).toMatchObject({ + address: 'local:receiver-agent', + deliveries: [expect.objectContaining({ deliveryId: sent.deliveryId, state: 'pending' })], + }); + + const pull = await runCli([ + '--json', '--agent', 'receiver-model', 'inbox', 'pull', sent.deliveryId, '--import', + ], { cwd: receiver.path }); + expect(pull.exitCode).toBe(0); + expect(JSON.parse(pull.stdout)).toMatchObject({ + deliveryId: sent.deliveryId, + importedPacketId: packed.packetId, + state: 'pulled', + }); + expect((await runCli(['check'], { cwd: receiver.path })).exitCode).toBe(0); + + const ack = await runCli(['inbox', 'ack', sent.deliveryId], { cwd: receiver.path }); + expect(ack.exitCode).toBe(0); + expect(ack.stdout).toContain(`Acknowledged delivery ${sent.deliveryId}`); + expect(ack.stdout).toContain('Packet bytes retained for audit.'); + expect((await runCli(['--json', 'inbox', 'list'], { cwd: receiver.path })).stdout).toContain('"deliveries": []'); + expect(JSON.parse((await runCli(['--json', 'inbox', 'list', '--all'], { cwd: receiver.path })).stdout)).toMatchObject({ + deliveries: [expect.objectContaining({ state: 'acked', importedPacketId: packed.packetId })], + }); + expect(await readFile(path.join(receiver.path, '.notch/inbox-config.json'), 'utf8')).toContain('local:receiver-agent'); + }); + }); + }); + + it('makes identical CLI send retries explicit and idempotent', async () => { + await withTempProject({}, async (sender) => { + await withTempProject({}, async (receiver) => { + await onboard(sender.path, 'sender'); + await onboard(receiver.path, 'receiver'); + const mailboxRoot = path.join(sender.path, 'mailbox'); + await runCli(['inbox', 'init', '--name', 'sender', '--root', mailboxRoot], { cwd: sender.path }); + await runCli(['inbox', 'init', '--name', 'receiver', '--root', mailboxRoot], { cwd: receiver.path }); + const packed = await createPackedPacket(sender.path, receiver.path, 'Retry handoff'); + const first = await runCli(['send', packed.archivePath, '--to', 'local:receiver'], { cwd: sender.path }); + const retry = await runCli(['send', packed.archivePath, '--to', 'local:receiver'], { cwd: sender.path }); + + expect(first.exitCode).toBe(0); + expect(first.stdout).toContain('Sent packet'); + expect(retry.exitCode).toBe(0); + expect(retry.stdout).toContain('Already delivered packet'); + expect(JSON.parse((await runCli(['--json', 'inbox', 'list'], { cwd: receiver.path })).stdout).deliveries).toHaveLength(1); + }); + }); + }); + + it('fails with actionable JSON when config is missing or review is requested without import', async () => { + await withTempProject({}, async (project) => { + await onboard(project.path, 'unconfigured'); + const list = await runCli(['--json', 'inbox', 'list'], { cwd: project.path }); + const pull = await runCli([ + '--json', 'inbox', 'pull', 'delivery_aaaaaaaaaaaaaaaaaaaaaaaa', '--as-reviewed', + ], { cwd: project.path }); + + expect(list.exitCode).toBe(2); + expect(JSON.parse(list.stderr)).toMatchObject({ + error: { + code: 'NOTCH_INBOX_NOT_CONFIGURED', + recovery: expect.stringContaining('notch inbox init'), + }, + }); + expect(pull.exitCode).toBe(1); + expect(JSON.parse(pull.stderr)).toMatchObject({ error: { code: 'NOTCH_INBOX_IMPORT_REQUIRED' } }); + }); + }); + + it('documents the minimal durable-inbox command surface in help', async () => { + const rootHelp = await runCli(['--help']); + const inboxHelp = await runCli(['inbox', '--help']); + + expect(rootHelp.stdout).toContain('send'); + expect(rootHelp.stdout).toContain('inbox'); + expect(inboxHelp.stdout).toContain('init'); + expect(inboxHelp.stdout).toContain('list'); + expect(inboxHelp.stdout).toContain('pull'); + expect(inboxHelp.stdout).toContain('ack'); + expect(inboxHelp.stdout).not.toContain('delete'); + }); +}); + +async function onboard(projectPath: string, name: string): Promise { + const result = await runCli(['onboard', '--yes', '--name', name], { cwd: projectPath }); + expect(result.exitCode).toBe(0); +} + +async function createPackedPacket( + senderPath: string, + receiverPath: string, + title: string, +): Promise<{ archivePath: string; packetId: string }> { + const create = await runCli([ + '--json', + 'packet', + 'create', + '--title', + title, + '--summary', + `${title} summary.`, + '--to-agent', + 'receiver-agent', + '--to-repo', + receiverPath, + '--next-steps', + 'Review and continue.', + ], { cwd: senderPath }); + expect(create.exitCode).toBe(0); + const packetId = (JSON.parse(create.stdout) as { packet: { id: string } }).packet.id; + const archivePath = path.join(senderPath, `${packetId}.notchpkt`); + const pack = await runCli([ + '--json', 'packet', 'pack', packetId, '--output', archivePath, + ], { cwd: senderPath }); + expect(pack.exitCode).toBe(0); + return { archivePath, packetId }; +} diff --git a/tests/cli/onboard.test.ts b/tests/cli/onboard.test.ts index aab0ab9..5457048 100644 --- a/tests/cli/onboard.test.ts +++ b/tests/cli/onboard.test.ts @@ -73,6 +73,17 @@ describe('notch onboard', () => { }); }); + it('prints project-scoped Grok MCP setup without changing client files', async () => { + await withTempProject({}, async (project) => { + const result = await runCli(['onboard', '--yes', '--mcp', 'grok'], { cwd: project.path }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('grok mcp add --scope project 3notch'); + expect(result.stdout).toContain('grok mcp doctor 3notch'); + expect(result.stdout).toContain('.grok/config.toml'); + }); + }); + it('supports JSON output', async () => { await withTempProject({}, async (project) => { const result = await runCli(['--json', 'onboard', '--yes', '--name', 'json-app'], { diff --git a/tests/cli/prompt.test.ts b/tests/cli/prompt.test.ts index 49dfc53..5ee38af 100644 --- a/tests/cli/prompt.test.ts +++ b/tests/cli/prompt.test.ts @@ -14,13 +14,18 @@ const v2Tools = [ 'get_packet', 'create_seed_packet', 'import_seed_packet', + 'inbox_init', + 'send_packet', + 'list_inbox', + 'pull_inbox_packet', + 'ack_inbox_delivery', 'create_mark', 'create_reply', 'check_store', 'get_status', 'run_doctor', ]; -const deferredSurface = ['pass', 'send', 'conflict', 'stale']; +const deferredSurface = ['pass', 'conflict', 'stale']; describe('notch prompt', () => { it.each(clients)('prints agent instructions for %s', async (client) => { diff --git a/tests/e2e/durable-inbox-smoke.test.ts b/tests/e2e/durable-inbox-smoke.test.ts new file mode 100644 index 0000000..d00a03c --- /dev/null +++ b/tests/e2e/durable-inbox-smoke.test.ts @@ -0,0 +1,152 @@ +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { runCli } from '../helpers/run-cli.js'; +import { withTempProject } from '../helpers/temp-project.js'; + +describe('e2e durable inbox request and reply', () => { + it('moves a request and typed reply between isolated stores with verified lineage, checks, traces, and no lock remnants', async () => { + await withTempProject({ prefix: 'notch-inbox-sender-' }, async (sender) => { + await withTempProject({ prefix: 'notch-inbox-receiver-' }, async (receiver) => { + await runCli(['onboard', '--yes', '--name', 'sender'], { cwd: sender.path }); + await runCli(['onboard', '--yes', '--name', 'receiver'], { cwd: receiver.path }); + const mailboxRoot = path.join(sender.path, 'durable-mailbox'); + await runCli(['inbox', 'init', '--name', 'sender', '--root', mailboxRoot], { cwd: sender.path }); + await runCli(['inbox', 'init', '--name', 'receiver', '--root', mailboxRoot], { cwd: receiver.path }); + + const request = await createPacket(sender.path, { + summary: 'Review the isolated handoff and reply with a confirmation.', + title: 'Durable request', + toAgent: 'receiver', + toRepo: 'receiver', + }); + const requestArchive = await packPacket(sender.path, request.id); + const sentRequest = await sendPacket(sender.path, requestArchive, 'local:receiver'); + const importedRequest = await pullPacket(receiver.path, sentRequest.deliveryId); + + expect(importedRequest.importedPacketId).toBe(request.id); + expect((await runCli(['check'], { cwd: receiver.path })).exitCode).toBe(0); + + const reply = await runCli([ + '--json', + '--agent', + 'receiver-model', + 'reply', + request.id, + '--type', + 'confirmation', + '--summary', + 'The durable request was received and reviewed.', + '--next-steps', + 'Continue from the confirmed handoff.', + ], { cwd: receiver.path }); + expect(reply.exitCode).toBe(0); + const replyData = JSON.parse(reply.stdout) as { packet: { id: string; replyTo: string }; path: string }; + expect(replyData.packet.replyTo).toBe(request.id); + expect(replyData.path).toContain(path.join('.notch', 'outbox')); + + const replyArchive = await packPacket(receiver.path, replyData.packet.id); + const sentReply = await sendPacket(receiver.path, replyArchive, 'local:sender'); + const importedReply = await pullPacket(sender.path, sentReply.deliveryId); + const shownReply = await runCli([ + '--json', 'packet', 'show', replyData.packet.id, '--inbox', + ], { cwd: sender.path }); + + expect(importedReply.importedPacketId).toBe(replyData.packet.id); + expect(JSON.parse(shownReply.stdout)).toMatchObject({ + packet: { + id: replyData.packet.id, + replyTo: request.id, + replyType: 'confirmation', + }, + }); + expect((await runCli(['check'], { cwd: sender.path })).exitCode).toBe(0); + + await expect(runCli(['inbox', 'ack', sentRequest.deliveryId], { cwd: receiver.path })).resolves.toMatchObject({ exitCode: 0 }); + await expect(runCli(['inbox', 'ack', sentReply.deliveryId], { cwd: sender.path })).resolves.toMatchObject({ exitCode: 0 }); + expect(await mailboxRemnants(mailboxRoot)).toEqual([]); + + for (const projectPath of [sender.path, receiver.path]) { + const auditLines = (await readFile(path.join(projectPath, '.notch/logs/audit.jsonl'), 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line) as { operation: string }); + expect(auditLines).toEqual(expect.arrayContaining([ + expect.objectContaining({ operation: 'inbox-init' }), + expect.objectContaining({ operation: 'inbox-send' }), + expect.objectContaining({ operation: 'inbox-pull' }), + expect.objectContaining({ operation: 'inbox-ack' }), + ])); + } + }); + }); + }, 30_000); +}); + +async function createPacket( + cwd: string, + input: { summary: string; title: string; toAgent: string; toRepo: string }, +): Promise<{ id: string }> { + const result = await runCli([ + '--json', + 'packet', + 'create', + '--title', + input.title, + '--summary', + input.summary, + '--to-agent', + input.toAgent, + '--to-repo', + input.toRepo, + '--next-steps', + 'Review this handoff and reply.', + ], { cwd }); + expect(result.exitCode).toBe(0); + return { id: (JSON.parse(result.stdout) as { packet: { id: string } }).packet.id }; +} + +async function packPacket(cwd: string, packetId: string): Promise { + const archivePath = path.join(cwd, `${packetId}.notchpkt`); + const result = await runCli([ + '--json', 'packet', 'pack', packetId, '--output', archivePath, + ], { cwd }); + expect(result.exitCode).toBe(0); + return archivePath; +} + +async function sendPacket(cwd: string, archivePath: string, to: string): Promise<{ deliveryId: string }> { + const result = await runCli(['--json', 'send', archivePath, '--to', to], { cwd }); + expect(result.exitCode).toBe(0); + return JSON.parse(result.stdout) as { deliveryId: string }; +} + +async function pullPacket(cwd: string, deliveryId: string): Promise<{ importedPacketId: string }> { + const result = await runCli([ + '--json', '--agent', 'receiving-model', 'inbox', 'pull', deliveryId, '--import', + ], { cwd }); + expect(result.exitCode).toBe(0); + return JSON.parse(result.stdout) as { importedPacketId: string }; +} + +async function mailboxRemnants(root: string): Promise { + const remnants: string[] = []; + + async function visit(directory: string): Promise { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + + if (entry.name.includes('.tmp') || entry.name.includes('.lock') || entry.name.includes('abandoned-')) { + remnants.push(path.relative(root, entryPath)); + } + + if (entry.isDirectory()) { + await visit(entryPath); + } + } + } + + await visit(root); + return remnants.sort(); +} diff --git a/tests/helpers/store-fixtures.ts b/tests/helpers/store-fixtures.ts index 28fdd47..29e4d6b 100644 --- a/tests/helpers/store-fixtures.ts +++ b/tests/helpers/store-fixtures.ts @@ -49,6 +49,10 @@ export async function createBareStore( 'create_reply', 'create_seed_packet', 'import_seed_packet', + 'inbox_init', + 'send_packet', + 'pull_inbox_packet', + 'ack_inbox_delivery', ], }, }, diff --git a/tests/mcp/inbox-tools.test.ts b/tests/mcp/inbox-tools.test.ts new file mode 100644 index 0000000..3fb241b --- /dev/null +++ b/tests/mcp/inbox-tools.test.ts @@ -0,0 +1,192 @@ +import { writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { createPacketArchive } from '../../src/core/archive-service.js'; +import { loadConfig } from '../../src/core/config-service.js'; +import { createPacket } from '../../src/core/packet-service.js'; +import { createNotchMcpServer } from '../../src/mcp/server.js'; +import { createBareStore } from '../helpers/store-fixtures.js'; +import { createMcpHarness } from '../helpers/mcp-harness.js'; +import { withTempProject } from '../helpers/temp-project.js'; + +describe('durable inbox MCP tools', () => { + it('runs the complete cross-store send, list, pull/import, and ack workflow with structured results', async () => { + await withTempProject({}, async (sender) => { + await withTempProject({}, async (receiver) => { + await createBareStore(sender.path, { name: 'sender' }); + await createBareStore(receiver.path, { name: 'receiver' }); + const senderHarness = await createMcpHarness(createNotchMcpServer({ cwd: sender.path, defaultActor: 'Sender Model' })); + const receiverHarness = await createMcpHarness(createNotchMcpServer({ cwd: receiver.path, defaultActor: 'Receiver Model' })); + const mailboxRoot = path.join(sender.path, 'mailbox'); + + try { + await expect(senderHarness.callTool('inbox_init', { + name: 'sender-model', + root: mailboxRoot, + })).resolves.toMatchObject({ + structuredContent: { ok: true, address: 'local:sender-model' }, + }); + await expect(receiverHarness.callTool('inbox_init', { + name: 'receiver-model', + root: mailboxRoot, + })).resolves.toMatchObject({ + structuredContent: { ok: true, address: 'local:receiver-model' }, + }); + + const packed = await packPacket(sender.path); + const send = await senderHarness.callTool('send_packet', { + packetPath: packed.archivePath, + to: 'local:receiver-model', + }) as { structuredContent: { deliveryId: string; packetHash: string } }; + expect(send).toMatchObject({ + structuredContent: { + deliveryId: expect.stringMatching(/^delivery_[a-f0-9]{24}$/u), + packetHash: expect.stringMatching(/^[a-f0-9]{64}$/u), + state: 'pending', + idempotent: false, + }, + }); + + await expect(receiverHarness.callTool('list_inbox')).resolves.toMatchObject({ + structuredContent: { + address: 'local:receiver-model', + deliveries: [expect.objectContaining({ deliveryId: send.structuredContent.deliveryId })], + }, + }); + await expect(receiverHarness.callTool('pull_inbox_packet', { + deliveryId: send.structuredContent.deliveryId, + import: true, + })).resolves.toMatchObject({ + structuredContent: { + deliveryId: send.structuredContent.deliveryId, + importedPacketId: packed.packetId, + state: 'pulled', + }, + }); + await expect(receiverHarness.callTool('ack_inbox_delivery', { + deliveryId: send.structuredContent.deliveryId, + })).resolves.toMatchObject({ + structuredContent: { + deliveryId: send.structuredContent.deliveryId, + state: 'acked', + }, + }); + await expect(receiverHarness.callTool('list_inbox')).resolves.toMatchObject({ + structuredContent: { deliveries: [] }, + }); + await expect(receiverHarness.callTool('list_inbox', { includeAll: true })).resolves.toMatchObject({ + structuredContent: { deliveries: [expect.objectContaining({ state: 'acked' })] }, + }); + } finally { + await senderHarness.close(); + await receiverHarness.close(); + } + }); + }); + }); + + it('advertises accurate titles, output schemas, and safety annotations', async () => { + await withTempProject({}, async (project) => { + await createBareStore(project.path, { name: 'metadata' }); + const harness = await createMcpHarness(createNotchMcpServer({ cwd: project.path })); + + try { + const response = await harness.client.listTools(); + const tools = new Map(response.tools.map((tool) => [tool.name, tool])); + expect(tools.get('send_packet')).toMatchObject({ + title: 'Send packed packet', + annotations: { + readOnlyHint: false, + destructiveHint: false, + openWorldHint: true, + }, + outputSchema: expect.objectContaining({ type: 'object' }), + }); + expect(tools.get('list_inbox')).toMatchObject({ + title: 'List durable inbox', + annotations: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }, + outputSchema: expect.objectContaining({ type: 'object' }), + }); + expect(tools.get('ack_inbox_delivery')?.description).toContain('never deletes'); + } finally { + await harness.close(); + } + }); + }); + + it('enforces MCP read-only mode and absolute-path setup boundaries', async () => { + await withTempProject({}, async (project) => { + await createBareStore(project.path, { name: 'read-only-inbox' }); + const writable = await createMcpHarness(createNotchMcpServer({ cwd: project.path })); + const readOnly = await createMcpHarness(createNotchMcpServer({ cwd: project.path, readOnly: true })); + + try { + await expect(writable.callTool('inbox_init', { + name: 'receiver', + root: 'relative/mailbox', + })).resolves.toMatchObject({ + isError: true, + structuredContent: { error: { code: 'NOTCH_MCP_PATH_INVALID' } }, + }); + await expect(readOnly.callTool('inbox_init', { + name: 'receiver', + root: path.join(project.path, 'mailbox'), + })).resolves.toMatchObject({ + isError: true, + structuredContent: { error: { code: 'NOTCH_MCP_READ_ONLY' } }, + }); + await expect(readOnly.callTool('list_inbox')).resolves.toMatchObject({ + isError: true, + structuredContent: { error: { code: 'NOTCH_INBOX_NOT_CONFIGURED' } }, + }); + } finally { + await writable.close(); + await readOnly.close(); + } + }); + }); + + it('rejects reviewed-without-import and invalid schema arguments before state change', async () => { + await withTempProject({}, async (project) => { + await createBareStore(project.path, { name: 'schema-guard' }); + const harness = await createMcpHarness(createNotchMcpServer({ cwd: project.path })); + + try { + await expect(harness.callTool('pull_inbox_packet', { + deliveryId: 'delivery_aaaaaaaaaaaaaaaaaaaaaaaa', + asReviewed: true, + })).resolves.toMatchObject({ + isError: true, + structuredContent: { error: { code: 'NOTCH_INBOX_IMPORT_REQUIRED' } }, + }); + await expect(harness.callTool('send_packet', { + packetPath: '/tmp/packet.notchpkt', + to: 'local:../escape', + })).resolves.toMatchObject({ isError: true }); + } finally { + await harness.close(); + } + }); + }); +}); + +async function packPacket(projectPath: string): Promise<{ archivePath: string; packetId: string }> { + const context = await loadConfig({ cwd: projectPath }); + const created = await createPacket(context, { + summary: 'MCP durable inbox handoff.', + title: 'MCP durable inbox', + toAgent: 'receiver-model', + }); + const archive = await createPacketArchive({ + markdownPath: created.outboxPath, + rootPath: created.outboxPath, + }); + const archivePath = path.join(projectPath, `${created.packet.id}.notchpkt`); + await writeFile(archivePath, archive); + return { archivePath, packetId: created.packet.id }; +} diff --git a/tests/mcp/server-start.test.ts b/tests/mcp/server-start.test.ts index d981a96..d513c64 100644 --- a/tests/mcp/server-start.test.ts +++ b/tests/mcp/server-start.test.ts @@ -24,6 +24,7 @@ describe('MCP server foundation', () => { try { const tools = await harness.listTools(); expect(tools.sort()).toEqual([ + 'ack_inbox_delivery', 'check_store', 'create_brief', 'create_mark', @@ -36,9 +37,13 @@ describe('MCP server foundation', () => { 'get_targeted_brief', 'import_packet', 'import_seed_packet', + 'inbox_init', 'list_briefs', + 'list_inbox', 'list_packets', + 'pull_inbox_packet', 'run_doctor', + 'send_packet', ].sort()); expect(tools).not.toContain('create_pass'); await expect(harness.client.ping()).resolves.toEqual({}); diff --git a/tests/schema/inbox-schemas.test.ts b/tests/schema/inbox-schemas.test.ts new file mode 100644 index 0000000..991026b --- /dev/null +++ b/tests/schema/inbox-schemas.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import { deliveryIdForPacketId } from '../../src/core/local-inbox-adapter.js'; +import { schemaService } from '../../src/core/schema-service.js'; +import type { InboxConfig, InboxDelivery } from '../../src/types/inbox.js'; +import type { AuditEntry } from '../../src/types/records.js'; + +describe('durable inbox schemas', () => { + it('accepts exact config and delivery records', () => { + const config: InboxConfig = { + schemaVersion: '1.0.0', + transport: 'local', + name: 'review-agent', + address: 'local:review-agent', + root: '/tmp/3notch-mailbox', + }; + const delivery = fixtureDelivery(); + + expect(schemaService.validate('inboxConfig', config).ok).toBe(true); + expect(schemaService.validate('inboxDelivery', delivery).ok).toBe(true); + }); + + it('rejects extra fields, traversal-shaped addresses, invalid hashes, and invalid states', () => { + expect(schemaService.validate('inboxConfig', { + schemaVersion: '1.0.0', + transport: 'local', + name: '../reviewer', + address: 'local:../reviewer', + root: '/tmp/mailbox', + }).ok).toBe(false); + expect(schemaService.validate('inboxDelivery', { + ...fixtureDelivery(), + packetHash: 'not-a-hash', + }).ok).toBe(false); + expect(schemaService.validate('inboxDelivery', { + ...fixtureDelivery(), + state: 'deleted', + }).ok).toBe(false); + expect(schemaService.validate('inboxDelivery', { + ...fixtureDelivery(), + packetContents: 'must never appear in metadata', + }).ok).toBe(false); + }); + + it('accepts redacted durable-inbox audit events without packet content', () => { + const delivery = fixtureDelivery(); + const audit: AuditEntry = { + schemaVersion: '1.0.0', + at: '2026-08-04T20:00:00Z', + operation: 'inbox-send', + result: 'success', + actor: { actorType: 'agent', name: 'Codex' }, + actorNameResolution: 'mcp-client', + actorTypeResolution: 'mcp-default', + sourceTool: { name: 'notch-mcp' }, + recordType: 'packet', + recordId: delivery.packetId, + deliveryId: delivery.deliveryId, + deliveryState: delivery.state, + packetHash: delivery.packetHash, + from: delivery.from, + to: delivery.to, + bytes: delivery.bytes, + }; + + expect(schemaService.validate('audit', audit).ok).toBe(true); + expect(JSON.stringify(audit)).not.toContain('packetContents'); + }); +}); + +function fixtureDelivery(): InboxDelivery { + const packetId = 'packet_schema_fixture'; + return { + schemaVersion: '1.0.0', + deliveryId: deliveryIdForPacketId(packetId), + transport: 'local', + from: 'local:sender', + to: 'local:review-agent', + packetId, + packetHash: 'a'.repeat(64), + bytes: 42, + state: 'pending', + createdAt: '2026-08-04T20:00:00Z', + updatedAt: '2026-08-04T20:00:00Z', + }; +} diff --git a/tests/schema/mcp-tool-schemas.test.ts b/tests/schema/mcp-tool-schemas.test.ts index dda71bd..8006637 100644 --- a/tests/schema/mcp-tool-schemas.test.ts +++ b/tests/schema/mcp-tool-schemas.test.ts @@ -18,6 +18,11 @@ const expectedTools = [ 'get_packet', 'create_seed_packet', 'import_seed_packet', + 'inbox_init', + 'send_packet', + 'list_inbox', + 'pull_inbox_packet', + 'ack_inbox_delivery', 'get_status', 'run_doctor', ]; @@ -53,4 +58,16 @@ describe('MCP tool input schemas', () => { })).toBe(true); expect(validateList({ tags: ['continuation', 'stream-feature-a'] })).toBe(true); }); + + it('fails closed on unsafe durable inbox addresses and delivery IDs', () => { + const ajv = new Ajv2020({ strict: true }); + ajv.addSchema(sharedSchema); + const validateSend = ajv.compile(getMcpToolInputSchema('send_packet')); + const validatePull = ajv.compile(getMcpToolInputSchema('pull_inbox_packet')); + + expect(validateSend({ packetPath: '/tmp/packet.notchpkt', to: 'local:review-agent' })).toBe(true); + expect(validateSend({ packetPath: '/tmp/packet.notchpkt', to: 'local:../review-agent' })).toBe(false); + expect(validatePull({ deliveryId: 'delivery_aaaaaaaaaaaaaaaaaaaaaaaa', import: true })).toBe(true); + expect(validatePull({ deliveryId: '../../delivery', import: true })).toBe(false); + }); }); diff --git a/tests/unit/archive-service.test.ts b/tests/unit/archive-service.test.ts index 7a89f13..67adf03 100644 --- a/tests/unit/archive-service.test.ts +++ b/tests/unit/archive-service.test.ts @@ -32,6 +32,15 @@ describe('archive service', () => { 'manifest.json', 'packet.md', ]); + expect(() => readArchiveEntries(first, { maxArchiveBytes: first.byteLength - 1 })).toThrowError( + expect.objectContaining({ notchError: expect.objectContaining({ code: 'NOTCH_ARCHIVE_TOO_LARGE' }) }), + ); + expect(() => readArchiveEntries(first, { maxEntries: 2 })).toThrowError( + expect.objectContaining({ notchError: expect.objectContaining({ code: 'NOTCH_ARCHIVE_TOO_LARGE' }) }), + ); + expect(() => readArchiveEntries(first, { maxUnpackedBytes: 32 })).toThrowError( + expect.objectContaining({ notchError: expect.objectContaining({ code: 'NOTCH_ARCHIVE_TOO_LARGE' }) }), + ); const unpacked = await unpackPacketArchiveToTemp(first); try { @@ -99,13 +108,45 @@ describe('archive service', () => { notchError: { code: 'NOTCH_ARCHIVE_UNSAFE' }, }); }); + + it('rejects duplicate paths, invalid sizes, and corrupted tar headers', () => { + const duplicateArchive = buildHostileArchive([ + { name: 'packet.md', content: Buffer.from('first') }, + { name: 'packet.md', content: Buffer.from('second') }, + ]); + const invalidSizeArchive = buildHostileArchive( + [{ name: 'packet.md', content: Buffer.from('body') }], + (header) => { + header.fill(0, 124, 136); + header.write('0000000000x\0', 124, 12, 'ascii'); + writeTarChecksum(header); + }, + ); + const corruptedHeaderArchive = buildHostileArchive( + [{ name: 'packet.md', content: Buffer.from('body') }], + (header) => { + header[0] = header[0] === 112 ? 113 : 112; + }, + ); + + for (const archive of [duplicateArchive, invalidSizeArchive, corruptedHeaderArchive]) { + expect(() => readArchiveEntries(archive)).toThrowError(expect.objectContaining({ + notchError: expect.objectContaining({ code: 'NOTCH_ARCHIVE_UNSAFE' }), + })); + } + }); }); -function buildHostileArchive(entries: Array<{ name: string; content: Buffer }>): Buffer { +function buildHostileArchive( + entries: Array<{ name: string; content: Buffer }>, + mutateHeader?: (header: Buffer, index: number) => void, +): Buffer { const tarParts: Buffer[] = []; - for (const entry of entries) { - tarParts.push(buildTarHeader(entry.name, entry.content.byteLength), entry.content); + for (const [index, entry] of entries.entries()) { + const header = buildTarHeader(entry.name, entry.content.byteLength); + mutateHeader?.(header, index); + tarParts.push(header, entry.content); const remainder = entry.content.byteLength % 512; if (remainder !== 0) { @@ -130,7 +171,12 @@ function buildTarHeader(name: string, size: number): Buffer { header.write('ustar', 257, 6, 'utf8'); header.write('00', 263, 2, 'utf8'); + writeTarChecksum(header); + return header; +} + +function writeTarChecksum(header: Buffer): void { + header.fill(0x20, 148, 156); const checksum = header.reduce((sum, byte) => sum + byte, 0); header.write(`${checksum.toString(8).padStart(7, '0')}\0`, 148, 8, 'ascii'); - return header; } diff --git a/tests/unit/inbox-service.test.ts b/tests/unit/inbox-service.test.ts new file mode 100644 index 0000000..fc4ee2b --- /dev/null +++ b/tests/unit/inbox-service.test.ts @@ -0,0 +1,322 @@ +import { gzipSync } from 'node:zlib'; +import { mkdir, readFile, symlink, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { createPacketArchive } from '../../src/core/archive-service.js'; +import { readAuditLog } from '../../src/core/audit-service.js'; +import { loadConfig } from '../../src/core/config-service.js'; +import { + ackInboxDelivery, + initializeDurableInbox, + listInboxDeliveries, + pullInboxDelivery, + sendInboxPacket, +} from '../../src/core/inbox-service.js'; +import { createPacket } from '../../src/core/packet-service.js'; +import type { NotchConfig, PacketPurpose, Sensitivity } from '../../src/types/records.js'; +import { createBareStore } from '../helpers/store-fixtures.js'; +import { withTempProject } from '../helpers/temp-project.js'; + +describe('durable inbox service', () => { + it('completes init, send, verify, import, idempotent pull, ack, and redacted audit lifecycle', async () => { + await withTempProject({}, async (sender) => { + await withTempProject({}, async (receiver) => { + await createBareStore(sender.path, { name: 'sender' }); + await createBareStore(receiver.path, { name: 'receiver' }); + const senderContext = await loadConfig({ cwd: sender.path }); + const receiverContext = await loadConfig({ cwd: receiver.path }); + const mailboxRoot = path.join(sender.path, 'mailbox'); + await initializeDurableInbox(senderContext, { name: 'sender', root: mailboxRoot }); + await initializeDurableInbox(receiverContext, { name: 'receiver', root: mailboxRoot }); + const packed = await packPacket(senderContext, sender.path, { title: 'Review request' }); + const sent = await sendInboxPacket(senderContext, { + packetPath: packed.archivePath, + to: 'local:receiver', + }); + const retry = await sendInboxPacket(senderContext, { + packetPath: packed.archivePath, + to: 'local:receiver', + }); + + expect(sent.idempotent).toBe(false); + expect(retry).toMatchObject({ deliveryId: sent.deliveryId, idempotent: true }); + expect(await listInboxDeliveries(receiverContext)).toMatchObject({ + deliveries: [expect.objectContaining({ deliveryId: sent.deliveryId, state: 'pending' })], + }); + + const verified = await pullInboxDelivery(receiverContext, { deliveryId: sent.deliveryId }); + const imported = await pullInboxDelivery(receiverContext, { deliveryId: sent.deliveryId, import: true }); + const importRetry = await pullInboxDelivery(receiverContext, { deliveryId: sent.deliveryId, import: true }); + const acked = await ackInboxDelivery(receiverContext, { deliveryId: sent.deliveryId }); + const ackRetry = await ackInboxDelivery(receiverContext, { deliveryId: sent.deliveryId }); + + expect(verified).toMatchObject({ state: 'pulled', packetId: packed.packetId }); + expect(imported).toMatchObject({ state: 'pulled', importedPacketId: packed.packetId }); + expect(importRetry).toMatchObject({ importedPacketId: packed.packetId }); + expect(acked.state).toBe('acked'); + expect(ackRetry.state).toBe('acked'); + expect(await readFile(acked.packetPath)).toEqual(packed.archive); + expect((await listInboxDeliveries(receiverContext)).deliveries).toEqual([]); + expect(await listInboxDeliveries(receiverContext, { includeAll: true })).toMatchObject({ + deliveries: [expect.objectContaining({ state: 'acked', importedPacketId: packed.packetId })], + }); + + const senderAudit = await readAuditLog(senderContext.paths.logs); + const receiverAudit = await readAuditLog(receiverContext.paths.logs); + expect(senderAudit).toEqual(expect.arrayContaining([ + expect.objectContaining({ operation: 'inbox-init', result: 'success' }), + expect.objectContaining({ operation: 'inbox-send', result: 'success', deliveryId: sent.deliveryId }), + expect.objectContaining({ operation: 'inbox-send', reason: 'idempotent-retry' }), + ])); + expect(receiverAudit).toEqual(expect.arrayContaining([ + expect.objectContaining({ operation: 'inbox-pull', result: 'success', deliveryId: sent.deliveryId }), + expect.objectContaining({ operation: 'inbox-ack', result: 'success', deliveryState: 'acked' }), + ])); + expect(JSON.stringify([...senderAudit, ...receiverAudit])).not.toContain('Review request summary.'); + }); + }); + }); + + it('serializes simultaneous import pulls into one receiver packet and deterministic results', async () => { + await withTempProject({}, async (sender) => { + await withTempProject({}, async (receiver) => { + await createBareStore(sender.path, { name: 'sender' }); + await createBareStore(receiver.path, { name: 'receiver' }); + const senderContext = await loadConfig({ cwd: sender.path }); + const receiverContext = await loadConfig({ cwd: receiver.path }); + const mailboxRoot = path.join(sender.path, 'mailbox'); + await initializeDurableInbox(senderContext, { name: 'sender', root: mailboxRoot }); + await initializeDurableInbox(receiverContext, { name: 'receiver', root: mailboxRoot }); + const packed = await packPacket(senderContext, sender.path, { title: 'Concurrent import' }); + const sent = await sendInboxPacket(senderContext, { packetPath: packed.archivePath, to: 'local:receiver' }); + const results = await Promise.all( + Array.from({ length: 10 }, async () => await pullInboxDelivery(receiverContext, { + deliveryId: sent.deliveryId, + import: true, + })), + ); + const inboxEntries = await import('node:fs/promises').then(async ({ readdir }) => await readdir(receiverContext.paths.inbox)); + + expect(results).toHaveLength(10); + expect(new Set(results.map((result) => result.importedPacketId))).toEqual(new Set([packed.packetId])); + expect(inboxEntries).toHaveLength(1); + }); + }); + }); + + it('rejects unknown recipients without silently creating a typo mailbox', async () => { + await withTempProject({}, async (sender) => { + await createBareStore(sender.path, { name: 'sender' }); + const context = await loadConfig({ cwd: sender.path }); + const mailboxRoot = path.join(sender.path, 'mailbox'); + await initializeDurableInbox(context, { name: 'sender', root: mailboxRoot }); + const packed = await packPacket(context, sender.path, { title: 'Unknown recipient' }); + + await expect(sendInboxPacket(context, { + packetPath: packed.archivePath, + to: 'local:recevier', + })).rejects.toMatchObject({ notchError: { code: 'NOTCH_INBOX_RECIPIENT_NOT_FOUND' } }); + await expect(readFile(path.join(mailboxRoot, 'recipients/recevier/deliveries'))).rejects.toBeDefined(); + expect(await readAuditLog(context.paths.logs)).toEqual(expect.arrayContaining([ + expect.objectContaining({ operation: 'inbox-send', result: 'failed', errorCode: 'NOTCH_INBOX_RECIPIENT_NOT_FOUND' }), + ])); + }); + }); + + it('rejects a valid packet ID reused with different archive bytes', async () => { + await withTempProject({}, async (sender) => { + await withTempProject({}, async (receiver) => { + await createBareStore(sender.path, { name: 'sender' }); + await createBareStore(receiver.path, { name: 'receiver' }); + const senderContext = await loadConfig({ cwd: sender.path }); + const receiverContext = await loadConfig({ cwd: receiver.path }); + const mailboxRoot = path.join(sender.path, 'mailbox'); + await initializeDurableInbox(senderContext, { name: 'sender', root: mailboxRoot }); + await initializeDurableInbox(receiverContext, { name: 'receiver', root: mailboxRoot }); + const packed = await packPacket(senderContext, sender.path, { title: 'Conflict packet' }); + await sendInboxPacket(senderContext, { packetPath: packed.archivePath, to: 'local:receiver' }); + const alteredPath = path.join(sender.path, 'altered.notchpkt'); + await writeFile(alteredPath, Buffer.concat([packed.archive, Buffer.from([0])])); + + await expect(sendInboxPacket(senderContext, { + packetPath: alteredPath, + to: 'local:receiver', + })).rejects.toMatchObject({ notchError: { code: 'NOTCH_INBOX_DELIVERY_CONFLICT' } }); + }); + }); + }); + + it('rejects secret-bearing and private packets before shared-mailbox write', async () => { + await withTempProject({}, async (sender) => { + await withTempProject({}, async (receiver) => { + await createBareStore(sender.path, { name: 'sender' }); + await createBareStore(receiver.path, { name: 'receiver' }); + const senderContext = await loadConfig({ cwd: sender.path }); + const receiverContext = await loadConfig({ cwd: receiver.path }); + const mailboxRoot = path.join(sender.path, 'mailbox'); + await initializeDurableInbox(senderContext, { name: 'sender', root: mailboxRoot }); + await initializeDurableInbox(receiverContext, { name: 'receiver', root: mailboxRoot }); + + const safe = await packPacket(senderContext, sender.path, { title: 'Content mutation' }); + const packetMarkdown = await readFile(safe.outboxPath, 'utf8'); + await writeFile(safe.outboxPath, `${packetMarkdown}\npassword should never enter the mailbox\n`, 'utf8'); + const secretArchive = await createPacketArchive({ + markdownPath: safe.outboxPath, + rootPath: safe.outboxPath, + }); + const secretPath = path.join(sender.path, 'secret.notchpkt'); + await writeFile(secretPath, secretArchive); + + await expect(sendInboxPacket(senderContext, { + packetPath: secretPath, + to: 'local:receiver', + })).rejects.toMatchObject({ notchError: { code: 'NOTCH_SECRET_DETECTED' } }); + + const privatePacket = await packPacket(senderContext, sender.path, { + purpose: 'seed', + sensitivity: 'private', + title: 'Private seed', + }); + await expect(sendInboxPacket(senderContext, { + packetPath: privatePacket.archivePath, + to: 'local:receiver', + })).rejects.toMatchObject({ notchError: { code: 'NOTCH_INBOX_PRIVATE_PACKET_BLOCKED' } }); + expect((await listInboxDeliveries(receiverContext, { includeAll: true })).deliveries).toEqual([]); + }); + }); + }); + + it('enforces configured artifact limits and bounded archive decompression', async () => { + await withTempProject({}, async (sender) => { + await withTempProject({}, async (receiver) => { + await createBareStore(sender.path, { name: 'sender' }); + await createBareStore(receiver.path, { name: 'receiver' }); + let senderContext = await loadConfig({ cwd: sender.path }); + const receiverContext = await loadConfig({ cwd: receiver.path }); + const mailboxRoot = path.join(sender.path, 'mailbox'); + await initializeDurableInbox(senderContext, { name: 'sender', root: mailboxRoot }); + await initializeDurableInbox(receiverContext, { name: 'receiver', root: mailboxRoot }); + await mkdir(path.join(sender.path, 'artifacts')); + await writeFile(path.join(sender.path, 'artifacts/large.txt'), 'a'.repeat(100)); + const packed = await packPacket(senderContext, sender.path, { + file: 'artifacts/large.txt', + title: 'Oversized for configured receiver', + }); + await setArtifactLimits(senderContext, { maxArtifactBytes: 50, maxPacketBytes: 1000 }); + senderContext = await loadConfig({ cwd: sender.path }); + + await expect(sendInboxPacket(senderContext, { + packetPath: packed.archivePath, + to: 'local:receiver', + })).rejects.toMatchObject({ notchError: { code: 'NOTCH_PACKET_TOO_LARGE' } }); + + await setArtifactLimits(senderContext, { maxArtifactBytes: 1024, maxPacketBytes: 1024 }); + senderContext = await loadConfig({ cwd: sender.path }); + const oversizedPath = path.join(sender.path, 'oversized.notchpkt'); + await writeFile(oversizedPath, Buffer.alloc(9 * 1024 * 1024, 65)); + await expect(sendInboxPacket(senderContext, { + packetPath: oversizedPath, + to: 'local:receiver', + })).rejects.toMatchObject({ notchError: { code: 'NOTCH_ARCHIVE_TOO_LARGE' } }); + + const bombPath = path.join(sender.path, 'bomb.notchpkt'); + await writeFile(bombPath, gzipSync(Buffer.alloc(9 * 1024 * 1024, 65))); + await expect(sendInboxPacket(senderContext, { + packetPath: bombPath, + to: 'local:receiver', + })).rejects.toMatchObject({ notchError: { code: 'NOTCH_ARCHIVE_TOO_LARGE' } }); + }); + }); + }); + + it('rejects corrupted delivery bytes before import and records rejected state and trace', async () => { + await withTempProject({}, async (sender) => { + await withTempProject({}, async (receiver) => { + await createBareStore(sender.path, { name: 'sender' }); + await createBareStore(receiver.path, { name: 'receiver' }); + const senderContext = await loadConfig({ cwd: sender.path }); + const receiverContext = await loadConfig({ cwd: receiver.path }); + const mailboxRoot = path.join(sender.path, 'mailbox'); + await initializeDurableInbox(senderContext, { name: 'sender', root: mailboxRoot }); + await initializeDurableInbox(receiverContext, { name: 'receiver', root: mailboxRoot }); + const packed = await packPacket(senderContext, sender.path, { title: 'Corruption test' }); + const sent = await sendInboxPacket(senderContext, { packetPath: packed.archivePath, to: 'local:receiver' }); + await writeFile(sent.packetPath, 'corrupted'); + + await expect(pullInboxDelivery(receiverContext, { + deliveryId: sent.deliveryId, + import: true, + })).rejects.toMatchObject({ notchError: { code: 'NOTCH_INBOX_HASH_MISMATCH' } }); + expect(await listInboxDeliveries(receiverContext, { includeAll: true })).toMatchObject({ + deliveries: [expect.objectContaining({ state: 'rejected', errorCode: 'NOTCH_INBOX_HASH_MISMATCH' })], + }); + expect(await readAuditLog(receiverContext.paths.logs)).toEqual(expect.arrayContaining([ + expect.objectContaining({ + operation: 'inbox-reject', + result: 'blocked', + errorCode: 'NOTCH_INBOX_HASH_MISMATCH', + }), + ])); + expect(await import('node:fs/promises').then(async ({ readdir }) => await readdir(receiverContext.paths.inbox))).toEqual([]); + }); + }); + }); + + it.skipIf(process.platform === 'win32')('rejects symlinked outgoing archives', async () => { + await withTempProject({}, async (sender) => { + await withTempProject({}, async (receiver) => { + await createBareStore(sender.path, { name: 'sender' }); + await createBareStore(receiver.path, { name: 'receiver' }); + const senderContext = await loadConfig({ cwd: sender.path }); + const receiverContext = await loadConfig({ cwd: receiver.path }); + const mailboxRoot = path.join(sender.path, 'mailbox'); + await initializeDurableInbox(senderContext, { name: 'sender', root: mailboxRoot }); + await initializeDurableInbox(receiverContext, { name: 'receiver', root: mailboxRoot }); + const packed = await packPacket(senderContext, sender.path, { title: 'Symlink test' }); + const linkPath = path.join(sender.path, 'packet-link.notchpkt'); + await symlink(packed.archivePath, linkPath); + + await expect(sendInboxPacket(senderContext, { + packetPath: linkPath, + to: 'local:receiver', + })).rejects.toMatchObject({ notchError: { code: 'NOTCH_SYMLINK_REJECTED' } }); + }); + }); + }); +}); + +async function packPacket( + context: Awaited>, + projectPath: string, + input: { + file?: string; + purpose?: PacketPurpose; + sensitivity?: Sensitivity; + title: string; + }, +): Promise<{ archive: Buffer; archivePath: string; outboxPath: string; packetId: string }> { + const created = await createPacket(context, { + files: input.file ? [{ path: input.file, purpose: 'source' }] : [], + ...(input.purpose ? { purpose: input.purpose } : {}), + ...(input.sensitivity ? { sensitivity: input.sensitivity } : {}), + summary: `${input.title} summary.`, + title: input.title, + toAgent: 'receiver', + }); + const archive = await createPacketArchive({ + markdownPath: created.outboxPath, + rootPath: created.outboxPath.endsWith('packet.md') ? path.dirname(created.outboxPath) : created.outboxPath, + }); + const archivePath = path.join(projectPath, `${created.packet.id}.notchpkt`); + await writeFile(archivePath, archive); + return { archive, archivePath, outboxPath: created.outboxPath, packetId: created.packet.id }; +} + +async function setArtifactLimits( + context: Awaited>, + artifacts: { maxArtifactBytes: number; maxPacketBytes: number }, +): Promise { + const config = JSON.parse(await readFile(context.paths.config, 'utf8')) as NotchConfig; + await writeFile(context.paths.config, `${JSON.stringify({ ...config, artifacts }, null, 2)}\n`, 'utf8'); +} diff --git a/tests/unit/local-inbox-adapter.test.ts b/tests/unit/local-inbox-adapter.test.ts new file mode 100644 index 0000000..0b24949 --- /dev/null +++ b/tests/unit/local-inbox-adapter.test.ts @@ -0,0 +1,227 @@ +import { readFile, readdir, symlink, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { sha256 } from '../../src/core/artifact-service.js'; +import { loadConfig } from '../../src/core/config-service.js'; +import { + deliveryIdForPacketId, + initializeInboxConfig, + loadInboxConfig, + LocalInboxAdapter, + localInboxAddress, + normalizeInboxName, + parseLocalInboxAddress, +} from '../../src/core/local-inbox-adapter.js'; +import type { InboxDelivery } from '../../src/types/inbox.js'; +import { createBareStore } from '../helpers/store-fixtures.js'; +import { withTempProject } from '../helpers/temp-project.js'; + +describe('local durable inbox adapter', () => { + it('normalizes safe local addresses and rejects path-shaped addresses', () => { + expect(normalizeInboxName('Review Agent')).toBe('review-agent'); + expect(localInboxAddress('Review Agent')).toBe('local:review-agent'); + expect(parseLocalInboxAddress('local:review-agent')).toBe('review-agent'); + expect(deliveryIdForPacketId('packet_example')).toMatch(/^delivery_[a-f0-9]{24}$/u); + + for (const unsafe of ['review-agent', 'local:../review-agent', 'local:/tmp', 'local:review\\agent', 'local:.']) { + expect(() => parseLocalInboxAddress(unsafe)).toThrowError(expect.objectContaining({ + notchError: expect.objectContaining({ code: 'NOTCH_INBOX_ADDRESS_INVALID' }), + })); + } + }); + + it('creates ignored machine-local config idempotently and refuses silent reconfiguration', async () => { + await withTempProject({}, async (project) => { + const storePath = await createBareStore(project.path, { name: 'receiver' }); + const context = await loadConfig({ cwd: project.path }); + const mailboxRoot = path.join(project.path, 'mailbox'); + const first = await initializeInboxConfig(context, { name: 'Review Agent', root: mailboxRoot }); + const second = await initializeInboxConfig(context, { name: 'Review Agent', root: mailboxRoot }); + + expect(first.alreadyInitialized).toBe(false); + expect(second.alreadyInitialized).toBe(true); + expect(await loadInboxConfig(context)).toMatchObject({ + address: 'local:review-agent', + root: mailboxRoot, + }); + expect(await readFile(path.join(storePath, '.gitignore'), 'utf8')).toContain('inbox-config.json'); + await expect(initializeInboxConfig(context, { + name: 'other-agent', + root: mailboxRoot, + })).rejects.toMatchObject({ notchError: { code: 'NOTCH_INBOX_ALREADY_CONFIGURED' } }); + }); + }); + + it('stores one atomic delivery, deduplicates identical retries, and rejects conflicting bytes', async () => { + await withTempProject({}, async (project) => { + const root = path.join(project.path, 'mailbox'); + const adapter = new LocalInboxAdapter(root); + await adapter.initializeRecipient('local:receiver'); + const packet = Buffer.from('packet-one'); + const delivery = fixtureDelivery(packet); + const first = await adapter.putDelivery('local:receiver', { delivery, packet }); + const retry = await adapter.putDelivery('local:receiver', { delivery, packet }); + const conflictingPacket = Buffer.from('packet-two'); + const conflict = { + ...delivery, + packetHash: sha256(conflictingPacket), + bytes: conflictingPacket.byteLength, + } satisfies InboxDelivery; + + expect(first.idempotent).toBe(false); + expect(retry.idempotent).toBe(true); + expect(retry.packetPath).toBe(first.packetPath); + await expect(adapter.putDelivery('local:receiver', { + delivery: conflict, + packet: conflictingPacket, + })).rejects.toMatchObject({ notchError: { code: 'NOTCH_INBOX_DELIVERY_CONFLICT' } }); + await expect(adapter.putDelivery('local:receiver', { + delivery: { ...delivery, to: 'local:other' }, + packet, + })).rejects.toMatchObject({ notchError: { code: 'NOTCH_INBOX_DELIVERY_CONFLICT' } }); + await expect(adapter.putDelivery('local:receiver', { + delivery: { ...delivery, deliveryId: 'delivery_000000000000000000000000' }, + packet, + })).rejects.toMatchObject({ notchError: { code: 'NOTCH_INBOX_DELIVERY_INVALID' } }); + + const entries = await readdir(path.dirname(first.packetPath)); + expect(entries.sort()).toEqual(['delivery.json', 'packet.notchpkt', 'packet.sha256']); + expect(entries.some((entry) => entry.startsWith('.'))).toBe(false); + }); + }); + + it('serializes simultaneous identical sends into one delivery without temp remnants', async () => { + await withTempProject({}, async (project) => { + const root = path.join(project.path, 'mailbox'); + const adapter = new LocalInboxAdapter(root); + await adapter.initializeRecipient('local:receiver'); + const packet = Buffer.from('same retry bytes'); + const delivery = fixtureDelivery(packet); + const results = await Promise.all( + Array.from({ length: 20 }, async () => await adapter.putDelivery('local:receiver', { delivery, packet })), + ); + const deliveriesPath = path.join(root, 'recipients/receiver/deliveries'); + + expect(results.filter((result) => !result.idempotent)).toHaveLength(1); + expect(results.filter((result) => result.idempotent)).toHaveLength(19); + expect(await readdir(deliveriesPath)).toEqual([delivery.deliveryId]); + }); + }); + + it('lists pending by default, retains acknowledged history, and keeps packet bytes', async () => { + await withTempProject({}, async (project) => { + const root = path.join(project.path, 'mailbox'); + const adapter = new LocalInboxAdapter(root); + await adapter.initializeRecipient('local:receiver'); + const packet = Buffer.from('retained packet'); + const delivery = fixtureDelivery(packet); + await adapter.putDelivery('local:receiver', { delivery, packet }); + + expect(await adapter.listDeliveries('local:receiver')).toHaveLength(1); + await adapter.withDelivery('local:receiver', delivery.deliveryId, { maxBytes: 1024 }, async (locked) => { + await locked.update({ state: 'acked', ackedAt: new Date().toISOString() }); + }); + + expect(await adapter.listDeliveries('local:receiver')).toEqual([]); + expect(await adapter.listDeliveries('local:receiver', { includeAll: true })).toEqual([ + expect.objectContaining({ deliveryId: delivery.deliveryId, state: 'acked' }), + ]); + expect(await readFile(path.join(root, `recipients/receiver/deliveries/${delivery.deliveryId}/packet.notchpkt`))).toEqual(packet); + }); + }); + + it('fails closed and marks a delivery rejected when packet bytes no longer match the hash', async () => { + await withTempProject({}, async (project) => { + const root = path.join(project.path, 'mailbox'); + const adapter = new LocalInboxAdapter(root); + await adapter.initializeRecipient('local:receiver'); + const packet = Buffer.from('original packet'); + const delivery = fixtureDelivery(packet); + const stored = await adapter.putDelivery('local:receiver', { delivery, packet }); + await writeFile(stored.packetPath, 'tampered packet'); + + await expect(adapter.withDelivery( + 'local:receiver', + delivery.deliveryId, + { maxBytes: 1024 }, + async () => undefined, + )).rejects.toMatchObject({ notchError: { code: 'NOTCH_INBOX_HASH_MISMATCH' } }); + expect(await adapter.listDeliveries('local:receiver', { includeAll: true })).toEqual([ + expect.objectContaining({ + deliveryId: delivery.deliveryId, + state: 'rejected', + errorCode: 'NOTCH_INBOX_HASH_MISMATCH', + }), + ]); + }); + }); + + it.skipIf(process.platform === 'win32')('rejects symlinked recipient and delivery paths', async () => { + await withTempProject({}, async (project) => { + const root = path.join(project.path, 'mailbox'); + const adapter = new LocalInboxAdapter(root); + await adapter.initializeRecipient('local:receiver'); + const outside = path.join(project.path, 'outside'); + await new LocalInboxAdapter(outside).initializeRecipient('local:unused'); + await symlink( + path.join(outside, 'recipients'), + path.join(root, 'recipients/linked'), + ); + + await expect(adapter.listDeliveries('local:linked')).rejects.toMatchObject({ + notchError: { code: 'NOTCH_SYMLINK_REJECTED' }, + }); + }); + }); + + it('rejects malformed metadata and unexpected delivery-store entries', async () => { + await withTempProject({}, async (project) => { + const root = path.join(project.path, 'mailbox'); + const adapter = new LocalInboxAdapter(root); + await adapter.initializeRecipient('local:receiver'); + const deliveriesPath = path.join(root, 'recipients/receiver/deliveries'); + await writeFile(path.join(deliveriesPath, 'unexpected.txt'), 'not a delivery'); + + await expect(adapter.listDeliveries('local:receiver')).rejects.toMatchObject({ + notchError: { code: 'NOTCH_INBOX_DELIVERY_INVALID' }, + }); + }); + }); + + it('rejects delivery metadata with an impossible lifecycle state', async () => { + await withTempProject({}, async (project) => { + const root = path.join(project.path, 'mailbox'); + const adapter = new LocalInboxAdapter(root); + await adapter.initializeRecipient('local:receiver'); + const packet = Buffer.from('lifecycle packet'); + const delivery = fixtureDelivery(packet); + const stored = await adapter.putDelivery('local:receiver', { delivery, packet }); + const metadataPath = path.join(path.dirname(stored.packetPath), 'delivery.json'); + await writeFile(metadataPath, `${JSON.stringify({ ...delivery, state: 'acked' }, null, 2)}\n`); + + await expect(adapter.listDeliveries('local:receiver', { includeAll: true })).rejects.toMatchObject({ + notchError: { code: 'NOTCH_INBOX_DELIVERY_INVALID' }, + }); + }); + }); +}); + +function fixtureDelivery(packet: Buffer): InboxDelivery { + const packetId = 'packet_adapter_fixture'; + const now = new Date().toISOString(); + + return { + schemaVersion: '1.0.0', + deliveryId: deliveryIdForPacketId(packetId), + transport: 'local', + from: 'local:sender', + to: 'local:receiver', + packetId, + packetHash: sha256(packet), + bytes: packet.byteLength, + state: 'pending', + createdAt: now, + updatedAt: now, + }; +} diff --git a/tests/unit/no-deferred-commands.test.ts b/tests/unit/no-deferred-commands.test.ts index dcbea71..a38e2eb 100644 --- a/tests/unit/no-deferred-commands.test.ts +++ b/tests/unit/no-deferred-commands.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { mcpToolInputSchemas } from '../../src/mcp/tool-schemas.js'; import { readRepoFile } from '../helpers/package-inspection.js'; -const deferredCliCommands = ['pass', 'send', 'decision', 'question', 'conflict', 'stale']; +const deferredCliCommands = ['pass', 'decision', 'question', 'conflict', 'stale']; const deferredMcpTools = [ 'create_pass', 'get_latest_pass', @@ -25,6 +25,7 @@ describe('deferred surface guard', () => { 'src/cli/commands/brief.ts', 'src/cli/commands/check.ts', 'src/cli/commands/doctor.ts', + 'src/cli/commands/inbox.ts', 'src/cli/commands/mark.ts', 'src/cli/commands/mcp.ts', 'src/cli/commands/onboard.ts', From 46e7862a695c8b93aadcb149fe4dee39ad54eb1c Mon Sep 17 00:00:00 2001 From: Sean Robbins <251665174+coldlogicAI@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:04:40 -0400 Subject: [PATCH 2/3] test: allow slower durable inbox CLI runs --- tests/cli/inbox.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cli/inbox.test.ts b/tests/cli/inbox.test.ts index 05654ee..b7669ac 100644 --- a/tests/cli/inbox.test.ts +++ b/tests/cli/inbox.test.ts @@ -76,7 +76,7 @@ describe('notch durable inbox CLI', () => { expect(await readFile(path.join(receiver.path, '.notch/inbox-config.json'), 'utf8')).toContain('local:receiver-agent'); }); }); - }); + }, 40_000); it('makes identical CLI send retries explicit and idempotent', async () => { await withTempProject({}, async (sender) => { From de09211143cc08ecb29185f0b2209c56c9bd44ca Mon Sep 17 00:00:00 2001 From: Sean Robbins Date: Fri, 7 Aug 2026 14:30:24 -0400 Subject: [PATCH 3/3] fix: keep durable inbox deliveries recoverable --- CHANGELOG.md | 4 ++ docs/active-plans/v5/v5-implementation-log.md | 5 ++ docs/guides/durable-inbox.md | 6 +- src/core/inbox-service.ts | 25 +++++-- src/core/local-inbox-adapter.ts | 40 ++++++++++- tests/cli/inbox.test.ts | 10 ++- tests/unit/inbox-service.test.ts | 68 +++++++++++++++++++ tests/unit/local-inbox-adapter.test.ts | 10 ++- 8 files changed, 155 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dbe659..8fa4f21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Fixed +- Receiver size-policy failures remain retryable after limits change, legacy size rejections can recover, and rejected deliveries no longer produce false-success resend results. +- Durable inbox listing ignores unmanaged sync-folder clutter while continuing to reject malformed entries in the managed delivery-ID namespace. + ## [0.6.0] - 2026-08-04 ### Added diff --git a/docs/active-plans/v5/v5-implementation-log.md b/docs/active-plans/v5/v5-implementation-log.md index 0c47a4e..3350393 100644 --- a/docs/active-plans/v5/v5-implementation-log.md +++ b/docs/active-plans/v5/v5-implementation-log.md @@ -12,3 +12,8 @@ - Hardened the archive boundary for untrusted delivery: reject oversized files before reading, bound decompression and entry count, verify tar header checksums and numeric fields, and reject malformed padding, duplicate paths, unsafe entries, missing end markers, and trailing data. - Refreshed the lockfile without forced major upgrades. The source graph and clean installed tarball both report zero npm advisories; a scoped `tsup` override keeps esbuild on the patched `0.28.1` line. - Final verification passed after a clean `npm ci`: lint, type-check, build, 234 tests across 78 files, and 23 e2e tests across 11 files. The packed `@3notch/cli@0.6.0` tarball installed into an empty prefix and completed a real request → import → typed reply → import → ack loop with lineage preserved and both stores passing `notch check`. + +## 2026-08-07 + +- Fixed PR review regressions in durable-inbox recovery and enumeration: receiver size-policy failures stay retryable, legacy size rejections clear after successful revalidation, terminal rejections cannot return false-success resend results, and unmanaged sync-folder clutter no longer blocks listing while malformed managed delivery entries still fail closed. +- Regression verification passed: lint, type-check, production build, 235 tests across 78 files, 23 e2e tests across 11 files, and the built CLI help smoke check. diff --git a/docs/guides/durable-inbox.md b/docs/guides/durable-inbox.md index 3706f29..6c05bdb 100644 --- a/docs/guides/durable-inbox.md +++ b/docs/guides/durable-inbox.md @@ -38,7 +38,7 @@ notch send .notchpkt --to local:review-agent Before a shared-mailbox write, 3Notch checks that the source is a regular `.notchpkt`, caps compressed and unpacked work, validates packet and artifact schemas, verifies artifact hashes, scans packet text and text-like artifacts, and blocks private or seed packets. The recipient must already be registered; a typo does not create a new mailbox. -Retrying the same packet ID and exact bytes is harmless and returns the existing delivery. Reusing a packet ID with different archive bytes fails. +Retrying the same packet ID and exact bytes returns the existing active or completed delivery. Reusing a packet ID with different archive bytes fails. A terminally rejected delivery is never reported as a successful resend; correct the packet and create a new packet ID instead. ## Receive @@ -119,8 +119,8 @@ Events include routing labels, delivery and packet IDs, SHA-256, byte count, sta - The local adapter does not authenticate addresses, authorize users, sign senders, or encrypt archives. - Anyone with filesystem access to the mailbox root may be able to read or change it. Use OS permissions and disk encryption appropriate to the data. - SHA-256 detects changed bytes; it does not prove who authored them. -- One-host locking and atomic renames are tested. Dropbox, iCloud, Syncthing, network mounts, and similar products have their own conflict and availability behavior; V1 does not claim distributed locks or guaranteed delivery across them. +- One-host locking and atomic renames are tested. Unmanaged sync-conflict files are ignored during listing, while malformed entries using the managed delivery-ID namespace still fail closed. Dropbox, iCloud, Syncthing, network mounts, and similar products have their own conflict and availability behavior; V1 does not claim distributed locks or guaranteed delivery across them. - There is no daemon, polling loop, notification service, account, hosted relay, retention deletion, or remote network listener. - Private and seed packets are intentionally blocked from this shared-mailbox flow. -If no config exists, run `notch inbox init`. If a delivery is rejected, use `notch inbox list --all`, preserve the evidence, and ask the sender to retry from the original archive rather than editing mailbox files. +If no config exists, run `notch inbox init`. A local size-limit failure remains pending: review the source, raise the receiver limit when appropriate, and pull the same delivery again. For a terminal rejection, use `notch inbox list --all`, preserve the evidence, and ask the sender for a corrected packet with a new packet ID rather than editing mailbox files. diff --git a/src/core/inbox-service.ts b/src/core/inbox-service.ts index 2f4e790..bbb7455 100644 --- a/src/core/inbox-service.ts +++ b/src/core/inbox-service.ts @@ -205,7 +205,10 @@ export async function pullInboxDelivery( input.deliveryId, { maxBytes: limits.maxArchiveBytes }, async (locked) => { - if (locked.delivery.state === 'rejected') { + const recoveringPolicyRejection = locked.delivery.state === 'rejected' + && isRetryableDeliveryError(locked.delivery.errorCode); + + if (locked.delivery.state === 'rejected' && !recoveringPolicyRejection) { throw invalidState(locked.delivery, 'Rejected deliveries cannot be pulled.'); } @@ -226,7 +229,7 @@ export async function pullInboxDelivery( } catch (error) { const notchError = errorToNotchError(error); - if (isRejectedDeliveryError(notchError.code)) { + if (isTerminalDeliveryError(notchError.code)) { const now = new Date().toISOString(); await locked.update({ state: 'rejected', @@ -265,6 +268,7 @@ export async function pullInboxDelivery( pulledAt: locked.delivery.pulledAt ?? now, importedAt: now, importedPacketId: imported.packet.id, + ...(recoveringPolicyRejection ? { clearRejection: true } : {}), }); return inboxResult(updated, locked.packetPath, 'Acknowledge the imported delivery after reviewing the packet.'); @@ -275,7 +279,12 @@ export async function pullInboxDelivery( const now = new Date().toISOString(); const updated = locked.delivery.state === 'pending' - ? await locked.update({ state: 'pulled', pulledAt: now }) + || recoveringPolicyRejection + ? await locked.update({ + state: 'pulled', + pulledAt: locked.delivery.pulledAt ?? now, + ...(recoveringPolicyRejection ? { clearRejection: true } : {}), + }) : locked.delivery; return inboxResult( @@ -309,7 +318,7 @@ export async function pullInboxDelivery( return result; } catch (error) { const notchError = errorToNotchError(error); - const rejected = isRejectedDeliveryError(notchError.code); + const rejected = isTerminalDeliveryError(notchError.code); await auditFailure(context, author, rejected ? 'inbox-reject' : 'inbox-pull', error, { deliveryId: input.deliveryId, ...(rejected ? { deliveryState: 'rejected' as const } : {}), @@ -634,20 +643,22 @@ async function auditFailure( }); } -function isRejectedDeliveryError(code: string): boolean { +function isTerminalDeliveryError(code: string): boolean { return [ - 'NOTCH_ARCHIVE_TOO_LARGE', 'NOTCH_ARCHIVE_UNSAFE', 'NOTCH_INBOX_DELIVERY_CONFLICT', 'NOTCH_INBOX_DELIVERY_INVALID', 'NOTCH_INBOX_HASH_MISMATCH', 'NOTCH_INBOX_PRIVATE_PACKET_BLOCKED', - 'NOTCH_PACKET_TOO_LARGE', 'NOTCH_RECORD_INVALID', 'NOTCH_SECRET_DETECTED', ].includes(code); } +function isRetryableDeliveryError(code: string | undefined): boolean { + return code === 'NOTCH_ARCHIVE_TOO_LARGE' || code === 'NOTCH_PACKET_TOO_LARGE'; +} + function validLocalAddress(value: string | undefined): value is string { if (!value) { return false; diff --git a/src/core/local-inbox-adapter.ts b/src/core/local-inbox-adapter.ts index a02faf0..9f382db 100644 --- a/src/core/local-inbox-adapter.ts +++ b/src/core/local-inbox-adapter.ts @@ -39,6 +39,7 @@ export type LockedLocalDelivery = { importedAt?: string; importedPacketId?: string; errorCode?: string; + clearRejection?: boolean; }) => Promise; }; @@ -282,7 +283,11 @@ export class LocalInboxAdapter implements InboxTransportAdapter { continue; } - if (!entry.isDirectory() || !deliveryIdPattern.test(entry.name)) { + if (!deliveryIdPattern.test(entry.name)) { + continue; + } + + if (!entry.isDirectory()) { throw malformedDelivery(path.join(deliveriesPath, entry.name), 'Unexpected entry in the delivery store.'); } @@ -313,6 +318,10 @@ export class LocalInboxAdapter implements InboxTransportAdapter { try { packet = await this.readVerifiedPacket(deliveryPath, delivery, options.maxBytes); } catch (error) { + if (!isTerminalDeliveryReadError(error)) { + throw error; + } + const now = new Date().toISOString(); delivery = await this.writeDeliveryMetadata(deliveryPath, { ...delivery, @@ -341,6 +350,12 @@ export class LocalInboxAdapter implements InboxTransportAdapter { ...(changes.importedPacketId ? { importedPacketId: changes.importedPacketId } : {}), ...(changes.errorCode ? { errorCode: changes.errorCode } : {}), }; + + if (changes.clearRejection) { + delete next.rejectedAt; + delete next.errorCode; + } + delivery = await this.writeDeliveryMetadata(deliveryPath, next); return delivery; }, @@ -418,6 +433,18 @@ export class LocalInboxAdapter implements InboxTransportAdapter { } await this.readVerifiedPacket(deliveryPath, existing, Number.MAX_SAFE_INTEGER); + + if (existing.state === 'rejected') { + throw new NotchException({ + code: 'NOTCH_INBOX_STATE_INVALID', + message: `Delivery ${expected.deliveryId} was rejected and was not resent.`, + path: deliveryPath, + recovery: 'Correct the packet, create a new packet ID, and send that archive. For a local size-limit failure, raise the receiver limit and pull the existing delivery again.', + severity: 'error', + exitCode: 6, + }); + } + return { delivery: existing, idempotent: true, @@ -687,3 +714,14 @@ function symlinkError(filePath: string): NotchException { exitCode: 5, }); } + +function isTerminalDeliveryReadError(error: unknown): boolean { + if (!(error instanceof NotchException)) { + return false; + } + + return [ + 'NOTCH_INBOX_DELIVERY_INVALID', + 'NOTCH_INBOX_HASH_MISMATCH', + ].includes(error.notchError.code); +} diff --git a/tests/cli/inbox.test.ts b/tests/cli/inbox.test.ts index b7669ac..e23d9c0 100644 --- a/tests/cli/inbox.test.ts +++ b/tests/cli/inbox.test.ts @@ -1,4 +1,4 @@ -import { readFile } from 'node:fs/promises'; +import { readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; @@ -46,6 +46,14 @@ describe('notch durable inbox CLI', () => { packetHash: expect.stringMatching(/^[a-f0-9]{64}$/u), }); + await writeFile( + path.join( + mailboxRoot, + 'recipients/receiver-agent/deliveries', + `${sent.deliveryId} (conflicted copy)`, + ), + 'sync conflict', + ); const humanList = await runCli(['inbox', 'list'], { cwd: receiver.path }); const list = await runCli(['--json', 'inbox', 'list'], { cwd: receiver.path }); expect(humanList.stdout).toContain(`${sent.deliveryId}\tpending\tlocal:sender-agent`); diff --git a/tests/unit/inbox-service.test.ts b/tests/unit/inbox-service.test.ts index fc4ee2b..b99b9d6 100644 --- a/tests/unit/inbox-service.test.ts +++ b/tests/unit/inbox-service.test.ts @@ -13,6 +13,7 @@ import { pullInboxDelivery, sendInboxPacket, } from '../../src/core/inbox-service.js'; +import { LocalInboxAdapter } from '../../src/core/local-inbox-adapter.js'; import { createPacket } from '../../src/core/packet-service.js'; import type { NotchConfig, PacketPurpose, Sensitivity } from '../../src/types/records.js'; import { createBareStore } from '../helpers/store-fixtures.js'; @@ -230,6 +231,73 @@ describe('durable inbox service', () => { }); }); + it('keeps size-limit failures retryable and recovers legacy size rejections without a false resend success', async () => { + await withTempProject({}, async (sender) => { + await withTempProject({}, async (receiver) => { + await createBareStore(sender.path, { name: 'sender' }); + await createBareStore(receiver.path, { name: 'receiver' }); + const senderContext = await loadConfig({ cwd: sender.path }); + let receiverContext = await loadConfig({ cwd: receiver.path }); + const mailboxRoot = path.join(sender.path, 'mailbox'); + await initializeDurableInbox(senderContext, { name: 'sender', root: mailboxRoot }); + await initializeDurableInbox(receiverContext, { name: 'receiver', root: mailboxRoot }); + await mkdir(path.join(sender.path, 'artifacts')); + await writeFile(path.join(sender.path, 'artifacts/retry.txt'), 'a'.repeat(100)); + const packed = await packPacket(senderContext, sender.path, { + file: 'artifacts/retry.txt', + title: 'Retry after receiver limit change', + }); + const sent = await sendInboxPacket(senderContext, { + packetPath: packed.archivePath, + to: 'local:receiver', + }); + + await setArtifactLimits(receiverContext, { maxArtifactBytes: 50, maxPacketBytes: 1000 }); + receiverContext = await loadConfig({ cwd: receiver.path }); + await expect(pullInboxDelivery(receiverContext, { + deliveryId: sent.deliveryId, + import: true, + })).rejects.toMatchObject({ notchError: { code: 'NOTCH_PACKET_TOO_LARGE' } }); + expect(await listInboxDeliveries(receiverContext)).toMatchObject({ + deliveries: [expect.objectContaining({ deliveryId: sent.deliveryId, state: 'pending' })], + }); + + await new LocalInboxAdapter(mailboxRoot).withDelivery( + 'local:receiver', + sent.deliveryId, + { maxBytes: Number.MAX_SAFE_INTEGER }, + async (locked) => { + const rejectedAt = new Date().toISOString(); + await locked.update({ + state: 'rejected', + rejectedAt, + errorCode: 'NOTCH_PACKET_TOO_LARGE', + }); + }, + ); + await expect(sendInboxPacket(senderContext, { + packetPath: packed.archivePath, + to: 'local:receiver', + })).rejects.toMatchObject({ notchError: { code: 'NOTCH_INBOX_STATE_INVALID' } }); + + await setArtifactLimits(receiverContext, { maxArtifactBytes: 1024, maxPacketBytes: 1024 }); + receiverContext = await loadConfig({ cwd: receiver.path }); + expect(await pullInboxDelivery(receiverContext, { + deliveryId: sent.deliveryId, + import: true, + })).toMatchObject({ + deliveryId: sent.deliveryId, + importedPacketId: packed.packetId, + state: 'pulled', + }); + const recovered = (await listInboxDeliveries(receiverContext, { includeAll: true })).deliveries[0]; + expect(recovered).toMatchObject({ deliveryId: sent.deliveryId, state: 'pulled' }); + expect(recovered).not.toHaveProperty('rejectedAt'); + expect(recovered).not.toHaveProperty('errorCode'); + }); + }); + }); + it('rejects corrupted delivery bytes before import and records rejected state and trace', async () => { await withTempProject({}, async (sender) => { await withTempProject({}, async (receiver) => { diff --git a/tests/unit/local-inbox-adapter.test.ts b/tests/unit/local-inbox-adapter.test.ts index 0b24949..f6c1e8c 100644 --- a/tests/unit/local-inbox-adapter.test.ts +++ b/tests/unit/local-inbox-adapter.test.ts @@ -175,13 +175,21 @@ describe('local durable inbox adapter', () => { }); }); - it('rejects malformed metadata and unexpected delivery-store entries', async () => { + it('ignores unmanaged delivery-store clutter but rejects malformed managed entries', async () => { await withTempProject({}, async (project) => { const root = path.join(project.path, 'mailbox'); const adapter = new LocalInboxAdapter(root); await adapter.initializeRecipient('local:receiver'); const deliveriesPath = path.join(root, 'recipients/receiver/deliveries'); await writeFile(path.join(deliveriesPath, 'unexpected.txt'), 'not a delivery'); + await writeFile( + path.join(deliveriesPath, 'delivery_aaaaaaaaaaaaaaaaaaaaaaaa (conflicted copy)'), + 'sync conflict', + ); + + expect(await adapter.listDeliveries('local:receiver')).toEqual([]); + + await writeFile(path.join(deliveriesPath, 'delivery_aaaaaaaaaaaaaaaaaaaaaaaa'), 'not a delivery'); await expect(adapter.listDeliveries('local:receiver')).rejects.toMatchObject({ notchError: { code: 'NOTCH_INBOX_DELIVERY_INVALID' },