Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions bin/iak-mcp-daemon.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
49 changes: 49 additions & 0 deletions src/confirmations.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/mcp-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
52 changes: 52 additions & 0 deletions test/confirmations.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
startConfirmationsServer,
startChatReplyPoller,
composeAnnouncers,
defaultCallbackBase,
_resetForTests,
} from '../src/confirmations.mjs';

Expand Down Expand Up @@ -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');
});
Loading