// node-login-shell's `execute`, and the reading of the account's zsh files (novox/hq ADR 0176, // ADR 0204). // // Who runs it. The node tools runtime runs as the operator account, not root (novox/hq ADR 0175 // §4), and launches this bundle as a process of its own (ADR 0188, ADR 0193). So the command runs // as this process's own user, which is the account; a command that needs root uses sudo inside the // shell, as a person would. // // The bounds are the seat's protocol (ADR 0204 §4), and each answers one way the runtime fails: // - time: the runtime gives up on a call after thirty seconds, so the command is ended before then // and the answer says it timed out, rather than the caller hearing nothing; // - the process group: the shell is started as the leader of its own group and the whole group is // killed on timeout, so `sleep 100 & wait` leaves no sleep behind; // - output: a reply over about a megabyte is lost on the way back, so each stream is cut at a bound // and the answer says it was cut. import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { homedir, userInfo } from "node:os"; /** The longest a command may run when the caller does not say, and the most it may ask for. Both * below the runtime's thirty-second call limit, leaving room for the answer to travel. */ export const DEFAULT_TIMEOUT_SECONDS = 20; export const MAX_TIMEOUT_SECONDS = 25; /** How much of each stream is kept: two of these are far below what one reply carries. */ export const OUTPUT_CAP_BYTES = 256 * 1024; export interface Executed { command: string; account: string; cwd: string; /** The shell's exit status; null when it was ended by a signal. */ status: number | null; signal: string | null; stdout: string; stderr: string; /** Which streams were cut at OUTPUT_CAP_BYTES. */ truncated: { stdout: boolean; stderr: boolean }; timed_out: boolean; timeout_seconds: number; } export interface ExecuteOptions { /** The shell to run; zsh for this module, a test may use another. */ shell?: string; cwd: string; env: NodeJS.ProcessEnv; timeoutSeconds: number; capBytes?: number; account?: string; } /** The timeout a caller asked for, held within the bounds. */ export function timeoutOf(asked: unknown): number { if (asked === undefined || asked === null || asked === "") return DEFAULT_TIMEOUT_SECONDS; const n = Number(asked); if (!Number.isFinite(n) || n <= 0) return DEFAULT_TIMEOUT_SECONDS; return Math.min(n, MAX_TIMEOUT_SECONDS); } /** Where the command runs: the account's home. */ export function homeOf(env: NodeJS.ProcessEnv): string { return env.MESH_OPERATOR_HOME?.trim() || env.HOME?.trim() || homedir(); } /** The environment the command is given: the runtime's, without the mesh's own words, and with the * account's session words when its runtime directory exists, so `systemctl --user` and anything * speaking to the session bus reach the account's own manager. */ export function commandEnv(env: NodeJS.ProcessEnv, uid: number | undefined = process.getuid?.(), exists: (p: string) => boolean = existsSync): NodeJS.ProcessEnv { const out: NodeJS.ProcessEnv = {}; for (const [k, v] of Object.entries(env)) { if (!k.startsWith("MESH_")) out[k] = v; } if (uid !== undefined) { const runtime = `/run/user/${uid}`; if (exists(runtime)) { out.XDG_RUNTIME_DIR ??= runtime; out.DBUS_SESSION_BUS_ADDRESS ??= `unix:path=${runtime}/bus`; } } return out; } /** Collects one stream up to a bound, and says whether it had to cut. */ class Capped { private readonly cap: number; private parts: Buffer[] = []; private size = 0; cut = false; constructor(cap: number) { this.cap = cap; } add(chunk: Buffer): void { const room = this.cap - this.size; if (room <= 0) { this.cut = true; return; } const kept = chunk.length > room ? chunk.subarray(0, room) : chunk; if (kept.length < chunk.length) this.cut = true; this.parts.push(kept); this.size += kept.length; } text(): string { return Buffer.concat(this.parts).toString("utf8"); } } /** Run one command line as ` -lc`, bounded in time and output, its process group ended on * timeout. */ export function execute(command: string, o: ExecuteOptions): Promise { const cap = o.capBytes ?? OUTPUT_CAP_BYTES; const account = o.account ?? userInfo().username; return new Promise((resolve) => { const out = new Capped(cap); const err = new Capped(cap); let timedOut = false; let settled = false; const child = spawn(o.shell ?? "zsh", ["-lc", command], { cwd: o.cwd, env: o.env, detached: true, // its own process group, so the whole group can be ended stdio: ["ignore", "pipe", "pipe"], }); const killGroup = () => { try { if (child.pid) process.kill(-child.pid, "SIGKILL"); } catch { // The group is already gone. } }; child.stdout.on("data", (d: Buffer) => out.add(d)); child.stderr.on("data", (d: Buffer) => err.add(d)); const timer = setTimeout(() => { timedOut = true; killGroup(); }, o.timeoutSeconds * 1000); const finish = (status: number | null, signal: string | null, extra = "") => { if (settled) return; settled = true; clearTimeout(timer); child.stdout.destroy(); child.stderr.destroy(); resolve({ command, account, cwd: o.cwd, status, signal, stdout: out.text(), stderr: err.text() + extra, truncated: { stdout: out.cut, stderr: err.cut }, timed_out: timedOut, timeout_seconds: o.timeoutSeconds, }); }; child.on("error", (e) => finish(null, null, e.message)); // The shell exiting is the answer. Its streams are read until they close, but a job it left // running in the background may hold them open for ever, so they are given a moment and no more. child.on("exit", (status, signal) => { const grace = setTimeout(() => finish(status, signal), 200); child.on("close", () => { clearTimeout(grace); finish(status, signal); }); }); }); } /** One zsh startup file as it is now, and how many of its lines are the mesh's block. */ export async function zshFile(path: string, cap: number = OUTPUT_CAP_BYTES): Promise> { const text = await readFile(path, "utf8").catch(() => null); if (text === null) return { path, exists: false }; const block = /^# BEGIN mesh [^\n]*\n([\s\S]*?)^# END mesh[^\n]*$/m.exec(text); const lines = text === "" ? 0 : text.replace(/\n$/, "").split("\n").length; return { path, exists: true, lines, mesh_block_lines: block && block[1] !== "" ? block[1].replace(/\n$/, "").split("\n").length : 0, truncated: text.length > cap, content: text.length > cap ? text.slice(0, cap) : text, }; }