Files
mesh-catalog/modules/zsh/shell.ts
T
jochen cfe1dffa45
mesh/merge-gate pass: builds zsh → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group withhold-login-shell-execute delivered: every member is delivered
zsh: let a machine withhold node-login-shell's execute
execute runs any command as the operator account, which can become root
without a person. The operator withholds it on the control-node until a
call needs a person's approval (hq ADR 0268); the other machines keep it
for now. The setting execute (serve by default) is written into a
root-owned file the tools bundle reads at start, and anything but serve
withholds the verb, so the machine's runtime does not offer it.
2026-10-09 00:10:28 +02:00

226 lines
9.2 KiB
TypeScript

// 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, readFileSync } 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 `<shell> -lc`, bounded in time and output, its process group ended on
* timeout. */
export function execute(command: string, o: ExecuteOptions): Promise<Executed> {
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<Record<string, unknown>> {
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,
};
}
// Whether this machine serves `execute` (novox/hq ADR 0268). The command runs as the operator account,
// which can become root without a person, so a machine may withhold the verb: the control-node does,
// until a call to it needs a person's approval. The choice is the module's setting `execute`, which the
// mesh writes into a root-owned file named by the bundle's word ZSH_TOOLS_CONF; the runtime is
// restarted when that file changes. **Anything but `serve` withholds it** — a wrong value, a missing line
// or an unreadable file — because a verb that roots the machine is not served by accident; the reason
// is said on stderr and in zsh_config.
/** The bundle's word naming the file the machine's setting is written to. */
export const TOOLS_CONF_WORD = "ZSH_TOOLS_CONF";
export interface ExecuteSetting {
served: boolean;
why: string;
}
/** The setting as the file's text says it; undefined when there is no file to read. */
export function executeServed(text: string | undefined): ExecuteSetting {
if (text === undefined) return { served: false, why: "no setting was read, so execute is withheld" };
let value: string | undefined;
for (const line of text.split("\n")) {
const t = line.trim();
if (!t || t.startsWith("#")) continue;
const at = t.indexOf("=");
if (at < 0) continue;
if (t.slice(0, at).trim() === "execute") value = t.slice(at + 1).trim();
}
if (value === "serve") return { served: true, why: "this machine's setting execute is serve" };
if (value === "withhold") return { served: false, why: "this machine's setting execute is withhold (novox/hq ADR 0268)" };
if (value === undefined) return { served: false, why: "the setting file says nothing of execute, so it is withheld" };
return { served: false, why: `the setting execute is ${JSON.stringify(value)}, neither serve nor withhold, so it is withheld` };
}
/** The setting as this machine's file says it, read once when the bundle registers its tools. */
export function readExecuteSetting(env: NodeJS.ProcessEnv): ExecuteSetting {
const path = env[TOOLS_CONF_WORD]?.trim();
if (!path) return { served: false, why: `no ${TOOLS_CONF_WORD} names the setting file, so execute is withheld` };
let text: string;
try {
text = readFileSync(path, "utf8");
} catch (err) {
return { served: false, why: `cannot read ${path} (${(err as Error).message}), so execute is withheld` };
}
return executeServed(text);
}