@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
281 lines (280 loc) • 15.2 kB
JavaScript
import { hookPeers } from "./peers.js";
import { peerFingerprint } from "./dispatch-hosts.js";
import { connect } from "node:net";
import { BridgeError } from "./protocol.js";
import { findingWriteSchema, taskCompleteSchema, isBranchName, isRepoPath, isSearchText, MAX_FILE_PATHS, MAX_FINDING_BODY, PROJECT_NAME, } from "./gitboy-contract.js";
// A scoped SSH key reaches the Hook through this gateway instead of the full
// byte pipe. The key's forced command names the scope
// (`sh ~/.local/share/phren/bridge/dispatch-scoped gitboy-read`); the gateway
// reads one HTTP request from the SSH channel, admits it only if the scope
// allows that exact method, path, query and body, and then sends the Hook a
// request it builds itself from the validated values. The client's bytes never
// reach the socket, so extra headers, query keys, JSON fields, pipelined
// requests and upgrades cannot widen the scope.
export const GATEWAY_SCOPES = ["gitboy-read", "gitboy-write"];
/** The authorized_keys comment each scope's key carries. */
export const SCOPE_KEY_COMMENT = { "gitboy-read": "phren-gitboy", "gitboy-write": "phren-gitboy-write" };
/** The only SSH command a scoped key may send, the same one the phone's pipe uses. */
export const SCOPED_COMMAND = "phren-hook v1 pipe";
/** Room for 500 repository paths in one query string; the Hook allows the same. */
const MAX_HEAD = 256 * 1024;
const MAX_HEADER_LINE = 8192;
const MAX_HEADERS = 32;
const READ_TIMEOUT_MS = 10_000;
/** How long the Hook may go quiet on an admitted request: every scoped route is
* a store read or one finding/task write (whose file lock waits at most 5 s),
* so a minute of silence means it is stuck, not slow. */
const ANSWER_IDLE_MS = 60_000;
const PROJECT = PROJECT_NAME.source.slice(1, -1);
const one = (query, key) => query.get(key)?.length === 1 ? query.get(key)[0] : undefined;
const only = (query, keys) => [...query.keys()].every(key => keys.includes(key));
const RULES = {
"gitboy-read": [
{ method: "GET", path: new RegExp(`^/v1/projects/${PROJECT}/memory$`) },
{ method: "GET", path: new RegExp(`^/v1/projects/${PROJECT}/memory/files$`), query: query => {
const paths = query.get("path") ?? [];
if (!only(query, ["path"]) || paths.length < 1 || paths.length > MAX_FILE_PATHS)
return `Send 1 to ${MAX_FILE_PATHS} path parameters and nothing else.`;
if (!paths.every(isRepoPath))
return "Each path must be repository-relative.";
return paths.map(value => ["path", value]);
} },
{ method: "GET", path: new RegExp(`^/v1/projects/${PROJECT}/memory/search$`), query: query => {
const q = one(query, "q"), limit = query.has("limit") ? one(query, "limit") : "5";
if (!only(query, ["q", "limit"]) || q === undefined || limit === undefined)
return "Send one q and at most one limit.";
if (!isSearchText(q))
return "q must be 1 to 1000 characters of text.";
if (!/^(?:[1-9]|1\d|20)$/.test(limit))
return "limit must be 1 to 20.";
return [["q", q], ["limit", limit]];
} },
{ method: "GET", path: new RegExp(`^/v1/projects/${PROJECT}/tasks$`), query: query => {
const branch = one(query, "branch");
if (!only(query, ["branch"]) || branch === undefined || !isBranchName(branch))
return "Send one valid branch.";
return [["branch", branch]];
} },
],
"gitboy-write": [
{ method: "POST", path: new RegExp(`^/v1/projects/${PROJECT}/tasks/complete$`), body: { max: 256, schema: taskCompleteSchema } },
{ method: "POST", path: new RegExp(`^/v1/projects/${PROJECT}/findings$`), body: { max: MAX_FINDING_BODY, schema: findingWriteSchema } },
],
};
const DESCRIBE = {
"gitboy-read": "GET /v1/projects/<project>/memory, /memory/files, /memory/search and /tasks",
"gitboy-write": "POST /v1/projects/<project>/findings and /tasks/complete",
};
export function isGatewayScope(value) {
return GATEWAY_SCOPES.includes(value);
}
const refuse = (status, error) => ({ ok: false, status, error });
function parseHead(head) {
const lines = head.split("\r\n");
if (lines.length < 3 || lines.at(-1) !== "" || lines.at(-2) !== "")
return refuse(400, "Malformed request.");
const request = /^([A-Z]+) (\/[^\s?#]*)(?:\?([^\s#]*))? HTTP\/1\.[01]$/.exec(lines[0]);
if (!request)
return refuse(400, "Malformed request.");
const headers = lines.slice(1, -2);
if (headers.length > MAX_HEADERS)
return refuse(400, "Too many headers.");
let contentLength, json = false;
for (const header of headers) {
if (header.length > MAX_HEADER_LINE)
return refuse(400, "Header too large.");
const match = /^([!#$%&'*+.^_`|~0-9A-Za-z-]+):[ \t]*(.*?)[ \t]*$/.exec(header);
if (!match)
return refuse(400, "Malformed request.");
const name = match[1].toLowerCase();
if (name === "transfer-encoding" || name === "upgrade" || name === "expect")
return refuse(400, "Send a plain request with a Content-Length body, if any.");
if (name === "content-length") {
if (contentLength !== undefined || !/^\d{1,7}$/.test(match[2]))
return refuse(400, "Invalid Content-Length.");
contentLength = Number(match[2]);
}
if (name === "content-type")
json = /^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(match[2]);
}
return { method: request[1], path: request[2], query: request[3] ?? null, contentLength: contentLength ?? 0, json };
}
function parseQuery(raw) {
const query = new Map();
for (const pair of raw.split("&")) {
const match = /^([a-z]{1,10})=([A-Za-z0-9._~%+!$'()*,;:@\/-]*)$/.exec(pair);
if (!match)
return undefined;
let value;
try {
value = decodeURIComponent(match[2].replace(/\+/g, " "));
}
catch {
return undefined;
}
query.set(match[1], [...(query.get(match[1]) ?? []), value]);
}
return query;
}
function findRule(scope, head) {
return RULES[scope].find(rule => rule.method === head.method && rule.path.test(head.path));
}
/** Decide one request: its head (through the blank line) and the bytes after it. */
export function admitScopedRequest(scope, rawHead, rest = Buffer.alloc(0)) {
const head = parseHead(rawHead);
if ("ok" in head)
return head;
const rule = findRule(scope, head);
if (!rule && head.path === "/v1/sessions/move")
return refuse(403, "Only the owner can move the conductor.");
if (!rule)
return refuse(403, `This key only permits ${DESCRIBE[scope]}.`);
let target = head.path;
if (rule.query) {
if (head.query === null)
return refuse(400, "This route needs its query parameters.");
const query = parseQuery(head.query);
if (!query)
return refuse(400, "Malformed query.");
const pairs = rule.query(query);
if (typeof pairs === "string")
return refuse(400, pairs);
target += `?${new URLSearchParams(pairs).toString()}`;
}
else if (head.query !== null)
return refuse(400, "This route takes no query.");
if (!rule.body) {
if (head.contentLength !== 0 || rest.length > 0)
return refuse(400, "Send one request with no body.");
return { ok: true, request: `${rule.method} ${target} HTTP/1.1\r\nHost: phren.local\r\nConnection: close\r\n\r\n` };
}
if (!head.json)
return refuse(415, "Send Content-Type: application/json.");
if (head.contentLength < 1 || head.contentLength > rule.body.max)
return refuse(413, `The body must be 1 to ${rule.body.max} bytes.`);
if (rest.length !== head.contentLength)
return refuse(400, "The body must match Content-Length, with nothing after it.");
let parsed;
try {
parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(rest));
}
catch {
return refuse(400, "The body is not JSON.");
}
const checked = rule.body.schema.safeParse(parsed);
if (!checked.success)
return refuse(400, `Invalid body: ${checked.error.issues.map(issue => `${issue.path.join(".") || "body"} ${issue.message}`).join("; ").slice(0, 300)}`);
const body = JSON.stringify(checked.data);
return { ok: true, request: `${rule.method} ${target} HTTP/1.1\r\nHost: phren.local\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}` };
}
/** A computer key's forced scope rebuilds the HTTP envelope. Client headers
* cannot impersonate the owner gateway, and peer keys cannot open terminals. */
export function admitPeerRequest(rawHead, rest = Buffer.alloc(0), identity) {
const head = parseHead(rawHead);
if ("ok" in head)
return head;
if (!["GET", "POST", "DELETE"].includes(head.method) || !head.path.startsWith("/v1/"))
return refuse(403, "This key only permits Hook API requests.");
if (head.contentLength > 8 * 1024 * 1024 || rest.length !== head.contentLength)
return refuse(400, "Send one bounded request matching Content-Length.");
if (rest.length && !head.json)
return refuse(415, "Send Content-Type: application/json.");
const body = rest.toString("utf8");
const target = head.path + (head.query === null ? "" : `?${head.query}`);
return { ok: true, request: `${head.method} ${target} HTTP/1.1\r\nHost: phren.local\r\nX-Phren-Origin: peer\r\n${identity ? `X-Phren-Peer: ${Buffer.from(JSON.stringify(identity)).toString("base64url")}\r\n` : ""}Connection: close\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}` };
}
const REASONS = { 400: "Bad Request", 403: "Forbidden", 408: "Request Timeout", 413: "Content Too Large", 415: "Unsupported Media Type", 503: "Service Unavailable" };
function httpResponse(status, error) {
const body = JSON.stringify({ error, code: status === 503 ? "hook-unavailable" : "scope-refused" });
return `HTTP/1.1 ${status} ${REASONS[status] ?? "Bad Request"}\r\nContent-Type: application/json\r\nCache-Control: no-store\r\nConnection: close\r\nContent-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`;
}
/** Read the head, then, when the scope has a body route and the head declares
* a body within its cap, exactly that many bytes. Stops at a limit, the
* timeout or EOF; bytes beyond the request make it refused. */
function readRequest(scope, input) {
const bodyCap = scope === "peer" ? 8 * 1024 * 1024 : Math.max(0, ...RULES[scope].map(rule => rule.body?.max ?? 0));
return new Promise(resolve => {
let buffer = Buffer.alloc(0), headEnd = -1, wanted = 0;
const finish = (value) => {
clearTimeout(timer);
input.off("data", data);
input.off("end", end);
input.off("error", end);
input.pause();
resolve(value);
};
const complete = () => finish({ head: buffer.subarray(0, headEnd).toString("latin1"), rest: buffer.subarray(headEnd) });
const data = (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
if (headEnd === -1) {
const at = buffer.indexOf("\r\n\r\n");
if (at === -1 || at + 4 > MAX_HEAD) {
if (buffer.length > MAX_HEAD)
finish({ status: 400, error: "Request is too large." });
return;
}
headEnd = at + 4;
const declared = /\r\ncontent-length:[ \t]*(\d{1,7})[ \t]*\r\n/i.exec(buffer.subarray(0, headEnd).toString("latin1"));
// Over the cap, admission refuses it without waiting for the bytes.
wanted = declared && Number(declared[1]) <= bodyCap ? Number(declared[1]) : 0;
}
if (buffer.length - headEnd >= wanted)
complete();
};
const end = () => headEnd === -1 ? finish({ status: 400, error: "Malformed request." }) : complete();
const timer = setTimeout(() => finish({ status: 408, error: "The request did not arrive in time." }), READ_TIMEOUT_MS);
input.on("data", data);
input.once("end", end);
input.once("error", end);
});
}
/**
* Serve one scoped SSH session. Commands other than the pipe are refused
* outright; a refused request gets an HTTP error on the channel so the client
* reads a normal response.
*/
export async function scopedDispatch(scope, command, io) {
let identity;
if (scope.startsWith("peer:")) {
const peer = (await hookPeers().catch(() => [])).find(peer => peer.name === scope.slice(5));
// The forced-command enrollment binds this client key to the reverse
// host-key + OS account pin. The Hook verifies its stable computer ID
// over that pin when authorizing a move. Do not probe here: two gateways
// probing one another's /health would recursively open SSH sessions.
if (peer)
identity = { name: peer.name, fingerprint: peerFingerprint(peer) };
scope = "peer";
}
if (scope !== "peer" && !isGatewayScope(scope))
throw new BridgeError(403, "Unknown key scope.");
if (command !== SCOPED_COMMAND)
throw new BridgeError(403, `This SSH key only permits \`${SCOPED_COMMAND}\` with ${scope === "peer" ? "Hook API requests" : DESCRIBE[scope]}.`);
const read = await readRequest(scope, io.input);
const admission = "head" in read ? (scope === "peer" ? admitPeerRequest(read.head, read.rest, identity) : admitScopedRequest(scope, read.head, read.rest)) : refuse(read.status, read.error);
if (!admission.ok) {
await new Promise(done => io.output.write(httpResponse(admission.status, admission.error), () => done()));
return;
}
await new Promise(resolve => {
const socket = connect(io.socket);
let answered = false;
socket.once("data", () => { answered = true; });
socket.on("connect", () => {
// Never half-close: the Hook answers and closes (Connection: close).
socket.write(admission.request);
socket.pipe(io.output, { end: false });
});
socket.on("error", () => {
if (answered)
return;
answered = true;
io.output.write(httpResponse(503, "Phren Hook is not running on this computer."));
});
// A stalled route or a gone SSH client must not hold this process open.
socket.setTimeout(io.answerIdleMs ?? ANSWER_IDLE_MS, () => socket.destroy());
const drop = () => socket.destroy();
io.output.once("error", drop);
io.output.once("close", drop);
socket.on("close", () => { io.output.off("error", drop); io.output.off("close", drop); resolve(); });
});
}