@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
195 lines (194 loc) • 10.8 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 { tryFileLock } from "../governance/locks.js";
import { logger } from "../logger.js";
import { hookRequest } from "./client.js";
import { dispatchKeyPath, publicComputerKey } from "./computers.js";
// computerName comes from protocol.js, where it is defined: through computers.js it can still be
// undefined when an import cycle (usage -> account-choice -> health -> peers) loads this file first.
import { atomic, BridgeError, bridgeRoot, computerName, 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);
return lockedHooksYaml(root, () => addHookPeerNow(peer, root));
}
/** One hooks.yaml change at a time, in this process and across processes, so
* two links read, check and write in turn and neither loses the other's peer. */
const hooksYamlQueues = new Map();
const HOOKS_YAML_LOCK_WAIT_MS = 5_000;
async function lockedHooksYaml(root, work) {
const file = path.join(path.resolve(root), "hooks.yaml");
const previous = hooksYamlQueues.get(file) ?? Promise.resolve();
const next = previous.catch(() => undefined).then(async () => {
const deadline = Date.now() + HOOKS_YAML_LOCK_WAIT_MS;
let release = tryFileLock(file);
while (!release) {
if (Date.now() >= deadline)
throw new BridgeError(409, "hooks.yaml is being changed by another process. Try again.");
await new Promise(resolve => setTimeout(resolve, 50));
release = tryFileLock(file);
}
try {
return await work();
}
finally {
release();
}
});
hooksYamlQueues.set(file, next);
try {
return await next;
}
finally {
if (hooksYamlQueues.get(file) === next)
hooksYamlQueues.delete(file);
}
}
async function addHookPeerNow(peer, root) {
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 });
}
}