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
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.
226 lines
9.2 KiB
TypeScript
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);
|
|
}
|