@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
157 lines (156 loc) • 9.22 kB
JavaScript
import { spawn } from "node:child_process";
import { lstat, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { Duplex } from "node:stream";
import { dump, load } from "js-yaml";
import { z } from "zod";
import { logger } from "../logger.js";
import { hookRequest } from "./client.js";
import { computerName, dispatchKeyPath, publicComputerKey } from "./computers.js";
import { atomic, BridgeError, bridgeRoot, serverName, withErrorCode } from "./protocol.js";
const peerSchema = z.object({
name: computerName,
address: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9.:-]{0,252}$/),
username: z.string().regex(/^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/),
port: z.number().int().min(1).max(65535).default(22),
hostKey: z.string().transform(publicComputerKey),
server: serverName.default("default"),
}).strict();
export async function hookPeers(root = bridgeRoot()) {
const file = path.join(root, "hooks.yaml");
const info = await lstat(file).catch(error => {
if (error.code === "ENOENT")
return undefined;
throw new BridgeError(409, `hooks.yaml could not be read (${error.code ?? "unknown error"}).`);
});
if (!info)
throw new BridgeError(409, "Configure peers and verified host keys in the Hook's hooks.yaml first.", { hooksYaml: "missing" });
if (!info.isFile() || info.isSymbolicLink() || info.size > 65_536 || (info.mode & 0o077))
throw new BridgeError(409, "hooks.yaml must be a private regular file (0600), at most 64 KiB.");
const { computers } = z.object({ version: z.literal(1), computers: z.array(peerSchema).max(32) }).strict().parse(load(await readFile(file, "utf8")));
if (new Set(computers.map(peer => peer.name)).size !== computers.length)
throw new BridgeError(409, "hooks.yaml has duplicate computer names.");
return computers;
}
/**
* Adds one verified peer to hooks.yaml, creating the file (0600) if needed.
* Linking again with the same details is a no-op; a different computer under
* the same name or address is refused rather than replaced.
*/
export async function addHookPeer(input, root = bridgeRoot()) {
const peer = peerSchema.parse(input);
const existing = await hookPeers(root).catch(error => {
if (error instanceof BridgeError && error.details?.hooksYaml === "missing")
return [];
throw error;
});
const same = (other) => JSON.stringify(peerSchema.parse(other)) === JSON.stringify(peer);
if (existing.some(same))
return { added: false, peer };
const clash = existing.find(other => other.name === peer.name || (other.address === peer.address && other.port === peer.port && other.server === peer.server));
if (clash)
throw new BridgeError(409, `hooks.yaml already has ${clash.name} at ${clash.address}; remove that entry first to link it differently.`);
if (existing.length >= 32)
throw new BridgeError(409, "hooks.yaml already has 32 computers.");
await atomic(path.join(root, "hooks.yaml"), dump({ version: 1, computers: [...existing, peer] }, { lineWidth: -1 }));
return { added: true, peer };
}
/**
* Peers for reads that keep working without them. No hooks.yaml means no
* peers; a broken one is logged and returned as `peerError` so the response
* can say why remote agents are missing instead of hiding them.
*/
export async function optionalHookPeers(root = bridgeRoot()) {
try {
return { peers: await hookPeers(root) };
}
catch (error) {
if (error instanceof BridgeError && error.details?.hooksYaml === "missing")
return { peers: [] };
const peerError = hooksYamlError(error);
// Polled routes call this often; log each distinct problem once.
if (peerError !== lastPeerError)
logger.warn("peers", peerError);
lastPeerError = peerError;
return { peers: [], peerError };
}
}
let lastPeerError;
function hooksYamlError(error) {
if (error instanceof BridgeError)
return error.message;
if (error instanceof z.ZodError) {
const issue = error.issues[0];
return `hooks.yaml is invalid${issue?.path.length ? ` at ${issue.path.join(".")}` : ""}: ${issue?.message ?? "unexpected shape"}`.slice(0, 300);
}
const first = (error instanceof Error ? error.message : String(error)).split("\n")[0].replace(/[\x00-\x1f\x7f]/g, " ");
return `hooks.yaml could not be parsed: ${first}`.slice(0, 300);
}
export function peerSSHArgs(peer, knownHosts, key) {
return ["-F", "/dev/null", "-T", "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes", "-o", "IdentityAgent=none",
"-o", "StrictHostKeyChecking=yes", "-o", "HostKeyAlias=phren-peer", "-o", `UserKnownHostsFile=${knownHosts}`,
"-o", "GlobalKnownHostsFile=/dev/null", "-o", "UpdateHostKeys=no", "-o", "HostKeyAlgorithms=ssh-ed25519",
"-o", "PasswordAuthentication=no", "-o", "KbdInteractiveAuthentication=no", "-o", "ForwardAgent=no",
"-o", "ClearAllForwardings=yes", "-o", "ControlMaster=no", "-o", "ControlPath=none", "-o", "ConnectTimeout=10",
"-i", key, "-p", String(peer.port), "-l", peer.username, "--", peer.address, "phren-hook v1 pipe"];
}
/** Keeps ssh's own first stderr line (one line, bounded) and its exit code. */
export function peerOfflineMessage(diagnostic, exitCode) {
const line = diagnostic.split(/\r?\n/).map(text => text.replace(/[\x00-\x1f\x7f]/g, " ").trim().replace(/^ssh:\s*/, "")).find(Boolean)?.slice(0, 200);
const details = [line ? `ssh: ${line}` : "", typeof exitCode === "number" ? `exit ${exitCode}` : ""].filter(Boolean).join("; ");
return `The remote Hook is offline or SSH did not confirm the request${details ? ` (${details})` : ""}.`;
}
/** A pin is supplied out of band; dispatch never learns or replaces host keys. */
export async function peerRequest(peer, route, data, timeout) {
const root = bridgeRoot(), key = dispatchKeyPath(root);
const info = await lstat(key).catch(() => undefined);
if (!info?.isFile() || info.isSymbolicLink() || (info.mode & 0o077))
throw new BridgeError(409, "Run phren bridge enroll-computer on this computer first (private key mode 0600).", { code: "dispatch-key-missing" });
const temporary = await mkdtemp(path.join(root, "peer-"));
let child, stream;
try {
const knownHosts = path.join(temporary, "known_hosts");
await writeFile(knownHosts, `phren-peer ${peer.hostKey}\n`, { mode: 0o600 });
const ssh = spawn("ssh", peerSSHArgs(peer, knownHosts, key), { stdio: ["pipe", "pipe", "pipe"] });
child = ssh;
stream = new Duplex({
read() { ssh.stdout.resume(); },
write(chunk, encoding, callback) { ssh.stdin.write(chunk, encoding, callback); },
final(callback) { ssh.stdin.end(callback); },
destroy(error, callback) { ssh.kill(); callback(error); },
});
ssh.stdout.on("data", chunk => { if (!stream.push(chunk))
ssh.stdout.pause(); });
ssh.stdout.on("end", () => stream.push(null));
ssh.stdout.on("error", error => stream?.destroy(error));
ssh.stdin.on("error", error => stream?.destroy(error));
let diagnostic = "";
child.stderr.on("data", bytes => { diagnostic = (diagnostic + bytes.toString()).slice(-4096); });
const exited = new Promise(resolve => ssh.once("exit", code => resolve(code)));
child.on("error", error => stream?.destroy(new BridgeError(503, `SSH is unavailable (${error.code ?? "spawn failed"}).`, { code: "ssh-unavailable" })));
try {
return await hookRequest(route, data, { createConnection: () => stream }, timeout ?? (data === undefined ? 15_000 : 65_000));
}
catch (error) {
if (!(error instanceof BridgeError)) {
// ssh's stderr and exit status usually land just after the stream ends.
const exitCode = await Promise.race([exited, new Promise(resolve => { setTimeout(() => resolve(undefined), 250).unref(); })]);
if (!/Permission denied|Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED/i.test(diagnostic))
throw new BridgeError(503, peerOfflineMessage(diagnostic, exitCode), { code: "peer-offline" });
}
if (/Permission denied/i.test(diagnostic))
throw new BridgeError(403, "The remote computer has not enrolled this dispatch key.", { code: "peer-key-not-enrolled" });
if (/Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED/i.test(diagnostic))
throw new BridgeError(403, "The remote SSH host key does not match its pin.", { code: "peer-host-key-mismatch" });
// Our own wait ran out (client.ts); a remote Hook's own 504 keeps its code.
if (error instanceof BridgeError && error.status === 504 && error.message.startsWith("Hook did not confirm"))
throw withErrorCode(error, "peer-timeout");
throw error;
}
}
finally {
stream?.destroy();
child?.kill();
await rm(temporary, { recursive: true, force: true });
}
}