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.
@@ -0,0 +1,60 @@
1
+ // LAN discovery by encrypted UDP broadcast beacons. Deliberately does NOT use mDNS/Bonjour, so it
2
+ // can never interfere with the OS's own hostname announcements.
3
+ import dgram from 'node:dgram'
4
+ import os from 'node:os'
5
+ import { sealBeacon, openBeacon } from './crypto.js'
6
+
7
+ const EVERY = 30_000
8
+ const MAX_SKEW = 5 * 60_000
9
+ const MAX_KEYS_TRIED = 64 // anyone can send a packet; a machine on an older key is found other ways
10
+
11
+ function broadcastAddrs() {
12
+ const out = new Set(['255.255.255.255'])
13
+ for (const list of Object.values(os.networkInterfaces())) {
14
+ for (const i of list || []) {
15
+ if (i.family !== 'IPv4' || i.internal || !i.netmask) continue
16
+ const ip = i.address.split('.').map(Number), mask = i.netmask.split('.').map(Number)
17
+ out.add(ip.map((o, k) => (o | (~mask[k] & 255)) >>> 0).join('.'))
18
+ }
19
+ }
20
+ return [...out]
21
+ }
22
+
23
+ /**
24
+ * onPeer({host, fp, addr}) is called for each beacon from another member. Beacons are encrypted
25
+ * under the network's discovery key, so outsiders can neither read nor forge them; but a captured
26
+ * beacon can be replayed and a member could lie, so callers treat addr as a hint and only route to
27
+ * it once a TLS handshake there presents that member's key.
28
+ * @param {import('./types.js').Config} cfg
29
+ * @param {() => string[]} keys discovery keys to accept, current (used for sending) last
30
+ * @param {(peer: { host: string, fp: string, addr: string }) => void} onPeer
31
+ * @param {(...a: any[]) => void} log
32
+ */
33
+ export function startDiscovery(cfg, keys, onPeer, log) {
34
+ const port = cfg.discoveryPort || cfg.port
35
+ const sock = dgram.createSocket({ type: 'udp4', reuseAddr: true })
36
+ /** @param {boolean} reply */
37
+ const beacon = reply => {
38
+ const dk = keys().at(-1)
39
+ return dk ? sealBeacon(dk, { host: cfg.host, fp: cfg.identity.fp, port: cfg.port, ts: Date.now(), reply }) : null
40
+ }
41
+ const announce = () => {
42
+ const msg = beacon(false)
43
+ if (msg) for (const a of broadcastAddrs()) sock.send(msg, port, a, () => {})
44
+ }
45
+ sock.on('message', (buf, rinfo) => {
46
+ const b = openBeacon(keys().slice(-MAX_KEYS_TRIED), buf)
47
+ if (!b || typeof b.host !== 'string' || typeof b.fp !== 'string' || b.fp === cfg.identity.fp) return
48
+ if (!Number.isFinite(b.ts) || Math.abs(Date.now() - b.ts) > MAX_SKEW) return
49
+ if (!Number.isInteger(b.port) || b.port <= 0 || b.port >= 65536) return
50
+ if (!b.reply) { const r = beacon(true); if (r) sock.send(r, port, rinfo.address, () => {}) } // answer so discovery is instant both ways
51
+ onPeer({ host: b.host, fp: b.fp, addr: `${rinfo.address}:${b.port}` })
52
+ })
53
+ sock.on('error', e => log('discovery socket error:', e.message))
54
+ sock.bind(port, () => {
55
+ sock.setBroadcast(true)
56
+ announce()
57
+ })
58
+ const t = setInterval(announce, EVERY)
59
+ return { announce, stop() { clearInterval(t); try { sock.close() } catch {} } }
60
+ }
@@ -0,0 +1,4 @@
1
+ // Imported first by the entry point, before anything reads the environment: AGENTLINK_* settings,
2
+ // from before the rename, still count.
3
+ import { legacyEnv } from './legacy.js'
4
+ legacyEnv()
package/src/legacy.js ADDED
@@ -0,0 +1,130 @@
1
+ // nattermatic was called agentlink until 0.2.0. This moves a machine set up under the old name
2
+ // onto the new one, keeping its key, network and messages, and keeps the old names working for
3
+ // whatever still uses them: the user's scripts and settings, and sessions started before the upgrade.
4
+ import fs from 'node:fs'
5
+ import os from 'node:os'
6
+ import path from 'node:path'
7
+ import { spawnSync } from 'node:child_process'
8
+
9
+ export const OLD = 'agentlink'
10
+ /** agentlink's home, where a machine set up before the rename keeps its key and messages. */
11
+ export const oldHome = () => path.join(os.homedir(), '.agentlink')
12
+
13
+ /**
14
+ * AGENTLINK_* settings still count, under their new names, unless the new name is set too.
15
+ * Run before anything reads the environment.
16
+ */
17
+ export function legacyEnv() {
18
+ for (const [k, v] of Object.entries(process.env)) {
19
+ if (!k.startsWith('AGENTLINK_')) continue
20
+ const n = `NATTERMATIC_${k.slice('AGENTLINK_'.length)}`
21
+ if (!(n in process.env)) process.env[n] = v
22
+ }
23
+ }
24
+
25
+ const uid = () => process.getuid?.() ?? 0
26
+
27
+ /** Stop and remove agentlink's background service, so it can't restart the old node. */
28
+ export function removeOldService() {
29
+ if (process.platform === 'darwin') {
30
+ const plist = path.join(os.homedir(), 'Library', 'LaunchAgents', 'com.agentlink.node.plist')
31
+ if (!fs.existsSync(plist)) return false
32
+ spawnSync('launchctl', ['bootout', `gui/${uid()}/com.agentlink.node`], { stdio: 'ignore' })
33
+ fs.rmSync(plist, { force: true })
34
+ return true
35
+ }
36
+ if (process.platform === 'linux') {
37
+ const unit = path.join(os.homedir(), '.config', 'systemd', 'user', 'agentlink.service')
38
+ if (!fs.existsSync(unit)) return false
39
+ spawnSync('systemctl', ['--user', 'disable', '--now', 'agentlink'], { stdio: 'ignore' })
40
+ fs.rmSync(unit, { force: true })
41
+ spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' })
42
+ return true
43
+ }
44
+ return false
45
+ }
46
+
47
+ /**
48
+ * Move ~/.agentlink to `home` and leave a link in its place, for sessions and shell lines that
49
+ * still use the old path. Only for the usual home, and only if `home` doesn't exist yet or holds
50
+ * nothing but what the installer has just put there (bin, pkg, node): entries already in `home`
51
+ * win, and what's left of the old home is the old program, which the new one replaces.
52
+ * If ~/.agentlink is itself a link (to a folder on another disk, say), the data stays where it is:
53
+ * the installer's entries go into that folder and `home` becomes a link to it too.
54
+ * Returns what happened, or null if there was nothing to move.
55
+ * @param {string} home @returns {string | null}
56
+ */
57
+ export function moveOldHome(home) {
58
+ const old = oldHome()
59
+ let st
60
+ try { st = fs.lstatSync(old) } catch { return null }
61
+ /** @type {string | null} */
62
+ let target = null // where a linked ~/.agentlink really is
63
+ if (st.isSymbolicLink()) {
64
+ try { target = fs.realpathSync(old) } catch { return `${old} was left alone: it is a link to a folder that isn't there` }
65
+ if (!fs.statSync(target).isDirectory()) return null
66
+ let real = null
67
+ try { real = fs.realpathSync(home) } catch {}
68
+ if (real === target) return null // moved already: both names lead to the same folder
69
+ } else if (!st.isDirectory()) return null
70
+ if (path.resolve(home) === old) return null
71
+ const installed = new Set(['bin', 'pkg', 'node'])
72
+ /** @type {string[]} */
73
+ let have = []
74
+ try { have = fs.readdirSync(home) } catch {}
75
+ if (have.some(e => !installed.has(e))) return `${old} was left alone: ${home} is already in use`
76
+ removeOldService()
77
+ if (target) {
78
+ // the new program replaces the old one in that folder; then home leads there
79
+ for (const e of have) {
80
+ fs.rmSync(path.join(target, e), { recursive: true, force: true })
81
+ moveEntry(path.join(home, e), path.join(target, e))
82
+ }
83
+ fs.rmSync(home, { recursive: true, force: true })
84
+ fs.symlinkSync(target, home)
85
+ return `linked ${home} to ${target}, where ${old} leads`
86
+ }
87
+ if (!have.length) {
88
+ fs.rmSync(home, { recursive: true, force: true })
89
+ fs.renameSync(old, home)
90
+ } else {
91
+ for (const e of fs.readdirSync(old)) if (!have.includes(e)) fs.renameSync(path.join(old, e), path.join(home, e))
92
+ fs.rmSync(old, { recursive: true, force: true })
93
+ }
94
+ fs.symlinkSync(home, old)
95
+ return `moved ${old} to ${home}`
96
+ }
97
+
98
+ /** rename, or copy and remove when the two are on different disks. @param {string} from @param {string} to */
99
+ function moveEntry(from, to) {
100
+ try { fs.renameSync(from, to) } catch (e) {
101
+ if (/** @type {NodeJS.ErrnoException} */ (e).code !== 'EXDEV') throw e
102
+ fs.cpSync(from, to, { recursive: true, verbatimSymlinks: true })
103
+ fs.rmSync(from, { recursive: true, force: true })
104
+ }
105
+ }
106
+
107
+ /**
108
+ * The database's old file name, renamed once nothing has it open: called by the node after it has
109
+ * the port, so no other node of this machine is running.
110
+ * @param {string} dir
111
+ */
112
+ export function renameOldDb(dir) {
113
+ const from = path.join(dir, 'agentlink.db'), to = path.join(dir, 'nattermatic.db')
114
+ if (!fs.existsSync(from) || fs.existsSync(to)) return
115
+ for (const ext of ['-wal', '-shm', '']) {
116
+ try { fs.renameSync(from + ext, to + ext) } catch (e) { if (/** @type {any} */ (e).code !== 'ENOENT') throw e }
117
+ }
118
+ }
119
+
120
+ /** A permission rule naming agentlink's tools, under its new name. @param {any} rule @returns {any} */
121
+ export const renamedRule = rule => typeof rule === 'string' ? rule.replace(/^mcp__agentlink(?=__|$|\()/, 'mcp__nattermatic') : rule
122
+
123
+ /** agentlink.app, beside the app this runs from: replaced by nattermatic.app. @param {string | null} bundle */
124
+ export function removeOldApp(bundle) {
125
+ if (!bundle) return false
126
+ const old = path.join(path.dirname(bundle), 'agentlink.app')
127
+ if (old === bundle || !fs.existsSync(path.join(old, 'Contents', 'Info.plist'))) return false
128
+ fs.rmSync(old, { recursive: true, force: true })
129
+ return true
130
+ }
package/src/local.js ADDED
@@ -0,0 +1,219 @@
1
+ // Client for the local node's loopback API, plus auto-start of the node.
2
+ //
3
+ // The local API runs over TLS on 127.0.0.1 with the same in-band identity check as connections
4
+ // between machines (see tls.js): the node must prove it holds this machine's key, and so must the
5
+ // client. Only a process that can read this machine's private key (this user) gets in, and a
6
+ // process squatting on the port is refused before it is sent anything.
7
+ import fs from 'node:fs'
8
+ import path from 'node:path'
9
+ import { spawn } from 'node:child_process'
10
+ import crypto from 'node:crypto'
11
+ import { authedRequest, authedRaw, PinError, PROTO } from './tls.js'
12
+ import { cmpVersion, ensureHomeDir, errMsg, readIdentity, rosterPath, sleep, VERSION } from './config.js'
13
+ import { Roster } from './roster.js'
14
+ import { selfCmd } from './claude.js'
15
+
16
+ const MAX_LOCAL_REPLY = 64 << 20 // the most an inbox read can come to: 100 messages of 64 KB, escaped
17
+
18
+ /**
19
+ * The running node is a newer nattermatic than this process: this process (a session's adapter
20
+ * started before an upgrade, say) is the one that is out of date, and must not replace the node.
21
+ */
22
+ export class StaleError extends Error {}
23
+
24
+ /** Is the node behind `h` a different version from this process, and is it newer? @param {import('./types.js').Health} h */
25
+ export function versionOf(h) {
26
+ const proto = h.proto ?? 0 // nodes from before the protocol number count as older
27
+ const differs = proto !== PROTO || h.version !== VERSION
28
+ const newer = proto !== PROTO ? proto > PROTO : cmpVersion(h.version, VERSION) > 0
29
+ return { differs, newer: differs && newer }
30
+ }
31
+
32
+ /** @type {{ stamp: string, dks: string[] } | null} */
33
+ let dkCache = null
34
+ /**
35
+ * The discovery keys that open this machine's node, read fresh (a removal elsewhere rotates them
36
+ * while long-lived clients, like MCP adapters, keep running) but through the roster's own rules, so
37
+ * only keys that count are used. Replaying the roster is only needed when the file has changed.
38
+ * @param {string} [dir] @returns {string[]}
39
+ */
40
+ function readDks(dir) {
41
+ try {
42
+ const file = rosterPath(dir)
43
+ const st = fs.statSync(file)
44
+ const stamp = `${file} ${st.mtimeMs} ${st.size}`
45
+ if (dkCache?.stamp !== stamp) dkCache = { stamp, dks: Roster.load(file).gateDks() }
46
+ return dkCache.dks
47
+ } catch { return [] }
48
+ }
49
+
50
+ export class Local {
51
+ /** @param {Pick<import('./types.js').Config, 'identity' | 'port' | 'dir'>} cfg */
52
+ constructor(cfg) {
53
+ this.cfg = cfg
54
+ this.addr = `127.0.0.1:${cfg.port}`
55
+ this.accept = new Map([[cfg.identity.fp, crypto.createPublicKey(cfg.identity.key)]])
56
+ }
57
+
58
+ /**
59
+ * This machine's key as it is on disk now: `natter setup --new-network` (or joining another
60
+ * network) replaces it while long-lived clients, like MCP adapters, keep running, and the node
61
+ * they reach then proves the new key. Returns whether it changed.
62
+ */
63
+ reloadIdentity() {
64
+ let id = null
65
+ try { id = readIdentity(this.cfg.dir) } catch {}
66
+ if (!id || id.fp === this.cfg.identity.fp) return false
67
+ this.cfg.identity = id
68
+ this.accept = new Map([[id.fp, crypto.createPublicKey(id.key)]])
69
+ return true
70
+ }
71
+
72
+ /** @param {{ path: string, method?: 'GET' | 'POST', body?: string, timeout?: number, signal?: AbortSignal }} o */
73
+ // anyProto: after an upgrade the running node may be the previous version; setup must still reach it to stop it
74
+ opts(o) { return { addr: this.addr, identity: this.cfg.identity, accept: this.accept, dks: readDks(this.cfg.dir), anyProto: true, ...o } }
75
+
76
+ /** @param {unknown} e */
77
+ wrap(e) {
78
+ if (e instanceof PinError || /** @type {any} */ (e)?.code === 'EPROTO') return new Error(`port ${this.cfg.port} is not answered by this machine's nattermatic node`)
79
+ return new Error(`cannot reach the nattermatic node on port ${this.cfg.port}: ${errMsg(e)}`)
80
+ }
81
+
82
+ /**
83
+ * Request to the local node's API; throws with the node's error message on failure.
84
+ * @param {'GET'|'POST'} method
85
+ * @param {string} p
86
+ * @param {object} [obj]
87
+ * @returns {Promise<any>}
88
+ */
89
+ async req(method, p, obj, query = '', timeout = 15_000) {
90
+ let r
91
+ const send = () => authedRequest(this.opts({ method, path: p + query, body: obj === undefined ? '' : JSON.stringify(obj), timeout }), MAX_LOCAL_REPLY)
92
+ try {
93
+ r = await send()
94
+ } catch (e) {
95
+ // refused before anything was sent: if this machine's key was replaced meanwhile, try again with the new one
96
+ if (!(e instanceof PinError) || !this.reloadIdentity()) throw this.wrap(e)
97
+ try { r = await send() } catch (e2) { throw this.wrap(e2) }
98
+ }
99
+ /** @type {any} */
100
+ let data = {}
101
+ try { data = r.text ? JSON.parse(r.text) : {} } catch {}
102
+ if (r.status < 200 || r.status >= 300) throw new Error(data.error || `HTTP ${r.status}`)
103
+ return data
104
+ }
105
+
106
+ /**
107
+ * Is a node running on the port? null: nothing is listening. { foreign: true }: something that
108
+ * isn't this machine's node (another network's node, another user's, or not nattermatic at all).
109
+ * @returns {Promise<import('./types.js').Health | { foreign: true } | null>}
110
+ */
111
+ async health() {
112
+ const h = await this.health1()
113
+ // the node may be this machine's under a key replaced since this process started
114
+ return h && 'foreign' in h && this.reloadIdentity() ? this.health1() : h
115
+ }
116
+
117
+ /** @returns {Promise<import('./types.js').Health | { foreign: true } | null>} */
118
+ async health1() {
119
+ try {
120
+ const r = await authedRequest(this.opts({ path: '/health', timeout: 1000 }))
121
+ return r.status === 200 ? JSON.parse(r.text) : { foreign: true }
122
+ } catch (e) {
123
+ return e instanceof PinError || /** @type {any} */ (e)?.code === 'EPROTO' ? { foreign: true } : null
124
+ }
125
+ }
126
+
127
+ /** Make sure a node is running on this machine (start one in the background if not). */
128
+ async ensureNode() {
129
+ let h = await this.health()
130
+ if (h && 'foreign' in h) throw new Error(`port ${this.cfg.port} is used by something that is not this machine's nattermatic node`)
131
+ if (h && versionOf(h).newer) {
132
+ // nattermatic was upgraded while this process was running: replacing the node would downgrade it
133
+ throw new StaleError(`nattermatic on this machine was upgraded (to ${h.version}, protocol ${h.proto}) while this program (${VERSION}, protocol ${PROTO}) was running, so it can't use the node any more`)
134
+ }
135
+ // nattermatic was upgraded under a running node: replace it with this version
136
+ if (h && versionOf(h).differs) {
137
+ try { await this.req('POST', '/local/shutdown') } catch {}
138
+ for (let i = 0; i < 30 && (h = await this.health()) && !('foreign' in h) && versionOf(h).differs; i++) await sleep(100)
139
+ }
140
+ if (h && !('foreign' in h) && !versionOf(h).differs) return h
141
+ if (fs.existsSync(path.join(this.cfg.dir, 'disabled'))) throw new Error('nattermatic is uninstalled on this machine (run `natter setup` to re-enable)')
142
+ ensureHomeDir(this.cfg.dir)
143
+ const out = fs.openSync(path.join(this.cfg.dir, 'node.log'), 'a', 0o600)
144
+ let exited = false, spawnErr = ''
145
+ try {
146
+ // --on-demand: if another node wins the race for the port, this one exits instead of standing by.
147
+ // selfCmd(): a Node path that survives upgrades (the versioned execPath may be removed by brew)
148
+ const [cmd, ...args] = selfCmd()
149
+ const child = spawn(cmd, [...args, 'node', '--on-demand'], { detached: true, stdio: ['ignore', out, out], env: process.env })
150
+ child.on('error', e => { exited = true; spawnErr = errMsg(e) }) // e.g. ENOENT: must not crash the caller
151
+ child.on('exit', () => { exited = true })
152
+ child.unref()
153
+ } finally { fs.closeSync(out) }
154
+ for (let i = 0; i < 60; i++) {
155
+ await sleep(100)
156
+ const h2 = await this.health()
157
+ if (h2 && !('foreign' in h2)) return h2
158
+ if (exited && i > 5) break // it died (lost the race to a node still starting, or failed): one last look below
159
+ }
160
+ const h3 = await this.health()
161
+ if (h3 && !('foreign' in h3)) return h3
162
+ throw new Error(`could not start the nattermatic node${spawnErr ? ` (${spawnErr})` : ''} (see ${path.join(this.cfg.dir, 'node.log')})`)
163
+ }
164
+
165
+ /**
166
+ * Wait for news for the session owned by one of `pids` (and, given `cwd`, working in that folder).
167
+ * null: there's no such session (yet).
168
+ * @param {number[]} pids @param {string} [cwd]
169
+ * @returns {Promise<{ event: string, name?: string, count?: number } | null>}
170
+ */
171
+ async wakeWait(pids, cwd) {
172
+ const { res } = await authedRaw(this.opts({ timeout: 0, path: `/local/wake?pids=${pids.join(',')}${cwd ? `&cwd=${encodeURIComponent(cwd)}` : ''}` }))
173
+ if (res.statusCode !== 200) { res.destroy(); return null }
174
+ for await (const { event, data } of sseEvents(res)) { res.destroy(); return { event, ...data } }
175
+ return { event: 'closed' }
176
+ }
177
+
178
+ /**
179
+ * Long-lived stream of messages for session `name`; `token` lets a reconnect reclaim the same name.
180
+ * Resolves when the stream ends.
181
+ * @param {string} name
182
+ * @param {string} token
183
+ * @param {(event: string, data: any) => Promise<void> | void} onEvent
184
+ * @param {{ signal?: AbortSignal, poll?: boolean }} [opts] poll: messages are read with the inbox tool, not pushed
185
+ */
186
+ async stream(name, token, onEvent, { signal, poll = false } = {}) {
187
+ let res
188
+ try {
189
+ ({ res } = await authedRaw(this.opts({
190
+ timeout: 0, signal,
191
+ // owner and cwd: the session that started this adapter, so its hook can find it: Claude Code's
192
+ // process (see wake.js), or Codex's shared app-server and the session's folder (see codex.js)
193
+ path: `/local/stream?name=${encodeURIComponent(name)}&token=${encodeURIComponent(token)}&owner=${process.ppid}&cwd=${encodeURIComponent(process.cwd())}${poll ? '&mode=poll' : ''}`,
194
+ })))
195
+ } catch (e) { throw /** @type {any} */ (e)?.name === 'AbortError' ? e : this.wrap(e) }
196
+ if (res.statusCode !== 200) { res.destroy(); throw new Error(`stream HTTP ${res.statusCode}`) }
197
+ for await (const { event, data } of sseEvents(res)) await onEvent(event, data)
198
+ }
199
+ }
200
+
201
+ /** The events of a server-sent stream, as they arrive. @param {import('node:http').IncomingMessage} res */
202
+ async function* sseEvents(res) {
203
+ const dec = new TextDecoder()
204
+ let buf = ''
205
+ for await (const chunk of res) {
206
+ buf += dec.decode(chunk, { stream: true })
207
+ let i
208
+ while ((i = buf.indexOf('\n\n')) >= 0) {
209
+ const raw = buf.slice(0, i)
210
+ buf = buf.slice(i + 2)
211
+ let event = 'message', data = ''
212
+ for (const line of raw.split('\n')) {
213
+ if (line.startsWith('event: ')) event = line.slice(7)
214
+ else if (line.startsWith('data: ')) data += line.slice(6)
215
+ }
216
+ if (data) yield { event, data: JSON.parse(data) }
217
+ }
218
+ }
219
+ }