UNPKG

@phren/cli

Version:

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

293 lines (292 loc) • 15.8 kB
import { createCipheriv, createHmac, createPrivateKey, randomBytes, sign } from "node:crypto"; import { readFile, stat } from "node:fs/promises"; import { connect } from "node:http2"; import path from "node:path"; import { z } from "zod"; import { atomic, bridgeRoot } from "./protocol.js"; import { approvalTitle, shortApproval } from "./approval-summary.js"; /** A phone registered through the phren push relay: the relay knows where to * deliver, and `key` (32 bytes, base64url) encrypts what the notification says * so only the phone reads it. The phone made the key and sent it over SSH. */ const relaySchema = z.object({ url: z.string().url().refine(value => /^https:\/\//.test(value) || /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?\//.test(`${value}/`), "The relay must use https."), relayId: z.string().regex(/^[A-Za-z0-9_-]{40,400}$/), secret: z.string().regex(/^[A-Za-z0-9_-]{20,100}$/), key: z.string().regex(/^[A-Za-z0-9_-]{43}$/), }); const deviceSchema = z.object({ deviceID: z.string().uuid(), hostID: z.string().uuid(), /** The APNs token, for a Hook with its own key (apns.json). */ token: z.string().regex(/^[0-9a-f]{64}$/).optional(), relay: relaySchema.optional(), environment: z.enum(["development", "production"]), kinds: z.array(z.enum(["approval", "scheduleStarted", "scheduleFinished", "scheduleFailed", "scheduleBlocked"])).max(5).default(["approval"]), }).refine(device => device.token !== undefined || device.relay !== undefined, "A phone needs an APNs token or a relay registration."); const configSchema = z.object({ keyId: z.string().regex(/^[A-Z0-9]{10}$/), teamId: z.string().regex(/^[A-Z0-9]{10}$/), topic: z.string().regex(/^[A-Za-z0-9.-]{1,255}$/).default("com.phren.ios"), privateKeyPath: z.string().min(1), }); export function scheduleCollapseId(kind, runId) { return `${runId.slice(0, 63 - kind.length)}-${kind}`; } export function approvalPushPayload(value, host) { const request = shortApproval(value.request ?? "Open Phren to review the request.") || "Open Phren to review the request."; return { aps: { alert: { title: approvalTitle(value.provider, value.project, value.computer), body: request }, sound: "default", category: value.question ? "PHREN_AGENT_QUESTION" : "PHREN_AGENT_APPROVAL", "interruption-level": "time-sensitive", }, phren: { version: 1, binding: value.binding, expiresAt: value.expiresAt, ...(host ? { host } : {}), agent: value.provider, ...(value.project ? { project: value.project } : {}), ...(value.computer ? { computer: value.computer } : {}), request, requestKind: value.requestKind ?? (value.question ? "question" : "other") }, }; } /** A sudo request waiting for its password. The command is what the owner * approves; the password itself is typed only in the app, never in a * notification action. */ export function sudoPushPayload(value, host) { const command = shortApproval(value.command) || "sudo"; return { aps: { alert: { title: `sudo on ${value.computer}`, body: command }, sound: "default", category: "PHREN_SUDO", "interruption-level": "time-sensitive", }, phren: { version: 1, kind: "sudo", id: value.id, ...(host ? { host } : {}), computer: value.computer, command, expiresAt: value.expiresAt }, }; } export function fanoutBlockedPushPayload(value) { return { aps: { alert: { title: `${value.label} blocked`, body: value.reason }, sound: "default", category: "PHREN_FANOUT" }, phren: { kind: "fanoutBlocked", job: value.job, provider: value.provider, label: value.label, reason: value.reason }, }; } export function schedulePushPayload(value) { const state = value.kind === "scheduleStarted" ? "started" : value.kind === "scheduleFinished" ? (value.status === "needs-you" ? "needs you" : "finished") : value.kind === "scheduleBlocked" ? "blocked" : "failed"; return { aps: { alert: { title: `${value.name} ${state}`, body: `${value.project} on ${value.computer}${value.reason ? `. ${value.reason}` : ""}` }, sound: "default", category: "PHREN_SCHEDULE", }, phren: { kind: value.kind, scheduleId: value.scheduleId, project: value.project, name: value.name, computer: value.computer, runId: value.runId, status: value.status, ...(value.reason ? { reason: value.reason } : {}), ...(value.route ? { route: value.route } : {}) }, }; } async function secureJSON(file, schema) { try { const metadata = await stat(file); if ((metadata.mode & 0o077) !== 0 || (process.getuid && metadata.uid !== process.getuid())) return undefined; return schema.parse(JSON.parse(await readFile(file, "utf8"))); } catch { return undefined; } } async function secureFile(file) { try { const metadata = await stat(file); if (!metadata.isFile() || (metadata.mode & 0o077) !== 0 || (process.getuid && metadata.uid !== process.getuid())) return undefined; return await readFile(file, "utf8"); } catch { return undefined; } } /** What `phren bridge doctor` prints when the Hook has no APNs sender. */ export function apnsSetupSteps(configFile = process.env.PHREN_APNS_CONFIG || path.join(bridgeRoot(), "apns.json")) { return [ "Approval push is not configured: the phone only alerts while Phren runs, and a registered phone gets nothing while suspended.", "1. In your Apple developer account, create an APNs key (Keys, Apple Push Notifications service) and download AuthKey_<KEYID>.p8.", `2. Save it next to ${configFile} with mode 600.`, `3. Write ${configFile} with mode 600: {"keyId":"<KEYID>","teamId":"<TEAMID>","topic":"com.phren.ios","privateKeyPath":"AuthKey_<KEYID>.p8"}`, "4. Restart the Hook (phren bridge install), then run phren bridge doctor again.", ].join("\n"); } /** The capability a phone reads: a Hook with its own APNs key sends direct; * one whose phones registered through the relay sends through it. */ export function approvalPushCapability(status) { if (!status.configured) return undefined; return status.direct === false ? "relay" : "direct-apns"; } export function upsertPushDevice(devices, value) { const device = deviceSchema.parse(value); return [...devices.filter(item => item.deviceID !== device.deviceID), device].slice(-16); } const base64url = (value) => Buffer.from(value).toString("base64url"); /** The relay refuses ciphertext longer than this (it keeps the APNs payload under 4 KB). */ export const RELAY_MAX_CIPHERTEXT = 2_800; /** What the phone's notification extension shows, encrypted with the phone's * key: ChaCha20-Poly1305, base64url of nonce (12) + ciphertext + tag (16), the * layout CryptoKit's `ChaChaPoly.SealedBox(combined:)` reads. A long body is * shortened until it fits the relay's limit. */ export function relayCiphertext(key, payload, nonce = randomBytes(12)) { const aps = (payload.aps ?? {}); let body = aps.alert?.body ?? ""; for (;;) { const content = JSON.stringify({ t: aps.alert?.title ?? "phren", b: body, ...(aps.category ? { c: aps.category } : {}), ...(aps["interruption-level"] ? { i: aps["interruption-level"] } : {}), ...(payload.phren ? { p: payload.phren } : {}) }); const cipher = createCipheriv("chacha20-poly1305", Buffer.from(key, "base64url"), nonce, { authTagLength: 16 }); const sealed = Buffer.concat([nonce, cipher.update(content, "utf8"), cipher.final(), cipher.getAuthTag()]).toString("base64url"); if (sealed.length <= RELAY_MAX_CIPHERTEXT || !body) return sealed; body = body.length > 40 ? `${body.slice(0, Math.floor(body.length * 0.8))}…` : ""; } } /** Signs a relay send: base64url HMAC-SHA256 of `${timestamp}.${body}` under the phone's send secret. */ export function relaySignature(secret, timestamp, body) { return createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("base64url"); } export async function sendThroughRelay(relay, payload, headers, fetcher = fetch, now = Date.now) { const aps = (payload.aps ?? {}); const body = JSON.stringify({ kind: "alert", ciphertext: relayCiphertext(relay.key, payload), collapseId: headers.collapseId.slice(0, 64), expiration: Number(headers.expiration) || 0, ...(aps.category ? { category: aps.category } : {}) }); const timestamp = String(Math.floor(now() / 1000)); try { const response = await fetcher(`${relay.url.replace(/\/+$/, "")}/v1/send`, { method: "POST", body, signal: AbortSignal.timeout(10_000), headers: { "content-type": "application/json", "x-phren-relay": relay.relayId, "x-phren-timestamp": timestamp, "x-phren-signature": relaySignature(relay.secret, timestamp, body) }, }); return response.status === 410 ? "gone" : response.ok ? "sent" : "failed"; } catch { return "failed"; } } class APNsSender { config; key; now; jwt; constructor(config, key, now = Date.now) { this.config = config; this.key = key; this.now = now; } token() { const created = Math.floor(this.now() / 1000); if (this.jwt && created - this.jwt.created < 50 * 60) return this.jwt.value; const header = base64url(JSON.stringify({ alg: "ES256", kid: this.config.keyId })); const claims = base64url(JSON.stringify({ iss: this.config.teamId, iat: created })); const input = `${header}.${claims}`; const signature = sign("sha256", Buffer.from(input), { key: createPrivateKey(this.key), dsaEncoding: "ieee-p1363" }); const value = `${input}.${base64url(signature)}`; this.jwt = { value, created }; return value; } send(device, payload, headers) { const authority = device.environment === "production" ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com"; return new Promise(resolve => { const client = connect(authority); let settled = false; const finish = (ok) => { if (settled) return; settled = true; client.close(); resolve(ok); }; client.once("error", () => finish(false)); const request = client.request({ ":method": "POST", ":path": `/3/device/${device.token}`, authorization: `bearer ${this.token()}`, "apns-topic": this.config.topic, "apns-push-type": "alert", "apns-priority": "10", "apns-expiration": headers.expiration, "apns-collapse-id": headers.collapseId }); request.on("response", headers => finish(Number(headers[":status"]) === 200)); request.once("error", () => finish(false)); request.setTimeout(10_000, () => { request.close(); finish(false); }); request.end(JSON.stringify(payload)); }); } } /** Direct Hook -> APNs delivery. Apple receives only a short-lived opaque * binding; conversation identity, action id, command and SSH key stay local. */ export class ApprovalPushService { devices = []; sender; devicesFile = path.join(bridgeRoot(), "push-devices.json"); async start() { this.devices = await secureJSON(this.devicesFile, z.array(deviceSchema).max(16)) ?? []; const configFile = process.env.PHREN_APNS_CONFIG || path.join(bridgeRoot(), "apns.json"); const config = await secureJSON(configFile, configSchema); if (!config) return; const keyPath = path.isAbsolute(config.privateKeyPath) ? config.privateKeyPath : path.resolve(path.dirname(configFile), config.privateKeyPath); const key = await secureFile(keyPath); if (key) this.sender = new APNsSender(config, key); } /** Phones this Hook can reach: through the relay, or direct with its own key. */ get reachable() { return this.devices.filter(device => device.relay || (this.sender && device.token)); } get available() { return this.reachable.length > 0; } get status() { const relay = this.devices.filter(device => device.relay).length; return { supported: true, configured: this.sender !== undefined || relay > 0, direct: this.sender !== undefined, devices: this.devices.length, relay }; } async register(value) { this.devices = upsertPushDevice(this.devices, value); await atomic(this.devicesFile, JSON.stringify(this.devices)); } /** One phone: the relay when it registered through one, otherwise direct. */ async send(device, payload, headers) { if (device.relay) { const result = await sendThroughRelay(device.relay, payload, headers); if (result === "gone") { this.devices = this.devices.filter(item => item.deviceID !== device.deviceID); await atomic(this.devicesFile, JSON.stringify(this.devices)).catch(() => { }); } return result === "sent"; } return this.sender && device.token ? this.sender.send(device, payload, headers) : false; } async notify(value) { const devices = this.reachable.filter(device => device.kinds.includes("approval")); if (!devices.length) return false; return (await Promise.all(devices.map(device => this.send(device, approvalPushPayload(value, device.hostID), { expiration: String(Math.floor(Date.parse(value.expiresAt) / 1000)), collapseId: value.binding, })))).some(Boolean); } async notifySudo(value) { const devices = this.reachable.filter(device => device.kinds.includes("approval")); if (!devices.length) return false; return (await Promise.all(devices.map(device => this.send(device, sudoPushPayload(value, device.hostID), { expiration: String(Math.floor(Date.parse(value.expiresAt) / 1000)), collapseId: `sudo-${value.id}`, })))).some(Boolean); } /** A headless worker whose permission the plugin refused. Approval-registered * phones already accept agent alerts; the payload carries the reason so the * notification is actionable on its own. */ async notifyFanoutBlocked(value) { const devices = this.reachable.filter(device => device.kinds.includes("approval")); if (!devices.length) return false; return (await Promise.all(devices.map(device => this.send(device, fanoutBlockedPushPayload(value), { expiration: "0", collapseId: `fanout-${value.job}`, })))).some(Boolean); } async notifySchedule(value) { const reachable = this.reachable; if (!reachable.length) return { notified: false, reason: this.sender || this.devices.length ? "no registered devices" : "no push config" }; let devices = reachable.filter(device => device.kinds.includes(value.kind)); if (!devices.length && value.kind === "scheduleBlocked") devices = reachable.filter(device => device.kinds.includes("scheduleFailed")); if (!devices.length) return { notified: false, reason: "no registered devices" }; try { const notified = (await Promise.all(devices.map(device => this.send(device, schedulePushPayload(value), { expiration: "0", collapseId: scheduleCollapseId(value.kind, value.runId), })))).some(Boolean); return notified ? { notified: true } : { notified: false, reason: "push delivery failed" }; } catch { return { notified: false, reason: "push delivery failed" }; } } }