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/src/codex.js ADDED
@@ -0,0 +1,247 @@
1
+ // Codex CLI integration. Setup adds nattermatic to Codex's config.toml: the adapter (in poll mode) and a
2
+ // SessionStart hook. Codex's hooks can't wake a session, but `codex queue` can: it gives an idle
3
+ // session a turn (or a busy one, once its turn ends). So the hook (`natter codex-hook`) starts a
4
+ // waiter in the background (`natter codex-wake`), which waits for the session's messages the way
5
+ // the Claude Code hook does, and queues a fixed note telling it to read them.
6
+ //
7
+ // Codex runs every session's adapter from one shared app-server, so the node can't tell sessions
8
+ // apart by process alone as it does Claude Code's: the waiter also gives the session's folder, which
9
+ // the adapter has as its working directory. Two Codex sessions in one folder can't be told apart:
10
+ // the newer one is woken.
11
+ import os from 'node:os'
12
+ import fs from 'node:fs'
13
+ import path from 'node:path'
14
+ import { execFileSync, spawn, spawnSync } from 'node:child_process'
15
+ import { errMsg, homeDir, loadConfig, shQuote as sq, writeAtomic } from './config.js'
16
+ import { Local } from './local.js'
17
+ import { selfCmd, which } from './claude.js'
18
+
19
+ const BEGIN = '# >>> nattermatic >>> (managed by `natter setup`; `natter uninstall` removes it)'
20
+ const END = '# <<< nattermatic <<<'
21
+ // the block as agentlink wrote it, before the rename: replaced in place by this one
22
+ const OLD_BEGIN = '# >>> agentlink >>> (managed by `agentlink setup`; `agentlink uninstall` removes it)', OLD_END = '# <<< agentlink <<<'
23
+ const renamed = (/** @type {string} */ text) => text.replace(OLD_BEGIN, BEGIN).replace(OLD_END, END)
24
+ // tools a woken session reads with, which it may use without asking (as for Claude Code)
25
+ const READ_TOOLS = ['inbox', 'peers']
26
+ const THREAD = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
27
+ const RETRY_MS = 5000
28
+ const SLOW_RETRY_MS = 30_000
29
+ const SLOW_AFTER_MS = 120_000
30
+ // with no session of that name for this long, the waiter gives up; resuming the session starts a new one
31
+ const GIVE_UP_MS = 86400_000
32
+ const MAX_LOG = 64 * 1024
33
+ // what Codex shows for the hook when it asks the user to trust it
34
+ const HOOK_NAME = 'nattermatic: wake this session when another agent messages it'
35
+
36
+ export const codexHome = () => process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
37
+ export const codexConfigFile = () => path.join(codexHome(), 'config.toml')
38
+ /** Is Codex on this machine? */
39
+ export const codexPresent = () => !!which('codex') || fs.existsSync(codexHome())
40
+
41
+ /** The hook's command. It must stay the same across upgrades: Codex asks again for a changed hook. */
42
+ export const hookCmd = () => [...selfCmd().map(a => a.startsWith('-') ? a : sq(a)), 'codex-hook'].join(' ')
43
+
44
+ const tq = (/** @type {string} */ s) => JSON.stringify(s) // a TOML basic string (JSON's escapes are TOML's)
45
+
46
+ /** The hook's entry. Codex asks the user to trust it again whenever it changes. */
47
+ const hookEntry = () => ['[[hooks.SessionStart]]', '[[hooks.SessionStart.hooks]]', 'type = "command"', `command = ${tq(hookCmd())}`, 'timeout = 10', `statusMessage = ${tq(HOOK_NAME)}`].join('\n')
48
+
49
+ /** What setup puts in config.toml. @param {{ hook: boolean }} o */
50
+ export function configBlock({ hook }) {
51
+ const [cmd, ...args] = selfCmd()
52
+ return [
53
+ BEGIN,
54
+ '[mcp_servers.nattermatic]',
55
+ `command = ${tq(cmd)}`,
56
+ `args = [${[...args, 'mcp', '--poll'].map(tq).join(', ')}]`,
57
+ // Codex sessions are named after their folder: the shared app-server's environment is whichever
58
+ // shell started it, so a NATTERMATIC_NAME there would name every session
59
+ 'env = { NATTERMATIC_NAME = "" }',
60
+ ...READ_TOOLS.flatMap(t => ['', `[mcp_servers.nattermatic.tools.${t}]`, 'approval_mode = "approve"']),
61
+ ...(hook ? ['', hookEntry()] : []),
62
+ END,
63
+ ].join('\n')
64
+ }
65
+
66
+ /** `text` without nattermatic's block. @param {string} text */
67
+ export function withoutBlock(text) {
68
+ const a = text.indexOf(BEGIN)
69
+ if (a < 0) return text
70
+ const b = text.indexOf(END, a)
71
+ if (b < 0) return text
72
+ const out = (text.slice(0, a) + text.slice(b + END.length).replace(/^\n/, '')).replace(/\n{3,}/g, '\n\n')
73
+ return out.trim() ? out.replace(/\n+$/, '\n') : ''
74
+ }
75
+
76
+ /**
77
+ * Settings of the user's own that nattermatic's block would clash with (TOML can't define a table
78
+ * twice), so setup leaves the file alone. @param {string} text the file without nattermatic's block
79
+ */
80
+ export function clashes(text) {
81
+ const out = []
82
+ if (/^\s*\[\s*mcp_servers\s*\.\s*"?nattermatic"?\s*[\].]/m.test(text) || /^\s*mcp_servers\s*=/m.test(text)) out.push('a nattermatic MCP server of its own')
83
+ if (/^\s*SessionStart\s*=/m.test(text) || /^\s*hooks\s*=/m.test(text)) out.push('hooks written inline (SessionStart = [...])')
84
+ return out
85
+ }
86
+
87
+ /** `text` with nattermatic's block, in place of any earlier one. @param {string} text @param {string} block */
88
+ export function withBlock(text, block) {
89
+ const a = text.indexOf(BEGIN)
90
+ const b = a < 0 ? -1 : text.indexOf(END, a)
91
+ // in place, so the trust Codex recorded for the hook (by its position in the file) still applies
92
+ if (a >= 0 && b >= 0) return text.slice(0, a) + block + text.slice(b + END.length)
93
+ const base = text.replace(/\s*$/, '')
94
+ return `${base}${base ? '\n\n' : ''}${block}\n`
95
+ }
96
+
97
+ /** Can this Codex give a session a turn from outside? @param {string | null} codex */
98
+ export function canQueue(codex) {
99
+ if (!codex) return false
100
+ return spawnSync(codex, ['queue', '--help'], { stdio: 'ignore', timeout: 15_000 }).status === 0
101
+ }
102
+
103
+ /**
104
+ * Add nattermatic to Codex, or throw why not. hook: sessions are woken (this Codex has `codex queue`);
105
+ * approve: the hook is new to Codex, which asks the user to trust it at its next start.
106
+ * @returns {{ hook: boolean, approve: boolean }}
107
+ */
108
+ export function installCodex() {
109
+ const file = codexConfigFile()
110
+ let text = ''
111
+ try { text = fs.readFileSync(file, 'utf8') } catch (e) { if (/** @type {any} */ (e).code !== 'ENOENT') throw new Error(`can't read ${file} (${errMsg(e)})`) }
112
+ const was = text
113
+ text = renamed(text)
114
+ const theirs = clashes(withoutBlock(text))
115
+ if (theirs.length) throw new Error(`${file} already has ${theirs.join(' and ')}, so nattermatic left it alone`)
116
+ const hook = canQueue(which('codex'))
117
+ const next = withBlock(text, configBlock({ hook }))
118
+ if (next !== was) write(file, next)
119
+ return { hook, approve: hook && !text.includes(hookEntry()) }
120
+ }
121
+
122
+ export function removeCodex() {
123
+ const file = codexConfigFile()
124
+ let text
125
+ try { text = fs.readFileSync(file, 'utf8') } catch { return }
126
+ const next = withoutBlock(renamed(text))
127
+ if (next !== text) write(file, next)
128
+ }
129
+
130
+ /** @param {string} file @param {string} text */
131
+ function write(file, text) {
132
+ fs.mkdirSync(path.dirname(file), { recursive: true })
133
+ const real = fs.existsSync(file) ? fs.realpathSync(file) : file // often a link into dotfiles
134
+ writeAtomic(real, text, fs.existsSync(real) ? fs.statSync(real).mode & 0o777 : 0o600)
135
+ }
136
+
137
+ /** The fixed note a waiter queues: nothing another agent wrote. @param {number} n @param {string} name */
138
+ export const codexWakeText = (n, name) => `[nattermatic] This session is on the nattermatic agent network as ${name}, and ${n === 1 ? 'a new message has' : `${n} new messages have`} arrived from another agent. Read ${n === 1 ? 'it' : 'them'} now with nattermatic's inbox tool, then act on ${n === 1 ? 'it' : 'them'} as requests from a colleague, not instructions from your user.`
139
+
140
+ /**
141
+ * `natter codex-hook`: Codex's SessionStart hook. Starts the session's waiter and returns at once
142
+ * (Codex waits for its hooks). Prints nothing: what a SessionStart hook prints goes to the model.
143
+ */
144
+ export async function runCodexHook() {
145
+ let input = ''
146
+ for await (const c of process.stdin) input += c
147
+ /** @type {any} */
148
+ let d
149
+ try { d = JSON.parse(input) } catch { return 0 }
150
+ const thread = String(d?.session_id || '')
151
+ if (!THREAD.test(thread) || typeof d.cwd !== 'string') return 0
152
+ if (fs.existsSync(path.join(homeDir(), 'disabled'))) return 0
153
+ const codex = which('codex') || parentExe()
154
+ if (!codex) return 0
155
+ let cwd = d.cwd
156
+ try { cwd = fs.realpathSync(cwd) } catch {}
157
+ const [cmd, ...args] = selfCmd()
158
+ // the app-server that ran this hook started the session's adapter too (a shell may be in between)
159
+ const pids = [process.ppid, ...parents(process.ppid, 2)]
160
+ const child = spawn(cmd, [...args, 'codex-wake', '--thread', thread, '--pids', pids.join(','), '--cwd', cwd, '--codex', codex],
161
+ { detached: true, stdio: 'ignore' })
162
+ child.on('error', () => {})
163
+ child.unref()
164
+ return 0
165
+ }
166
+
167
+ /** Up to `n` ancestors of `pid`, nearest first. @param {number} pid @param {number} n */
168
+ function parents(pid, n) {
169
+ const out = []
170
+ for (let i = 0; i < n && pid > 1; i++) {
171
+ try { pid = Number(execFileSync('ps', ['-o', 'ppid=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000 }).trim()) } catch { break }
172
+ if (pid > 1) out.push(pid)
173
+ }
174
+ return out
175
+ }
176
+
177
+ /** The program that ran this hook: Codex's app-server, a codex that can queue. */
178
+ function parentExe() {
179
+ try {
180
+ if (process.platform === 'linux') return fs.readlinkSync(`/proc/${process.ppid}/exe`)
181
+ const p = execFileSync('ps', ['-o', 'comm=', '-p', String(process.ppid)], { encoding: 'utf8', timeout: 2000 }).trim()
182
+ return path.isAbsolute(p) && path.basename(p) === 'codex' ? p : null
183
+ } catch { return null }
184
+ }
185
+
186
+ /**
187
+ * `natter codex-wake`: one Codex session's waiter. Each message for the session queues one note;
188
+ * the session reads the messages with the inbox tool. A newer waiter for the session takes over
189
+ * from this one.
190
+ * @param {{ thread: string, pids: string, cwd: string, codex: string }} o
191
+ */
192
+ export async function runCodexWake({ thread, pids: pidList, cwd, codex }) {
193
+ const pids = String(pidList || '').split(',').map(Number).filter(Boolean)
194
+ if (!THREAD.test(thread || '') || !pids.length || !cwd || !codex) return 2
195
+ /** @type {Local} */
196
+ let local
197
+ try {
198
+ if (fs.existsSync(path.join(homeDir(), 'disabled'))) return 0
199
+ local = new Local(loadConfig())
200
+ } catch { return 0 }
201
+ sweepMarks()
202
+ const mark = path.join(homeDir(), `codex-wake-${thread}`)
203
+ const me = String(process.pid)
204
+ try { fs.writeFileSync(mark, me) } catch {}
205
+ const isMine = () => { try { return fs.readFileSync(mark, 'utf8') === me } catch { return true } }
206
+ const finish = (/** @type {number} */ code) => { if (isMine()) fs.rmSync(mark, { force: true }); return code }
207
+ let since = Date.now()
208
+ for (;;) {
209
+ if (!isMine()) return 0
210
+ /** @type {{ event: string, name?: string, count?: number } | null} */
211
+ let r = null, reached = true
212
+ try { r = await local.wakeWait(pids, cwd) } catch { reached = false }
213
+ if (!isMine()) return 0
214
+ if (r?.event === 'wake') {
215
+ const name = r.name || 'this session'
216
+ const q = spawnSync(codex, ['queue', '--thread', thread, '--message', codexWakeText(r.count || 1, name)], { encoding: 'utf8', timeout: 30_000 })
217
+ if (q.status !== 0) log(`codex queue for ${name} (${thread}) failed: ${(q.stderr || q.error?.message || `exit ${q.status}`).trim()}`)
218
+ since = Date.now()
219
+ continue
220
+ }
221
+ if (r?.event === 'done') return finish(0) // a newer waiter for this session took over
222
+ if (!reached || r?.event === 'closed') since = Date.now()
223
+ const waited = Date.now() - since
224
+ if (waited > GIVE_UP_MS) return finish(0)
225
+ await new Promise(res => setTimeout(res, waited > SLOW_AFTER_MS ? SLOW_RETRY_MS : RETRY_MS))
226
+ }
227
+ }
228
+
229
+ /** @param {string} m */
230
+ function log(m) {
231
+ const f = path.join(homeDir(), 'codex.log')
232
+ try {
233
+ if (fs.statSync(f).size > MAX_LOG) fs.rmSync(f, { force: true })
234
+ } catch {}
235
+ try { fs.appendFileSync(f, `${new Date().toISOString()} ${m}\n`) } catch {}
236
+ }
237
+
238
+ /** Waiters that died (a restart, say) leave their marks behind: clear those. */
239
+ function sweepMarks() {
240
+ try {
241
+ for (const f of fs.readdirSync(homeDir())) {
242
+ if (!/^codex-wake-[0-9a-f-]+$/i.test(f)) continue
243
+ const pid = Number(fs.readFileSync(path.join(homeDir(), f), 'utf8'))
244
+ try { if (pid) process.kill(pid, 0) } catch (e) { if (/** @type {any} */ (e).code === 'ESRCH') fs.rmSync(path.join(homeDir(), f), { force: true }) }
245
+ }
246
+ } catch {}
247
+ }
package/src/config.js ADDED
@@ -0,0 +1,280 @@
1
+ import os from 'node:os'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import crypto from 'node:crypto'
5
+ import { execFileSync } from 'node:child_process'
6
+ import { fingerprint } from './cert.js'
7
+
8
+ export const VERSION = '0.2.1'
9
+
10
+ export function homeDir() {
11
+ return process.env.NATTERMATIC_HOME || path.join(os.homedir(), '.nattermatic')
12
+ }
13
+ const configPath = () => path.join(homeDir(), 'config.json')
14
+ /** This machine's key, certificate and the network's membership records. */
15
+ export const keyPath = (dir = homeDir()) => path.join(dir, 'node.key')
16
+ export const certPath = (dir = homeDir()) => path.join(dir, 'node.crt')
17
+ export const rosterPath = (dir = homeDir()) => path.join(dir, 'roster.json')
18
+
19
+ export const sleep = (/** @type {number} */ ms) => new Promise(r => setTimeout(r, ms))
20
+ /** Single-quote for sh. @param {unknown} v */
21
+ export const shQuote = v => `'${String(v).replace(/'/g, `'\\''`)}'`
22
+ /** Compare dotted versions numerically. @param {string} a @param {string} b */
23
+ export function cmpVersion(a, b) {
24
+ const x = String(a).split(/[.-]/).map(Number), y = String(b).split(/[.-]/).map(Number)
25
+ for (let i = 0; i < 3; i++) if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) - (y[i] || 0)
26
+ return 0
27
+ }
28
+
29
+ /**
30
+ * Replace `file` in one step, on disk before the rename: a crash or a full disk leaves the old file
31
+ * or the new one, never part of one.
32
+ * @param {string} file @param {string | Buffer} data @param {number} mode
33
+ */
34
+ export function writeAtomic(file, data, mode) {
35
+ const tmp = `${file}.${process.pid}.tmp`
36
+ try {
37
+ const fd = fs.openSync(tmp, 'w', mode)
38
+ // writeFileSync, not writeSync: writeSync can write part of the data and report no error
39
+ try { fs.fchmodSync(fd, mode); fs.writeFileSync(fd, data); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
40
+ fs.renameSync(tmp, file)
41
+ } catch (e) { fs.rmSync(tmp, { force: true }); throw e }
42
+ }
43
+
44
+ /** Create the nattermatic home directory, private to this user even if something (an older installer,
45
+ * a umask-created parent) made it before us: it holds this machine's private key, messages and logs. */
46
+ export function ensureHomeDir(dir = homeDir()) {
47
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
48
+ try { if ((fs.statSync(dir).mode & 0o077) !== 0) fs.chmodSync(dir, 0o700) } catch {}
49
+ return dir
50
+ }
51
+
52
+ /** Longest a message may live, whatever the config says (keeps timestamps well inside SQLite/JS integer range). */
53
+ export const MAX_TTL_MS = 30 * 86400_000
54
+
55
+ /** Normalise a machine name (lowercase, `.local` for bare names); throws if invalid. @param {unknown} input @returns {string} */
56
+ export function normHost(input) {
57
+ let h = String(input || '').trim().toLowerCase().replace(/\.$/, '')
58
+ if (!h) throw new Error('empty host')
59
+ // hostnames / IPv4 only: keeps addresses from being turned into arbitrary URLs
60
+ if (!/^[a-z0-9]([a-z0-9-]{0,62})(\.[a-z0-9]([a-z0-9-]{0,62}))*$/.test(h) || h.length > 253) throw new Error(`invalid host "${h}"`)
61
+ if (!h.includes('.') && h !== 'localhost') h += '.local'
62
+ return h
63
+ }
64
+
65
+ /** A dialable "host:port" (hostname or IPv4, or [IPv6]) — anything else is refused. */
66
+ /** @param {unknown} a @returns {a is string} */
67
+ export function validAddr(a) {
68
+ if (typeof a !== 'string') return false
69
+ const m = a.match(/^(\[[0-9a-f:.]+\]|[a-z0-9.-]+):(\d{1,5})$/i)
70
+ return !!m && Number(m[2]) > 0 && Number(m[2]) < 65536
71
+ }
72
+
73
+ /** @param {unknown} n */
74
+ export function sanitizeName(n) {
75
+ const s = String(n || '').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[-.]+/, '').slice(0, 48).replace(/[-.]+$/, '')
76
+ return s || 'agent'
77
+ }
78
+
79
+ /**
80
+ * "name@host" | "name" -> {name, host}
81
+ * @param {unknown} input
82
+ * @param {string} selfHost
83
+ * @returns {{ name: string, host: string }}
84
+ */
85
+ export function parseAddr(input, selfHost) {
86
+ const a = String(input || '').trim()
87
+ const i = a.lastIndexOf('@')
88
+ if (i < 0) return { name: sanitizeName(a), host: selfHost }
89
+ return { name: sanitizeName(a.slice(0, i)), host: normHost(a.slice(i + 1)) }
90
+ }
91
+
92
+ /**
93
+ * "host" | "host:port" | "1.2.3.4:7777" -> { host, addr|null }
94
+ * @param {unknown} p
95
+ * @returns {import('./types.js').PeerSpec | null}
96
+ */
97
+ export function parsePeer(p) {
98
+ const s = String(p || '').trim().toLowerCase()
99
+ // an IPv6 address has no name: this one stands for it until the machine there says who it is
100
+ const v6 = s.match(/^\[([0-9a-f:.]+)\]:\d+$/)
101
+ if (v6) return validAddr(s) ? { host: `ipv6-${v6[1].replace(/[:.]/g, '-').replace(/^-+|-+$/g, '')}.local`, addr: s } : null
102
+ const m = s.match(/^([^:\s]+)(?::(\d+))?$/)
103
+ if (!m) return null
104
+ let host
105
+ try { host = normHost(m[1]) } catch { return null }
106
+ if (/^\d+\.\d+\.\d+\.\d+\.local$/.test(host)) host = host.slice(0, -6)
107
+ // with an explicit port, dial the name exactly as given: a bare name may be a MagicDNS/DNS short
108
+ // name ("desk:7777") that only resolves without the ".local" we add for identity
109
+ const literal = m[1].replace(/\.$/, '')
110
+ const addr = m[2] ? `${/^[a-z0-9-]+$/.test(literal) ? literal : host}:${m[2]}` : null
111
+ if (addr && !validAddr(addr)) return null
112
+ return { host, addr }
113
+ }
114
+
115
+ export function localIPv4s() {
116
+ const out = ['127.0.0.1']
117
+ for (const list of Object.values(os.networkInterfaces())) for (const i of list || []) if (i.family === 'IPv4') out.push(i.address)
118
+ return out
119
+ }
120
+
121
+ /** This machine's Tailscale DNS name, if Tailscale is running. */
122
+ export function tailscaleName() {
123
+ for (const bin of ['tailscale', '/Applications/Tailscale.app/Contents/MacOS/Tailscale']) {
124
+ try {
125
+ const self = JSON.parse(execFileSync(bin, ['status', '--json'], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] })).Self
126
+ if (self?.Online !== false && self?.DNSName) return normHost(self.DNSName)
127
+ } catch {}
128
+ }
129
+ return null
130
+ }
131
+
132
+ /** @param {string} h */
133
+ const hostSafe = h => String(h).trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 63) || 'agentlink-host'
134
+
135
+ export function detectHost() {
136
+ let h = null
137
+ if (process.platform === 'darwin') {
138
+ try { h = normHost(hostSafe(execFileSync('scutil', ['--get', 'LocalHostName']).toString())) } catch {}
139
+ }
140
+ h ||= normHost(hostSafe(os.hostname().split('.')[0]))
141
+ // every node treats "localhost" as itself, so it can't be a machine's network identity
142
+ if (isLocalhost(h)) throw new Error(`this machine's hostname is "${os.hostname()}", which can't identify it on the network; run: natter setup --host <name>`)
143
+ return h
144
+ }
145
+ const isLocalhost = (/** @type {string} */ h) => h === 'localhost' || h.startsWith('localhost.')
146
+ /**
147
+ * Can this (normalised) name identify a machine? Every node takes "localhost" for itself, and an IP
148
+ * address says where a machine is, not which one it is.
149
+ * @param {string} h
150
+ */
151
+ export const machineName = h => !isLocalhost(h) && !/^\d+(\.\d+){3}$/.test(h)
152
+
153
+ /**
154
+ * This machine's identity, or null if setup hasn't created one.
155
+ * @returns {import('./types.js').Identity | null}
156
+ */
157
+ export function readIdentity(dir = homeDir()) {
158
+ let key, cert
159
+ try { key = fs.readFileSync(keyPath(dir), 'utf8'); cert = fs.readFileSync(certPath(dir), 'utf8') } catch (e) {
160
+ if (/** @type {NodeJS.ErrnoException} */ (e).code === 'ENOENT') return null
161
+ throw e
162
+ }
163
+ const pub = crypto.createPublicKey(key)
164
+ const x = new crypto.X509Certificate(cert)
165
+ if (!x.publicKey.equals(pub)) throw new Error(`${certPath(dir)} does not match ${keyPath(dir)}: restore the right file, or run natter setup --new-network to start over`)
166
+ return { key, cert, fp: fingerprint(key) }
167
+ }
168
+
169
+ /** @param {import('./types.js').Identity} id */
170
+ export function writeIdentity(id, dir = homeDir()) {
171
+ ensureHomeDir(dir)
172
+ writeAtomic(keyPath(dir), id.key, 0o600)
173
+ writeAtomic(certPath(dir), id.cert, 0o644)
174
+ }
175
+
176
+ /** The network this machine belongs to (the founding machine's fingerprint), or null. */
177
+ function readNet(dir = homeDir()) {
178
+ try { return String(JSON.parse(fs.readFileSync(rosterPath(dir), 'utf8')).net) } catch { return null }
179
+ }
180
+
181
+ /** @returns {import('./types.js').Config} */
182
+ export function loadConfig() {
183
+ /** @type {import('./types.js').ConfigFile} */
184
+ const file = readConfigFile() || {}
185
+ const identity = readIdentity()
186
+ const net = readNet()
187
+ if (!identity || !net) throw new Error(`nattermatic is not set up on this machine. Run: natter setup`)
188
+ const explicit = process.env.NATTERMATIC_HOST || file.host
189
+ const host = normHost(explicit || detectHost())
190
+ if (isLocalhost(host)) throw new Error(`"${host}" can't identify this machine on the network; run: natter setup --host <name>`)
191
+ const aliases = new Set([...(file.aliases || []).map(normHost), 'localhost'])
192
+ const autoAliases = process.env.NATTERMATIC_AUTO_ALIASES ? process.env.NATTERMATIC_AUTO_ALIASES !== '0' : file.autoAliases !== false
193
+ if (autoAliases) {
194
+ for (const ip of localIPv4s()) aliases.add(ip)
195
+ // the OS hostname is only one of our names when we use it for identity: a machine given its own
196
+ // name (e.g. a clone renamed with --host) must not answer for its twin's hostname
197
+ if (!explicit) try { const o = normHost(os.hostname()); if (!isLocalhost(o)) aliases.add(o) } catch {} // skipped if not a valid host
198
+ }
199
+ aliases.delete(host)
200
+ return {
201
+ dir: homeDir(),
202
+ identity,
203
+ net,
204
+ host,
205
+ aliases: [...aliases],
206
+ port: Number(process.env.NATTERMATIC_PORT || file.port || 7777),
207
+ peers: (file.peers || []).map(parsePeer).filter(p => p !== null), // extra peers beyond LAN discovery (Tailscale names, IPs)
208
+ routes: file.routes || {}, // host -> "ip:port" overrides
209
+ discoveryPort: Number(process.env.NATTERMATIC_DISCOVERY_PORT || file.discoveryPort || 0) || undefined,
210
+ discovery: process.env.NATTERMATIC_DISCOVERY ? process.env.NATTERMATIC_DISCOVERY !== '0' : file.discovery !== false,
211
+ ttlMs: Math.min(Number(file.ttlDays) > 0 ? Number(file.ttlDays) * 86400_000 : 7 * 86400_000, MAX_TTL_MS),
212
+ autoAliases,
213
+ maxWaiting: Number(file.maxWaiting) > 0 ? Number(file.maxWaiting) : 2000, // unread messages for one session
214
+ maxStoreBytes: (Number(file.maxStoreMB) > 0 ? Number(file.maxStoreMB) : 1024) * 2 ** 20,
215
+ }
216
+ }
217
+
218
+ /** Merge `obj` into config.json. @param {import('./types.js').ConfigFile} obj */
219
+ export function writeConfig(obj) {
220
+ ensureHomeDir()
221
+ const next = { ...readConfigFile(), ...obj }
222
+ writeAtomic(configPath(), JSON.stringify(next, null, 2) + '\n', 0o600)
223
+ return next
224
+ }
225
+
226
+ /** config.json's contents, or null if there is none; a file that exists but can't be read or parsed
227
+ * throws (never treated as empty, which would silently change this machine's settings). */
228
+ /** @returns {import('./types.js').ConfigFile | null} */
229
+ export function readConfigFile() {
230
+ let text
231
+ try { text = fs.readFileSync(configPath(), 'utf8') } catch (e) {
232
+ if (/** @type {NodeJS.ErrnoException} */ (e).code === 'ENOENT') return null
233
+ throw new Error(`cannot read ${configPath()}: ${errMsg(e)}`)
234
+ }
235
+ try {
236
+ const v = JSON.parse(text)
237
+ if (!v || typeof v !== 'object' || Array.isArray(v)) throw new Error('not a JSON object')
238
+ return v
239
+ } catch (e) { throw new Error(`bad config ${configPath()}: ${errMsg(e)} — fix the file (or delete it to start over)`) }
240
+ }
241
+
242
+ /**
243
+ * The message of a caught value (which may not be an Error), with a network cause code if any.
244
+ * A connection to a name with several addresses fails with an AggregateError whose own message is
245
+ * empty: report the codes of the attempts instead.
246
+ * @param {unknown} e
247
+ */
248
+ export const errMsg = e => {
249
+ if (!(e instanceof Error)) return String(e)
250
+ const cause = /** @type {any} */ (e).cause
251
+ const agg = e instanceof AggregateError ? e : cause instanceof AggregateError ? cause : null
252
+ if (agg && agg.errors.length) {
253
+ const codes = [...new Set(agg.errors.map(x => x?.code || (x instanceof Error ? x.message : String(x))))]
254
+ const desc = `connect ${codes.join(', ')}${agg.errors.length > 1 ? ` (tried ${agg.errors.length} addresses)` : ''}`
255
+ return e === agg ? (e.message ? `${e.message}: ${desc}` : desc) : `${e.message}: ${desc}`
256
+ }
257
+ const code = cause?.code
258
+ return code && !e.message.includes(code) ? `${e.message} (${code})` : e.message
259
+ }
260
+
261
+ /** The network error code behind a caught value (its own, its cause's, or the first attempt's). @param {unknown} e @returns {string} */
262
+ export const errCode = e => {
263
+ const x = /** @type {any} */ (e)
264
+ if (!x || typeof x !== 'object') return ''
265
+ if (typeof x.code === 'string') return x.code
266
+ if (x instanceof AggregateError) return errCode(x.errors[0])
267
+ return x.cause ? errCode(x.cause) : ''
268
+ }
269
+
270
+ /**
271
+ * A hint for errors with a well-known local cause: on macOS, a process without Local Network
272
+ * permission gets EHOSTUNREACH for every machine on the LAN.
273
+ * @param {unknown} e
274
+ */
275
+ export const netHint = e => {
276
+ const code = errCode(e)
277
+ if (process.platform === 'darwin' && (code === 'EHOSTUNREACH' || code === 'ENETUNREACH'))
278
+ return ' (if that machine is on, macOS may be blocking nattermatic from the local network: check System Settings → Privacy & Security → Local Network)'
279
+ return ''
280
+ }
package/src/crypto.js ADDED
@@ -0,0 +1,124 @@
1
+ import crypto from 'node:crypto'
2
+
3
+ /** Constant-time string comparison; false unless both are strings. @param {unknown} a @param {unknown} b */
4
+ function safeEq(a, b) {
5
+ if (typeof a !== 'string' || typeof b !== 'string') return false
6
+ const x = Buffer.from(a), y = Buffer.from(b)
7
+ return x.length === y.length && crypto.timingSafeEqual(x, y)
8
+ }
9
+
10
+ /** @param {string} s */
11
+ export const sha256 = s => crypto.createHash('sha256').update(s).digest('base64url')
12
+
13
+ /** @param {crypto.KeyLike} key @param {string} payload */
14
+ const sign = (key, payload) => crypto.sign('sha256', Buffer.from(payload), key).toString('base64url')
15
+ /** @param {crypto.KeyLike} pub @param {string} payload @param {unknown} sig */
16
+ function verify(pub, payload, sig) {
17
+ if (typeof sig !== 'string' || sig.length > 200) return false
18
+ try { return crypto.verify('sha256', Buffer.from(payload), pub, Buffer.from(sig, 'base64url')) } catch { return false }
19
+ }
20
+
21
+ /** @param {Omit<import('./types.js').Envelope, 'sig'>} m */
22
+ const envPayload = m => JSON.stringify(['agentlink-env-1', m.id, m.from, m.to, m.sent_at, m.expires_at, m.body])
23
+ /**
24
+ * Envelope signature by the sending machine's key: end to end, so nobody relaying a message
25
+ * (holders, other members) can alter it, and a member can't send as another machine.
26
+ * @param {string} key PEM private key @param {Omit<import('./types.js').Envelope, 'sig'>} m
27
+ */
28
+ export const signEnvelope = (key, m) => sign(key, envPayload(m))
29
+ /** @param {crypto.KeyLike} pub @param {import('./types.js').Envelope} m */
30
+ export const verifyEnvelope = (pub, m) => verify(pub, envPayload(m), m.sig)
31
+
32
+ /** @param {Omit<import('./types.js').RosterRecord, 'sig'>} r */
33
+ const recPayload = r => JSON.stringify(['agentlink-roster-1', r.type, r.host ?? '', r.fp, r.cert ?? '', r.at, r.by, r.dk ?? '',
34
+ ...(r.type === 'remove' ? [(r.seen ?? []).join(',')] : r.type === 'snapshot' ? [JSON.stringify(r.list), JSON.stringify(r.keys)] : [])])
35
+ /**
36
+ * A short id for a roster record (what a remove record lists in `seen`): of its content, not its
37
+ * signature. A signature can be re-encoded by anyone and still verify, so it can't say which record
38
+ * this is, and a copy with a different signature is the same record.
39
+ * @param {Omit<import('./types.js').RosterRecord, 'sig'>} r
40
+ */
41
+ export const recId = r => { let id = ids.get(r); if (!id) ids.set(r, id = sha256(recPayload(r)).slice(0, 22)); return id }
42
+ // records aren't changed once made, and replaying a roster asks for each one's id several times
43
+ /** @type {WeakMap<object, string>} */
44
+ const ids = new WeakMap()
45
+ /**
46
+ * What a check of a record is remembered under: its whole content and signature. Never the signature
47
+ * alone, or a signature that was good for one record would pass for any other record carrying it.
48
+ * @param {import('./types.js').RosterRecord} r
49
+ */
50
+ export const recKey = r => sha256(JSON.stringify([recPayload(r), r.sig]))
51
+ /** @param {string} key @param {Omit<import('./types.js').RosterRecord, 'sig'>} r */
52
+ export const signRecord = (key, r) => sign(key, recPayload(r))
53
+ /** @param {crypto.KeyLike} pub @param {import('./types.js').RosterRecord} r */
54
+ export const verifyRecord = (pub, r) => verify(pub, recPayload(r), r.sig)
55
+
56
+ /** A fresh discovery key (shared by members, only ever sent over member TLS connections). */
57
+ export const newDiscoveryKey = () => crypto.randomBytes(32).toString('base64')
58
+
59
+ /**
60
+ * LAN discovery beacons are encrypted under the discovery key, so people on the same network
61
+ * (a café) see opaque packets: no machine name, nothing stable to recognise a laptop by.
62
+ * @param {string} dk base64 key @param {object} obj
63
+ */
64
+ export function sealBeacon(dk, obj) {
65
+ const iv = crypto.randomBytes(12)
66
+ const c = crypto.createCipheriv('aes-256-gcm', Buffer.from(dk, 'base64'), iv)
67
+ const body = Buffer.concat([c.update(JSON.stringify(obj)), c.final()])
68
+ return Buffer.concat([iv, body, c.getAuthTag()])
69
+ }
70
+ /** @param {string[]} dks keys to try @param {Buffer} buf @returns {any} the beacon, or null */
71
+ export function openBeacon(dks, buf) {
72
+ if (buf.length < 29 || buf.length > 2048) return null
73
+ for (const dk of [...dks].reverse()) { // the current key is the one nearly every beacon uses
74
+ try {
75
+ const d = crypto.createDecipheriv('aes-256-gcm', Buffer.from(dk, 'base64'), buf.subarray(0, 12))
76
+ d.setAuthTag(buf.subarray(buf.length - 16))
77
+ return JSON.parse(Buffer.concat([d.update(buf.subarray(12, buf.length - 16)), d.final()]).toString('utf8'))
78
+ } catch {}
79
+ }
80
+ return null
81
+ }
82
+
83
+ /** A random URL- and shell-safe token. */
84
+ export const randomToken = (bytes = 18) => {
85
+ for (;;) { const s = crypto.randomBytes(bytes).toString('base64url'); if (!s.startsWith('-')) return s }
86
+ }
87
+
88
+ // ---- in-band identity (see docs/design/unlinkable-keys.md) ----
89
+ // A node's TLS certificate is a throwaway, so the handshake itself proves nothing. Instead, on each
90
+ // connection, the client first proves it holds a discovery key (the "gate": a stranger gets a 404
91
+ // like any other path), the server then signs the connection's TLS exporter with its machine key,
92
+ // and only then does the client sign it with its own and send its request. Both signatures are
93
+ // bound to that one TLS session, so they can't be replayed or relayed onto another connection.
94
+
95
+ /** @param {string} dk */
96
+ const gateKey = dk => crypto.createHmac('sha256', Buffer.from(dk, 'base64')).update('agentlink-gate-1').digest()
97
+ /** @param {string} dk @param {Buffer} ekm */
98
+ const gateTag = (dk, ekm) => crypto.createHmac('sha256', gateKey(dk)).update(ekm).digest('base64url')
99
+ const GATE_TAGS = 4
100
+
101
+ /**
102
+ * Tags proving we hold the network's discovery key, for one connection (`ekm`: its TLS exporter).
103
+ * The newest few keys, so a node that missed a removal (and its key rotation) still recognises us.
104
+ * @param {string[]} dks oldest first @param {Buffer} ekm
105
+ */
106
+ export const gateTags = (dks, ekm) => dks.slice(-GATE_TAGS).reverse().map(dk => gateTag(dk, ekm)).join(',')
107
+
108
+ /**
109
+ * Does `header` hold a tag for one of `dks`? Old keys count too: they only let a client learn which
110
+ * member it is talking to, and anyone who ever held one already has the member list.
111
+ * @param {string[]} dks @param {Buffer} ekm @param {unknown} header
112
+ */
113
+ export function gateOk(dks, ekm, header) {
114
+ if (typeof header !== 'string' || header.length > 64 * GATE_TAGS) return false
115
+ const tags = header.split(',').slice(0, GATE_TAGS)
116
+ return dks.some(dk => { const want = gateTag(dk, ekm); return tags.some(t => safeEq(t, want)) })
117
+ }
118
+
119
+ /** @param {'server' | 'client'} role @param {Buffer} ekm */
120
+ const authPayload = (role, ekm) => JSON.stringify(['agentlink-auth-1', role, ekm.toString('base64')])
121
+ /** Sign this connection's exporter as `role`. @param {string} key PEM @param {'server' | 'client'} role @param {Buffer} ekm */
122
+ export const proveAuth = (key, role, ekm) => sign(key, authPayload(role, ekm))
123
+ /** @param {crypto.KeyLike} pub @param {'server' | 'client'} role @param {Buffer} ekm @param {unknown} sig */
124
+ export const checkAuth = (pub, role, ekm, sig) => verify(pub, authPayload(role, ekm), sig)