@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
318 lines (317 loc) • 16 kB
JavaScript
import { execFile } from "node:child_process";
import { createHash, createHmac, randomInt, timingSafeEqual } from "node:crypto";
import { lstat, readFile } from "node:fs/promises";
import { createServer } from "node:http";
import { connect } from "node:net";
import { homedir, hostname, networkInterfaces, userInfo } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { z } from "zod";
import { appendAuthorizedKey, publicComputerKey } from "./computers.js";
import { localHostKeyLine } from "./host-key.js";
import { forcedCommand, install, scopedForcedCommand, scopedGatewayPath } from "./install.js";
import { isGatewayScope, SCOPE_KEY_COMMENT } from "./scoped-gateway.js";
import { BridgeError, bridgeRoot } from "./protocol.js";
// `phren pair` connects a phone in one scan. The QR code carries where to
// reach this computer, its SSH host key fingerprint and a one-time code. The
// phone makes its own key and posts the public half here with an HMAC keyed
// by the code, so the code itself never crosses the network; this computer
// answers with an HMAC over its fingerprint, which lets a phone that typed
// the code by hand trust the host key too. One success, five bad proofs or
// the time limit closes the listener.
const exec = promisify(execFile);
export const PAIR_PORT = 47291;
const ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
const MAX_FAILURES = 5;
export function newPairingCode() {
return Array.from({ length: 6 }, () => ALPHABET[randomInt(ALPHABET.length)]).join("");
}
/** Codes are typed as ABC-234 or abc234; both mean the same code. */
export function normalizeCode(code) {
return code.toUpperCase().replace(/[^A-Z0-9]/g, "");
}
export function pairingProof(code, label, ...parts) {
return createHmac("sha256", `phren-pair-v1:${normalizeCode(code)}`).update([label, ...parts].join("\n")).digest("base64url");
}
function sameProof(expected, received) {
const a = Buffer.from(expected), b = Buffer.from(received);
return a.length === b.length && timingSafeEqual(a, b);
}
/** OpenSSH's SHA256 fingerprint of a public key line, as the phone computes it. */
export function keyFingerprint(publicKeyLine) {
const blob = publicKeyLine.trim().split(/\s+/)[1];
if (!blob)
return undefined;
return "SHA256:" + createHash("sha256").update(Buffer.from(blob, "base64")).digest("base64").replace(/=+$/, "");
}
export async function hostFingerprint(file) {
return keyFingerprint((file ? await readFile(file, "utf8").catch(() => "") : await localHostKeyLine()) ?? "");
}
/** Addresses the phone can try, best first: Tailscale name and address, then LAN IPv4. */
export async function pairingHosts() {
const hosts = [];
for (const binary of ["tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"]) {
try {
const { stdout } = await exec(binary, ["status", "--json", "--peers=false"], { timeout: 3000, maxBuffer: 4 * 1024 * 1024 });
const self = JSON.parse(stdout).Self;
if (self?.DNSName)
hosts.push(self.DNSName.replace(/\.$/, ""));
const ipv4 = self?.TailscaleIPs?.find(ip => /^\d+\.\d+\.\d+\.\d+$/.test(ip));
if (ipv4)
hosts.push(ipv4);
break;
}
catch { /* not installed here */ }
}
for (const entries of Object.values(networkInterfaces())) {
for (const entry of entries ?? []) {
if (entry.family === "IPv4" && !entry.internal && !hosts.includes(entry.address))
hosts.push(entry.address);
}
}
return hosts.slice(0, 5);
}
/** The name people call this computer: macOS's Computer Name, else the host name. */
export async function computerDisplayName() {
if (process.platform === "darwin") {
const name = await exec("scutil", ["--get", "ComputerName"], { timeout: 2000 }).then(({ stdout }) => stdout.trim()).catch(() => "");
if (name)
return name.slice(0, 64);
}
return hostname().replace(/\.local$/i, "").split(".")[0] || "Computer";
}
/** The QR payload; the phone opens phren://pair links from the camera too. */
export function pairingURL(offer) {
const query = new URLSearchParams({ v: "1", h: offer.hosts.join(","), p: String(offer.port), u: offer.user,
s: String(offer.sshPort), c: offer.code, n: offer.name });
if (offer.fingerprint)
query.set("fp", offer.fingerprint);
return `phren://pair?${query.toString()}`;
}
const request = z.object({
v: z.literal(1), publicKey: z.string().max(512), proof: z.string().max(128),
device: z.enum(["ios", "android"]).default("ios"), name: z.string().max(80).optional(),
});
/** Listen for one phone. Resolves once its key is authorized; rejects on timeout or too many bad proofs. */
export async function startPairing(options) {
let failures = 0, finished = false, timer;
let resolve, reject;
const done = new Promise((yes, no) => { resolve = yes; reject = no; });
const server = createServer((incoming, response) => { void handle(incoming, response); });
const finish = (error, paired) => {
if (finished)
return;
finished = true;
clearTimeout(timer);
server.close();
if (error)
reject(error);
else
resolve(paired);
};
const reply = (response, status, body) => {
response.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
response.end(JSON.stringify(body));
};
async function handle(incoming, response) {
try {
if (finished)
throw new BridgeError(410, "Pairing is closed. Run phren pair again.");
if (incoming.method !== "POST" || incoming.url !== "/v1/pair")
throw new BridgeError(404, "Not found.");
let size = 0;
const chunks = [];
for await (const chunk of incoming) {
size += chunk.length;
if (size > 4096)
throw new BridgeError(413, "Request is too large.");
chunks.push(chunk);
}
const input = request.parse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
const publicKey = publicComputerKey(input.publicKey);
if (!sameProof(pairingProof(options.code, "phone", publicKey), input.proof)) {
failures++;
if (failures >= MAX_FAILURES)
setImmediate(() => finish(new Error("Too many wrong pairing codes; pairing closed. Run phren pair again.")));
throw new BridgeError(403, "That pairing code doesn't match this computer.");
}
const comment = input.device === "android" ? "phren-android" : "phren-iphone";
const encoded = publicKey.split(" ")[1];
await appendAuthorizedKey(`restrict,pty,${forcedCommand} ${publicKey} ${comment}`, existing => existing.split(/\s+/).includes(encoded), "This phone key is already authorized with different options.", options.sshDirectory);
reply(response, 200, {
v: 1, name: options.name, user: options.user, sshPort: options.sshPort, fingerprint: options.fingerprint ?? null,
proof: pairingProof(options.code, "computer", options.fingerprint ?? "", publicKey),
});
setImmediate(() => finish(undefined, { device: input.device, name: input.name, publicKey }));
}
catch (error) {
const status = error instanceof BridgeError ? error.status : error instanceof z.ZodError || error instanceof SyntaxError ? 400 : 500;
reply(response, status, { error: error instanceof z.ZodError ? "Invalid pairing request." : error.message });
}
}
const listen = (port) => new Promise((ok, fail) => {
server.once("error", fail);
server.listen(port, () => { server.off("error", fail); ok(server.address().port); });
});
let port;
try {
port = await listen(options.port ?? PAIR_PORT);
}
catch (error) {
if (error.code !== "EADDRINUSE" || options.port === 0)
throw error;
port = await listen(0);
}
timer = setTimeout(() => finish(new Error("Pairing timed out. Run phren pair again.")), options.timeoutMs ?? 5 * 60_000);
return { port, done, close: () => finish(new Error("Pairing cancelled.")) };
}
async function sshListening(port) {
return new Promise(ok => {
const socket = connect({ host: "127.0.0.1", port, timeout: 1500 });
socket.once("connect", () => { socket.destroy(); ok(true); });
socket.once("timeout", () => { socket.destroy(); ok(false); });
socket.once("error", () => ok(false));
});
}
function sshHint() {
return process.platform === "darwin"
? "Turn on Remote Login: System Settings → General → Sharing → Remote Login."
: "Start the SSH server, for example: sudo systemctl enable --now sshd";
}
export const PAIR_USAGE = "phren pair [--minutes <1-30>] [--port <n>] [--no-install] | phren pair --scope gitboy-read|gitboy-write --key <public-key-file|-> [--no-install]";
/** The authorized_keys line for a scoped key: no PTY, no forwarding, its own forced command. */
export function scopedKeyLine(scope, publicKey) {
return `restrict,${scopedForcedCommand(scope)} ${publicComputerKey(publicKey)} ${SCOPE_KEY_COMMENT[scope]}`;
}
/** Authorize a server's key for one scope. Options on the supplied line are discarded and rebuilt. */
export async function acceptScopedKey(scope, input, sshDirectory) {
const raw = input.trim().split("\n").map(line => line.trim()).filter(Boolean);
if (raw.length !== 1)
throw new BridgeError(400, "Supply one ed25519 public key.");
const at = raw[0].indexOf("ssh-ed25519 ");
if (at === -1)
throw new BridgeError(400, "Supply one ed25519 public key.");
const line = scopedKeyLine(scope, raw[0].slice(at));
const encoded = line.split(" ").at(-2);
await appendAuthorizedKey(line, existing => existing.split(/\s+/).includes(encoded), "This key is already authorized with different options. Remove its line from authorized_keys first.", sshDirectory);
return line;
}
async function runScopedPair(args, version) {
let scope, key, installHook = true;
for (let i = 0; i < args.length; i++) {
if (args[i] === "--scope" && args[i + 1])
scope = args[++i];
else if (args[i] === "--key" && args[i + 1])
key = args[++i];
else if (args[i] === "--no-install")
installHook = false;
else {
console.error(`Usage: ${PAIR_USAGE}`);
return 1;
}
}
if (!scope || !isGatewayScope(scope)) {
console.error(`Unknown scope${scope ? ` "${scope}"` : ""}. Supported: gitboy-read, gitboy-write.`);
return 1;
}
if (!key) {
console.error(`Usage: ${PAIR_USAGE}`);
return 1;
}
if (!["darwin", "linux"].includes(process.platform)) {
console.error("Scoped keys support macOS and Linux.");
return 1;
}
if (!await lstat(scopedGatewayPath()).catch(() => undefined)) {
if (!installHook) {
console.error("This Phren Hook has no scoped gateway yet. Run phren bridge update first.");
return 1;
}
console.log("Installing Phren Hook with the scoped gateway…");
await install(version);
}
let text;
if (key === "-") {
const chunks = [];
for await (const chunk of process.stdin)
chunks.push(chunk);
text = Buffer.concat(chunks).toString("utf8");
}
else
text = await readFile(key, "utf8");
const line = await acceptScopedKey(scope, text);
const fingerprint = await hostFingerprint();
console.log(`Authorized ${SCOPE_KEY_COMMENT[scope]} for ${scope}: ${scope === "gitboy-write" ? "POST /v1/projects/<project>/findings and tasks/complete only" : "GET /v1/projects/<project>/memory, memory/files, memory/search and tasks only"}.`);
console.log(` ${line}`);
console.log(` SSH user: ${userInfo().username} host key: ${fingerprint ?? "unknown (no /etc/ssh/ssh_host_ed25519_key.pub)"}`);
console.log(` Addresses: ${(await pairingHosts()).join(", ") || "none found"}`);
console.log(` Revoke: remove the ${SCOPE_KEY_COMMENT[scope]} line from ${path.join(homedir(), ".ssh/authorized_keys")}.`);
return 0;
}
export async function runPair(args, version) {
if (args.includes("--scope"))
return runScopedPair(args, version);
let minutes = 5, port, installHook = true;
for (let i = 0; i < args.length; i++) {
if (args[i] === "--minutes" && args[i + 1])
minutes = Number(args[++i]);
else if (args[i] === "--port" && args[i + 1])
port = Number(args[++i]);
else if (args[i] === "--no-install")
installHook = false;
else {
console.error(`Usage: ${PAIR_USAGE}`);
return 1;
}
}
if (!Number.isInteger(minutes) || minutes < 1 || minutes > 30 || (port !== undefined && (!Number.isInteger(port) || port < 0 || port > 65535))) {
console.error(`Usage: ${PAIR_USAGE}`);
return 1;
}
if (!["darwin", "linux"].includes(process.platform)) {
console.error("Phone pairing supports macOS and Linux.");
return 1;
}
if (installHook && !await lstat(path.join(bridgeRoot(), "installed.json")).catch(() => undefined)) {
console.log("Installing Phren Hook, which your phone talks to…");
await install(version);
}
const sshPort = 22;
if (!await sshListening(sshPort))
console.log(`\n⚠ SSH isn't answering on this computer. ${sshHint()}\n Pairing can finish now; the phone connects once SSH is on.`);
const code = newPairingCode();
const name = await computerDisplayName(), user = userInfo().username, fingerprint = await hostFingerprint();
const hosts = await pairingHosts();
if (!hosts.length) {
console.error("No network address found. Connect to Wi-Fi or Tailscale and try again.");
return 1;
}
const session = await startPairing({ code, user, sshPort, fingerprint, name, port, timeoutMs: minutes * 60_000 });
const url = pairingURL({ hosts, port: session.port, user, sshPort, fingerprint, code, name });
const { renderUnicodeCompact } = await import("uqr");
console.log(`\nConnect your phone to ${name}`);
console.log(" In the Phren app: Agents → Add computer → Scan pairing code\n");
console.log(renderUnicodeCompact(url, { border: 2 }));
const typed = `${code.slice(0, 3)}-${code.slice(3)}`;
console.log(`\n Can't scan? Choose "Enter code" and type: ${hosts[0]}${session.port === PAIR_PORT ? "" : `:${session.port}`} ${typed}`);
if (process.env.WSL_DISTRO_NAME) {
console.log(`\n This is WSL: Windows may block inbound port ${session.port} (and SSH) from the phone. If it cannot connect, run once in an admin PowerShell:`);
console.log(` New-NetFirewallHyperVRule -Name phren-pair -DisplayName "Phren pairing" -Direction Inbound -VMCreatorId '{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}' -Protocol TCP -LocalPorts ${session.port},22`);
}
console.log(` Waiting for your phone (${minutes} min). Ctrl-C cancels.`);
const cancel = () => session.close();
process.once("SIGINT", cancel);
try {
const paired = await session.done;
console.log(`\n✓ Paired ${paired.name ?? "your phone"}. It can reach Phren Hook on ${name} as ${user}.`);
console.log(` To revoke it later, remove its ${paired.device === "android" ? "phren-android" : "phren-iphone"} line from ${path.join(homedir(), ".ssh/authorized_keys")}.`);
return 0;
}
catch (error) {
console.error(`\n${error.message}`);
return 1;
}
finally {
process.off("SIGINT", cancel);
}
}