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.
- package/LICENSE +21 -0
- package/README.md +228 -4
- package/SECURITY.md +178 -0
- package/bin/natter.js +191 -0
- package/install.sh +229 -0
- package/package.json +62 -6
- package/src/cert.js +86 -0
- package/src/claude.js +406 -0
- package/src/cli.js +223 -0
- package/src/codex.js +247 -0
- package/src/config.js +280 -0
- package/src/crypto.js +124 -0
- package/src/discovery.js +60 -0
- package/src/legacy-env.js +4 -0
- package/src/legacy.js +100 -0
- package/src/local.js +193 -0
- package/src/mcp.js +370 -0
- package/src/node.js +1387 -0
- package/src/roster.js +517 -0
- package/src/self.js +41 -0
- package/src/setup.js +451 -0
- package/src/store.js +39 -0
- package/src/tls.js +229 -0
- package/src/types.d.ts +150 -0
- package/src/wake.js +143 -0
package/src/tls.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Connections between nattermatic machines.
|
|
2
|
+
//
|
|
3
|
+
// Nodes present a throwaway TLS certificate (a fresh key per connection), so someone who connects
|
|
4
|
+
// to a node's port learns nothing they could recognise the machine by later, on another network.
|
|
5
|
+
// Who is who is settled inside the encrypted connection instead (authedRaw): the client proves it
|
|
6
|
+
// belongs to the network, the server proves it is the machine expected, and only then does the
|
|
7
|
+
// client reveal itself and send its request. Each proof signs the connection's TLS exporter, so it
|
|
8
|
+
// is worthless on any other connection, and a relay in the middle (two TLS sessions) can't pass it on.
|
|
9
|
+
//
|
|
10
|
+
// Pinned TLS (pinnedRaw) is kept for joining: an invite names the key the inviting node presents
|
|
11
|
+
// while invites are open, and the joining machine (and curl) accept only that key.
|
|
12
|
+
import https from 'node:https'
|
|
13
|
+
import tls from 'node:tls'
|
|
14
|
+
import net from 'node:net'
|
|
15
|
+
import crypto from 'node:crypto'
|
|
16
|
+
import { fingerprint } from './cert.js'
|
|
17
|
+
import { gateTags, proveAuth, checkAuth } from './crypto.js'
|
|
18
|
+
|
|
19
|
+
/** Split "host:port" / "[v6]:port". @param {string} addr */
|
|
20
|
+
function splitAddr(addr) {
|
|
21
|
+
const m = addr.match(/^\[([0-9a-f:.]+)\]:(\d+)$/i) || addr.match(/^([^:]+):(\d+)$/)
|
|
22
|
+
if (!m) throw new Error(`invalid address ${addr}`)
|
|
23
|
+
return { host: m[1], port: Number(m[2]) }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class PinError extends Error {}
|
|
27
|
+
/** The other machine speaks a different version of the protocol. */
|
|
28
|
+
export class ProtoError extends Error {
|
|
29
|
+
/** @param {number} theirs */
|
|
30
|
+
constructor(theirs) {
|
|
31
|
+
super(`it runs ${theirs > PROTO ? 'a newer' : 'an older'} version of nattermatic (protocol ${theirs}; this machine: ${PROTO}). Upgrade the older machine: re-run the installer, or \`npm install -g nattermatic\` and \`natter setup\``)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The version of the machine-to-machine protocol (the identity exchange, roster and message
|
|
37
|
+
* formats). Machines exchange it first, so a mismatch is reported as such instead of looking like
|
|
38
|
+
* a stranger. Bump it on any incompatible change.
|
|
39
|
+
*/
|
|
40
|
+
export const PROTO = 1
|
|
41
|
+
/** The largest request or response between machines. A roster always fits (see roster.js). */
|
|
42
|
+
export const MAX_BODY = 8 << 20
|
|
43
|
+
/** The slowest link we wait for, in bytes per ms (about 200 kbit/s). */
|
|
44
|
+
export const MIN_RATE = 25
|
|
45
|
+
const notExpected = () => new PinError('not the expected machine (key mismatch)')
|
|
46
|
+
|
|
47
|
+
/** A connection's TLS exporter: the value both identity proofs sign. @param {tls.TLSSocket} sock */
|
|
48
|
+
export const exporterOf = sock => sock.exportKeyingMaterial(32, 'EXPORTER-agentlink-auth', Buffer.alloc(0))
|
|
49
|
+
|
|
50
|
+
/** @param {unknown} e */
|
|
51
|
+
function mapErr(e) {
|
|
52
|
+
const code = /** @type {any} */ (e)?.code
|
|
53
|
+
// the far end doesn't speak TLS (a plain HTTP server answers our handshake with garbage)
|
|
54
|
+
if (code === 'EPROTO' || /^ERR_SSL_/.test(code || '')) return Object.assign(new Error('not a nattermatic node (no TLS)'), { code: 'EPROTO' })
|
|
55
|
+
return e
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @param {import('node:http').IncomingMessage} res @param {number} max */
|
|
59
|
+
async function readAll(res, max) {
|
|
60
|
+
if (Number(res.headers['content-length']) > max) { res.destroy(); throw new Error('response too large') }
|
|
61
|
+
/** @type {Buffer[]} */
|
|
62
|
+
const chunks = []
|
|
63
|
+
let n = 0
|
|
64
|
+
for await (const c of res) {
|
|
65
|
+
n += c.length
|
|
66
|
+
if (n > max) { res.destroy(); throw new Error('response too large') }
|
|
67
|
+
chunks.push(c)
|
|
68
|
+
}
|
|
69
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @typedef {{
|
|
74
|
+
* addr: string, method?: 'GET' | 'POST', path: string, body?: string, headers?: Record<string, string>,
|
|
75
|
+
* identity: import('./types.js').Identity,
|
|
76
|
+
* accept: Map<string, crypto.KeyObject>, dks: string[], timeout?: number, signal?: AbortSignal,
|
|
77
|
+
* anyProto?: boolean, deadline?: number,
|
|
78
|
+
* }} AuthOpts
|
|
79
|
+
* accept: the machines we are willing to be talking to (fingerprint -> public key)
|
|
80
|
+
* dks: the network's discovery keys (oldest first), which open the gate
|
|
81
|
+
* anyProto: talk to a node with a different protocol version (the local node after an upgrade, to
|
|
82
|
+
* stop it); otherwise that fails with ProtoError
|
|
83
|
+
* deadline: ms the whole exchange may take (authedRequest), when the caller can't wait for the usual limit
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A request to the machine at `addr`, which must prove it holds one of `accept`'s keys before we
|
|
88
|
+
* reveal who we are or send a byte of the request. Resolves once the response headers arrive.
|
|
89
|
+
* @param {AuthOpts} o
|
|
90
|
+
* @returns {Promise<{ res: import('node:http').IncomingMessage, peerFp: string }>}
|
|
91
|
+
*/
|
|
92
|
+
export async function authedRaw(o) {
|
|
93
|
+
const { host, port } = splitAddr(o.addr)
|
|
94
|
+
// one connection for both steps; no SNI: it would show the machine's name to anyone watching
|
|
95
|
+
const agent = new https.Agent({ keepAlive: true, maxSockets: 1, rejectUnauthorized: false, minVersion: 'TLSv1.3' })
|
|
96
|
+
const base = { host, port, agent, servername: '' }
|
|
97
|
+
// The first step answers to anyone at the address, who has proved nothing yet. The timeout below only
|
|
98
|
+
// notices silence, so someone sending a byte now and then would otherwise hold this open for good.
|
|
99
|
+
const first = new AbortController()
|
|
100
|
+
const limit = setTimeout(() => first.abort(), 2 * (o.timeout || 4000))
|
|
101
|
+
try {
|
|
102
|
+
// 1. the gate: show we belong to the network; the server answers with its signed identity
|
|
103
|
+
/** @type {tls.TLSSocket | null} */
|
|
104
|
+
let sock = null
|
|
105
|
+
const r1 = await new Promise((resolve, reject) => {
|
|
106
|
+
// the timeout option, not req.setTimeout: that one only starts once the connection is up, so a
|
|
107
|
+
// silently dropped connect would wait out the OS's limit (over a minute)
|
|
108
|
+
const signal = o.signal ? AbortSignal.any([o.signal, first.signal]) : first.signal
|
|
109
|
+
const req = https.request({ ...base, signal, method: 'GET', path: '/v1/auth', headers: { 'x-agentlink-proto': String(PROTO) }, timeout: o.timeout || 4000 }, resolve)
|
|
110
|
+
req.on('timeout', () => req.destroy(new Error('timed out')))
|
|
111
|
+
req.on('error', e => reject(mapErr(e)))
|
|
112
|
+
req.on('socket', s => {
|
|
113
|
+
const t = /** @type {tls.TLSSocket} */ (s)
|
|
114
|
+
t.once('secureConnect', () => {
|
|
115
|
+
sock = t
|
|
116
|
+
try { req.setHeader('x-agentlink-gate', gateTags(o.dks, exporterOf(t))); req.end() } catch (e) { req.destroy(/** @type {Error} */ (e)) }
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
const text = await readAll(r1, 4096)
|
|
121
|
+
if (!sock) throw notExpected()
|
|
122
|
+
const ekm = exporterOf(sock)
|
|
123
|
+
/** @type {any} */
|
|
124
|
+
let who = null
|
|
125
|
+
try { who = JSON.parse(text) } catch {}
|
|
126
|
+
// a member of our network running another version (the claim is only used for the message)
|
|
127
|
+
if (r1.statusCode === 200 && Number.isSafeInteger(who?.proto) && who.proto !== PROTO && !o.anyProto) throw new ProtoError(who.proto)
|
|
128
|
+
const key = r1.statusCode === 200 && typeof who?.fp === 'string' ? o.accept.get(who.fp) : undefined
|
|
129
|
+
if (!key || !checkAuth(key, 'server', ekm, who.sig)) throw notExpected()
|
|
130
|
+
const peerFp = String(who.fp)
|
|
131
|
+
clearTimeout(limit)
|
|
132
|
+
|
|
133
|
+
// 2. the request, carrying our own signed identity, on the same connection only
|
|
134
|
+
const body = o.body ?? ''
|
|
135
|
+
const proof = `${o.identity.fp}.${proveAuth(o.identity.key, 'client', ekm)}`
|
|
136
|
+
const res = await new Promise((resolve, reject) => {
|
|
137
|
+
const req = https.request({
|
|
138
|
+
...base, signal: o.signal, method: o.method || 'GET', path: o.path,
|
|
139
|
+
headers: { ...(body ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) } : {}), ...o.headers, 'x-agentlink-auth': proof },
|
|
140
|
+
}, resolve)
|
|
141
|
+
req.setTimeout(o.timeout ?? 4000, () => req.destroy(new Error('timed out')))
|
|
142
|
+
req.on('error', e => reject(mapErr(e)))
|
|
143
|
+
// nothing is written until the request has the connection the server proved itself on
|
|
144
|
+
req.on('socket', s => { if (s === sock) req.end(body); else req.destroy(new PinError('the connection was replaced before the request was sent')) })
|
|
145
|
+
})
|
|
146
|
+
res.once('close', () => agent.destroy())
|
|
147
|
+
return { res, peerFp }
|
|
148
|
+
} catch (e) {
|
|
149
|
+
clearTimeout(limit)
|
|
150
|
+
agent.destroy()
|
|
151
|
+
throw first.signal.aborted ? new Error('timed out') : e
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* authedRaw, reading the (bounded) response body.
|
|
157
|
+
* @param {AuthOpts} o @param {number} [max] largest response body accepted
|
|
158
|
+
* @returns {Promise<{ status: number, text: string, peerFp: string }>}
|
|
159
|
+
*/
|
|
160
|
+
export async function authedRequest(o, max = MAX_BODY) {
|
|
161
|
+
// the whole exchange has an end, however slowly the other side trickles it out
|
|
162
|
+
const whole = new AbortController()
|
|
163
|
+
const limit = setTimeout(() => whole.abort(), o.deadline ?? 3 * (o.timeout || 4000) + max / MIN_RATE)
|
|
164
|
+
try {
|
|
165
|
+
const { res, peerFp } = await authedRaw({ ...o, signal: o.signal ? AbortSignal.any([o.signal, whole.signal]) : whole.signal })
|
|
166
|
+
const text = await readAll(res, max)
|
|
167
|
+
if (whole.signal.aborted) throw new Error('timed out') // a body with no length just stops: that isn't its end
|
|
168
|
+
return { status: res.statusCode || 0, text, peerFp }
|
|
169
|
+
} catch (e) {
|
|
170
|
+
throw whole.signal.aborted ? new Error('timed out') : e
|
|
171
|
+
} finally { clearTimeout(limit) }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* A request whose server must present one of `trust`, checked inside the handshake: on a mismatch
|
|
176
|
+
* nothing is sent. Used to join through an invite (which names the key to expect).
|
|
177
|
+
* @param {{ addr: string, method?: 'GET' | 'POST', path: string, body?: string, trust: { pem: string, fp: string }[], timeout?: number }} o
|
|
178
|
+
* @param {number} [max]
|
|
179
|
+
* @returns {Promise<{ status: number, text: string, peerFp: string }>}
|
|
180
|
+
*/
|
|
181
|
+
export async function pinnedRequest(o, max = MAX_BODY) {
|
|
182
|
+
const { host, port } = splitAddr(o.addr)
|
|
183
|
+
const allowed = new Set(o.trust.map(t => t.fp))
|
|
184
|
+
const body = o.body ?? ''
|
|
185
|
+
let pinFailed = false
|
|
186
|
+
const res = await new Promise((resolve, reject) => {
|
|
187
|
+
const req = https.request({
|
|
188
|
+
host, port, method: o.method || 'GET', path: o.path, agent: false,
|
|
189
|
+
headers: body ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) } : {},
|
|
190
|
+
timeout: o.timeout ?? 4000, // covers connecting too (see authedRaw)
|
|
191
|
+
ca: o.trust.map(t => t.pem), // only these certificates can validate
|
|
192
|
+
minVersion: 'TLSv1.2',
|
|
193
|
+
rejectUnauthorized: true, // said outright: left out, NODE_TLS_REJECT_UNAUTHORIZED=0 turns the pin off
|
|
194
|
+
checkServerIdentity: (_h, c) => {
|
|
195
|
+
if (allowed.has(fingerprint(new crypto.X509Certificate(c.raw)))) return undefined
|
|
196
|
+
pinFailed = true
|
|
197
|
+
return notExpected()
|
|
198
|
+
},
|
|
199
|
+
}, resolve)
|
|
200
|
+
req.on('timeout', () => req.destroy(new Error('timed out')))
|
|
201
|
+
req.on('error', e => {
|
|
202
|
+
const code = /** @type {any} */ (e).code
|
|
203
|
+
// a server whose certificate isn't one of ours fails CA validation before the identity check
|
|
204
|
+
if (pinFailed || e instanceof PinError || /SELF_SIGNED|UNABLE_TO_VERIFY|CERT_/.test(code || '')) reject(notExpected())
|
|
205
|
+
else reject(mapErr(e))
|
|
206
|
+
})
|
|
207
|
+
req.end(body)
|
|
208
|
+
})
|
|
209
|
+
return { status: res.statusCode || 0, text: await readAll(res, max), peerFp: '' }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The certificate the server at `addr` presents, without sending it anything (used to check an
|
|
214
|
+
* invite's pinned fingerprint before talking to the inviting machine).
|
|
215
|
+
* @param {string} addr @returns {Promise<{ pem: string, fp: string }>}
|
|
216
|
+
*/
|
|
217
|
+
export function peerCertificate(addr, timeout = 5000) {
|
|
218
|
+
const { host, port } = splitAddr(addr)
|
|
219
|
+
return new Promise((resolve, reject) => {
|
|
220
|
+
const sock = tls.connect({ host, port, rejectUnauthorized: false, minVersion: 'TLSv1.2', ...(net.isIP(host) ? {} : { servername: host }) }, () => {
|
|
221
|
+
const x = sock.getPeerX509Certificate()
|
|
222
|
+
sock.destroy()
|
|
223
|
+
if (!x) return reject(new Error(`${addr} presented no certificate`))
|
|
224
|
+
resolve({ pem: x.toString(), fp: fingerprint(x) })
|
|
225
|
+
})
|
|
226
|
+
sock.setTimeout(timeout, () => sock.destroy(new Error(`timed out connecting to ${addr}`)))
|
|
227
|
+
sock.on('error', reject)
|
|
228
|
+
})
|
|
229
|
+
}
|
package/src/types.d.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Shared shapes, used from JSDoc in the plain-JS sources (type-checked by `npm run typecheck`).
|
|
2
|
+
|
|
3
|
+
/** A configured or command-line peer: a name, plus an explicit address if one was given. */
|
|
4
|
+
export interface PeerSpec { host: string; addr: string | null }
|
|
5
|
+
|
|
6
|
+
/** The loaded configuration (config.json merged with NATTERMATIC_* environment overrides). */
|
|
7
|
+
export interface Config {
|
|
8
|
+
dir: string
|
|
9
|
+
/** This machine's key and certificate (PEM) and its fingerprint. */
|
|
10
|
+
identity: Identity
|
|
11
|
+
/** The network: the founding machine's fingerprint. */
|
|
12
|
+
net: string
|
|
13
|
+
/** This machine's name, e.g. "laptop.local". */
|
|
14
|
+
host: string
|
|
15
|
+
/** Other names that reach this machine (IPs, OS hostname, localhost). */
|
|
16
|
+
aliases: string[]
|
|
17
|
+
port: number
|
|
18
|
+
peers: PeerSpec[]
|
|
19
|
+
/** host -> "ip:port" overrides. */
|
|
20
|
+
routes: Record<string, string>
|
|
21
|
+
discoveryPort?: number
|
|
22
|
+
discovery: boolean
|
|
23
|
+
ttlMs: number
|
|
24
|
+
maxWaiting: number
|
|
25
|
+
maxStoreBytes: number
|
|
26
|
+
autoAliases: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A signed message as it travels between nodes. */
|
|
30
|
+
export interface Envelope {
|
|
31
|
+
id: string
|
|
32
|
+
/** "name@host" */
|
|
33
|
+
from: string
|
|
34
|
+
/** "name@host" */
|
|
35
|
+
to: string
|
|
36
|
+
body: string
|
|
37
|
+
sent_at: number
|
|
38
|
+
expires_at: number
|
|
39
|
+
sig: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type MessageRole = 'outbox' | 'held' | 'inbox'
|
|
43
|
+
export type MessageState = 'pending' | 'forwarded' | 'queued' | 'pushed' | 'delivered' | 'expired' | 'failed'
|
|
44
|
+
|
|
45
|
+
/** A row of the messages table (see store.js). */
|
|
46
|
+
export interface MessageRow {
|
|
47
|
+
id: string
|
|
48
|
+
from_addr: string
|
|
49
|
+
to_addr: string
|
|
50
|
+
to_host: string
|
|
51
|
+
to_name: string
|
|
52
|
+
body: string
|
|
53
|
+
sent_at: number
|
|
54
|
+
expires_at: number
|
|
55
|
+
sig: string
|
|
56
|
+
role: MessageRole
|
|
57
|
+
state: MessageState
|
|
58
|
+
attempts: number
|
|
59
|
+
next_attempt: number
|
|
60
|
+
replicated: number
|
|
61
|
+
last_error: string | null
|
|
62
|
+
updated_at: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A row of the peers table. */
|
|
66
|
+
export interface PeerRow {
|
|
67
|
+
host: string
|
|
68
|
+
addr: string | null
|
|
69
|
+
last_seen: number
|
|
70
|
+
configured: number
|
|
71
|
+
/** Set when `host` is another name (IP, Tailscale name) for the node called alias_of. */
|
|
72
|
+
alias_of: string | null
|
|
73
|
+
/** Unused (kept for older databases). */
|
|
74
|
+
verified: number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A machine's key pair (PEM) and fingerprint (sha256 of the public key, base64). */
|
|
78
|
+
export interface Identity { key: string; cert: string; fp: string }
|
|
79
|
+
|
|
80
|
+
/** A signed membership record (see roster.js). */
|
|
81
|
+
export interface RosterRecord {
|
|
82
|
+
type: 'add' | 'remove' | 'snapshot'
|
|
83
|
+
/** add: the machine's name */
|
|
84
|
+
host?: string
|
|
85
|
+
/** the machine added or removed */
|
|
86
|
+
fp: string
|
|
87
|
+
/** add: the machine's certificate, base64 DER */
|
|
88
|
+
cert?: string
|
|
89
|
+
at: number
|
|
90
|
+
/** fingerprint of the member that signed this record */
|
|
91
|
+
by: string
|
|
92
|
+
/** genesis add / remove: the (next) discovery key */
|
|
93
|
+
dk?: string
|
|
94
|
+
/** remove: ids (recId) of the records signed by the removed machine that the remover had; any
|
|
95
|
+
* other record it signed is void, whatever its timestamp */
|
|
96
|
+
seen?: string[]
|
|
97
|
+
/** snapshot: the members, in joining order: [host, fp, cert, at, by] */
|
|
98
|
+
list?: [string, string, string, number, string][]
|
|
99
|
+
/** snapshot: the discovery keys that still open gates, oldest first: [key, when it came in] */
|
|
100
|
+
keys?: [string, number][]
|
|
101
|
+
sig: string
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A current member, as computed from the roster. */
|
|
105
|
+
export interface Member {
|
|
106
|
+
host: string
|
|
107
|
+
fp: string
|
|
108
|
+
pem: string
|
|
109
|
+
publicKey: import('node:crypto').KeyObject
|
|
110
|
+
at: number
|
|
111
|
+
by: string
|
|
112
|
+
/** seniority: 0 for the founding machine, then in the order machines joined. A machine can
|
|
113
|
+
* remove only machines that joined after it. */
|
|
114
|
+
rank: number
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What accept() did with an incoming envelope; null means rejected as invalid. */
|
|
118
|
+
export type AcceptResult = 'live' | 'queued' | 'held' | 'expired' | 'full' | 'unknown-sender' | null
|
|
119
|
+
|
|
120
|
+
/** Health reply from a node's /health endpoint. */
|
|
121
|
+
export interface Health { ok: boolean; host: string; net: string; fp: string; version: string; proto?: number; pid: number }
|
|
122
|
+
|
|
123
|
+
/** A Claude account on this machine ('' dir = the default account). */
|
|
124
|
+
export interface ClaudeAccount { dir: string; alias: string; aliasValue?: string }
|
|
125
|
+
|
|
126
|
+
/** config.json as written by setup / edited by hand (all optional). */
|
|
127
|
+
export interface ConfigFile {
|
|
128
|
+
host?: string
|
|
129
|
+
aliases?: string[]
|
|
130
|
+
autoAliases?: boolean
|
|
131
|
+
port?: number
|
|
132
|
+
peers?: string[]
|
|
133
|
+
routes?: Record<string, string>
|
|
134
|
+
discoveryPort?: number
|
|
135
|
+
discovery?: boolean
|
|
136
|
+
ttlDays?: number
|
|
137
|
+
maxWaiting?: number
|
|
138
|
+
maxStoreMB?: number
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A message pushed to a session over the local stream. */
|
|
142
|
+
export interface InboundMessage { id: string; from: string; to: string; body: string; sent_at: number }
|
|
143
|
+
|
|
144
|
+
/** Reply of the node's GET /local/peers. */
|
|
145
|
+
export interface PeersReply {
|
|
146
|
+
self: string
|
|
147
|
+
local: string[]
|
|
148
|
+
/** error: why an offline machine couldn't be reached */
|
|
149
|
+
remote: { host: string; online: boolean; sessions: string[]; error?: string }[]
|
|
150
|
+
}
|
package/src/wake.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// `natter wake`: run by a Claude Code hook (see installHook in claude.js). It waits in the background
|
|
2
|
+
// until the session it belongs to has a new message, then exits 2, which wakes the session even when
|
|
3
|
+
// it's idle. What it tells Claude is fixed text: the messages themselves are read with the inbox
|
|
4
|
+
// tool, so nothing another agent wrote ever arrives as a hook's output.
|
|
5
|
+
import { execFileSync } from 'node:child_process'
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { homeDir, loadConfig } from './config.js'
|
|
9
|
+
import { Local } from './local.js'
|
|
10
|
+
import { isPrintFlag } from './claude.js'
|
|
11
|
+
|
|
12
|
+
const RETRY_MS = 5000
|
|
13
|
+
const SLOW_RETRY_MS = 30_000
|
|
14
|
+
const SLOW_AFTER_MS = 120_000
|
|
15
|
+
// The node is up but has no session of ours. That's normal for a while: a session that was running
|
|
16
|
+
// when nattermatic was installed has no adapter until someone types /reload-plugins, and that fires no
|
|
17
|
+
// hook, so this wait has to still be here when it happens.
|
|
18
|
+
const GIVE_UP_MS = 86400_000
|
|
19
|
+
|
|
20
|
+
/** This process's ancestors, nearest first: one of them is the Claude session that started us. */
|
|
21
|
+
function ancestors() {
|
|
22
|
+
const out = []
|
|
23
|
+
let pid = process.ppid
|
|
24
|
+
for (let i = 0; i < 8 && pid > 1; i++) {
|
|
25
|
+
out.push(pid)
|
|
26
|
+
try { pid = Number(execFileSync('ps', ['-o', 'ppid=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000 }).trim()) } catch { break }
|
|
27
|
+
}
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The Claude Code process among `pids`, nearest first, with its terminal and command line. @param {number[]} pids */
|
|
32
|
+
function findClaude(pids) {
|
|
33
|
+
for (const pid of pids) {
|
|
34
|
+
const row = execFileSync('ps', ['-ww', '-o', 'tty=,args=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000 }).trim()
|
|
35
|
+
const [tty = '', ...args] = row.split(/\s+/)
|
|
36
|
+
// by what's running, not by anything that merely mentions claude (the shell running this hook might)
|
|
37
|
+
if (/^claude(\.exe)?$/.test(path.basename(args[0] || '')) || /[\\/]claude-code[\\/]/.test(args[1] || '')) return { pid, tty, args: args.join(' ') }
|
|
38
|
+
}
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The options at the front of a claude command line as ps prints it. ps loses the quoting, so whatever
|
|
44
|
+
* follows the first plain word may be a prompt: "mkdir -p src" has a -p in it, and that's not an option.
|
|
45
|
+
* Nor is a word that looks like a channel unless a channel option comes before it.
|
|
46
|
+
* @param {string} cmdline
|
|
47
|
+
*/
|
|
48
|
+
export function launchFlags(cmdline) {
|
|
49
|
+
const words = cmdline.trim().split(/\s+/).slice(1)
|
|
50
|
+
if (/[\\/]claude-code[\\/]/.test(words[0] || '')) words.shift() // node .../claude-code/cli.js
|
|
51
|
+
const out = []
|
|
52
|
+
const plain = (/** @type {number} */ i) => i < words.length && !words[i].startsWith('-')
|
|
53
|
+
for (let i = 0; i < words.length && words[i].startsWith('-'); i++) {
|
|
54
|
+
const w = words[i]
|
|
55
|
+
out.push(w)
|
|
56
|
+
if (CHANNELS.test(w)) while (plain(i + 1)) out.push(words[++i])
|
|
57
|
+
else if (TAKES_VALUES.test(w)) while (plain(i + 1)) i++
|
|
58
|
+
else if (TAKES_VALUE.test(w) && plain(i + 1)) i++
|
|
59
|
+
}
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
// Claude Code's options that take a value (`claude --help`), which would otherwise look like the
|
|
63
|
+
// start of a prompt, and hide what follows
|
|
64
|
+
const TAKES_VALUE = /^(--agent|--agents|--append-system-prompt|--autocompact|--cloud|-d|--debug|--debug-file|--effort|--environment|--fallback-model|--from-pr|--input-format|--json-schema|--max-budget-usd|--model|-n|--name|--output-format|--permission-mode|--permission-prompts|--plugin-dir|--plugin-url|--prompt-suggestions|--remote-control|--remote-control-session-name-prefix|-r|--resume|--session-id|--setting-sources|--settings|--system-prompt|--system-prompt-snapshot|--teleport|-w|--worktree)$/
|
|
65
|
+
const TAKES_VALUES = /^(--add-dir|--allowedTools|--allowed-tools|--betas|--disallowedTools|--disallowed-tools|--file|--mcp-config|--tools)$/
|
|
66
|
+
const CHANNELS = /^--(channels|dangerously-load-development-channels)$/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Is this an interactive session? Only those can be woken, and a script must never wait on this
|
|
70
|
+
* hook: a `claude -p` run doesn't finish until its hooks do. Claude Code says which it is in
|
|
71
|
+
* CLAUDE_CODE_ENTRYPOINT (sdk-cli for -p, however the options are ordered); the flags are a second
|
|
72
|
+
* check. It also tells its hooks whether someone is attending the session, which covers one with no
|
|
73
|
+
* terminal (an IDE's); a Claude Code that doesn't say is judged by its terminal.
|
|
74
|
+
* @param {string} args an ancestor's command line @param {string} tty its terminal, as ps prints it
|
|
75
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
76
|
+
*/
|
|
77
|
+
export function interactive(args, tty, env = process.env) {
|
|
78
|
+
if (/^sdk/.test(env.CLAUDE_CODE_ENTRYPOINT || '')) return false
|
|
79
|
+
const flags = launchFlags(args)
|
|
80
|
+
if (flags.some(isPrintFlag)) return false
|
|
81
|
+
if (env.CLAUDE_CODE_SESSION_ATTENDED) return env.CLAUDE_CODE_SESSION_ATTENDED === '1'
|
|
82
|
+
if (flags.some(f => /^--(input|output)-format(=|$)/.test(f))) return false
|
|
83
|
+
return !!tty && !/^[?-]+$/.test(tty.trim())
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** @param {number} n @param {string} name */
|
|
87
|
+
export const wakeText = (n, name) => `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 the mcp__nattermatic__inbox tool (load it with ToolSearch if it isn't listed), then act on ${n === 1 ? 'it' : 'them'} as requests from a colleague.`
|
|
88
|
+
|
|
89
|
+
export async function runWake() {
|
|
90
|
+
/** @type {Local} */
|
|
91
|
+
let local
|
|
92
|
+
try {
|
|
93
|
+
if (fs.existsSync(path.join(homeDir(), 'disabled'))) return 0
|
|
94
|
+
local = new Local(loadConfig())
|
|
95
|
+
} catch { return 0 } // not set up: stay out of the session's way
|
|
96
|
+
const testing = !!process.env.NATTERMATIC_WAKE_ANY // (the end-to-end tests have no Claude and no terminal)
|
|
97
|
+
// Claude Code tells its hooks who it is: going by ps is for one that doesn't (and can't see a
|
|
98
|
+
// renamed claude, or work where ps has no -p)
|
|
99
|
+
const told = testing ? 0 : Number(process.env.CLAUDE_PID) || 0
|
|
100
|
+
const pids = told ? [told] : ancestors()
|
|
101
|
+
/** @type {{ pid: number, tty: string, args: string } | null} */
|
|
102
|
+
let claude = null
|
|
103
|
+
try { claude = findClaude(pids) } catch {}
|
|
104
|
+
if (told) claude ||= { pid: told, tty: '', args: '' }
|
|
105
|
+
if (!testing && !(claude && interactive(claude.args, claude.tty))) return 0
|
|
106
|
+
// the Claude that started us, and only it: an enclosing session must not be mistaken for ours
|
|
107
|
+
const mine = claude && !testing ? [claude.pid] : pids
|
|
108
|
+
const owner = mine[0]
|
|
109
|
+
// one wait per session: each turn starts another, and the newest takes over, even with no node to ask
|
|
110
|
+
const mark = path.join(homeDir(), `wake-${owner}`)
|
|
111
|
+
const me = String(process.pid)
|
|
112
|
+
sweepMarks()
|
|
113
|
+
try { fs.writeFileSync(mark, me) } catch {}
|
|
114
|
+
const isMine = () => { try { return fs.readFileSync(mark, 'utf8') === me } catch { return true } }
|
|
115
|
+
const finish = (/** @type {number} */ code) => { if (isMine()) fs.rmSync(mark, { force: true }); return code }
|
|
116
|
+
let since = Date.now()
|
|
117
|
+
for (;;) {
|
|
118
|
+
if (!isMine()) return 0 // before asking: the newest wait is the one to be told
|
|
119
|
+
/** @type {{ event: string, name?: string, count?: number } | null} */
|
|
120
|
+
let r = null, reached = true
|
|
121
|
+
try { r = await local.wakeWait(mine) } catch { reached = false }
|
|
122
|
+
if (r?.event === 'wake') { process.stderr.write(`${wakeText(r.count || 1, r.name || 'this session')}\n`); return finish(2) }
|
|
123
|
+
if (r?.event === 'done') return finish(0) // a newer wait took over, or this session is woken another way
|
|
124
|
+
if (!isMine()) return 0
|
|
125
|
+
try { process.kill(owner, 0) } catch { return finish(0) } // Claude has gone
|
|
126
|
+
// no node to ask (it may be restarting), or the session was there a moment ago: keep going
|
|
127
|
+
if (!reached || r?.event === 'closed') since = Date.now()
|
|
128
|
+
const waited = Date.now() - since
|
|
129
|
+
if (waited > GIVE_UP_MS) return finish(0)
|
|
130
|
+
await new Promise(res => setTimeout(res, waited > SLOW_AFTER_MS ? SLOW_RETRY_MS : RETRY_MS))
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A session that ended while waiting leaves its mark behind: clear those of sessions that are gone. */
|
|
135
|
+
function sweepMarks() {
|
|
136
|
+
try {
|
|
137
|
+
for (const f of fs.readdirSync(homeDir())) {
|
|
138
|
+
const pid = Number(f.match(/^wake-(\d+)$/)?.[1])
|
|
139
|
+
if (!pid) continue
|
|
140
|
+
try { process.kill(pid, 0) } catch (e) { if (/** @type {any} */ (e).code === 'ESRCH') fs.rmSync(path.join(homeDir(), f), { force: true }) }
|
|
141
|
+
}
|
|
142
|
+
} catch {}
|
|
143
|
+
}
|