UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

144 lines (143 loc) • 8.89 kB
import { execFile } from "node:child_process"; import { readFile } from "node:fs/promises"; import { homedir, hostname, userInfo } from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import { listMachines } from "../profile-store.js"; import { findPhrenPath } from "../phren-paths.js"; import { computerLabel } from "./hand-off.js"; import { localNames } from "./computer-names.js"; import { acceptComputer, computerName, enrollComputer, publicComputerKey } from "./computers.js"; import { hostKeyLine, hostKeyScript, localHostKeyLine, noHostKey } from "./host-key.js"; import { addHookPeer, optionalHookPeers, peerRequest } from "./peers.js"; import { BridgeError, bridgeRoot, object } from "./protocol.js"; /** * Computers the owner already reaches over plain ssh that run phren, and * linking one in a single explicit step. Discovery only reports; nothing is * linked until the owner runs `phren bridge link <host>`. */ const exec = promisify(execFile); // A non-login ssh shell often lacks Homebrew's node (macOS), Entware's /opt/bin (NAS) or ~/.local/bin. const REMOTE_PATH = 'PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:/opt/bin:$PATH"'; const GIT_HOSTS = /(^|\.)(github\.com|gitlab\.com|bitbucket\.org)$/i; /** Concrete Host aliases from an ssh config: no patterns, no Git hosting services. */ export function sshConfigHosts(text) { const hosts = []; let block = [], skip = false; const flush = () => { if (!skip) hosts.push(...block); block = []; skip = false; }; for (const raw of text.split(/\r?\n/)) { const match = /^\s*([A-Za-z]+)(?:\s*=\s*|\s+)(.+?)\s*$/.exec(raw.replace(/\s#.*$|^\s*#.*$/, "")); if (!match) continue; const [key, value] = [match[1].toLowerCase(), match[2]]; if (key === "host" || key === "match") { flush(); if (key === "host") block = value.split(/\s+/).filter(alias => !/[*?!]/.test(alias)); } else if (key === "hostname" && GIT_HOSTS.test(value)) skip = true; } flush(); return [...new Set(hosts.filter(alias => !GIT_HOSTS.test(alias)))]; } async function ssh(host, command, input, timeout = 20_000) { return new Promise((resolve, reject) => { const child = execFile("ssh", ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "--", host, command], { timeout, maxBuffer: 1_048_576 }, (error, stdout, stderr) => { if (error) reject(new BridgeError(503, `ssh ${host}: ${(stderr || error.message).split("\n").find(Boolean)?.slice(0, 200) ?? "failed"}`)); else resolve(stdout); }); child.stdin?.end(input ?? ""); }); } /** What a host reports over the owner's own ssh login: its Hook's computer id * (present once Phren Hook is installed), login user and this computer's * address as that host sees it. */ async function probe(host) { const out = await ssh(host, `printf 'id=%s\\nuser=%s\\nclient=%s\\n' "$(cat ~/.local/share/phren/bridge/computer-id 2>/dev/null)" "$(id -un)" "\${SSH_CONNECTION%% *}"`, undefined, 12_000); const field = (name) => new RegExp(`^${name}=(.*)$`, "m").exec(out)?.[1]?.trim() || undefined; const computerId = field("id"); return computerId ? { host, computerId, user: field("user") ?? "", client: field("client") } : undefined; } /** Hosts from ~/.ssh/config and the store's machines.yaml that answer over ssh, * run Phren Hook and are not this computer or an already linked peer. */ export async function discoverComputers(options = {}) { const config = options.sshConfig ?? await readFile(path.join(homedir(), ".ssh", "config"), "utf8").catch(() => ""); const store = options.store !== undefined ? options.store : findPhrenPath(); const machines = store ? listMachines(store) : undefined; const { peers } = await optionalHookPeers(); const own = (await readFile(path.join(bridgeRoot(), "computer-id"), "utf8").catch(() => "")).trim(); const known = new Set([...localNames(), ...peers.flatMap(peer => [peer.name, peer.address])].map(computerLabel)); const candidates = [...new Set([...sshConfigHosts(config), ...(machines?.ok ? Object.keys(machines.data) : [])])] .filter(host => !known.has(computerLabel(host))).slice(0, 32); // A linked peer registered under another name is still linked: match it by its Hook's computer id. const [found, peerIds] = await Promise.all([ Promise.all(candidates.map(host => probe(host).catch(() => undefined))), Promise.all(peers.map(peer => peerRequest(peer, "/v1/health").then(health => object(health.computer).id, () => undefined))), ]); const seen = new Set([own, ...peerIds.filter((id) => typeof id === "string")]); const reachable = found.filter((item) => { if (!item || seen.has(item.computerId)) return false; seen.add(item.computerId); return true; }); return { reachable, checked: candidates }; } function slugName(value) { const label = computerLabel(value).replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^[^A-Za-z0-9_]+/, "") || "computer"; return computerName.parse(label.slice(0, 100)); } /** * Links this computer and `host` in both directions over the owner's existing * ssh login: each side's dispatch key is enrolled on the other, and each * side's ed25519 host key is read over that login (never keyscanned) and * pinned in the other's hooks.yaml. Both sides then check the link. */ export async function linkComputer(host, options = {}) { const remote = await probe(host); if (!remote) throw new BridgeError(409, `${host} answers over ssh but Phren Hook is not installed there. Run phren bridge install on it first.`); const own = (await readFile(path.join(bridgeRoot(), "computer-id"), "utf8").catch(() => "")).trim(); if (remote.computerId === own) throw new BridgeError(409, `${host} is this computer.`); const name = computerName.parse(options.name ?? slugName(host)); const as = computerName.parse(options.as ?? slugName(hostname())); const backAddress = options.backAddress ?? remote.client; if (!backAddress) throw new BridgeError(409, `${host} did not report this computer's address; pass --back-address.`); // The address the Hook dials skips ~/.ssh/config, so resolve the alias the way ssh does. const resolved = Object.fromEntries((await exec("ssh", ["-G", "--", host], { timeout: 5_000 })).stdout.split("\n") .map(line => line.split(" ")).filter(parts => parts.length >= 2).map(([key, ...rest]) => [key, rest.join(" ")])); const remoteKeyLine = hostKeyLine(await ssh(host, hostKeyScript())); if (!remoteKeyLine) throw noHostKey(host); const localLine = await localHostKeyLine(); if (!localLine) throw noHostKey("This computer"); const remoteHostKey = publicComputerKey(remoteKeyLine.split(/\s+/).slice(0, 2).join(" ")); const localHostKey = publicComputerKey(localLine.split(/\s+/).slice(0, 2).join(" ")); const remoteLine = (await ssh(host, `${REMOTE_PATH} phren bridge enroll-computer ${name}`)).trim(); await ssh(host, `f=$(mktemp) && cat > "$f" && ${REMOTE_PATH} phren bridge enroll-computer ${as} --accept "$f"; s=$?; rm -f "$f"; exit $s`, await enrollComputer(as)); await acceptComputer(name, remoteLine); const peer = { name, address: String(resolved.hostname || host), username: remote.user || String(resolved.user), port: Number(resolved.port) || 22, server: "default", hostKey: remoteHostKey }; const local = await addHookPeer(peer); const back = { name: as, address: backAddress, username: userInfo().username, port: 22, server: "default", hostKey: localHostKey }; const answer = JSON.parse(await ssh(host, `${REMOTE_PATH} phren bridge add-peer`, JSON.stringify(back), 45_000).catch((error) => { if (/Usage: phren bridge/.test(error.message)) throw new BridgeError(409, `phren on ${host} is too old to finish linking. Update it there and run this again; the keys already exchanged are reused.`); throw error; })); const reached = await peerRequest(peer, "/v1/health").then(() => undefined, (error) => error.message); return { name, as, local: { added: local.added }, remote: { added: !!answer.added, reachable: !!answer.reachable, ...(answer.error ? { error: String(answer.error) } : {}) }, reachable: !reached, ...(reached ? { error: reached } : {}) }; } /** The receiving half of a link: pin the caller in hooks.yaml, then dial it back. */ export async function addPeerFromLink(input) { const { added, peer } = await addHookPeer(JSON.parse(input)); const error = await peerRequest(peer, "/v1/health").then(() => undefined, (failure) => failure.message); return { added, reachable: !error, ...(error ? { error } : {}) }; }