diff --git a/bin/iak-mcp-daemon.mjs b/bin/iak-mcp-daemon.mjs index c9552b1..d9a074c 100755 --- a/bin/iak-mcp-daemon.mjs +++ b/bin/iak-mcp-daemon.mjs @@ -15,7 +15,7 @@ // Run: node bin/iak-mcp-daemon.mjs [--config path/to/config.json] import { loadConfig } from '../src/config.mjs'; -import { +import { defaultCallbackBase, startConfirmationsServer, startChatReplyPoller, configureActionStatusPush, @@ -43,9 +43,13 @@ const apiKey = config?.poller?.api_key; const room = cc.room; const serverAnnouncerMap = {}; +const callbackBase = defaultCallbackBase(cc, undefined, (pick, all) => { + if (!pick) console.log('[iak-mcp-daemon] callback_base: no LAN IPv4 found, cards will link to 127.0.0.1'); + else console.log(`[iak-mcp-daemon] callback_base: ${pick.address} on ${pick.name}` + (all.length > 1 ? ` (also ${all.slice(1).map((c) => `${c.address}@${c.name}`).join(', ')})` : '')); +}); if (cc.room && apiKey) { serverAnnouncerMap.groupmind = makeGroupmindAnnouncer({ - apiKey, room: cc.room, callbackBase: cc.callback_base || `http://127.0.0.1:${cc.port || 8788}`, + apiKey, room: cc.room, callbackBase, // Per-agent author attribution: configure // `mcp.confirmations.api_keys` as { "@CodexMB": "xfb_...", ... } // and forwarding daemons that include `from_handle` in POST /intent @@ -130,7 +134,7 @@ if (argv.includes('--demo')) { const announcerMap = {}; if (cc.room && apiKey) { announcerMap.groupmind = makeGroupmindAnnouncer({ - apiKey, room: cc.room, callbackBase: cc.callback_base || `http://127.0.0.1:${cc.port || 8788}`, + apiKey, room: cc.room, callbackBase: defaultCallbackBase(cc), }); } if (cc.codewatch_gate_url) { diff --git a/src/confirmations.mjs b/src/confirmations.mjs index 9ca2362..4e23d4a 100644 --- a/src/confirmations.mjs +++ b/src/confirmations.mjs @@ -18,6 +18,7 @@ // audit, every transition is appended to receipts. import { createServer } from 'node:http'; +import { networkInterfaces } from 'node:os'; import { randomUUID, createHmac, timingSafeEqual } from 'node:crypto'; import { appendFileSync } from 'node:fs'; import { spawn } from 'node:child_process'; @@ -991,6 +992,54 @@ function renderIntentsHtml() { // Post the intent prompt to a GroupMind room with quick-reply text the user // can copy / type, and a curl example for the watch-gate. Idempotent (same // id is harmless). +// The address other devices use to reach this daemon. An explicit +// `callback_base` always wins. Without one, a loopback-only listener can +// only be reached at its loopback address, and a listener bound to one +// concrete address is advertised at that address. A daemon bound to the +// wildcard (0.0.0.0 / ::) is meant to be reached from phones and tablets, +// and a 127.0.0.1 link in the room post goes nowhere from those, so we +// pick a LAN address on this host instead. Interface enumeration order is +// not a reachability order (docker0, VPN tunnels and VM bridges come +// first on many hosts), so the pick is a policy, not "the first one": +// 1. `callback_interface` in config, when set, and only that interface +// 2. skip interfaces whose name says virtual (docker, veth, br-, utun, +// tun/tap, wg, tailscale, vbox/vmnet, lo) +// 3. prefer 192.168/16, then 10/8, then 172.16/12, then anything else +// The result is best-effort: it is the most plausible LAN address, not a +// proven-reachable one. Callers get the alternatives back via `onPick` +// so the choice can be logged at startup. +const VIRTUAL_IFACE = /^(docker|veth|br-|virbr|utun|tun|tap|wg|tailscale|ts|vboxnet|vmnet|vmenet|bridge|lo|awdl|llw)\d*/i; +function lanRank(ip) { + if (ip.startsWith('192.168.')) return 0; + if (ip.startsWith('10.')) return 1; + if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return 2; + return 3; +} +function formatHost(addr) { + return addr.includes(':') ? `[${addr}]` : addr; +} +export function defaultCallbackBase(cc = {}, ifaces = networkInterfaces(), onPick = null) { + if (cc.callback_base) return String(cc.callback_base).replace(/\/$/, ''); + const port = cc.port || 8788; + const host = cc.host || '127.0.0.1'; + if (host === 'localhost') return `http://127.0.0.1:${port}`; + if (host !== '0.0.0.0' && host !== '::') return `http://${formatHost(host)}:${port}`; + const wanted = cc.callback_interface ? String(cc.callback_interface) : null; + const candidates = []; + for (const [name, addrs] of Object.entries(ifaces || {})) { + if (wanted ? name !== wanted : VIRTUAL_IFACE.test(name)) continue; + for (const a of addrs || []) { + const v4 = a.family === 4 || a.family === 'IPv4'; + if (!v4 || a.internal || String(a.address).startsWith('169.254.')) continue; + candidates.push({ name, address: a.address, rank: lanRank(a.address) }); + } + } + candidates.sort((x, y) => x.rank - y.rank); + const pick = candidates[0] || null; + if (onPick) onPick(pick, candidates); + return pick ? `http://${pick.address}:${port}` : `http://127.0.0.1:${port}`; +} + export function makeGroupmindAnnouncer({ apiKey, room, callbackBase, apiKeys }) { // apiKeys: optional map of agent handle (e.g. "@claudemm") → API key. // When the intent payload includes `fromHandle`, the announcer uses diff --git a/src/mcp-server.mjs b/src/mcp-server.mjs index a6d3805..d3e41fd 100644 --- a/src/mcp-server.mjs +++ b/src/mcp-server.mjs @@ -35,7 +35,7 @@ import { nudgeTmux } from './common/notify.mjs'; import { tmuxRun } from './ide/tmux-runner.mjs'; import { loadConfig } from './config.mjs'; import { assertRoomVoice } from './responder-lock.mjs'; -import { +import { defaultCallbackBase, createIntent, decideIntent, waitForDecision, @@ -400,7 +400,7 @@ export async function runMcpServer({ configPath } = {}) { announcerMap.groupmind = makeGroupmindAnnouncer({ apiKey: config.poller.api_key, room: confirmCfg.room, - callbackBase: confirmCfg.callback_base || `http://127.0.0.1:${confirmCfg.port || 8788}`, + callbackBase: defaultCallbackBase(confirmCfg), }); } if (confirmCfg.codewatch_gate_url) { diff --git a/test/confirmations.test.mjs b/test/confirmations.test.mjs index fdee5eb..21cc4bd 100644 --- a/test/confirmations.test.mjs +++ b/test/confirmations.test.mjs @@ -11,6 +11,7 @@ import { startConfirmationsServer, startChatReplyPoller, composeAnnouncers, + defaultCallbackBase, _resetForTests, } from '../src/confirmations.mjs'; @@ -430,3 +431,54 @@ test('GET /intents?status=pending returns only open intents, and rejects unknown server.close(); } }); + +test('defaultCallbackBase: explicit callback_base wins, loopback stays loopback, wildcard picks a LAN address', () => { + const ifaces = { + lo0: [{ address: '127.0.0.1', family: 'IPv4', internal: true }], + en5: [{ address: '169.254.10.7', family: 'IPv4', internal: false }], + en0: [ + { address: 'fe80::1', family: 'IPv6', internal: false }, + { address: '192.168.50.241', family: 'IPv4', internal: false }, + ], + }; + assert.equal(defaultCallbackBase({ callback_base: 'http://gate.example:9000/' }, ifaces), 'http://gate.example:9000'); + assert.equal(defaultCallbackBase({}, ifaces), 'http://127.0.0.1:8788'); + assert.equal(defaultCallbackBase({ host: '127.0.0.1', port: 9001 }, ifaces), 'http://127.0.0.1:9001'); + assert.equal(defaultCallbackBase({ host: 'localhost' }, ifaces), 'http://127.0.0.1:8788'); + assert.equal(defaultCallbackBase({ host: '0.0.0.0' }, ifaces), 'http://192.168.50.241:8788'); + assert.equal(defaultCallbackBase({ host: '0.0.0.0', port: 8790 }, { lo0: ifaces.lo0 }), 'http://127.0.0.1:8790'); + assert.equal(defaultCallbackBase({ host: '192.168.50.5' }, ifaces), 'http://192.168.50.5:8788'); +}); + +test('defaultCallbackBase: virtual interfaces and enumeration order do not win over the LAN', () => { + const ifaces = { + docker0: [{ address: '172.17.0.1', family: 'IPv4', internal: false }], + utun3: [{ address: '10.8.0.2', family: 'IPv4', internal: false }], + tailscale0: [{ address: '100.97.140.13', family: 'IPv4', internal: false }], + en0: [{ address: '192.168.50.241', family: 'IPv4', internal: false }], + }; + assert.equal(defaultCallbackBase({ host: '0.0.0.0' }, ifaces), 'http://192.168.50.241:8788'); + // a real LAN on 10/8 still beats a physical interface on a public range + const tenNet = { + eth1: [{ address: '203.0.113.5', family: 'IPv4', internal: false }], + eth0: [{ address: '10.1.2.3', family: 'IPv4', internal: false }], + }; + assert.equal(defaultCallbackBase({ host: '0.0.0.0' }, tenNet), 'http://10.1.2.3:8788'); + // only virtual interfaces present: nothing plausible, fall back to loopback + assert.equal(defaultCallbackBase({ host: '0.0.0.0' }, { docker0: ifaces.docker0, utun3: ifaces.utun3 }), 'http://127.0.0.1:8788'); + // explicit interface selection wins over the policy, even for a "virtual" name + assert.equal(defaultCallbackBase({ host: '0.0.0.0', callback_interface: 'tailscale0' }, ifaces), 'http://100.97.140.13:8788'); + // the pick and its alternatives are reported for logging + let seen; + defaultCallbackBase({ host: '0.0.0.0' }, ifaces, (pick, all) => { seen = { pick, all }; }); + assert.equal(seen.pick.name, 'en0'); + assert.deepEqual(seen.all.map((c) => c.name), ['en0']); +}); + +test('defaultCallbackBase: a bound IPv6 host is preserved and bracketed', () => { + const ifaces = { en0: [{ address: '192.168.50.241', family: 'IPv4', internal: false }] }; + assert.equal(defaultCallbackBase({ host: '::1' }, ifaces), 'http://[::1]:8788'); + assert.equal(defaultCallbackBase({ host: 'fd00::123', port: 8790 }, ifaces), 'http://[fd00::123]:8790'); + // the v6 wildcard still advertises a LAN IPv4, which is what phones dial + assert.equal(defaultCallbackBase({ host: '::' }, ifaces), 'http://192.168.50.241:8788'); +});