nattermatic 0.0.1 → 0.2.0

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,100 @@
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
+ * Returns what happened, or null if there was nothing to move.
53
+ * @param {string} home @returns {string | null}
54
+ */
55
+ export function moveOldHome(home) {
56
+ const old = oldHome()
57
+ let st
58
+ try { st = fs.lstatSync(old) } catch { return null }
59
+ if (!st.isDirectory() || path.resolve(home) === old) return null
60
+ const installed = new Set(['bin', 'pkg', 'node'])
61
+ /** @type {string[]} */
62
+ let have = []
63
+ try { have = fs.readdirSync(home) } catch {}
64
+ if (have.some(e => !installed.has(e))) return `${old} was left alone: ${home} is already in use`
65
+ removeOldService()
66
+ if (!have.length) {
67
+ fs.rmSync(home, { recursive: true, force: true })
68
+ fs.renameSync(old, home)
69
+ } else {
70
+ for (const e of fs.readdirSync(old)) if (!have.includes(e)) fs.renameSync(path.join(old, e), path.join(home, e))
71
+ fs.rmSync(old, { recursive: true, force: true })
72
+ }
73
+ fs.symlinkSync(home, old)
74
+ return `moved ${old} to ${home}`
75
+ }
76
+
77
+ /**
78
+ * The database's old file name, renamed once nothing has it open: called by the node after it has
79
+ * the port, so no other node of this machine is running.
80
+ * @param {string} dir
81
+ */
82
+ export function renameOldDb(dir) {
83
+ const from = path.join(dir, 'agentlink.db'), to = path.join(dir, 'nattermatic.db')
84
+ if (!fs.existsSync(from) || fs.existsSync(to)) return
85
+ for (const ext of ['-wal', '-shm', '']) {
86
+ try { fs.renameSync(from + ext, to + ext) } catch (e) { if (/** @type {any} */ (e).code !== 'ENOENT') throw e }
87
+ }
88
+ }
89
+
90
+ /** A permission rule naming agentlink's tools, under its new name. @param {any} rule @returns {any} */
91
+ export const renamedRule = rule => typeof rule === 'string' ? rule.replace(/^mcp__agentlink(?=__|$|\()/, 'mcp__nattermatic') : rule
92
+
93
+ /** agentlink.app, beside the app this runs from: replaced by nattermatic.app. @param {string | null} bundle */
94
+ export function removeOldApp(bundle) {
95
+ if (!bundle) return false
96
+ const old = path.join(path.dirname(bundle), 'agentlink.app')
97
+ if (old === bundle || !fs.existsSync(path.join(old, 'Contents', 'Info.plist'))) return false
98
+ fs.rmSync(old, { recursive: true, force: true })
99
+ return true
100
+ }
package/src/local.js ADDED
@@ -0,0 +1,193 @@
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, 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
+ /** @param {{ path: string, method?: 'GET' | 'POST', body?: string, timeout?: number, signal?: AbortSignal }} o */
59
+ // anyProto: after an upgrade the running node may be the previous version; setup must still reach it to stop it
60
+ opts(o) { return { addr: this.addr, identity: this.cfg.identity, accept: this.accept, dks: readDks(this.cfg.dir), anyProto: true, ...o } }
61
+
62
+ /** @param {unknown} e */
63
+ wrap(e) {
64
+ 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`)
65
+ return new Error(`cannot reach the nattermatic node on port ${this.cfg.port}: ${errMsg(e)}`)
66
+ }
67
+
68
+ /**
69
+ * Request to the local node's API; throws with the node's error message on failure.
70
+ * @param {'GET'|'POST'} method
71
+ * @param {string} p
72
+ * @param {object} [obj]
73
+ * @returns {Promise<any>}
74
+ */
75
+ async req(method, p, obj, query = '', timeout = 15_000) {
76
+ let r
77
+ try {
78
+ r = await authedRequest(this.opts({ method, path: p + query, body: obj === undefined ? '' : JSON.stringify(obj), timeout }), MAX_LOCAL_REPLY)
79
+ } catch (e) { throw this.wrap(e) }
80
+ /** @type {any} */
81
+ let data = {}
82
+ try { data = r.text ? JSON.parse(r.text) : {} } catch {}
83
+ if (r.status < 200 || r.status >= 300) throw new Error(data.error || `HTTP ${r.status}`)
84
+ return data
85
+ }
86
+
87
+ /**
88
+ * Is a node running on the port? null: nothing is listening. { foreign: true }: something that
89
+ * isn't this machine's node (another network's node, another user's, or not nattermatic at all).
90
+ * @returns {Promise<import('./types.js').Health | { foreign: true } | null>}
91
+ */
92
+ async health() {
93
+ try {
94
+ const r = await authedRequest(this.opts({ path: '/health', timeout: 1000 }))
95
+ return r.status === 200 ? JSON.parse(r.text) : { foreign: true }
96
+ } catch (e) {
97
+ return e instanceof PinError || /** @type {any} */ (e)?.code === 'EPROTO' ? { foreign: true } : null
98
+ }
99
+ }
100
+
101
+ /** Make sure a node is running on this machine (start one in the background if not). */
102
+ async ensureNode() {
103
+ let h = await this.health()
104
+ if (h && 'foreign' in h) throw new Error(`port ${this.cfg.port} is used by something that is not this machine's nattermatic node`)
105
+ if (h && versionOf(h).newer) {
106
+ // nattermatic was upgraded while this process was running: replacing the node would downgrade it
107
+ 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`)
108
+ }
109
+ // nattermatic was upgraded under a running node: replace it with this version
110
+ if (h && versionOf(h).differs) {
111
+ try { await this.req('POST', '/local/shutdown') } catch {}
112
+ for (let i = 0; i < 30 && (h = await this.health()) && !('foreign' in h) && versionOf(h).differs; i++) await sleep(100)
113
+ }
114
+ if (h && !('foreign' in h) && !versionOf(h).differs) return h
115
+ if (fs.existsSync(path.join(this.cfg.dir, 'disabled'))) throw new Error('nattermatic is uninstalled on this machine (run `natter setup` to re-enable)')
116
+ ensureHomeDir(this.cfg.dir)
117
+ const out = fs.openSync(path.join(this.cfg.dir, 'node.log'), 'a', 0o600)
118
+ let exited = false, spawnErr = ''
119
+ try {
120
+ // --on-demand: if another node wins the race for the port, this one exits instead of standing by.
121
+ // selfCmd(): a Node path that survives upgrades (the versioned execPath may be removed by brew)
122
+ const [cmd, ...args] = selfCmd()
123
+ const child = spawn(cmd, [...args, 'node', '--on-demand'], { detached: true, stdio: ['ignore', out, out], env: process.env })
124
+ child.on('error', e => { exited = true; spawnErr = errMsg(e) }) // e.g. ENOENT: must not crash the caller
125
+ child.on('exit', () => { exited = true })
126
+ child.unref()
127
+ } finally { fs.closeSync(out) }
128
+ for (let i = 0; i < 60; i++) {
129
+ await sleep(100)
130
+ const h2 = await this.health()
131
+ if (h2 && !('foreign' in h2)) return h2
132
+ if (exited && i > 5) break // it died (lost the race to a node still starting, or failed): one last look below
133
+ }
134
+ const h3 = await this.health()
135
+ if (h3 && !('foreign' in h3)) return h3
136
+ throw new Error(`could not start the nattermatic node${spawnErr ? ` (${spawnErr})` : ''} (see ${path.join(this.cfg.dir, 'node.log')})`)
137
+ }
138
+
139
+ /**
140
+ * Wait for news for the session owned by one of `pids` (and, given `cwd`, working in that folder).
141
+ * null: there's no such session (yet).
142
+ * @param {number[]} pids @param {string} [cwd]
143
+ * @returns {Promise<{ event: string, name?: string, count?: number } | null>}
144
+ */
145
+ async wakeWait(pids, cwd) {
146
+ const { res } = await authedRaw(this.opts({ timeout: 0, path: `/local/wake?pids=${pids.join(',')}${cwd ? `&cwd=${encodeURIComponent(cwd)}` : ''}` }))
147
+ if (res.statusCode !== 200) { res.destroy(); return null }
148
+ for await (const { event, data } of sseEvents(res)) { res.destroy(); return { event, ...data } }
149
+ return { event: 'closed' }
150
+ }
151
+
152
+ /**
153
+ * Long-lived stream of messages for session `name`; `token` lets a reconnect reclaim the same name.
154
+ * Resolves when the stream ends.
155
+ * @param {string} name
156
+ * @param {string} token
157
+ * @param {(event: string, data: any) => Promise<void> | void} onEvent
158
+ * @param {{ signal?: AbortSignal, poll?: boolean }} [opts] poll: messages are read with the inbox tool, not pushed
159
+ */
160
+ async stream(name, token, onEvent, { signal, poll = false } = {}) {
161
+ let res
162
+ try {
163
+ ({ res } = await authedRaw(this.opts({
164
+ timeout: 0, signal,
165
+ // owner and cwd: the session that started this adapter, so its hook can find it: Claude Code's
166
+ // process (see wake.js), or Codex's shared app-server and the session's folder (see codex.js)
167
+ path: `/local/stream?name=${encodeURIComponent(name)}&token=${encodeURIComponent(token)}&owner=${process.ppid}&cwd=${encodeURIComponent(process.cwd())}${poll ? '&mode=poll' : ''}`,
168
+ })))
169
+ } catch (e) { throw /** @type {any} */ (e)?.name === 'AbortError' ? e : this.wrap(e) }
170
+ if (res.statusCode !== 200) { res.destroy(); throw new Error(`stream HTTP ${res.statusCode}`) }
171
+ for await (const { event, data } of sseEvents(res)) await onEvent(event, data)
172
+ }
173
+ }
174
+
175
+ /** The events of a server-sent stream, as they arrive. @param {import('node:http').IncomingMessage} res */
176
+ async function* sseEvents(res) {
177
+ const dec = new TextDecoder()
178
+ let buf = ''
179
+ for await (const chunk of res) {
180
+ buf += dec.decode(chunk, { stream: true })
181
+ let i
182
+ while ((i = buf.indexOf('\n\n')) >= 0) {
183
+ const raw = buf.slice(0, i)
184
+ buf = buf.slice(i + 2)
185
+ let event = 'message', data = ''
186
+ for (const line of raw.split('\n')) {
187
+ if (line.startsWith('event: ')) event = line.slice(7)
188
+ else if (line.startsWith('data: ')) data += line.slice(6)
189
+ }
190
+ if (data) yield { event, data: JSON.parse(data) }
191
+ }
192
+ }
193
+ }