@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
210 lines (209 loc) • 9.21 kB
TypeScript
import { type IncomingMessage, type ServerResponse } from "node:http";
import { type Json } from "./protocol.js";
/**
* sudo with no terminal, answered from the phone.
*
* `sudo -A` runs `SUDO_ASKPASS` (`<bridge>/askpass`, a two-line script the
* Hook installs) with its prompt as the argument and reads the password from
* its stdout. The script runs the bundle's `askpass`, which asks the Hook over
* agent.sock and holds the connection. The password must reach sudo and no
* one else, so before asking the phone the Hook checks the whole chain
* (`verifyAsker`): the asker is this Hook's node running its bundle with no
* flags, its parent is the installed script, that script's parent is a sudo
* running as root (euid 0, which no process of the owner's can fake), and the
* asker's stdout is a pipe no other process of the owner's reads. The
* connection is bound to that process too: on macOS its other end must be
* held by the asker alone; on Linux the Hook writes the password straight into
* the asker's stdout (`/proc/<pid>/fd/1`), so a connection that names another
* process's pid gets nothing. It reads
* that sudo's command line itself and pushes the request to the phone; `GET /v1/sudo` and the overview socket's `sudo` frame
* list it. `POST /v1/sudo/answer` writes the password into the held
* connection and forgets the request: the password is never logged, stored,
* or kept past that write. Deny, a timeout, no reachable phone, or the asker
* going away ends the request, and askpass exits non-zero, so sudo fails.
*/
export declare const SUDO_HOLD_MS: number;
/** Held askpass requests at once; more are refused. */
export declare const MAX_SUDO_PENDING = 8;
/** How long after a password is handed over the Hook waits to see whether
* sudo asks again, before calling it accepted. */
export declare const SUDO_OUTCOME_MS: number;
export type SudoOutcome = "accepted" | "rejected" | "unknown";
export declare const ASKPASS_FILE = "askpass";
export declare const askpassPath: () => string;
export declare const askpassInstalled: () => boolean;
/** SUDO_ASKPASS for a launch, when this computer's Hook installed its helper. */
export declare const askpassEnv: () => Record<string, string>;
/** sudo runs SUDO_ASKPASS with no arguments of its own, so the helper is a
* script: the node that installed the Hook, the current bundle, `askpass`.
* It stays node's parent (no exec) so the Hook can see it in the chain, and
* drops what would load other code into node. */
export declare function askpassScript(node: string, bundle: string): string;
export declare function installAskpass(node: string, bundle: string): Promise<void>;
export declare function removeAskpass(): Promise<void>;
export interface SudoSession {
source?: string;
label?: string;
server?: string;
workspace?: string;
tab?: string;
pane?: string;
}
/** What the phone sees. Never carries a password. */
export interface SudoRequestView {
/** `account` is whose password sudo asks for (the invoking user); `user` is who the command runs as. */
id: string;
computer: string;
command: string;
account?: string;
user?: string;
cwd?: string;
session?: SudoSession;
askedAt: string;
expiresAt: string;
}
/** What askpass gets: the password to print, word that the Hook wrote it into
* askpass's stdout itself (Linux), or why not. */
export type SudoReply = {
password: string;
} | {
delivered: true;
} | {
error: string;
status: number;
};
/** The command a sudo process will run, without sudo's own options, and the
* user it runs as. Falls back to the whole line when there is no command
* (`sudo -v`, `sudo -s`). */
export declare function sudoCommand(argv: string[]): {
command: string;
user?: string;
};
export interface ProcessRecord {
ppid: number;
/** Effective user id. */
euid: number;
/** Executable name (`comm`). */
name: string;
/** Arguments, split on NULs on Linux and on spaces on macOS. */
argv: string[];
/** The whole command line as ps prints it. */
line: string;
/** Environment names, where the platform shows them (Linux). */
env?: string[];
/** When it started (`ps -o lstart`), to tell it from a later process with its pid. */
started?: string;
}
/** One process's parent, effective uid, name and arguments. */
export declare function readProcess(pid: number): Promise<ProcessRecord | undefined>;
/** Whether `pid`'s stdout is a pipe that no process of the owner's other than
* `allowed` holds. sudo, running as root, is never visible here. */
export declare function stdoutReaders(pid: number, allowed: number[]): Promise<"sudo" | "other" | "unknown">;
/** macOS: whether the other end of the Hook's connection `hookFd` is held by
* `pid` and no other process of the owner's. */
export declare function connectionHolders(pid: number, hookFd: number | undefined): Promise<"asker" | "other" | "unknown">;
/** Linux: a process's start time, to tell it from a later one with its pid. */
export declare function processStart(pid: number): Promise<string | undefined>;
export interface AskerCheck {
processes: (pid: number) => Promise<ProcessRecord | undefined>;
stdout: (pid: number, allowed: number[]) => Promise<"sudo" | "other" | "unknown">;
/** Whether the connection that asked belongs to the asker alone. */
connection: (pid: number) => Promise<"asker" | "other" | "unknown">;
/** This Hook's node and the bundle paths askpass may run from. */
node: string;
bundles: string[];
script: string;
unprivilegedSudo?: boolean;
}
/** The chain from askpass up to sudo, or why it is refused. */
export declare function verifyAsker(pid: number, check: AskerCheck): Promise<{
sudo: ProcessRecord;
sudoPid: number;
} | {
refused: string;
}>;
export interface SudoBrokerOptions {
computer: () => string;
push?: {
readonly available: boolean;
notifySudo(value: SudoRequestView): Promise<boolean>;
};
/** Label and agent for the pane that asked, when the Hook can place it. */
describe?: (place: {
server: string;
workspace: string;
tab: string;
pane: string;
}) => Promise<Pick<SudoSession, "source" | "label"> | undefined>;
label?: (dispatchId: string) => Promise<string | undefined>;
/** Reads a process, to see whether sudo is still running; tests replace it. */
processes?: (pid: number) => Promise<ProcessRecord | undefined>;
/** The askpass chain check and how the password then reaches it; tests replace it. */
verify?: (pid: number, hookFd: number | undefined) => Promise<Verified | {
refused: string;
}>;
holdMs?: number;
outcomeMs?: number;
/** Whose password sudo asks for; this Hook's user. */
account?: string;
now?: () => number;
}
export interface Verified {
sudo: ProcessRecord;
sudoPid: number;
deliver: (password: string) => Promise<SudoReply>;
}
export declare class SudoBroker {
private options;
private pending;
private events;
private watchers;
private tries;
constructor(options: SudoBrokerOptions);
private get now();
list(): SudoRequestView[];
/** Overview sockets that asked for sudo frames. Each counts as a phone
* that can answer while it is open. */
subscribe(listener: (requests: SudoRequestView[]) => void): () => void;
private changed;
/** An askpass call: checks the caller, builds the request, holds it until
* an answer, a timeout, or `cancel` (the caller went away). */
ask(body: Json, respond: (reply: SudoReply) => void, hookFd?: number): Promise<{
cancel: () => void;
}>;
/** The phone's answer. Single use: the request is gone once answered.
* `outcome` resolves to whether sudo took the password: rejected when the
* same sudo asks again, accepted when it has not within `outcomeMs` (or
* is still running then), unknown when it gave up or the Hook cannot tell. */
answer(id: string, answer: {
password: string;
} | {
deny: true;
}): false | {
outcome: Promise<SudoOutcome | undefined>;
};
private watchOutcome;
close(): void;
/** The agent.sock `/sudo` route. */
handle(req: IncomingMessage, res: ServerResponse): Promise<void>;
}
/** A password as sudo reads it from askpass: one line, nothing it would cut. */
export declare function sudoAnswer(data: Json): {
id: string;
answer: {
password: string;
} | {
deny: true;
};
outcome: boolean;
};
export declare class SudoAnswerError extends Error {
constructor();
}
/** The bundle's `askpass` command, run by sudo through `<bridge>/askpass`.
* Prints the password and exits 0, or explains on stderr and exits 1. */
export declare function askpass(prompt: string | undefined, env?: NodeJS.ProcessEnv): Promise<number>;
/** `phren sudo <command...>`: `sudo -A` with this Hook's askpass, so the
* password comes from the phone even with no terminal (a `!` command, an
* agent's shell). Exits with sudo's status. */
export declare function runSudo(args: string[]): Promise<number>;