Files
mesh-catalog/modules/zsh/shell.ts
T
jochen 566739e02c zsh: hold the mesh's login-shell seat, source the environment, and leave the rest to slots
The seat is now the mesh's node-login-shell, which a shell module claims rather than
declares (novox/hq ADR 0204), and the environment is one module's that every module
contributes to (ADR 0203). Per hq to-be 41 WP3:

- no seat declaration; the claim is node-login-shell serving execute;
- EDITOR, VISUAL, XDG_CONFIG_HOME and the three PATH entries are an environment
  contribution, not exports in the block;
- a ~/.zshenv block sources ~/.config/mesh/environment.sh, so a script, a login and
  execute all see the environment;
- the ~/.zshrc block goes at the start, so the operator's lines run after it, and holds
  today's shared defaults between the first, normal and last slots. The prompt, the
  plugins and the operator's own lines are no longer in it;
- execute is bounded below the runtime's call limit (20 s default, 25 s at most), kills its
  whole process group on timeout, cuts each stream at 256 KiB and says so, runs in the
  account's home without the mesh's words, with the account's session words. The dead
  runuser branch is gone, because the runtime is the account;
- zsh_config shows both files with their block line counts;
- the README lists the one-off migration (ADR 0182).
2026-10-04 04:02:54 +02:00

180 lines
6.8 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 } 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,
};
}