From f92b1c8d6407d2933d5c2257da52011b88c7835b Mon Sep 17 00:00:00 2001 From: ThinkOff Date: Tue, 15 Sep 2026 18:10:29 +0300 Subject: [PATCH 1/2] fix: room approval cards link to a reachable address, not 127.0.0.1 When mcp.confirmations has no callback_base, the daemon announced every intent with "Tap to decide: http://127.0.0.1:8788/". That link is correct only for a loopback-bound daemon. A daemon bound to 0.0.0.0 exists to be reached from phones and tablets, and from those the link goes nowhere (seen today from the BOOX tablet on the Mini's cards). defaultCallbackBase() now keeps 127.0.0.1 for loopback listeners, uses the configured host when it is a concrete address, and for 0.0.0.0 picks the first routable IPv4 on the host at startup. An explicit callback_base still wins. The LAN address of the Mini has changed three times in a month, so deriving it beats hardcoding it in config. Co-Authored-By: Claude Fable 5.1 --- bin/iak-mcp-daemon.mjs | 6 +++--- src/confirmations.mjs | 23 +++++++++++++++++++++++ src/mcp-server.mjs | 4 ++-- test/confirmations.test.mjs | 18 ++++++++++++++++++ 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/bin/iak-mcp-daemon.mjs b/bin/iak-mcp-daemon.mjs index c9552b1..8e993c9 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, @@ -45,7 +45,7 @@ const room = cc.room; const serverAnnouncerMap = {}; 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: defaultCallbackBase(cc), // Per-agent author attribution: configure // `mcp.confirmations.api_keys` as { "@CodexMB": "xfb_...", ... } // and forwarding daemons that include `from_handle` in POST /intent @@ -130,7 +130,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..0713dcc 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,28 @@ 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 127.0.0.1, but a daemon bound to 0.0.0.0 (or a LAN +// address) 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 pick the +// first routable IPv4 on this host instead; the machine's LAN address +// changes with DHCP, so hardcoding it in config rots. +export function defaultCallbackBase(cc = {}, ifaces = networkInterfaces()) { + 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 === '127.0.0.1' || host === 'localhost' || host === '::1') return `http://127.0.0.1:${port}`; + if (host !== '0.0.0.0' && host !== '::') return `http://${host}:${port}`; + for (const addrs of Object.values(ifaces || {})) { + for (const a of addrs || []) { + const fam = a.family === 4 || a.family === 'IPv4'; + if (fam && !a.internal && !String(a.address).startsWith('169.254.')) return `http://${a.address}:${port}`; + } + } + return `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..5849541 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,20 @@ test('GET /intents?status=pending returns only open intents, and rejects unknown server.close(); } }); + +test('defaultCallbackBase: explicit callback_base wins, loopback stays loopback, 0.0.0.0 picks a routable 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: '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'); +}); From 4782d5bbf9117b2eeb7f4acf7ca700572b2aa6ec Mon Sep 17 00:00:00 2001 From: ThinkOff Date: Tue, 15 Sep 2026 18:16:04 +0300 Subject: [PATCH 2/2] fix: pick the callback address by policy, not interface order; bracket IPv6 Review findings from @codexmb on #103: 1. The first non-internal IPv4 is not a LAN address on hosts where docker0, a VPN tunnel or a VM bridge enumerates before the physical interface. The pick is now a policy: an explicit callback_interface wins; otherwise interfaces whose name says virtual (docker, veth, br-, utun, tun/tap, wg, tailscale, vbox/vmnet, lo, awdl) are skipped and 192.168/16 is preferred over 10/8 over 172.16/12 over anything else. Only virtual candidates means no plausible LAN address, so loopback. The daemon logs the pick and the alternatives at startup. The comment now says best-effort, not routable. 2. A daemon bound to ::1 or a concrete IPv6 address advertised 127.0.0.1 or an unbracketed host. The bound address is kept and formatted as [addr]:port. The v6 wildcard still advertises a LAN IPv4, which is what phones dial. Tests: docker0-first, utun-first, public-vs-10/8, virtual-only fallback, callback_interface override, onPick reporting, ::1, fd00::123, ::. Co-Authored-By: Claude Fable 5.1 --- bin/iak-mcp-daemon.mjs | 6 ++++- src/confirmations.mjs | 50 ++++++++++++++++++++++++++++--------- test/confirmations.test.mjs | 36 +++++++++++++++++++++++++- 3 files changed, 78 insertions(+), 14 deletions(-) diff --git a/bin/iak-mcp-daemon.mjs b/bin/iak-mcp-daemon.mjs index 8e993c9..d9a074c 100755 --- a/bin/iak-mcp-daemon.mjs +++ b/bin/iak-mcp-daemon.mjs @@ -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: defaultCallbackBase(cc), + 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 diff --git a/src/confirmations.mjs b/src/confirmations.mjs index 0713dcc..4e23d4a 100644 --- a/src/confirmations.mjs +++ b/src/confirmations.mjs @@ -994,24 +994,50 @@ function renderIntentsHtml() { // 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 127.0.0.1, but a daemon bound to 0.0.0.0 (or a LAN -// address) 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 pick the -// first routable IPv4 on this host instead; the machine's LAN address -// changes with DHCP, so hardcoding it in config rots. -export function defaultCallbackBase(cc = {}, ifaces = networkInterfaces()) { +// 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 === '127.0.0.1' || host === 'localhost' || host === '::1') return `http://127.0.0.1:${port}`; - if (host !== '0.0.0.0' && host !== '::') return `http://${host}:${port}`; - for (const addrs of Object.values(ifaces || {})) { + 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 fam = a.family === 4 || a.family === 'IPv4'; - if (fam && !a.internal && !String(a.address).startsWith('169.254.')) return `http://${a.address}:${port}`; + 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) }); } } - return `http://127.0.0.1:${port}`; + 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 }) { diff --git a/test/confirmations.test.mjs b/test/confirmations.test.mjs index 5849541..21cc4bd 100644 --- a/test/confirmations.test.mjs +++ b/test/confirmations.test.mjs @@ -432,7 +432,7 @@ test('GET /intents?status=pending returns only open intents, and rejects unknown } }); -test('defaultCallbackBase: explicit callback_base wins, loopback stays loopback, 0.0.0.0 picks a routable LAN address', () => { +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 }], @@ -444,7 +444,41 @@ test('defaultCallbackBase: explicit callback_base wins, loopback stays loopback, 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'); +});