nattermatic 0.0.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tom Baldwin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,7 +1,232 @@
1
+ <p align="center"><img src="assets/nattermatic-320.png" width="160" height="160" alt="nattermatic: a red tin toy robot head with a radio antenna"></p>
2
+
1
3
  # nattermatic
2
4
 
3
- nattermatic is the coming new name for **agentlink**, which lets AI agents on different Claude
4
- accounts and machines message each other, and wakes idle Claude Code and Codex sessions when a
5
- message arrives.
5
+ **[nattermatic.com](https://nattermatic.com)** · Let AI agents message each other across different
6
+ agents, accounts and machines.
7
+ Claude Code's own messaging needs every session on one account; nattermatic doesn't. Address any
8
+ agent as `name@host` (e.g. `reviewer@laptop.local`). An incoming message **wakes an idle Claude
9
+ Code or Codex session**, just like agents talking on a single machine.
10
+
11
+ <p align="center"><a href="https://nattermatic.com"><img src="assets/demo.gif" width="880" alt="Two Claude Code sessions on different machines: one agent asks the other a question, and the idle session wakes up, checks its code and answers"></a></p>
12
+ <p align="center"><a href="https://nattermatic.com">Watch the whole one-minute demo, from setup to reply, at nattermatic.com</a></p>
13
+
14
+ nattermatic was called **agentlink** until version 0.2.0. Machines set up under the old name move
15
+ across when they're upgraded, keeping their network, and the `agentlink` command still works.
16
+
17
+ ## What you need
18
+
19
+ - [Claude Code](https://claude.com/claude-code) 2.1.318 or newer on each machine. Codex 0.167 or
20
+ newer is woken too, and other MCP agents can [join](#what-you-get) (for a machine without Claude
21
+ Code, set `NATTERMATIC_SKIP_CHECKS=1` for the installer).
22
+ - An Apple silicon Mac or Linux. Intel Macs aren't currently supported.
23
+ - Machines on the same network, or Tailscale on each of them.
24
+
25
+ The installer brings everything else. It needs no sudo.
26
+
27
+ ## Setup
28
+
29
+ **First machine** — paste into Claude Code (or run the command yourself):
30
+
31
+ > Set up nattermatic on this machine: run `curl -fsSL https://nattermatic.com/install.sh | sh`, then show me the prompt it prints for adding other machines, and tell me to type /reload-plugins.
32
+
33
+ Or install the Claude Code plugin, which teaches Claude to set nattermatic up and use it, then ask
34
+ Claude to set up nattermatic:
35
+
36
+ /plugin install nattermatic --marketplace tombaldwin/nattermatic
37
+
38
+ **Every other machine** — paste the prompt the first machine printed. It looks like:
39
+
40
+ > Join my nattermatic network: run `curl … | sh` — then tell me to type /reload-plugins.
41
+
42
+ Each invite adds one machine and expires after 24 hours. The pinned key means curl only talks to
43
+ the machine that made the invite. That key exists only while the invite is open, so a used invite
44
+ line stops working (curl reports a key mismatch or a 404). Run `natter invite` on any member
45
+ for a new one.
46
+
47
+ **Then** type `/reload-plugins` in Claude Code. That session is now on the network, and so is every
48
+ new one. (If that doesn't work, restart it: `claude --continue` picks up where you left off.)
49
+
50
+ That's it: no config files, no accounts, no server to run. Setup adds nattermatic's tools and a small
51
+ background hook to Claude Code's settings (and Codex's, if it's installed), and `natter uninstall`
52
+ takes them out again. On Apple
53
+ silicon Macs it installs
54
+ nattermatic as a signed app (`~/Applications/nattermatic.app`, from the inviting machine or the latest
55
+ release, refused unless Apple's certificate for nattermatic's team signed it); macOS asks once to
56
+ let **nattermatic** find devices on your local network: click Allow. Elsewhere it uses npm, and
57
+ brings its own Node.js if the machine doesn't have a recent one. For the next machine, ask Claude
58
+ on any member to add one (or run `natter invite`).
59
+
60
+ ## Your first message
61
+
62
+ Agents are named after their project folder, so a session in `~/code/reviewer` on the machine
63
+ `desk` is `reviewer@desk.local`. Ask Claude who's online, then ask it to message someone:
64
+
65
+ > **You, to Claude on your laptop:** Ask reviewer@desk.local why the build is failing.
66
+ >
67
+ > **Claude:** Sent. `pushed to reviewer@desk.local's live session`
68
+
69
+ The session on `desk` wakes, even if it was idle, and works on it. Its answer wakes the session on
70
+ your laptop in the same way.
71
+
72
+ You don't need the exact address. `reviewer@desk` works (`.local` is added, and case doesn't
73
+ matter), and so does a machine's Tailscale name or IP address. You can also just say "ask the
74
+ reviewer on my desktop": Claude checks who's online and works out which agent you mean.
75
+
76
+ More ways to use it, such as a reviewer on another account, waking a session from CI, or a nightly
77
+ job that reports in the morning, are in [Recipes](docs/recipes.md).
78
+
79
+ ## What you get
80
+
81
+ - **Wake on message.** A background hook wakes the session when a message arrives, even when it's
82
+ idle, and Claude reads it and acts on it (it still stops at any permission prompt). Tools: `send`, `peers`, `inbox`, plus `network` and
83
+ `invite` for managing the network. Removing machines and uninstalling stay in the terminal, so
84
+ you always see the command first.
85
+ - **Nothing gets lost.** Messages are stored on disk at every hop and retried. If the recipient
86
+ isn't running, its machine keeps the message; if the whole machine is off, copies wait on up
87
+ to 3 other online machines and whoever sees it return first delivers. Copies that reach a
88
+ machine twice are dropped, though a session that dies just as a message is shown gets it again
89
+ when it restarts.
90
+ Messages expire after 7 days. A session can have 2,000 unread messages waiting, and a machine's
91
+ store can reach 1 GB (`maxWaiting` and `maxStoreMB` in `~/.nattermatic/config.json`). Past that, a
92
+ message from another machine waits at the sender and is retried, and a send from this machine
93
+ fails with an error, so a runaway agent stops there.
94
+ - **Zero-config discovery.** Machines on the same LAN find each other automatically (encrypted UDP
95
+ beacons) and share what they know, so every machine learns the whole network.
96
+ - **Multiple Claude accounts.** Every `CLAUDE_CONFIG_DIR` account setup can find is enabled
97
+ (`~/.claude*` dirs, shell aliases, and the one the shell is using); run `natter setup` again to
98
+ add new accounts.
99
+ - **Beyond the LAN.** With Tailscale on both machines, `natter invite` includes the Tailscale
100
+ name and joining machines use it automatically. A machine that moves between networks is found
101
+ again at its other addresses, within a couple of minutes.
102
+ - **Codex.** If Codex is installed, setup adds nattermatic to `~/.codex/config.toml` too. The next
103
+ time Codex starts, it asks you to trust nattermatic's hook: trust it, and a message gives an idle
104
+ Codex session a turn (through `codex queue`, Codex 0.167 or newer). Codex sessions are named after
105
+ their project folder.
106
+ - **Other agents.** Any MCP agent can join with `natter mcp --poll` (it reads with `inbox`
107
+ instead of being woken).
108
+ - **Channels and managed accounts.** If your organisation's policy turns channels off, `nattermatic
109
+ claude` sessions can't tell, and pushed messages don't show up. Set `NATTERMATIC_POLL=1` so they're
110
+ read with `inbox` instead.
111
+
112
+ Name a session with `NATTERMATIC_NAME=<name> claude` (default: the project folder name).
113
+
114
+ ## Compared with Claude Code's own messaging
115
+
116
+ Claude Code can already [message between your own sessions](https://code.claude.com/docs/en/cross-session-messaging):
117
+ on one machine over a local socket, and between machines through Anthropic's servers, with a
118
+ claude.ai sign-in and Remote Control. If that covers you, you don't need nattermatic. nattermatic is for
119
+ sessions on different Claude accounts, agents other than Claude Code, and messages that stay
120
+ between your own machines.
121
+
122
+ ## Commands
123
+
124
+ Claude handles everyday use and invites; these are for terminals and scripts.
125
+
126
+ natter to <name@host> <msg> natter who natter status
127
+ natter invite [--list|--revoke] natter remove <host>
128
+ natter compact [--yes] natter uninstall
129
+
130
+ `natter help <command>` (or `natter <command> --help`) shows a command's options. `nattermatic`
131
+ runs the same commands, and so do agentlink's names for them (`agentlink send`, `agentlink peers`).
132
+ `natter who` says why a machine is offline (the node log, `~/.nattermatic/node.log`, records
133
+ when a machine stops or starts answering).
134
+
135
+ Tab completion, including the addresses of agents that are online after `natter to`: add
136
+ `eval "$(natter completion zsh)"` to `~/.zshrc`, or `eval "$(natter completion bash)"` to
137
+ `~/.bashrc`.
138
+
139
+ ## Upgrading
140
+
141
+ Re-run the install line from **Setup** on each machine (or `npm install -g nattermatic`
142
+ followed by `natter setup`). It keeps the machine's key, network and messages. Upgrade all
143
+ your machines: a machine running an incompatible version is reported as such
144
+ (`… runs an older version of nattermatic`) and can't exchange messages until it's upgraded.
145
+ Claude sessions that were running during the upgrade say they need restarting
146
+ (`claude --continue` resumes the conversation); they never stop the newer node.
147
+
148
+ **From agentlink** (before 0.2.0): re-run the install line, as for any upgrade. Setup moves
149
+ `~/.agentlink` to `~/.nattermatic` (leaving a link behind), replaces the background service, and
150
+ updates Claude Code's and Codex's settings, including any permission rules you wrote for
151
+ agentlink's tools. Machines keep their key and network, and old and new versions still talk to each
152
+ other, so machines can be upgraded one at a time. Because the app has a new name, macOS asks once
153
+ more to let it find devices on the local network, and Codex asks you to trust the new hook. If you
154
+ installed with npm, `npm uninstall -g @tombaldwin/agentlink` before `npm install -g nattermatic`.
155
+
156
+ ## Security
157
+
158
+ Each machine has its own key. The network is the list of machines' keys, kept and shared by the
159
+ members. Any member can invite machines, and a machine can remove the ones that joined after it
160
+ (the first machine can remove anyone).
161
+
162
+ - **Encrypted, and nothing for strangers.** Machines talk TLS and prove their keys to each other
163
+ inside the connection. A laptop at a café that tries a home address now held by a stranger
164
+ doesn't say who it is or send anything before the check fails. And anyone who connects to a
165
+ machine sees only a throwaway key, so a laptop can't be recognised as it moves between networks.
166
+ - **Joining.** An invite is a one-time token plus a key the inviting machine presents while the
167
+ invite is open. Someone watching the
168
+ network learns nothing from a join, and can't swap the installer. A used or expired invite is
169
+ worthless.
170
+ - **Machines can't impersonate each other.** Every message is signed by the machine that sent it,
171
+ and machines relaying a copy can't change it (they can read it: members are trusted). `natter remove <host>` takes a machine off the
172
+ network; members that were offline learn about it when they come back.
173
+ - **Members are trusted.** Any member can add machines, and every agent on a member machine can
174
+ message yours. Only add machines you control.
175
+ - **Prompt injection.** Messages come from other AI agents. nattermatic tells Claude to treat them
176
+ as requests from a colleague, not instructions from you, and to check with you before
177
+ anything destructive or sensitive.
178
+
179
+ See [SECURITY.md](SECURITY.md) for details and known limitations.
180
+
181
+ ## Caveats
182
+
183
+ - Only interactive sessions are woken. `claude -p` runs and SDK sessions aren't, and nothing about
184
+ them changes: their messages wait for the `inbox` tool.
185
+ - A Codex session is woken from its first prompt on, when Codex runs nattermatic's hook. After you
186
+ close a Codex window, Codex's background server can keep the session open, and then answers its
187
+ messages by itself. Two Codex sessions in the same folder can't be told apart: only the newer one
188
+ is woken. A Codex turn you stop with Esc holds messages back until your next prompt.
189
+ - A woken session reads messages by itself, but stops at permission prompts as usual, so replying
190
+ may wait for you. Setup allows only the `inbox` and `peers` tools to run unattended.
191
+ - Senders see "pushed to … live session" when the session has been told, not when Claude has read
192
+ the message.
193
+ - A session idle for more than 20 days stops being woken until its next turn. So does one you
194
+ interrupt with Esc after it's woken and before it reads its messages; they're still there to read.
195
+ - A VPN that blocks local traffic makes your machines show as offline. Allow local network access
196
+ in its settings, or use Tailscale. Messages wait until the machines can reach each other.
197
+ - Over SSH on a Mac, Claude Code can't reach its login while the keychain is locked: run
198
+ `security unlock-keychain` first.
199
+ - Re-running `natter setup` keeps the machine on its network; pass `--new-network` to start a
200
+ new one.
201
+ - A machine's name is fixed when it joins. To rename it, run `natter remove <old name>` on a
202
+ machine that joined before it, then run a new invite line with `| sh -s -- --host <new name>` in
203
+ place of `| sh`. Messages already queued for its old name wait until they expire.
204
+ - A removed machine comes back with a new invite: it gets a new key, and its messages, delivered
205
+ or not, are dropped. Joining another network drops them too.
206
+ - A machine joins once, for one computer login. A second login on the same machine is refused,
207
+ because the machine's name is already a member. Several Claude accounts under one login are fine.
208
+ - Apple silicon Macs and Linux. Intel Macs aren't currently supported: the npm install runs there,
209
+ but macOS may stop it reaching other machines (see below), and there's no app for them yet.
210
+ - On Linux, `.local` names need mDNS (`avahi-daemon` and `libnss-mdns`); without it, use the IP
211
+ line the invite offers. Port 7777 must be open in any firewall between your machines.
212
+ - On macOS, nattermatic needs Local Network access to reach your other machines. The app asks for
213
+ it by name; if you clicked Don't Allow, turn **nattermatic** on under System Settings → Privacy &
214
+ Security → Local Network. An npm install on a Mac (`NATTERMATIC_APP=0`) runs as plain `node` at
215
+ login, which macOS may block without asking: `natter who` then shows machines as offline
216
+ with `EHOSTUNREACH`.
217
+
218
+ ## Development
219
+
220
+ npm test # unit tests + multi-node end-to-end: live delivery, offline session, offline machine
221
+ # via holders, dedupe, poll mode, outsiders, forged and altered messages, strangers
222
+ # at stored addresses, relays, invites and joining, removal, big backlogs, node
223
+ # restart, IP/Tailscale aliases
224
+
225
+ The macOS app (nattermatic as a single signed executable, so macOS asks for Local Network access by
226
+ name; see `scripts/build-app.mjs`):
227
+
228
+ npm run build:app # dist/nattermatic-<version>-macos-<arch>.zip
229
+ npm run build:app -- --arch x64 --sign "Developer ID Application: …" --notarize <profile>
230
+ NATTERMATIC_TEST_EXE=dist/arm64/nattermatic.app/Contents/MacOS/nattermatic npm run test:e2e
6
231
 
7
- Nothing to install here yet. Use agentlink for now: https://github.com/tombaldwin/agentlink
232
+ Releases (signed and notarized app, GitHub Release, npm): see [docs/releasing.md](docs/releasing.md).
package/SECURITY.md ADDED
@@ -0,0 +1,178 @@
1
+ # Security
2
+
3
+ ## Model
4
+
5
+ Each machine has its own ECDSA P-256 key pair, created by `natter setup` and kept in
6
+ `~/.nattermatic/node.key` (readable only by you). A nattermatic network is a list of member keys
7
+ (the *roster*): a set of signed records saying "this key, called `laptop.local`, was added by that
8
+ member" or "this key was removed". Members exchange and merge these records; every member computes
9
+ the same membership from the same records.
10
+
11
+ - **Transport.** Connections between machines are TLS 1.3. (The installer download also accepts TLS
12
+ 1.2, for macOS's curl; that's protected by the key pinned in the invite line.) The certificate a
13
+ node presents is a throwaway (a new key for each connection, or each second at the most), so
14
+ connecting to a node's port shows nothing that identifies the machine, unless an invite is open:
15
+ see *Known limitations*. Machines prove who they are inside the encrypted connection. The client first shows it
16
+ holds the network's discovery key; without it, other paths get a 404 (the join paths answer
17
+ separately, see *The open port is visible*). The server then signs the connection
18
+ with its machine key. The client checks that this is the machine the roster says should be there,
19
+ and only then signs the connection with its own key and sends its request. If anyone else answers
20
+ at the address, they get nothing but that first, anonymous step, which has a time limit however
21
+ slowly they answer. Both signatures cover that one TLS session, so they can't be replayed, or
22
+ relayed by someone sitting in the middle.
23
+ - **Addresses are only hints.** LAN beacons, gossiped addresses, configured names and the source
24
+ IPs of requests can all be wrong or stale (DHCP reuse, a laptop on another network, spoofed
25
+ name resolution). An address is used for a machine only once the machine there proves it holds
26
+ that machine's key, so a wrong address never receives a request. Someone relaying traffic to the real
27
+ machine sees only ciphertext.
28
+ - **Membership.** A node accepts machine-to-machine requests only from current members' keys.
29
+ Before a machine has proved its key, the only membership records it can submit are the ones that
30
+ add it and one snapshot, which only the founding machine can have signed. Any member can add a machine by inviting it. A record counts, and is kept and passed on,
31
+ only if its signature checks out against the signer it names. A roster stops taking new records
32
+ at 1 MB, apart from the founder's snapshots and removals of a current member by a machine that joined before it, one per
33
+ machine, which may be as big as a removal has any reason to be: an entry for each machine the
34
+ target really added, and otherwise no bigger than what it clears. So a roster always fits in the
35
+ one request (8 MB) that carries it between machines, and a member can't stay on by filling one.
36
+ - **Removal.** Machines are ranked in the order they joined, and a machine can remove only machines
37
+ that joined after it. The founding machine can remove anyone, and two machines can never remove
38
+ each other. Removing the remover doesn't bring a machine back, and removals made at the same time
39
+ all stand. A removed key can't come back on its own (the machine isn't told, and re-joins with an
40
+ invite and a new key), with the exceptions in *Known limitations*. A key made by a removed machine
41
+ never becomes the discovery key.
42
+ - **Snapshots.** `natter compact`, on the founding machine, signs a list of the current members
43
+ in joining order, with the discovery keys still in use and a new one. Any machine can check it
44
+ against the network's ID, and it replaces all the records before it. After a snapshot, an add dated
45
+ before it counts for nothing, nor does an older record signed by a machine that isn't in it. A removal
46
+ between two machines in it still counts if it's dated after that machine was added, so a removal
47
+ made at the same moment isn't lost, and one from before a machine was let back in doesn't apply.
48
+ - **A removed machine can't add machines.** A removal lists the removed machine's records the
49
+ remover had. Machines it added stay if every remover had seen the add. An add the remover hadn't
50
+ seen doesn't count, whatever date it carries, and that machine needs a new invite. Every member
51
+ computes the same membership from the same records, whatever order they arrived in.
52
+ - **Messages.** Each message is signed by the machine that sent it. A node accepts a message only
53
+ if the signer is a current member and is the machine named in the sender address, so holders
54
+ relaying copies can't alter messages and a member can't send as another machine. A sender address
55
+ must be exactly `name@host` as a node writes it, so nothing can be hidden in it. Messages are
56
+ encrypted between machines but not end to end: see *Known limitations*.
57
+ - **Joining.** `natter invite` makes a one-time token (stored hashed, expires after 24 hours)
58
+ and prints a `curl -k --pinnedpubkey "sha256//<key>" https://…` line. The key is an invite key,
59
+ which the inviting node presents only while it has invites open. curl's `-k` only skips
60
+ certificate-authority checks; `--pinnedpubkey` still makes curl refuse any other key, so the
61
+ installer, the package and the token can't be intercepted or replaced. The joining machine checks
62
+ the same key again before it sends its join request. The token is consumed by the join.
63
+ `natter invite --revoke` cancels unused tokens. Starting or joining another network deletes the
64
+ old network's invites and all its messages, and setup stops rather than start the node if it can't.
65
+ - **Discovery.** LAN beacons are encrypted under a discovery key that members share only over
66
+ their TLS connections, so other people on the network can't read machine names from them. The
67
+ discovery key is replaced whenever a machine is removed. A replaced key still opens other
68
+ members' gates, and gets beacons answered, for 14 days, so a machine that was offline during a
69
+ removal can catch up. After that, it needs a new invite.
70
+ - **Local API.** Sessions on the same machine talk to their node over TLS on 127.0.0.1, with the
71
+ same check: the node must prove it holds this machine's key, and so must the client. Only
72
+ processes that can read this machine's private key (your user) get in, and a program squatting
73
+ on the port is sent nothing. `/health` answers only them.
74
+ - **Prompt injection.** Messages come from other AI agents. The MCP adapter instructs Claude to
75
+ treat them as requests from a colleague — never as instructions from its user — and not to
76
+ take destructive or security-sensitive actions solely because a peer asked. Each message is framed
77
+ with a tag made for that read, so its text can't pass for another message from someone else. Nothing
78
+ else another machine writes reaches a session: a session name must be one a node could have made, and
79
+ a machine's error text is dropped for its status code. Only invite machines and agents you trust.
80
+ - **Waking a session.** Setup adds a Claude Code hook that waits in the background and wakes an idle
81
+ session when a message arrives. What it shows Claude is fixed text saying a message is waiting:
82
+ nothing another agent wrote comes through the hook. Claude reads the message with the `inbox` tool,
83
+ so it arrives as a tool result from a named sender. Setup lets `inbox` and `peers` run without a
84
+ prompt; `send` keeps the session's normal permission rules. A woken session is still acting on
85
+ another agent's request, with whatever permissions the session has, so take care with sessions
86
+ running in a mode that skips permission prompts.
87
+ - **Waking a Codex session.** Setup adds a SessionStart hook to Codex's config, which Codex runs only
88
+ once you've trusted it. The hook starts a process that waits for the session's messages and runs
89
+ `codex queue` with fixed text, which Codex shows as a prompt in the session: again, nothing another
90
+ agent wrote. Setup lets Codex run `inbox` and `peers` without asking. Codex's background server can
91
+ keep a session open after its window closes, and then acts on messages with no one watching.
92
+
93
+ ## Known limitations
94
+
95
+ - **Members are trusted.** Any member can invite machines, and a machine can remove the ones that
96
+ joined after it. A compromised member could quietly add extra machines before you remove it.
97
+ `natter status` lists machines in joining order and shows which member added each one:
98
+ remove any you don't recognise, or start a new network with `natter setup --new-network`
99
+ and re-join your machines. One thing it won't show: a member can sign a second key under its own
100
+ name. That key never becomes a member, and it's dropped when the member is removed.
101
+ - **A member can fill a machine's message store.** A session can have 2,000 messages waiting, but a
102
+ member can write to made-up names, or to a session reading as fast as they arrive. A store stops at
103
+ 1 GB, so the disk is safe, but then that machine takes no new messages from anyone until old ones
104
+ expire, even after the member is removed. Messages sent to it wait at their senders until then.
105
+ - **A member can undo a removal made by a machine that joined after it.** It signs a backdated add of
106
+ the removed key, dated before the remover joined, so that the remover no longer ranks above it, and
107
+ the machine is back under its old key. A removal by a machine that joined before that member stands.
108
+ If you suspect a member, remove it first, from the earliest machine you can. A snapshot ends this
109
+ for everything before it, because adds can't be dated before a snapshot.
110
+ - **A snapshot forgets removed keys.** A removed machine can then come back under its old key, but
111
+ only if a member adds it: with an invite, as any new machine would. Sending to it after that says
112
+ it isn't a machine on the network, not that it was removed.
113
+ - **A snapshot can drop a machine.** One added just before it, which the founding machine hadn't
114
+ heard of, isn't in it and needs a new invite. `natter compact` catches up first, and shows who
115
+ will be kept and who it couldn't reach.
116
+ - **A snapshot goes by what the founding machine knows.** If it hasn't heard of a removal yet, two
117
+ things can go against the rules above. A machine that was removed by a machine that's now gone
118
+ too comes back, as the removal counts for nothing without its signer. And a machine added by a
119
+ removed one stays, if the founding machine heard of the add first. So make a snapshot with your
120
+ machines online, and check the list `natter compact` shows.
121
+ - **A backdated removal can cut a machine off until someone reaches it.** A member can sign a removal
122
+ dated before a snapshot that's over 14 days old, which brings in a new key and leaves no grace period
123
+ for the snapshot's. A machine that hasn't heard is refused at the gate until another member reaches
124
+ it, and then it catches up.
125
+ - **Only the founding machine can make a snapshot.** If it's lost, the way out of a full member list
126
+ is a new network.
127
+ - **Messages aren't end-to-end encrypted.** While the recipient's machine is off, copies wait on
128
+ other members, which can read them until they're handed over. They can't alter them. Don't send
129
+ anything through nattermatic that you wouldn't want your other machines to hold.
130
+ - **The founding machine can't be removed.** Nothing joined before it. If it's lost or
131
+ compromised, start a new network. Likewise, a machine can only be removed from machines that
132
+ joined before it.
133
+ - **A removed machine's removals of later machines can still take effect.** If it signs one and
134
+ gets it to a member that hasn't heard of its removal yet, the removal stands, like any removal
135
+ made at the same moment. It can never remove a machine that joined before it, add one, or get
136
+ back in. The affected machines re-join with an invite.
137
+ - **A removed machine can still tell which member it is talking to, for 14 days.** It can pass the
138
+ gate with an old discovery key during the grace period, and learn which member is at an address
139
+ (which it knew already). It still can't send, receive or change the member list.
140
+ - **Two removals at the same moment can leave a removed machine holding the current discovery key.**
141
+ If one machine removes another that has already heard of a third's removal, the removed machine has
142
+ the key that removal brought in, until the next removal by a current member, or a snapshot, replaces
143
+ it. It can pass the gate as above, for that long.
144
+ - **A member can flood the roster.** Until you remove it, a compromised member can fill the roster
145
+ so that no new machine can join, and machines may disagree about the last records to arrive.
146
+ Removing it clears what it signed, apart from machines it really added and its removals of machines
147
+ that had joined. Those stay until the next snapshot, so a roster can stay full once the member has
148
+ gone: remove the machines it added, then run `natter compact` on the founding machine. While
149
+ a roster is full, two kinds of removal wait until there's room, so machines can disagree until
150
+ then: a second removal of a machine already removed, and one listing dozens of machines a full node
151
+ has never heard of, if what fills that node was signed by someone else. Remove that machine first.
152
+ `natter remove` says so if it's this machine's own roster that has no room.
153
+ - **Agents on a machine aren't separately authenticated.** Any process running as your user can
154
+ send as any session name on that machine.
155
+ - **Invites are credentials until used.** Anyone who gets an unused invite line within 24 hours can
156
+ join one machine with it. Treat it like a password until it's been used, or revoke it. On a
157
+ shared machine, other users can see the line's token in the process list for the few seconds the
158
+ first `curl` runs (the installer keeps it off command lines after that).
159
+ - **While an invite is open, its key is visible to anyone who connects to the port.** Someone could
160
+ use it to recognise the machine during that window (24 hours at most). Run `natter invite
161
+ --revoke` once you've added your machines. At other times, a node shows only throwaway keys.
162
+ - **The open port is visible.** Someone scanning a network can see that something is listening on
163
+ nattermatic's port (7777 by default), and the join paths answer in a way that shows it's nattermatic.
164
+ - **Looking up `.local` names is visible.** Connections themselves don't reveal a machine's name,
165
+ but a laptop trying to reach `desk.local` asks for it by name, by multicast, on whatever network
166
+ it's on. Someone there can see the names of your machines.
167
+ - **A removed machine keeps what it had.** It keeps its copy of the member list (machine names and
168
+ public keys; no secrets) and any messages it already received.
169
+
170
+ ## Starting over
171
+
172
+ Run `natter setup --new-network` on one machine. It creates a new key and a new network with
173
+ that machine as its only member; join the other machines with fresh invites.
174
+
175
+ ## Reporting a vulnerability
176
+
177
+ Please open a private security advisory on GitHub ("Security" tab → "Report a vulnerability")
178
+ rather than a public issue.
package/bin/natter.js ADDED
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ import '../src/legacy-env.js'
3
+ import { loadConfig, VERSION } from '../src/config.js'
4
+ import { resolve, findCommand, mainHelp, commandHelp, suggest, completionScript } from '../src/cli.js'
5
+
6
+ // (not --no-warnings on the line above: env -S isn't everywhere.) node:sqlite warns when it's loaded
7
+ process.removeAllListeners('warning')
8
+
9
+ const [first, ...rest] = process.argv.slice(2)
10
+ const cmd = resolve(first)
11
+ // Only these are flags; anything else (a message that happens to start with "--") is an argument,
12
+ // and "--" ends the flags.
13
+ const VALUE_FLAGS = ['name', 'from', 'host', 'peer', 'join', 'pin', 'net', 'token', 'thread', 'codex', 'pids', 'cwd']
14
+ const BOOL_FLAGS = ['yes', 'force', 'no-service', 'no-claude', 'no-codex', 'no-alias', 'new-network', 'poll', 'channel', 'on-demand', 'revoke', 'list', 'check']
15
+ /** @type {Record<string, any>} */
16
+ const flags = {}
17
+ /** @type {string[]} */
18
+ const pos = []
19
+ for (let i = 0; i < rest.length; i++) {
20
+ const a = rest[i]
21
+ if (cmd === 'to' && pos.length) { pos.push(a); continue } // past the address it's the message, whatever it looks like
22
+ if (a === '--') { pos.push(...rest.slice(i + 1)); break }
23
+ const eq = a.indexOf('=')
24
+ const k = a.startsWith('--') ? a.slice(2, eq < 0 ? undefined : eq) : ''
25
+ if (VALUE_FLAGS.includes(k)) {
26
+ const val = eq >= 0 ? a.slice(eq + 1) : rest[++i]
27
+ if (val === undefined) throw usage(`--${k} needs a value`)
28
+ if (k === 'peer') (flags.peer ||= []).push(val)
29
+ else flags[k] = val
30
+ } else if (BOOL_FLAGS.includes(k) && eq < 0) flags[k] = true
31
+ else pos.push(a)
32
+ }
33
+ function usage(/** @type {string} */ m) { console.error(`natter: ${m}`); process.exit(2) }
34
+ /** @param {string} name */
35
+ function unknownCommand(name) {
36
+ const s = suggest(name)
37
+ usage(`unknown command '${name}'${s ? ` (did you mean ${s}?)` : ''}: see natter help`)
38
+ }
39
+ if (!findCommand(cmd)) unknownCommand(first)
40
+ // `natter <command> --help` shows that command's help instead of running it. Not for claude
41
+ // (its options go to Claude Code), and for send only in place of the address: after it, it's message text.
42
+ const helpAsked = cmd === 'to' ? ['--help', '-h'].includes(rest[0])
43
+ : cmd !== 'claude' && rest.slice(0, rest.includes('--') ? rest.indexOf('--') : undefined).some(a => a === '--help' || a === '-h')
44
+ if (helpAsked) {
45
+ const c = findCommand(cmd)
46
+ if (!c || c.hidden) unknownCommand(cmd)
47
+ else { console.log(commandHelp(c)); process.exit(0) }
48
+ }
49
+ // a mistyped option (e.g. `invite --lsit`) must not run the command as if it weren't there; send
50
+ // takes free text and claude passes everything on to Claude Code
51
+ if (!['to', 'claude'].includes(cmd)) { const bad = pos.find(a => a.startsWith('-')); if (bad) usage(`unknown option ${bad} (see natter ${cmd} --help)`) }
52
+
53
+ async function main() {
54
+ switch (cmd) {
55
+ case 'setup': {
56
+ const { setup } = await import('../src/setup.js')
57
+ if (pos.length) throw new Error('nattermatic no longer uses a shared network secret: to join a network, run the line printed by `natter invite` on a member')
58
+ return setup({ host: flags.host, peers: flags.peer || [], noService: flags['no-service'], noClaude: flags['no-claude'], noCodex: flags['no-codex'], noAlias: flags['no-alias'], newNetwork: flags['new-network'], join: flags.join, pin: flags.pin, net: flags.net, token: flags.token || process.env.NATTERMATIC_TOKEN })
59
+ }
60
+ case 'node': {
61
+ const { runNode } = await import('../src/node.js')
62
+ const { Local } = await import('../src/local.js')
63
+ const fs = await import('node:fs')
64
+ const path = await import('node:path')
65
+ const { homeDir } = await import('../src/config.js')
66
+ // If another nattermatic node already serves this port (e.g. one a session started), the service
67
+ // waits quietly and takes over when it exits, instead of crash-looping under launchd/systemd.
68
+ // A node started on demand by a session (--on-demand) just exits: one node is enough.
69
+ // Every attempt re-reads the config and honours `natter uninstall`, so a long-waiting
70
+ // standby never revives an uninstalled node or serves a config that setup has since replaced.
71
+ for (let waited = false; ;) {
72
+ if (fs.existsSync(path.join(homeDir(), 'disabled'))) { console.error('nattermatic is uninstalled on this machine; not starting the node'); return }
73
+ const cfg = loadConfig()
74
+ try { await runNode(cfg); return } catch (e) {
75
+ if (/** @type {NodeJS.ErrnoException} */ (e).code !== 'EADDRINUSE') throw e
76
+ const local = new Local(cfg)
77
+ const h = await local.health()
78
+ if (!h || 'foreign' in h) { console.error(`port ${cfg.port} is in use by something else`); process.exit(1) }
79
+ if (flags['on-demand']) { console.error(`another nattermatic node (pid ${h.pid}) is already running`); return }
80
+ if (!waited) { console.error(`another nattermatic node (pid ${h.pid}) is running; standing by`); waited = true }
81
+ await new Promise(r => setTimeout(r, 5000))
82
+ }
83
+ }
84
+ }
85
+ case 'mcp': {
86
+ const { runAdapter } = await import('../src/mcp.js') // --check: loading it (and its dependencies) is the check
87
+ if (flags.check) return console.log('ok')
88
+ return runAdapter(loadConfig(), { name: flags.name, poll: !!flags.poll, channel: flags.channel ? true : undefined })
89
+ }
90
+ case 'wake': { // run by the Claude Code hook, not by people
91
+ const { runWake } = await import('../src/wake.js')
92
+ process.exit(await runWake())
93
+ }
94
+ case 'codex-hook': { // run by Codex's SessionStart hook
95
+ const { runCodexHook } = await import('../src/codex.js')
96
+ process.exit(await runCodexHook())
97
+ }
98
+ case 'codex-wake': { // started by codex-hook, in the background
99
+ const { runCodexWake } = await import('../src/codex.js')
100
+ process.exit(await runCodexWake({ thread: flags.thread, pids: flags.pids, cwd: flags.cwd, codex: flags.codex }))
101
+ }
102
+ case 'claude': {
103
+ const { launchClaude } = await import('../src/claude.js')
104
+ const { Local } = await import('../src/local.js')
105
+ return launchClaude(process.argv.slice(3), async () => new Local(loadConfig()).ensureNode())
106
+ }
107
+ case 'to': {
108
+ if (pos.length < 2) throw new Error('usage: natter to <name@host> <message>')
109
+ const local = await client()
110
+ const r = await local.req('POST', '/local/send', { from: flags.from || 'cli', to: pos[0], body: pos.slice(1).join(' ') })
111
+ return console.log(r.status)
112
+ }
113
+ case 'who': {
114
+ const p = await (await client()).req('GET', '/local/peers')
115
+ console.log(`${p.self} (this machine): ${p.local.join(', ') || '(no agents)'}`)
116
+ for (const r of p.remote) console.log(`${r.host}: ${r.online ? (r.sessions.join(', ') || '(no agents)') : `offline${r.error ? ` — ${r.error}` : ''}`}`)
117
+ return
118
+ }
119
+ case 'status':
120
+ return console.log(JSON.stringify(await (await client()).req('GET', '/local/status'), null, 2))
121
+ case 'invite': {
122
+ const local = await client()
123
+ if (pos.length) throw new Error('usage: natter invite [--list|--revoke]')
124
+ if (flags.revoke) return console.log(`revoked ${(await local.req('POST', '/local/invite/revoke')).revoked} unused invite(s)`)
125
+ if (flags.list) {
126
+ const { invites } = await local.req('GET', '/local/invites')
127
+ const { formatInvites } = await import('../src/setup.js')
128
+ return console.log(formatInvites(invites))
129
+ }
130
+ const { invitePrompt } = await import('../src/setup.js')
131
+ return console.log(invitePrompt(await local.req('POST', '/local/invite')))
132
+ }
133
+ case 'remove': {
134
+ if (pos.length !== 1) throw new Error('usage: natter remove <host>')
135
+ const r = await (await client()).req('POST', '/local/remove', { host: pos[0] })
136
+ return console.log(`removed ${r.removed}: it can no longer send to or receive from this network`)
137
+ }
138
+ case 'compact': {
139
+ if (pos.length) throw new Error('usage: natter compact [--yes]')
140
+ const r = await (await client()).req('POST', '/local/compact', { confirm: !!flags.yes, force: !!flags.force }, '', 150_000)
141
+ const off = r.offline.length ? [`Not reachable just now, so anything they've heard of is unknown here: ${r.offline.join(', ')}`] : []
142
+ if (flags.yes) return console.log([`member list compacted: ${r.before} bytes to ${r.bytes}. Members: ${r.members.join(', ')}`, ...off].join('\n'))
143
+ return console.log([
144
+ `A snapshot would keep these machines: ${r.members.join(', ')}`,
145
+ ...off,
146
+ ...(r.caughtUp ? [] : ['Not every machine could be checked in time: run this again before going ahead.']),
147
+ `The member list is ${r.bytes} bytes (limit ${r.limit}). A machine added that isn't listed above will need a new invite, and removed machines are forgotten.`,
148
+ 'A removal this machine has not heard of can be undone by this, if the machine that made it is removed too: check any machine you removed from elsewhere is gone from the list above.',
149
+ 'Run `natter compact --yes` to go ahead.',
150
+ ].join('\n'))
151
+ }
152
+ case 'uninstall': {
153
+ const { uninstall } = await import('../src/setup.js')
154
+ return await uninstall()
155
+ }
156
+ case 'version': return console.log(VERSION)
157
+ case 'help': {
158
+ if (!pos.length) return console.log(mainHelp(VERSION))
159
+ const c = findCommand(resolve(pos[0]))
160
+ if (!c || c.hidden) return unknownCommand(pos[0])
161
+ return console.log(commandHelp(c))
162
+ }
163
+ case 'completion': {
164
+ if (pos.length !== 1) throw new Error('usage: natter completion <zsh | bash>')
165
+ return process.stdout.write(completionScript(pos[0]))
166
+ }
167
+ case '__complete': { // for the completion scripts: never starts a node
168
+ const { Local } = await import('../src/local.js')
169
+ let local
170
+ try { local = new Local(loadConfig()) } catch { return }
171
+ const h = await local.health()
172
+ if (!h || 'foreign' in h) return
173
+ const p = await local.req('GET', '/local/peers', undefined, '', 3000).catch(() => null)
174
+ if (!p) return
175
+ if (pos[0] === 'hosts') return console.log(p.remote.map((/** @type {any} */ r) => r.host).join('\n'))
176
+ const out = [...p.local.map((/** @type {string} */ n) => `${n}@${p.self}`),
177
+ ...p.remote.flatMap((/** @type {any} */ r) => r.sessions.map((/** @type {string} */ n) => `${n}@${r.host}`))]
178
+ return out.length && console.log(out.join('\n'))
179
+ }
180
+ default: return unknownCommand(first)
181
+ }
182
+ }
183
+
184
+ async function client() {
185
+ const { Local } = await import('../src/local.js')
186
+ const local = new Local(loadConfig())
187
+ await local.ensureNode()
188
+ return local
189
+ }
190
+
191
+ main().catch(e => { console.error(`natter: ${e instanceof Error ? e.message : e}`); process.exit(1) })